@llmnative/react 1.32.0 → 1.34.0

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,1306 +1,1364 @@
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
- ### AppThemeProviderConfig
67
- ```ts
68
- {
69
- mode?: 'light' | 'dark' | 'system';
70
- theme: string;
71
- themes?: Record<string, {
72
- preset?: { colors?: ColorScale; radius?: number; font?: string };
73
- motion?: MotionRegistry;
74
- components?: Record<string, Partial<ThemeComponent>>;
75
- }>;
76
- themeOverride?: Record<string, Partial<ThemeComponent>>;
77
- }
78
- ```
79
-
80
- Built-in themes: `default`, `flat`, `cyber`, `vscode-dark-modern`, `vscode-light-modern`, `vscode-dark-plus`, `vscode-light-plus`, `tokyo-night`, `tokyo-night-light`.
81
- 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.
82
-
83
- `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)));`.
84
-
85
- ---
86
-
87
- ## Provider System
88
-
89
- ### Matrix completa
90
-
91
- | Servizio | Interfaccia | Driver | Config key | Obbligatorio |
92
- |---|---|---|---|---|
93
- | data | `DataProviderAdapter` | `mock`, `dbRealtime`, `firestoreDb`, `supabaseDb` | `providers.mock/firebase/supabase` | sì (fallback: mock) |
94
- | storage | `StorageProviderAdapter` | `firestorage`, `supabaseStorage` | `providers.firebase/supabase` | no |
95
- | auth | `AuthProviderAdapter` | `googleAuth`, `firebaseAuth`, `supabaseAuth`, `dropboxAuth`, `githubAuth` | `providers.google/firebase/supabase/dropbox/github` | sì (fallback: googleAuth) |
96
- | email | `EmailProviderAdapter` | `gmail` | `providers.google` | no |
97
- | ai | `AIProviderAdapter` | `openai`, `gemini`, `anthropic`, `deepseek`, `mistral`, `openrouter`, `opencode`, `cloudflare`, `groq` | `Config.tsx AIConfig` | no |
98
- | credentials | `CredentialsAdapter` | `googleServiceAccount` | `providers.google.serviceAccount` | no |
99
-
100
- ### Hooks
101
-
102
- ```ts
103
- const data = useDataProvider() // DataProviderAdapter | null
104
- const storage = useStorageProvider() // StorageProviderAdapter | null
105
- const auth = useAuthProvider() // AuthProviderAdapter | null
106
- const email = useEmailProvider() // EmailProviderAdapter | null
107
- const ai = useAIProvider() // AIProviderAdapter | null
108
- const creds = useCredentialsProvider() // CredentialsAdapter | null
109
-
110
- // Named provider (multi-provider setup)
111
- const supabase = useDataProvider('supabase')
112
- ```
113
-
114
- ### GitHub (`githubAuth`, sessione sul server dell'app)
115
-
116
- GitHub richiede il client secret per scambiare il codice: scambio, token e sessione stanno sul server
117
- dell'app, il browser vede solo il profilo. Config `providers.github = { sessionUrl: '/api/auth/github' }`,
118
- driver `auth: 'githubAuth'`; funziona con `AuthButton` (pulsante e `aspect="avatar"`) e
119
- `useAuthProvider('githubAuth')`. Il server espone sotto `sessionUrl`:
120
-
121
- | Rotta | Risposta |
122
- |---|---|
123
- | `GET /login?returnTo=<path>[&scope=…]` | redirect a GitHub, poi di nuovo a `<path>` |
124
- | `GET /me` | `{ login, name, avatarUrl, email }`, 401 senza sessione |
125
- | `POST /logout` | chiude la sessione |
126
-
127
- Lato server, da `@llmnative/react/server`:
128
-
129
- ```ts
130
- const oauth = createGitHubOAuth({ clientId, clientSecret, scopes: ['read:org'] })
131
- oauth.authorizeUrl({ state, redirectUri, codeChallenge }) // PKCE S256
132
- const { accessToken } = await oauth.exchangeCode({ code, redirectUri, codeVerifier }) // GitHubOAuthError se rifiutato
133
- await oauth.getUser(accessToken) // { login, name, avatarUrl, email }
134
- await oauth.getOrganizations(accessToken) // [{ login, avatarUrl, role: 'admin' | 'member' }]
135
- ```
136
-
137
- ### Interfacce principali
138
-
139
- ```ts
140
- // DataProviderAdapter
141
- read(path, options?) / set(path, data) / update(path, data) / remove(path)
142
- subscribe(path, callback: (records: RecordArray) => void): () => void
143
- count?(path, where?): Promise<number>
144
-
145
- // StorageProviderAdapter
146
- upload(file, path): Promise<string|undefined>
147
- getURL(path) / download(path): Promise<Blob|undefined> / delete(path): Promise<boolean>
148
-
149
- // AuthProviderAdapter
150
- signIn(intent?, options?) / signOut() / getUser(): UserProfile|null
151
- onAuthChange(callback) / getAccessToken(): Promise<string|null>
152
- ```
153
-
154
- ### Auth sign-in methods
155
-
156
- | method | Firebase | Supabase | Google |
157
- |---|---|---|---|
158
- | `password` | ✅ | ✅ | — |
159
- | `anonymous` | ✅ | ✅ | — |
160
- | `magic_link` | — | ✅ | — |
161
- | `oauth` (github, apple…) | ✅ | ✅ | — |
162
- | GIS + scoped token | — | — | ✅ |
163
-
164
- ```ts
165
- await auth.signIn({ method: 'password', email: '...', password: '...' })
166
- await auth.signIn({ method: 'oauth', provider: 'github' })
167
- // Google: token per API scope
168
- const token = await auth.getAccessToken('https://www.googleapis.com/auth/gmail.send')
169
- ```
170
-
171
- ### CredentialsAdapter (Google Service Account)
172
- ```tsx
173
- // Chiama Google APIs lato browser senza Node.js
174
- <App providers={{ google: { serviceAccount: { clientEmail, privateKey } }, services: { credentials: 'googleServiceAccount' } }} />
175
-
176
- const creds = useCredentialsProvider()
177
- const token = await creds.getToken('https://www.googleapis.com/auth/solar')
178
- ```
179
-
180
- ### Provider custom
181
- ```ts
182
- import { DataProviderAdapter, RecordArray } from '@llmnative/react'
183
-
184
- export class RestDataProvider implements DataProviderAdapter {
185
- async read(path: string): Promise<unknown> { ... }
186
- async set(path: string, data: object): Promise<void> { ... }
187
- async update(path: string, data: object): Promise<void> { ... }
188
- async remove(path: string): Promise<void> { ... }
189
- subscribe(path: string | undefined, callback: (records: RecordArray) => void): () => void {
190
- return () => undefined
191
- }
192
- }
193
- ```
194
-
195
- ---
196
-
197
- ## Grid — CRUD table/gallery
198
-
199
- ```tsx
200
- <Grid
201
- path="/users" // DataProvider-backed — oppure records={array} per in-memory
202
- columns={[
203
- { key: 'name', label: 'Nome', sortable: true },
204
- { key: 'email', label: 'Email' },
205
- { key: 'role', label: 'Ruolo', render: ({ value }) => <Badge>{value}</Badge> },
206
- { key: 'date', label: 'Data', render: 'date' },
207
- ]}
208
- actions={['add', 'edit', 'delete']} // oppure Record<string, GridAction>
209
- view="table" // 'table' | 'gallery'
210
- pagination={{ limit: 20 }}
211
- groupBy="status"
212
- sortable={{ field: 'name', dir: 'asc' }}
213
- form={<FormFields />}
214
- onSave={async ({ record, action }) => record._key}
215
- onComplete={async ({ record, action }) => true}
216
- selection={{ mode: 'multiple', onChange: (sel) => {} }}
217
- reorderable
218
- onReorder={(records, meta) => {}}
219
- header={(ctx) => <div>{ctx.title}</div>}
220
- footer={(ctx) => <div>{ctx.records.length} records</div>}
221
- />
222
- ```
223
-
224
- | Prop | Tipo | Note |
225
- |---|---|---|---|
226
- | `path` | `string \| "fromUrl"` | Path provider-backed |
227
- | `records` | `RecordArray` | Dati in memoria (alternativa a path) |
228
- | `columns` | `GridColumn[]` | `{ key, label, sortable?, render?, className? }` |
229
- | `actions` | `Array<'add'\|'edit'\|'delete'> \| Record<string, GridAction>` | |
230
- | `view` | `'table' \| 'gallery'` | |
231
- | `groupBy` | `string \| string[]` | |
232
- | `pagination` | `PaginationParams` | `{ limit, page?, maxPageButtons?, align?, sticky? }` |
233
- | `sortable` | `boolean \| OrderConfig` | |
234
- | `selection` | `false \| 'single' \| 'multiple' \| GridSelectionConfig` | |
235
- | `form` | `ReactElement \| ((ctx) => ReactNode)` | Form condiviso per CRUD |
236
- | `onSave` | `GridMutationSaveHandler` | `async ({ record, action }) => string` |
237
- | `onDelete` | `GridMutationDeleteHandler` | `async ({ record }) => string` |
238
- | `onComplete` | `GridAfterActionHandler` | `async ({ record, action }) => boolean` |
239
- | `onRowClick` | `(record) => void` | |
240
- | `reorderable` | `boolean` | Abilita drag & drop righe |
241
- | `onReorder` | `GridReorderHandler` | `(records, meta) => void` |
242
- | `onLoad` | `(records) => TRecord[]` | Trasforma records dopo il fetch |
243
- | `editDeepLink` | `boolean` | Sincronizza modal edit con URL |
244
- | `audit` | `boolean` | Logga mutazioni |
245
- | `sticky` | `'top' \| 'bottom'` | Header/footer sticky |
246
- | `wrapperClassName` | `string` | CSS wrapper esterno |
247
- | `loading` | `boolean` | Stato loading override |
248
- | `title` | `ReactNode` | Titolo header grid |
249
- | `before` / `after` | `ReactNode` | Slot prima/dopo il contenuto |
250
- | `header` / `footer` | `ReactNode \| ((ctx) => ReactNode)` | Header/footer custom |
251
- | `searchable` | `boolean \| GridSearchConfig` | Search box nell'header default — `{ fields?, placeholder? }` |
252
- | `filters` | `GridFilterConfig[]` | Checkbox toggle nell'header default, applicati PRIMA di `searchable` — `{ key, label, defaultValue?, predicate(record, value) }` |
253
-
254
- **GridAction kind:** `'modal'` (default), `'route'`, `'external'`, `'inline'`, `'delete'`
255
-
256
- ---
257
-
258
- ## Form — form CRUD con lifecycle
259
-
260
- ```tsx
261
- <Form
262
- path="/products"
263
- appearance="card" // 'card' | 'empty'
264
- showBack
265
- defaultValues={{ status: 'draft' }}
266
- keyGenerator={() => `prod_${Date.now()}`}
267
- onLoad={(data) => { /* side-effect hook, non trasforma */ }}
268
- onSave={async ({ record, action }) => { /* return storagePath or undefined */ }}
269
- onDelete={async ({ record }) => { /* confirm */ }}
270
- onComplete={async ({ action }) => { if (action === 'save') navigate('/products') }}
271
- >
272
- <Input name="title" label="Titolo" required />
273
- <Input name="price" label="Prezzo" type="number" />
274
- <Input name="address.city" label="Città" /> {/* oggetto annidato */}
275
- <Repeat name="tags" defaultLength={1}>
276
- {(i) => <Input name={`tags.${i}`} label={`Tag ${i+1}`} />}
277
- </Repeat>
278
- </Form>
279
- ```
280
-
281
- | Prop | Tipo | Note |
282
- |---|---|---|---|
283
- | `path` | `string` | Path provider-backed |
284
- | `appearance` | `'card' \| 'empty'` | Guscio visuale |
285
- | `controller` | `FormController` | Controller condiviso per bottoni custom esterni |
286
- | `showBack` | `boolean` | Pulsante Back nel footer |
287
- | `defaultValues` | `RecordProps` | Valori iniziali per nuovo record |
288
- | `keyGenerator` | `(record) => string` | Generatore chiave primaria custom |
289
- | `draftBucket` | `string` | Bucket stabile che isola la bozza locale; omettilo per disabilitare la persistenza |
290
- | `noticeAnchorRef` | `React.RefObject<HTMLElement>` | Ancoraggio sticky per notifiche |
291
- | `showNotice` | `boolean` | Mostra notifiche save/delete (default `true`) |
292
- | `children` | `ReactNode \| ((fields) => ReactNode) \| (({ record }) => ReactNode)` | Contenuto form |
293
- | `header` | `ReactNode` | Contenuto header custom |
294
- | `footer` | `ReactNode` | Contenuto footer custom |
295
- | `wrapperClassName` | `string` | CSS wrapper esterno |
296
- | `headerClassName` | `string` | CSS header |
297
- | `className` | `string` | CSS body form |
298
- | `footerClassName` | `string` | CSS footer |
299
- | `log` | `boolean` | Logga ogni modifica su console |
300
- | `onLoad` | `(record) => void` | Side-effect dopo il caricamento (non trasforma) |
301
- | `onRecordChange` | `(record) => void` | Cambio campo in tempo reale |
302
- | `onSave` | `FormSaveHandler` | `async ({ record, prevRecord?, storagePath?, action }) => string \| undefined` |
303
- | `onDelete` | `FormDeleteHandler` | `async ({ record }) => string \| undefined` |
304
- | `onComplete` | `FormFinallyHandler` | `async ({ record, action }) => boolean` |
305
-
306
- **Form controller** — stato e azioni native del form accessibili da qualsiasi punto della pagina.
307
-
308
- Due hook:
309
-
310
- | Hook | Dove usarlo | Cosa restituisce |
311
- |---|---|---|
312
- | `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. |
313
- | `useFormActions()` | Solo dentro `<Form>` (legge dal context) | Restituisce lo stesso `FormController` del form genitore. Lancia errore se usato fuori. |
314
-
315
- Esempio — bottone custom fuori dal form:
316
- ```tsx
317
- const form = useFormController();
318
-
319
- <ActionButton disabled={form.saveDisabled} onClick={() => { void form.save(); }} />
320
- <Form controller={form} draftBucket="workspace/acme" onSave={...} />
321
- ```
322
-
323
- Esempio — uso dentro il form (senza passare controller):
324
- ```tsx
325
- function MySaveButton() {
326
- const form = useFormActions();
327
- return <LoadingButton disabled={form.saveDisabled} loading={form.isSaving} onClick={form.save} />;
328
- }
329
- ```
330
-
331
- Stato esposto dal controller: `record`, `isNewRecord`, `isDirty`, `canSave`, `canDelete`, `isSaving`, `isDeleting`, `saveDisabled`, `deleteDisabled`, `hasDraft`, `draftStatus`, `errors`, `hasErrors`.
332
-
333
- Azioni: `save()`, `delete()`, `reset()`, `restoreDraft()`, `discardDraft()`.
334
-
335
- ---
336
-
337
- ## Campi form
338
-
339
- ### Input
340
- ```tsx
341
- <Input name="x" label="..." required /> // text (default)
342
- <Input name="price" type="number" min={0} max={100} step={0.01} />
343
- <Input name="email" type="email" />
344
- <Input name="pwd" type="password" />
345
- <Input name="date" type="date" />
346
- <Input name="color" type="color" />
347
- <TextArea name="bio" minHeight={96} />
348
- <Checkbox name="active" valueChecked="yes" />
349
- ```
350
- `Input` props: `name`(req) `label` `type` `required` `disabled` `placeholder` `min` `max` `step` `validator:(v)=>string|undefined`
351
- `type`: `'text' | 'number' | 'email' | 'password' | 'color' | 'date' | 'time' | 'datetime-local' | 'week' | 'month' | 'range' | 'checkbox' | 'radio' | 'url'`
352
-
353
- **NOTA**: il tipo HTML si passa con `type`. `inputType` e i sotto-componenti `Input.Number`/`Input.Email`/`Input.Date` non esistono.
354
-
355
- **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)`.
356
- ```tsx
357
- <Select name="skill" required options={skills} label={<>Skill <ActionButton icon="info" ariaLabel="Apri la skill" variant="link" onClick={openSkill} /></>} />
358
- ```
359
-
360
- ### Select / Autocomplete / Checklist
361
- ```tsx
362
- <Select name="role" options={[{ label: 'Admin', value: 'admin' }]} />
363
- <Select name="categoryId" optionsSource={{ path: '/categories', labelField: 'name', valueField: '_key' }} />
364
- <Autocomplete name="userId" optionsSource={{ path: '/users', labelField: 'email' }} maxItems={1} />
365
- <Checklist name="tags" options={['a', 'b', 'c']} />
366
- ```
367
-
368
- | Prop | Tipo | Note |
369
- |---|---|---|
370
- | `options` | `Option[] \| string[] \| number[]` | |
371
- | `optionsSource` | `{ path, labelField, valueField?, where?, order?, fieldMap?, onLoad? }` | Carica da provider |
372
- | `order` | `{ field: 'label' \| 'value', dir: 'asc' \| 'desc' }` | |
373
- | `placeholderOption` | `Option` | Solo Select |
374
- | `placeholder` | `string` | Solo Autocomplete |
375
- | `creatable` | `boolean` | Solo Autocomplete — crea nuovi valori |
376
- | `onCreate` | `(value) => void \| boolean \| Promise<void \| boolean>` | Solo Autocomplete — restituire `false` per non selezionare il valore creato (es. aprire un modulo di creazione) |
377
- | `listClassName` / `optionClassName` | `string` | Solo Autocomplete — classi della lista e delle voci (anche da tema `Autocomplete.listClassName` / `optionClassName`) |
378
- | `minItems` / `maxItems` | `number` | Solo Autocomplete |
379
- | `itemClassName` | `string` | Solo Checklist |
380
- | `validator` | `(value) => string \| undefined` | Tutte le varianti |
381
- | `readOnlyAfterSet` | `boolean` | readonly dopo primo valore |
382
- | `disabled` | `boolean` | |
383
- | `title` | `string` | |
384
- | `feedback` | `string` | Messaggio di errore/suggerimento |
385
-
386
- `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.
387
-
388
- ### UploadImage / UploadDocument / UploadCSV
389
- ```tsx
390
- <UploadImage name="cover" label="Copertina" /> {/* con crop editor */}
391
- <UploadDocument name="pdf" label="PDF" />
392
- <UploadCSV name="data" label="Importa CSV" /> {/* con preview tabellare */}
393
- ```
394
-
395
- **UploadImageProps** (eredita FormFieldProps):
396
- | Prop | Tipo | Note |
397
- |---|---|---|
398
- | `name` | `string` (required) | |
399
- | `uploadPath` | `string` | Path storage per upload automatico |
400
- | `accept` | `string` | MIME types accettati |
401
- | `editable` | `boolean` | Abilita crop editor |
402
- | `multiple` | `boolean` | Multipla selezione |
403
- | `max` | `number` | Numero massimo file |
404
- | `feedback` | `string` | Testo placeholder drop zone |
405
- | `srcsetWidths` | `number[]` | Larghezze varianti responsive |
406
- | `previewHeight` / `previewWidth` | `number` | Dimensioni preview |
407
-
408
- **UploadDocumentProps** (eredita FormFieldProps): `name`, `uploadPath`, `accept`, `editable`, `multiple`, `max`
409
-
410
- ### UploadCSV
411
- ```tsx
412
- <UploadCSV name="data" label="Importa CSV" />
413
- ```
414
- | Prop | Tipo |
415
- |---|---|
416
- | `name` | `string` (required) |
417
- | `onDataLoaded` | `(data: UploadCSVData) => void` |
418
- | `onParseField` | `([key, value]) => [key, value] \| undefined` |
419
- | `normalizeKeys` | `boolean` |
420
- | `removeEmptyFields` | `boolean` |
421
- | `delimiter` | `string` |
422
- | `label` / `icon` | Opzionali |
423
-
424
- ### ImageField
425
- ```tsx
426
- <ImageField
427
- name="hero"
428
- label="Hero image"
429
- defaultValue={{ src: 'https://example.com/hero.jpg', alt: 'Hero shot' }}
430
- srcsetWidths={[400, 800]}
431
- uploadPath="/content/images"
432
- />
433
- ```
434
-
435
- Gestisce un oggetto `{ src, alt, srcset?, sizes? }`, preview inline, crop editor e generazione opzionale di varianti responsive.
436
-
437
- ### CropImage
438
- ```tsx
439
- <CropImage img={imageObj} title="Ritaglia" scales={{ '1:1': 1, '4:3': 1.33 }} />
440
- ```
441
- 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.
442
-
443
- ### CodeEditor
444
- ```tsx
445
- <CodeEditor
446
- name="code"
447
- label="Template"
448
- language="html"
449
- placeholder="Scrivi HTML..."
450
- minHeight={200}
451
- maxHeight={600}
452
- disabled={false}
453
- required
454
- validateSyntax
455
- commands={[{ name: 'wrap-section', handler: ({ value }) => `<section>\n${value}\n</section>` }]}
456
- commandsTrigger="/"
457
- />
458
- ```
459
- Editor basato su CodeMirror con syntax highlighting e validazione. Linguaggi: `liquid`, `html`, `json`, `js`, `ts`, `css`.
460
- Quando `commands` e presente, `CodeEditor` apre internamente `ContextMenu` sul trigger configurato.
461
-
462
- | Prop extra | Tipo |
463
- |---|---|
464
- | `language` | `CodeEditorLanguage` |
465
- | `placeholder` | `string` |
466
- | `minHeight` / `maxHeight` | `number` |
467
- | `validateSyntax` | `boolean` |
468
- | `validator` | `(value) => string \| undefined \| Promise<string \| undefined>` |
469
- | `extensions` | `unknown[]` |
470
-
471
- ### RichText
472
- ```tsx
473
- <RichText
474
- name="body"
475
- label="Article body"
476
- placeholder="Scrivi..."
477
- toolbar="fixed" // 'fixed' | 'floating' | false
478
- toolbarCommands={['bold', 'italic', 'heading1', '|', 'bulletList', 'link']}
479
- outputFormat="html" // 'html' | 'json' | 'text'
480
- statusBar={{ tagBreadcrumb: true, wordCount: true }}
481
- minHeight={200}
482
- maxHeight={800}
483
- imageUpload={{ path: '/content/images', srcsetWidths: [400, 800] }}
484
- commands={[{ name: 'summary', icon: 'sparkles' }]}
485
- commandsTrigger="/"
486
- />
487
- ```
488
-
489
- | Prop extra | Tipo |
490
- |---|---|
491
- | `placeholder` | `string` |
492
- | `disabled` | `boolean` |
493
- | `feedback` | `string` |
494
- | `toolbar` | `'fixed' \| 'floating' \| false` |
495
- | `toolbarCommands` | `ToolbarCommand[]` |
496
- | `outputFormat` | `'html' \| 'json' \| 'text'` |
497
- | `statusBar` | `boolean \| StatusBarConfig` |
498
- | `minHeight` / `maxHeight` | `number` |
499
- | `imageUpload` | `RichTextImageUploadConfig` |
500
- | `documentUpload` | `RichTextDocumentUploadConfig` |
501
- | `validator` | `(value) => string \| undefined` |
502
-
503
- ### Repeat — array dinamici
504
- ```tsx
505
- <Repeat name="items" defaultLength={3} minItems={1} maxItems={10} label="Elementi" layout="vertical" readOnly>
506
- {({ index, record, records, remove }) => (
507
- <>
508
- <Input name={`items.${index}.name`} label="Nome" />
509
- <UploadImage name={`items.${index}.photo`} label="Foto" />
510
- </>
511
- )}
512
- </Repeat>
513
- ```
514
-
515
- | Prop | Tipo | Note |
516
- |---|---|---|
517
- | `name` | `string` (required) | |
518
- | `children` | `ReactNode \| ((args) => ReactNode)` | Render prop con `{ index, record, records, remove }` |
519
- | `defaultLength` | `number` | Righe iniziali |
520
- | `minItems` / `maxItems` | `number` | Min/max righe |
521
- | `layout` | `'vertical' \| 'horizontal' \| 'inline'` | |
522
- | `label` | `string` | Etichetta gruppo |
523
- | `labelPosition` | `'top' \| 'bottom'` | |
524
- | `hideRemoveButton` | `boolean` | Nascondi rimozione |
525
- | `readOnly` | `boolean` | Sola lettura |
526
- | `onChange` | `FieldOnChange` | |
527
- | `onAdd` / `onRemove` | `(records) => void` | Callback aggiunta/rimozione |
528
-
529
- ### Prompt — campo AI-potenziato
530
- ```tsx
531
- <Prompt
532
- name="summary"
533
- mode="run" // 'edit' | 'run'
534
- onRunPrompt={async (prompt, options) => AI.fetch(prompt)}
535
- commands={[{ name: 'translate', icon: 'languages', handler: (v) => `Translate:\n${v}` }]}
536
- commandsTrigger="/" // default automatico quando commands e presente
537
- attachments
538
- statusItems={['tokensIn', 'tokensOut', 'contextPercent', 'duration']}
539
- />
540
- ```
541
-
542
- 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.
543
-
544
- `renderFallback`: quando l'AI non e configurata, `Prompt` monta un fallback testuale; passa `renderFallback` per personalizzarlo (riceve `{ name, label, required, onChange }`).
545
-
546
- ---
547
-
548
- ## UI Primitives
549
-
550
- ### Alert
551
- ```tsx
552
- <Alert variant="success">Salvato!</Alert>
553
- <Alert variant="warning" icon placement="fixed" timeout={3000}>Attenzione</Alert>
554
- ```
555
- `variant`: `'info' | 'success' | 'warning' | 'danger' | 'primary' | 'secondary' | 'light' | 'dark'`
556
- `appearance`: `'default' | 'text'`
557
- `placement`: `'inline' | 'fixed'`
558
-
559
- ### Badge
560
- ```tsx
561
- <Badge variant="success">Attivo</Badge>
562
- ```
563
- `variant`: stessi valori di Alert
564
-
565
- ### Button variants
566
- ```tsx
567
- <ActionButton variant="primary" label="Salva" icon="save" onClick={handleSave} />
568
- <LoadingButton variant="primary" label="Invia" onClick={async (e, setMsg) => { await save(); setMsg('Salvato!') }} />
569
- <BackLink label="Indietro" />
570
- <GoSite href="https://..." label="Visita" />
571
- ```
572
-
573
- **IButton props:** `variant`, `label`, `icon`, `badge`, `disabled`, `ariaLabel`, `title`, `iconClassName`, `onClick`
574
- **LoadingButton aggiunge:** `loading`, `loadingLabel` — `onClick` riceve `(e, setMessage?) => Promise<unknown>`
575
-
576
- ### Card
577
- ```tsx
578
- <Card title="Titolo" header={<Actions />} footer={<Pagination />} loading={isLoading}>
579
- {children}
580
- </Card>
581
- ```
582
-
583
- ### Modal
584
- ```tsx
585
- <Modal title="Conferma" size="md" position="center" onClose={() => {}} onSave={handleSave} onDelete={handleDelete} motion="fade" allowFullscreen stackedBehind>
586
- {content}
587
- </Modal>
588
- <ModalYesNo title="Sicuro?" onClose={() => {}} onYes={async () => true}>Eliminare il record?</ModalYesNo>
589
- <ModalOk title="Completato!" onClose={() => {}}>Salvato.</ModalOk>
590
- ```
591
- `size`: `'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'fullscreen'`
592
- `position`: `'center' | 'top' | 'left' | 'right' | 'bottom'`
593
- `ModalYesNo` e `ModalOk` sono export separati: `Modal.YesNo` e `Modal.Ok` non esistono.
594
- Tastiera: Esc chiude il modal in cima alla pila (`closeOnEscape`, default `true`).
595
- 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 })`.
596
-
597
- | Prop | Tipo | Note |
598
- |---|---|---|
599
- | `children` | `ReactNode` (required) | |
600
- | `title` | `ReactNode` | |
601
- | `header` / `footer` | `ReactNode \| false` | Custom header/footer |
602
- | `size` / `position` | Come sopra | |
603
- | `onClose` | `() => void` | |
604
- | `onSave` / `onDelete` | `(e) => Promise<boolean>` | |
605
- | `allowFullscreen` | `boolean` | Toggle fullscreen |
606
- | `showCancel` | `boolean` | Default `true` |
607
- | `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 |
608
- | `closeOnBackdrop` | `boolean` | Default `true` |
609
- | `closeOnEscape` | `boolean` | Default `true` — solo il modal in cima reagisce |
610
- | `zIndex` | `number` | |
611
- | `motion` | `MotionReference` | Preset animazione |
612
- | Classi CSS header/title/subtitle/body/footer | `string` | |
613
-
614
- ### Table
615
- ```tsx
616
- <Table
617
- columns={[{ key: 'name', label: 'Nome', sort: true }]}
618
- records={records}
619
- onRowClick={(record) => {}}
620
- selection="multiple"
621
- sortable={{ field: 'name', dir: 'asc' }}
622
- pagination={{ limit: 20 }}
623
- onReorder={(records, meta) => {}}
624
- onSelectionChange={(sel) => {}}
625
- groupBy="status"
626
- renderCell={(record, key, index) => <CustomCell />}
627
- />
628
- ```
629
-
630
- | Prop | Tipo |
631
- |---|---|
632
- | `columns` | `TableHeaderProp[]` |
633
- | `records` | `RecordArray` |
634
- | `sortable` | `boolean \| OrderConfig` |
635
- | `pagination` | `PaginationParams` |
636
- | `selection` | `'single' \| 'multiple'` |
637
- | `onRowClick` | `(record) => void` |
638
- | `onReorder` | `(records, meta) => void` |
639
- | `onSelectionChange` | `(selection) => void` |
640
- | `renderCell` | `(record, key, index) => ReactNode` |
641
- | `groupBy` | `string \| string[]` |
642
- | `activeKey` | `string \| null` |
643
- | `selectedKeys` | `string[]` |
644
- | `header` / `footer` | `ReactNode` |
645
- | `headerClassName` / `bodyClassName` / `footerClassName` / `scrollClassName` / `selectedClassName` / `heightClassName` | `string` |
646
-
647
- ### Gallery
648
- ```tsx
649
- <Gallery
650
- records={records}
651
- overlays={[{ position: 'topRight', badge: { variant: 'success', children: 'New' } }]}
652
- columns={3}
653
- gap={2}
654
- sortable={{ field: 'name', dir: 'asc' }}
655
- pagination={{ limit: 20 }}
656
- onRowClick={(record) => {}}
657
- onSelectionChange={(sel) => {}}
658
- header="Titolo"
659
- groupBy="status"
660
- />
661
- ```
662
-
663
- `position`: `'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' | 'middleLeft' | 'middleRight'`
664
-
665
- | Prop | Tipo |
666
- |---|---|
667
- | `records` | `GalleryRecord[]` (`{ img?, thumbnail?, name?, width?, height? }`) |
668
- | `columns` | `1 \| 2 \| 3 \| 4 \| 6` |
669
- | `gap` | `0 \| 1 \| 2 \| 3 \| 4 \| 5` |
670
- | `overlays` | `GalleryOverlay[]` |
671
- | `sortable` | `boolean \| OrderConfig` |
672
- | `pagination` | `PaginationParams` |
673
- | `onRowClick` | `(record) => void` |
674
- | `onSelectionChange` | `(selection) => void` |
675
- | `groupBy` | `string \| string[]` |
676
- | `selectedKeys` | `string[]` |
677
- | `header` / `footer` | `ReactNode` |
678
- | Classi CSS: `headerClassName`, `bodyClassName`, `footerClassName`, `scrollClassName`, `selectedClassName` | `string` |
679
-
680
- ### Tab
681
- ```tsx
682
- import { Tab, TabItem } from '@llmnative/react'
683
-
684
- <Tab defaultIndex={0} layout="top">
685
- <TabItem label="Generale">{content1}</TabItem>
686
- <TabItem label="SEO">{content2}</TabItem>
687
- </Tab>
688
- ```
689
- `layout`: `'default' | 'top' | 'left' | 'right' | 'bottom'`
690
- Le schede sono `<TabItem>`, export separato: `Tab.Item` non esiste.
691
-
692
- ### Image
693
- ```tsx
694
- <Image src="..." fit="cover" position="center" width={400} height={300} />
695
- ```
696
- `fit`: `'cover' | 'contain' | 'fill' | 'scale-down' | 'none'`
697
-
698
- ### Pagination (standalone)
699
- ```tsx
700
- <Pagination records={items} limit={20}>
701
- {(pageRecords, offset) => pageRecords.map(r => <Row key={r._key} record={r} />)}
702
- </Pagination>
703
- ```
704
-
705
- ### Percentage
706
- ```tsx
707
- <Percentage value={75} appearance="bar" variant="success" showText />
708
- <Percentage value={0.75} appearance="circle" size={60} />
709
- ```
710
-
711
- | Prop | Tipo | Default |
712
- |---|---|---|
713
- | `value` | `number` | — |
714
- | `max` | `number` | `100` |
715
- | `min` | `number` | — |
716
- | `appearance` | `'bar' \| 'circle'` | `'bar'` |
717
- | `variant` | `ColorType` | — |
718
- | `trackVariant` | `ColorType` | — |
719
- | `thickness` | `number` | — (px) |
720
- | `showText` | `boolean` | — |
721
- | `size` | `number` | Diametro in modalità `'circle'` (px) |
722
- | `fontSize` | `number` | — |
723
- | `label` | `string` | — |
724
-
725
- ### DescriptionList
726
- 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>`.
727
- ```tsx
728
- <DescriptionList items={[{ label: 'Email', value: 'a@b.it' }, { label: 'Telefono', value: null }]} />
729
- <DescriptionList layout="stacked" columns={4} truncate items={[{ label: 'Stato', value: <Badge variant="success">Spedito</Badge> }]} />
730
- ```
731
-
732
- | Prop | Tipo | Default |
733
- |---|---|---|
734
- | `items` | `DescriptionListItem[]` — `{ key?, label, value?, title? }` | — |
735
- | `layout` | `'horizontal' \| 'stacked'` | `'horizontal'` (etichette a sinistra, impilate su mobile) |
736
- | `columns` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `1` (solo `stacked`, dal breakpoint `lg`) |
737
- | `labelWidth` | `string` (lunghezza CSS) | `'12rem'` (solo `horizontal`) |
738
- | `emptyValue` | `ReactNode` | `'—'` per `null` / `undefined` / `''` (lo `0` resta) |
739
- | `truncate` | `boolean` | `false`; un valore testuale diventa il suo `title` |
740
- | Classi | `wrapperClassName`, `className`, `itemClassName`, `labelClassName`, `valueClassName` | tema `DescriptionList` |
741
-
742
- ### Tree
743
- 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.
744
- ```tsx
745
- <Tree nodes={treeFromPaths(paths)} defaultExpanded selectedId={path} onSelect={(node) => setPath(node.id)} ariaLabel="File" />
746
- <Tree nodes={[{ id: 'a', label: 'Docs', children: [{ id: 'a/1', label: 'README.md', badge: <Badge>new</Badge> }] }]} />
747
- ```
748
- | Prop | Tipo | Note |
749
- |---|---|---|
750
- | `nodes` | `TreeNode[]` — `{ id, label, icon?, badge?, description?, children?, disabled? }` | `id` unico; con `children` è una cartella |
751
- | `selectedId` + `onSelect` / `defaultSelectedId` | `string \| null` | selezione controllata o no |
752
- | `expanded` + `onExpandedChange` / `defaultExpanded` | `string[]`; `defaultExpanded` anche `true` (tutto aperto) | apertura controllata o no |
753
- | `selectableBranches` | `boolean` (default `false`) | anche le cartelle si selezionano |
754
- | `ariaLabel` | `string` | nome accessibile |
755
- | Classi | `wrapperClassName`, `className`, `itemClassName`, `selectedClassName` | tema `Tree` |
756
-
757
- `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…).
758
-
759
- ### Dropdown
760
- ```tsx
761
- <Dropdown trigger={<Icon name="menu" />} position="end">
762
- <DropdownItem label="Modifica" icon="edit" onClick={() => {}} />
763
- <DropdownItem label="Elimina" icon="trash" variant="danger" />
764
- <DropdownDivider />
765
- <DropdownHeader label="Azioni rapide" />
766
- </Dropdown>
767
- ```
768
-
769
- Azioni secondarie di una voce (CR-107): `actions` mostra icone in fondo alla riga, al passaggio
770
- del mouse o al focus (sempre sui dispositivi touch). Non selezionano la voce e lasciano il menu aperto
771
- salvo `closeOnClick`.
772
-
773
- ```tsx
774
- <DropdownItem icon="messages-square" onClick={() => open(chat)}
775
- actions={[{ icon: 'archive', label: 'Archivia', onClick: () => archive(chat) }]}>
776
- {chat.title}
777
- </DropdownItem>
778
- ```
779
-
780
- Per elenchi caricati dinamicamente usare `AsyncDropdown`: gestisce ricerca con debounce,
781
- cancellazione delle richieste precedenti tramite `AbortSignal` e stati loading/empty/error.
782
-
783
- ```tsx
784
- <AsyncDropdown
785
- trigger={<ActionButton label="Select user" />}
786
- loadItems={(query, signal) => users.search(query, { signal })}
787
- getItemId={user => user.id}
788
- renderItem={user => user.name}
789
- onSelect={user => setUser(user)}
790
- selectedId={user?.id}
791
- />
792
- ```
793
-
794
- ### ContextMenu
795
- ```tsx
796
- <ContextMenu trigger="/" searchable>
797
- <ContextMenu.Heading>Comandi</ContextMenu.Heading>
798
- <ContextMenu.Item label="Titolo" value="# " icon="heading" />
799
- <ContextMenu.Separator />
800
- <ContextMenu.Item label="Bold" value="**bold**" icon="bold" />
801
- </ContextMenu>
802
- ```
803
- Funziona su qualsiasi `<textarea>` / `<input>` figlio. Trigger custom (`/`, `@`, `{`), filtro searchable, navigazione tastiera, floating positioning.
804
-
805
- | Prop | Tipo | Note |
806
- |---|---|---|
807
- | `trigger` | `string` (required) | Es. `"/"`, `"@"`, `"{"` |
808
- | `searchable` | `boolean` | Filtro testuale |
809
- | `controlled` | `ContextMenuControlledState` | Stato controllato esterno |
810
- | `onSelect` | `(item, context) => void` | |
811
-
812
- **EditorCommand** (usato da `commands` prop su `RichText`, `CodeEditor`, `Prompt`):
813
- ```ts
814
- {
815
- name: string;
816
- description?: string;
817
- icon?: string;
818
- group?: string;
819
- consumeSuffix?: string;
820
- handler?: (context: TextCommandContext) => string | Promise<string>;
821
- }
822
- ```
823
-
824
- `consumeSuffix` elimina una chiusura automatica adiacente durante la sostituzione del trigger,
825
- utile quando un comando cambia famiglia sintattica (per esempio da `{{ ... }}` a `{% ... %}`).
826
-
827
- ### Altre primitive
828
- ```tsx
829
- <Icon name="user" size={20} /> // usa provider icone configurato
830
- <ImageAvatar src="..." title="Mario" alt="Mario" width={40} height={40} />
831
- <Identicon value="acme/app" size={32} title="acme/app" /> // icona generata dal valore, stile GitHub
832
- <Loader show={isLoading}>{content}</Loader> // indicatore al posto dei figli finché show è true
833
- <Loader show>{null}</Loader> // solo indicatore (es. pagina in caricamento)
834
- <Code language="tsx">{snippet}</Code> // syntax highlight
835
- <GridSystem.Row><GridSystem.Col xs={12} md={6}>...</GridSystem.Col></GridSystem.Row>
836
- <LocaleSwitcher locales={[{ code: 'it', label: 'Italiano' }]} />
837
- <ThemeSwitcher />
838
- <ProviderSwitcher />
839
- <LayoutBuilder components={...} />
840
- ```
841
-
842
- ---
843
-
844
- ## Blocks
845
-
846
- ```tsx
847
- <Breadcrumbs trail={[{ label: 'Home', href: '/' }, { label: 'Utenti' }]} separator="/" jsonLd />
848
- <Brand url="/" label="MyApp" logo="/logo.svg" width={120} />
849
- <Search onQueryChange={(e) => setQ(e.target.value)} />
850
- <Notifications items={[...]} seeAllUrl="/notifications" onMarkAllRead={() => {}} onItemClick={(item) => markRead(item.id)} />
851
- <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' }] }]} />
852
- <Carousel images={[...]} />
853
- <Toolbar items={[...]}>
854
- {children}
855
- </Toolbar>
856
- <SideNav />
857
- <ListCard records={records} onRowClick={(r) => {}} />
858
- ```
859
-
860
- **Brand:**
861
- | Prop | Tipo |
862
- |---|---|
863
- | `url` | `string` |
864
- | `label` | `string` |
865
- | `logo` | `string` |
866
- | `width` / `height` | `number` |
867
- | `wrapperClassName` / `className` / `logoClassName` / `labelClassName` | `string` |
868
-
869
- **SideNav:**
870
- | Prop | Tipo |
871
- |---|---|
872
- | `items` | `SideNavItemDef[]` (in alternativa a `menuKey`) |
873
- | `menuKey` | `string` — voci dal menu registrato in `<App>` |
874
- | `bottomItems` | `SideNavItemDef[]` — voci fissate in fondo, sopra il footer e fuori dalla lista scorrevole (es. Impostazioni) |
875
- | `header` / `footer` | `ReactNode` |
876
- | `collapsed` + `onCollapsedChange` / `defaultCollapsed` | `boolean` — compressione controllata o no |
877
- | `showIcons` / `showCollapseButton` | `boolean` (default `true`) |
878
- | `embedded` | `boolean` — solo le voci, senza guscio (drawer, pannelli); `bottomItems` seguono le voci principali |
879
-
880
- `SideNavItemDef`: `{ path, title?, icon?, group?, end?, badge?, children? }`. La voce attiva ha
881
- `aria-current="page"`.
882
-
883
- ```tsx
884
- <SideNav
885
- items={[{ path: '/dashboard', title: 'Dashboard', icon: 'home' }]}
886
- bottomItems={[{ path: '/settings', title: 'Impostazioni', icon: 'settings' }]}
887
- />
888
- ```
889
-
890
- **ImageAvatar:**
891
- | Prop | Tipo |
892
- |---|---|
893
- | `src` | `string` (obbligatoria) |
894
- | `width` / `height` | `number` |
895
- | `title` / `alt` | `string` |
896
- | `fit` | `AvatarFit` |
897
- | `badge` | `BadgeDescriptor` |
898
- | `feedback` | `ReactNode` |
899
-
900
- **Identicon:** l'icona di un'entità senza immagine (repository, tenant, utente senza foto), come quelle di
901
- GitHub: griglia 5×5 simmetrica, celle e tinta da un hash di `value`. Lo stesso valore dà sempre la stessa
902
- icona; niente rete né storage. Default: `rounded-md bg-muted` (tema `Identicon.className`).
903
- `identiconPattern(value)` restituisce celle e tinta, per chi la disegna altrove.
904
- | Prop | Tipo |
905
- |---|---|
906
- | `value` | `string` (obbligatoria) |
907
- | `size` | `number` (px, default 40) |
908
- | `title` | `string`: nome accessibile (`role="img"`); senza, `aria-hidden` |
909
- | `before` / `after` / `className` / `wrapperClassName` | come le altre primitive |
910
-
911
- **Search:**
912
- | Prop | Tipo |
913
- |---|---|
914
- | `onQueryChange` | `(e: React.ChangeEvent<HTMLInputElement>) => void` |
915
-
916
- **Notifications:**
917
- | Prop | Tipo |
918
- |---|---|
919
- | `items` | `NotificationItem[]` |
920
- | `badge` | `BadgeProps` |
921
- | `seeAllUrl` | `string` |
922
- | `onMarkAllRead` | `() => void` |
923
- | `onItemClick` | `(item, index) => void` — una voce aperta (es. per segnarla letta); il menu si chiude |
924
- | `motion` | `MotionReference` |
925
-
926
- `NotificationItem`: `{ id?, title, description?, url?, external?, time, icon, read?, actions? }`. `description` è la
927
- riga sotto il titolo; senza `url` la voce si mostra e basta; con `external` l'`url` è un indirizzo aperto
928
- in una nuova scheda (`<a target="_blank">`), altrimenti una rotta (`Link`). `actions`
929
- (`NotificationAction[]`: `{ label, icon?, onClick, variant?: 'primary' | 'default', closeOnClick? }`) sono
930
- pulsanti sotto la voce: non la aprono, mostrano il caricamento finché la promessa di `onClick` è in corso
931
- e lasciano il menu aperto salvo `closeOnClick`.
932
-
933
- **ListCard:**
934
- | Prop | Tipo |
935
- |---|---|
936
- | `title` / `description` / `meta` | `ReactNode` |
937
- | `badge` | `ReactNode` |
938
- | `icon` | `string \| ReactNode` |
939
- | `trailing` / `footer` / `children` | `ReactNode` |
940
- | `href` | `string` |
941
- | `external` / `active` / `disabled` / `dashed` / `compact` | `boolean` |
942
- | `align` | `'start' \| 'center'` |
943
- | `onClick` | `(e) => void` |
944
- | Classi CSS: `titleClassName`, `descriptionClassName`, `metaClassName`, `badgeClassName`, `leadingClassName`, `bodyClassName`, `headerClassName`, `trailingClassName`, `footerClassName` | `string` |
945
-
946
- **Dropdown:**
947
- | Prop | Tipo |
948
- |---|---|
949
- | `trigger` | `string \| ReactNode \| DropdownTogglerProps` |
950
- | `badge` | `BadgeProps` |
951
- | `header` / `footer` | `ReactNode` |
952
- | `defaultOpen` / `open` | `boolean` |
953
- | `onOpenChange` | `(open) => void` |
954
- | `position` | `'start' \| 'end'` |
955
- | `placement` | `'auto' \| 'top' \| 'bottom'` |
956
- | `strategy` | `'fixed' \| 'absolute'` |
957
-
958
- // Menu — renderizza una sezione del menuConfig passato ad <App>
959
- <Menu menuKey="main" /> // 'main' o qualsiasi chiave definita in MenuConfig
960
- <Menu menuKey="topbar" className="nav flex-row gap-2" />
961
- // NON usare context= — la prop si chiama menuKey
962
-
963
- // AuthButton — login/logout con il provider auth configurato
964
- <AuthButton />
965
- ```
966
-
967
- ---
968
-
969
- ## Widgets
970
-
971
- ### MarkdownReader
972
- ```tsx
973
- <MarkdownReader
974
- content={markdownString}
975
- onInternalLinkClick={(href, e) => { e.preventDefault(); navigate(href) }}
976
- wrapperClassName="prose"
977
- />
978
- ```
979
-
980
- ### ImageEditor
981
- ```tsx
982
- <ImageEditor src="..." onSave={(dataUrl) => save(dataUrl)} />
983
- ```
984
-
985
- `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.
986
-
987
- ### TabDynamic
988
- Tab con lazy loading dei contenuti — gestione dinamica aggiunta/rimozione tab.
989
-
990
- ---
991
-
992
- ## AI Integration
993
-
994
- ```ts
995
- import { useAIProvider, getAIModelCatalog, createAIProviderRegistry } from '@llmnative/react'
996
-
997
- // Hooks
998
- const provider = useAIProvider() // provider di default
999
- const registry = useAIProviderRegistry() // tutti i provider AI registrati
1000
- const catalog = getAIModelCatalog() // modelli disponibili
1001
- // ogni modello: { id, provider, model, label, pricing? } — pricing in USD per 1M token
1002
- // { input, output, currency: 'USD', source: 'provider' | 'models.dev' }; assente = prezzo
1003
- // non trovato (il modello resta in lista). isFreeAIModel(m) → input e output a 0.
1004
- // Il model picker di Prompt/Chatbot mostra "$in / $out", evidenzia i free, segnala i mancanti,
1005
- // e raggruppa per provider (ChatbotModelOption.group = providerLabel) con header sticky.
1006
-
1007
- // Provider adapter restituito da useAIProvider()
1008
- const text = await provider.fetch("Scrivi un titolo per: {keyword}", { keyword: "React" })
1009
- const json = await provider.json("Lista di 5 categorie per blog tech")
1010
- const arr = await provider.array("5 tag per: machine learning")
1011
-
1012
- // Provider custom
1013
- class MyAIProvider implements AIProviderAdapter {
1014
- async fetch(prompt: string, options?: AIRequestOptions): Promise<string> { ... }
1015
- async json<T>(prompt: string, options?: AIRequestOptions): Promise<T> { ... }
1016
- async array(prompt: string, options?: AIRequestOptions): Promise<string[]> { ... }
1017
- }
1018
-
1019
- // Factory per registri esterni
1020
- const registry = createAIProviderRegistry(definitions)
1021
- // Azioni di Chatbot (CR-107): con `content` aprono un menu; senza, sono pulsanti con `onClick`.
1022
- // <Chatbot actions={[{ key: 'finish', icon: 'git-pull-request', label: 'Concludi', showLabel: true,
1023
- // disabled: running, onClick: finish }]} … />
1024
- ```
1025
-
1026
- ### Identità dei modelli indipendente dal provider (CR-103)
1027
-
1028
- ```ts
1029
- const index = await loadModelIdentityIndex() // models.dev, un fetch per sessione
1030
- canonicalModelName('DeepSeek V4.1 Flash (Alibaba Cloud)') // 'deepseek v4.1 flash'
1031
- index.models['deepseek v4.1 flash'] // { key, name, family?, providers: { openrouter: [{ id, name }], … } }
1032
- resolveModel(index, 'DeepSeek V4.1 Flash', ['openrouter', 'deepseek'])
1033
- // → { provider: 'openrouter', model: 'deepseek/deepseek-v4.1-flash', ref: 'openrouter/deepseek/deepseek-v4.1-flash' }
1034
- ```
1035
-
1036
- Il nome canonico toglie data, Free, Latest e host o regione tra parentesi; una modalità
1037
- ("(Thinking)", "(Reasoning)", "(Fast)") resta. Per ogni provider l'id preferito è quello stabile (non
1038
- alias `~`, non free tier, non datato). I provider sono le chiavi di models.dev, le stesse di opencode.
1039
- Puro: anche da `@llmnative/react/server`.
1040
-
1041
- ### Chiavi tenute dal server (CR-102)
1042
-
1043
- Per un'app che non mette mai le chiavi dei provider nel browser. Il browser conosce solo quali
1044
- provider sono collegati; il proxy dell'app mette la chiave.
1045
-
1046
- ```ts
1047
- // Browser: gli stessi adapter, con un segnaposto dove andrebbe la chiave. Serve il proxy attivo.
1048
- const registry = createServerCredentialAIRegistry(
1049
- [{ id: 'anthropic' }, { id: 'cloudflare', accountId }, { id: 'openai-compatible', baseUrl }],
1050
- { scope: companyId }, // lettere, cifre, - e _
1051
- )
1052
- Object.entries(registry).forEach(([id, adapter]) => setProvider('ai', id, adapter))
1053
-
1054
- // Server (Node, nessuna dipendenza da React): nel proxy, prima di inoltrare.
1055
- import { resolveServerCredentials } from '@llmnative/react/server'
1056
- const resolved = await resolveServerCredentials({ url: target, headers },
1057
- (scope, provider) => keysOf(scope)[provider]) // { id, apiKey, accountId?, baseUrl? } | undefined
1058
- if (!resolved.ok) return reply(resolved.status, resolved.error) // 404 non collegato, 403 host estraneo
1059
- await fetch(resolved.url, { headers: resolved.headers, ... })
1060
- ```
1061
-
1062
- La chiave va solo agli host del provider (`AI_PROVIDER_HOSTS`, il `baseUrl` di openai-compatible,
1063
- l'account di Cloudflare): una destinazione diversa è rifiutata. `createProxyPlugin(route, log,
1064
- lookup)` fa lo stesso nel proxy di sviluppo di Vite. Senza proxy, `proxyFetch` rifiuta di inviare
1065
- un segnaposto. `createAIProviderRegistry` (chiavi nel browser) resta com'è.
1066
-
1067
- 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`).
1068
-
1069
- ---
1070
-
1071
- ## Theme e icone
1072
-
1073
- ```tsx
1074
- const theme = useThemeController()
1075
- theme.applyTheme('flat') // BuiltInThemeId ('default' | 'flat' | 'cyber' | 'vscode-dark-modern' | …) | custom
1076
- theme.setMode('dark') // 'light' | 'dark' | 'system'
1077
-
1078
- const icons = useIconController()
1079
- icons.setProvider('phosphor') // 'lucide' | 'phosphor' | custom
1080
- ```
1081
-
1082
- ---
1083
-
1084
- ## Head (SEO)
1085
-
1086
- ```ts
1087
- useHead({
1088
- title: 'Pagina',
1089
- description: '...',
1090
- robots: 'noindex',
1091
- canonical: 'https://...',
1092
- og: { title: '...', image: '...' }
1093
- })
1094
- ```
1095
-
1096
- ---
1097
-
1098
- ## Global state (localStorage)
1099
-
1100
- ```ts
1101
- const vars = useGlobalVars()
1102
- getGlobalVars('key')
1103
- setGlobalVars({ key: 'value' })
1104
- removeGlobalVars('key')
1105
- ```
1106
-
1107
- ---
1108
-
1109
- ## Hooks completi
1110
-
1111
- | Hook | Ritorna |
1112
- |---|---|
1113
- | `useDataProvider(name?)` | `DataProviderAdapter \| null` |
1114
- | `useStorageProvider(name?)` | `StorageProviderAdapter \| null` |
1115
- | `useAuthProvider(name?)` | `AuthProviderAdapter \| null` |
1116
- | `useEmailProvider(name?)` | `EmailProviderAdapter \| null` |
1117
- | `useAIProvider(name?)` | `AIProviderAdapter \| null` |
1118
- | `useAIProviderRegistry()` | `Record<string, AIProviderAdapter>` |
1119
- | `useCredentialsProvider()` | `CredentialsAdapter \| null` |
1120
- | `useTheme()` | `Theme \| undefined` |
1121
- | `useThemeController()` | `ThemeController` |
1122
- | `useIconController()` | `IconController` |
1123
- | `useAccessToken(clientID, renew?)` | `boolean` | Ottiene token per servizio esterno (true = ok) |
1124
- | `useGlobalVars()` | `Record<string, unknown>` |
1125
- | `useHead()` | `HeadController` |
1126
- | `useDocumentHead()` | `HeadController` |
1127
- | `useAssetsHead(title?)` | `void` |
1128
- | `useLanguageHead(lang?)` | `void` |
1129
- | `useSocialHead(config)` | `void` |
1130
- | `usePaginationHead(config)` | `void` |
1131
- | `usePwaHead(config)` | `void` |
1132
- | `useSchemaOrgHead(config)` | `void` |
1133
- | `useHeadLinks(links)` | `void` |
1134
- | `useProxy()` | `(url, init?) => Promise<Response>` |
1135
- | `useImage()` | `UseImageResult` |
1136
- | `useI18n()` | `I18nController` |
1137
- | `useMenu()` | Menu hook |
1138
- | `useFormController(controller?)` | `FormController` |
1139
- | `useFormActions()` | `FormController` |
1140
- | `useApiProvider(pathname?)` | `ApiProviderAdapter \| null` |
1141
- | `useSetProvider(service, driver?)` | `void` |
1142
- | `useProvider(name)` | `ProviderConfigurationState` |
1143
- | `useProviderSession()` | `ProviderSession` |
1144
- | `useDropBoxConnect(config?)` | `DropBoxConnectResult` |
1145
- | `useEnterMotion(baseStyle?, ref?, fallback?)` | `CSSProperties` |
1146
- | `useMotionState(active, ref?, fallback?, baseStyle?)` | `CSSProperties` |
1147
- | `useMotionEffect(ref?, fallback?)` | `ResolvedMotionEffect` |
1148
- | `usePressMotion(hovered, baseStyle?, ref?, fallback?)` | `{ style, pressHandlers }` |
1149
- | `useIconProvider()` | `IconProvider` |
1150
-
1151
- ---
1152
-
1153
- ## Tipi chiave
1154
-
1155
- ```ts
1156
- type RecordProps = Record<string, FieldValue>
1157
- type FieldValue = string | number | boolean | null | undefined | Record<string, unknown> | unknown[]
1158
- type RecordArray = RecordProps[]
1159
-
1160
- type OrderConfig = { field: string; dir: 'asc' | 'desc' }
1161
- type WhereClause = { field: string; op: '==' | '!=' | '<' | '<=' | '>' | '>=' | 'in' | 'not-in' | 'array-contains' | 'array-contains-any'; value: unknown }
1162
-
1163
- interface UIProps {
1164
- before?: React.ReactNode
1165
- after?: React.ReactNode
1166
- wrapperClassName?: string
1167
- className?: string
1168
- }
1169
-
1170
- type UserProfile = { uid?: string; email?: string; displayName?: string; photoURL?: string; [key: string]: unknown }
1171
- ```
1172
-
1173
- ---
1174
-
1175
- ## Utilities
1176
-
1177
- ```ts
1178
- import { cn, converter, Sanitizer, Order, path, fetchJson, fetchRest, fetchWithRetry } from '@llmnative/react'
1179
- import { getKeywordIdeas, getGoogleTrendsData, getGoogleTrendsRelated } from '@llmnative/react'
1180
-
1181
- cn('base', condition && 'extra', className) // tailwind-merge classname
1182
- new Sanitizer().sanitize(html) // XSS sanitizer (non usare dangerouslySetInnerHTML)
1183
- new Order().order(records, { field: 'name', dir: 'asc' }) // ordina record array
1184
- fetchJson(url, init?) // fetch + JSON parse + error handling
1185
- fetchRest(path, init?) // fetch REST con auth
1186
- getKeywordIdeas(keyword) // Google Ads Keyword Ideas
1187
- getGoogleTrendsData(keyword) // Google Trends interest over time
1188
- getGoogleTrendsRelated(keyword) // Google Trends related queries
1189
- ```
1190
-
1191
- ---
1192
-
1193
- ## Scaffold (npx create)
1194
-
1195
- ```bash
1196
- npx @llmnative/react create
1197
- npx @llmnative/react create --yes --provider=mock --template=admin
1198
- ```
1199
-
1200
- Flag disponibili: `--name` `--provider=firebase|supabase|mock|custom` `--ai-provider` `--icon-provider` `--theme` `--template=blank|admin|crm|inventory|project`
1201
-
1202
- **5 template disponibili:**
1203
- | Template | Pagine incluse | Uso tipico |
1204
- |---|---|---|
1205
- | `blank` | home | App vuota — massima libertà |
1206
- | `admin` | home, users, roles, settings | Dashboard admin/backoffice |
1207
- | `crm` | home, companies, contacts, deals | CRM |
1208
- | `inventory` | home + gestione inventario | Magazzino |
1209
- | `project` | home + gestione progetti | PM tool |
1210
-
1211
- **Struttura generata in-place nella current directory:**
1212
- ```
1213
- src/
1214
- index.tsx ← App entry
1215
- vite-env.d.ts
1216
- conf/
1217
- app.ts ← providers config letta da .env (VITE_PROVIDER, VITE_AI_PROVIDER...)
1218
- menu.ts ← MenuConfig con path, title, icon, page (layout opzionale: c'è LayoutDefault)
1219
- data/
1220
- mockData.ts ← dati fake per MockDataProvider
1221
- layouts/
1222
- Default.tsx ← layout wrapper (header + sidebar + main + footer)
1223
- sections/
1224
- Header.tsx / Sidebar.tsx / PageHeader.tsx / Footer.tsx ← solo componenti pubblici (SideNav, AuthButton, ActionButton, Brand, Breadcrumbs)
1225
- pages/
1226
- home/HomePage.tsx
1227
- [altre pagine dal template scelto]
1228
- styles/
1229
- globals.css ← @import '@llmnative/react/dist/index.css' + Tailwind override
1230
- .env ← VITE_PROVIDER, VITE_THEME, chiavi API (non committare)
1231
- .env.example ← stessa struttura, valori vuoti (committare)
1232
- vite.config.ts / tsconfig.json / postcss.config.js / index.html
1233
- ```
1234
-
1235
- **Pattern conf/app.ts** (auto-generato, leggere per capire come funziona):
1236
- ```ts
1237
- import type { AppProvidersConfig } from '@llmnative/react'
1238
- import { mockData } from '../data/mockData'
1239
- const env = import.meta.env
1240
-
1241
- export const providers: AppProvidersConfig = {
1242
- mock: { data: mockData },
1243
- ai: { openaiApiKey: env.VITE_OPENAI_API_KEY ?? '' }, // AI dentro providers, non prop <App>
1244
- services: { data: 'mock', auth: 'googleAuth' },
1245
- }
1246
- ```
1247
-
1248
- Driver per `VITE_PROVIDER`: `firebase` → `firestoreDb` + `firestorage` + `firebaseAuth`; `supabase` → `supabaseDb` + `supabaseStorage` + `supabaseAuth`; `mock` → `mock` + `googleAuth`. `index.tsx` passa `LayoutDefault={Default}` ad `<App>`.
1249
-
1250
- ---
1251
-
1252
- ## Gotchas — pitfall frequenti
1253
-
1254
- | Errore | Causa | Fix |
1255
- |---|---|---|
1256
- | `aiConfig` prop non esiste su `<App>` | La config AI va dentro `providers.ai`, NON come prop separata | `providers={{ ai: { openaiApiKey } }}` |
1257
- | `inputType` o `Input.Number`/`Input.Email` non esistono | Il tipo HTML è la prop `type` | `<Input type="number">`, `<Input type="email">`, `<Input type="date">` |
1258
- | `Menu context=` non esiste | La prop si chiama `menuKey` | `<Menu menuKey="main" />` |
1259
- | `SignInButton` non esiste | Rinominato | `<AuthButton />` |
1260
- | `firebase: { config: ... }` errato | FirebaseConfig è flat | `firebase: { apiKey, authDomain, databaseURL, ... }` |
1261
- | `google: { oAuth2: { clientId } }` errato | GoogleProviderConfig è flat | `google: { clientId, scope }` |
1262
- | `children` su `Grid` | Grid non accetta children | Usa `form={<Fields />}` per il form inline |
1263
- | `recordId` su `Form` | Prop non esiste | La chiave del record viene da URL automaticamente |
1264
- | `MenuConfig` non importabile | Era type interno | Ora esportato: `import type { MenuConfig } from '@llmnative/react'` |
1265
- | `GridCellContext` non importabile | Mancava dall'index | Ora esportato: `import type { GridCellContext } from '@llmnative/react'` |
1266
-
1267
- ---
1268
-
1269
- ## Dove cercare nel codice sorgente
1270
-
1271
- | Cosa | File |
1272
- |---|---|
1273
- | Form | `src/components/widgets/Form.tsx` |
1274
- | Grid | `src/components/widgets/Grid.tsx` |
1275
- | Prompt | `src/components/widgets/Prompt.tsx` |
1276
- | Campi form | `src/components/ui/fields/` |
1277
- | Blocks (Menu, Toolbar, Notifications, Dropdown…) | `src/components/blocks/` |
1278
- | Provider data Firebase (RTDB) | `src/providers/data/firebase.ts` |
1279
- | Provider data Firestore | `src/providers/data/firestore.ts` |
1280
- | Provider data Supabase | `src/providers/data/supabase.ts` |
1281
- | Provider storage Firebase | `src/providers/storage/firebase.ts` |
1282
- | Provider storage Supabase | `src/providers/storage/supabase.ts` |
1283
- | Provider auth Firebase | `src/providers/auth/firebase/FirebaseAuthProvider.ts` |
1284
- | Provider auth Supabase | `src/providers/auth/supabase/` |
1285
- | Provider auth Google | `src/providers/auth/google/` |
1286
- | Provider auth Dropbox | `src/providers/auth/dropbox/` |
1287
- | Provider email Gmail | `src/providers/email/google/GmailEmailProvider.ts` |
1288
- | Provider AI definitions | `src/providers/ai/` |
1289
- | Provider credenziali | `src/providers/credentials/` |
1290
- | Provider session | `src/providers/ProviderSession.ts` |
1291
- | Proxy CORS | `src/providers/proxy.ts` |
1292
- | Motion system | `src/motion.ts` |
1293
- | I18n | `src/I18n.tsx` |
1294
- | Dropbox integration | `src/providers/dropbox/` |
1295
- | Interfaccia DataProvider | `src/providers/data/DataProvider.ts` |
1296
- | Interfaccia StorageProvider | `src/providers/storage/StorageProvider.ts` |
1297
- | Interfaccia AuthProvider | `src/providers/auth/AuthProvider.ts` |
1298
- | Interfaccia EmailProvider | `src/providers/email/EmailProvider.ts` |
1299
- | Interfaccia AIProvider | `src/providers/ai/AIProvider.ts` |
1300
- | Registry provider | `src/providers/manifest.ts` |
1301
- | Config provider | `src/providers/ProviderConfiguration.ts` |
1302
- | SEO / Google Trends | `src/providers/seo/google/` |
1303
- | Sistema tema | `src/Theme.tsx` |
1304
- | Config runtime | `src/Config.tsx` |
1305
- | Tipi TypeScript | `src/types/` |
1306
- | 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)
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`, `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
+ | `children` | `ReactNode \| ((fields) => ReactNode) \| (({ record }) => ReactNode)` | Contenuto form |
309
+ | `header` | `ReactNode` | Contenuto header custom |
310
+ | `footer` | `ReactNode` | Contenuto footer custom |
311
+ | `wrapperClassName` | `string` | CSS wrapper esterno |
312
+ | `headerClassName` | `string` | CSS header |
313
+ | `className` | `string` | CSS body form |
314
+ | `footerClassName` | `string` | CSS footer |
315
+ | `log` | `boolean` | Logga ogni modifica su console |
316
+ | `onLoad` | `(record) => void` | Side-effect dopo il caricamento (non trasforma) |
317
+ | `onRecordChange` | `(record) => void` | Cambio campo in tempo reale |
318
+ | `onSave` | `FormSaveHandler` | `async ({ record, prevRecord?, storagePath?, action }) => string \| undefined` |
319
+ | `onDelete` | `FormDeleteHandler` | `async ({ record }) => string \| undefined` |
320
+ | `onComplete` | `FormFinallyHandler` | `async ({ record, action }) => boolean` |
321
+
322
+ **Form controller** — stato e azioni native del form accessibili da qualsiasi punto della pagina.
323
+
324
+ Due hook:
325
+
326
+ | Hook | Dove usarlo | Cosa restituisce |
327
+ |---|---|---|
328
+ | `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. |
329
+ | `useFormActions()` | Solo dentro `<Form>` (legge dal context) | Restituisce lo stesso `FormController` del form genitore. Lancia errore se usato fuori. |
330
+
331
+ Esempio — bottone custom fuori dal form:
332
+ ```tsx
333
+ const form = useFormController();
334
+
335
+ <ActionButton disabled={form.saveDisabled} onClick={() => { void form.save(); }} />
336
+ <Form controller={form} draftBucket="workspace/acme" onSave={...} />
337
+ ```
338
+
339
+ Esempio — uso dentro il form (senza passare controller):
340
+ ```tsx
341
+ function MySaveButton() {
342
+ const form = useFormActions();
343
+ return <LoadingButton disabled={form.saveDisabled} loading={form.isSaving} onClick={form.save} />;
344
+ }
345
+ ```
346
+
347
+ Stato esposto dal controller: `record`, `isNewRecord`, `isDirty`, `canSave`, `canDelete`, `isSaving`, `isDeleting`, `saveDisabled`, `deleteDisabled`, `hasDraft`, `draftStatus`, `errors`, `hasErrors`.
348
+
349
+ Azioni: `save()`, `delete()`, `reset()`, `restoreDraft()`, `discardDraft()`.
350
+
351
+ ---
352
+
353
+ ## Campi form
354
+
355
+ ### Input
356
+ ```tsx
357
+ <Input name="x" label="..." required /> // text (default)
358
+ <Input name="price" type="number" min={0} max={100} step={0.01} />
359
+ <Input name="email" type="email" />
360
+ <Input name="pwd" type="password" />
361
+ <Input name="date" type="date" />
362
+ <Input name="color" type="color" />
363
+ <TextArea name="bio" minHeight={96} />
364
+ <Checkbox name="active" valueChecked="yes" />
365
+ ```
366
+ `Input` props: `name`(req) `label` `type` `required` `disabled` `placeholder` `min` `max` `step` `validator:(v)=>string|undefined`
367
+ `type`: `'text' | 'number' | 'email' | 'password' | 'color' | 'date' | 'time' | 'datetime-local' | 'week' | 'month' | 'range' | 'checkbox' | 'radio' | 'url'`
368
+
369
+ **NOTA**: il tipo HTML si passa con `type`. `inputType` e i sotto-componenti `Input.Number`/`Input.Email`/`Input.Date` non esistono.
370
+
371
+ **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)`.
372
+ ```tsx
373
+ <Select name="skill" required options={skills} label={<>Skill <ActionButton icon="info" ariaLabel="Apri la skill" variant="link" onClick={openSkill} /></>} />
374
+ ```
375
+
376
+ ### Select / Autocomplete / Checklist
377
+ ```tsx
378
+ <Select name="role" options={[{ label: 'Admin', value: 'admin' }]} />
379
+ <Select name="categoryId" optionsSource={{ path: '/categories', labelField: 'name', valueField: '_key' }} />
380
+ <Autocomplete name="userId" optionsSource={{ path: '/users', labelField: 'email' }} maxItems={1} />
381
+ <Checklist name="tags" options={['a', 'b', 'c']} />
382
+ ```
383
+
384
+ | Prop | Tipo | Note |
385
+ |---|---|---|
386
+ | `options` | `Option[] \| string[] \| number[]` | |
387
+ | `optionsSource` | `{ path, labelField, valueField?, where?, order?, fieldMap?, onLoad? }` | Carica da provider |
388
+ | `order` | `{ field: 'label' \| 'value', dir: 'asc' \| 'desc' }` | |
389
+ | `placeholderOption` | `Option` | Solo Select |
390
+ | `placeholder` | `string` | Solo Autocomplete |
391
+ | `creatable` | `boolean` | Solo Autocomplete — crea nuovi valori |
392
+ | `onCreate` | `(value) => void \| boolean \| Promise<void \| boolean>` | Solo Autocomplete — restituire `false` per non selezionare il valore creato (es. aprire un modulo di creazione) |
393
+ | `listClassName` / `optionClassName` | `string` | Solo Autocomplete — classi della lista e delle voci (anche da tema `Autocomplete.listClassName` / `optionClassName`) |
394
+ | `minItems` / `maxItems` | `number` | Solo Autocomplete |
395
+ | `itemClassName` | `string` | Solo Checklist |
396
+ | `validator` | `(value) => string \| undefined` | Tutte le varianti |
397
+ | `readOnlyAfterSet` | `boolean` | readonly dopo primo valore |
398
+ | `disabled` | `boolean` | |
399
+ | `title` | `string` | |
400
+ | `feedback` | `string` | Messaggio di errore/suggerimento |
401
+
402
+ `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.
403
+
404
+ ### UploadImage / UploadDocument / UploadCSV
405
+ ```tsx
406
+ <UploadImage name="cover" label="Copertina" /> {/* con crop editor */}
407
+ <UploadDocument name="pdf" label="PDF" />
408
+ <UploadCSV name="data" label="Importa CSV" /> {/* con preview tabellare */}
409
+ ```
410
+
411
+ **UploadImageProps** (eredita FormFieldProps):
412
+ | Prop | Tipo | Note |
413
+ |---|---|---|
414
+ | `name` | `string` (required) | |
415
+ | `uploadPath` | `string` | Path storage per upload automatico |
416
+ | `accept` | `string` | MIME types accettati |
417
+ | `editable` | `boolean` | Abilita crop editor |
418
+ | `multiple` | `boolean` | Multipla selezione |
419
+ | `max` | `number` | Numero massimo file |
420
+ | `feedback` | `string` | Testo placeholder drop zone |
421
+ | `srcsetWidths` | `number[]` | Larghezze varianti responsive |
422
+ | `previewHeight` / `previewWidth` | `number` | Dimensioni preview |
423
+
424
+ **UploadDocumentProps** (eredita FormFieldProps): `name`, `uploadPath`, `accept`, `editable`, `multiple`, `max`
425
+
426
+ ### UploadCSV
427
+ ```tsx
428
+ <UploadCSV name="data" label="Importa CSV" />
429
+ ```
430
+ | Prop | Tipo |
431
+ |---|---|
432
+ | `name` | `string` (required) |
433
+ | `onDataLoaded` | `(data: UploadCSVData) => void` |
434
+ | `onParseField` | `([key, value]) => [key, value] \| undefined` |
435
+ | `normalizeKeys` | `boolean` |
436
+ | `removeEmptyFields` | `boolean` |
437
+ | `delimiter` | `string` |
438
+ | `label` / `icon` | Opzionali |
439
+
440
+ ### ImageField
441
+ ```tsx
442
+ <ImageField
443
+ name="hero"
444
+ label="Hero image"
445
+ defaultValue={{ src: 'https://example.com/hero.jpg', alt: 'Hero shot' }}
446
+ srcsetWidths={[400, 800]}
447
+ uploadPath="/content/images"
448
+ />
449
+ ```
450
+
451
+ Gestisce un oggetto `{ src, alt, srcset?, sizes? }`, preview inline, crop editor e generazione opzionale di varianti responsive.
452
+
453
+ ### CropImage
454
+ ```tsx
455
+ <CropImage img={imageObj} title="Ritaglia" scales={{ '1:1': 1, '4:3': 1.33 }} />
456
+ ```
457
+ 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.
458
+
459
+ ### CodeEditor
460
+ ```tsx
461
+ <CodeEditor
462
+ name="code"
463
+ label="Template"
464
+ language="html"
465
+ placeholder="Scrivi HTML..."
466
+ minHeight={200}
467
+ maxHeight={600}
468
+ disabled={false}
469
+ required
470
+ validateSyntax
471
+ commands={[{ name: 'wrap-section', handler: ({ value }) => `<section>\n${value}\n</section>` }]}
472
+ commandsTrigger="/"
473
+ />
474
+ ```
475
+ Editor basato su CodeMirror con syntax highlighting e validazione. Linguaggi: `liquid`, `html`, `json`, `js`, `ts`, `css`.
476
+ Quando `commands` e presente, `CodeEditor` apre internamente `ContextMenu` sul trigger configurato.
477
+
478
+ | Prop extra | Tipo |
479
+ |---|---|
480
+ | `language` | `CodeEditorLanguage` |
481
+ | `placeholder` | `string` |
482
+ | `minHeight` / `maxHeight` | `number` |
483
+ | `validateSyntax` | `boolean` |
484
+ | `validator` | `(value) => string \| undefined \| Promise<string \| undefined>` |
485
+ | `extensions` | `unknown[]` |
486
+
487
+ ### RichText
488
+ ```tsx
489
+ <RichText
490
+ name="body"
491
+ label="Article body"
492
+ placeholder="Scrivi..."
493
+ toolbar="fixed" // 'fixed' | 'floating' | false
494
+ toolbarCommands={['bold', 'italic', 'heading1', '|', 'bulletList', 'link']}
495
+ outputFormat="html" // 'html' | 'json' | 'text'
496
+ statusBar={{ tagBreadcrumb: true, wordCount: true }}
497
+ minHeight={200}
498
+ maxHeight={800}
499
+ imageUpload={{ path: '/content/images', srcsetWidths: [400, 800] }}
500
+ commands={[{ name: 'summary', icon: 'sparkles' }]}
501
+ commandsTrigger="/"
502
+ />
503
+ ```
504
+
505
+ | Prop extra | Tipo |
506
+ |---|---|
507
+ | `placeholder` | `string` |
508
+ | `disabled` | `boolean` |
509
+ | `feedback` | `string` |
510
+ | `toolbar` | `'fixed' \| 'floating' \| false` |
511
+ | `toolbarCommands` | `ToolbarCommand[]` |
512
+ | `outputFormat` | `'html' \| 'json' \| 'text'` |
513
+ | `statusBar` | `boolean \| StatusBarConfig` |
514
+ | `minHeight` / `maxHeight` | `number` |
515
+ | `imageUpload` | `RichTextImageUploadConfig` |
516
+ | `documentUpload` | `RichTextDocumentUploadConfig` |
517
+ | `validator` | `(value) => string \| undefined` |
518
+
519
+ ### Repeat — array dinamici
520
+ ```tsx
521
+ <Repeat name="items" defaultLength={3} minItems={1} maxItems={10} label="Elementi" layout="vertical" readOnly>
522
+ {({ index, record, records, remove }) => (
523
+ <>
524
+ <Input name={`items.${index}.name`} label="Nome" />
525
+ <UploadImage name={`items.${index}.photo`} label="Foto" />
526
+ </>
527
+ )}
528
+ </Repeat>
529
+ ```
530
+
531
+ | Prop | Tipo | Note |
532
+ |---|---|---|
533
+ | `name` | `string` (required) | |
534
+ | `children` | `ReactNode \| ((args) => ReactNode)` | Render prop con `{ index, record, records, remove }` |
535
+ | `defaultLength` | `number` | Righe iniziali |
536
+ | `minItems` / `maxItems` | `number` | Min/max righe |
537
+ | `layout` | `'vertical' \| 'horizontal' \| 'inline'` | |
538
+ | `label` | `string` | Etichetta gruppo |
539
+ | `labelPosition` | `'top' \| 'bottom'` | |
540
+ | `hideRemoveButton` | `boolean` | Nascondi rimozione |
541
+ | `readOnly` | `boolean` | Sola lettura |
542
+ | `onChange` | `FieldOnChange` | |
543
+ | `onAdd` / `onRemove` | `(records) => void` | Callback aggiunta/rimozione |
544
+
545
+ ### Prompt — campo AI-potenziato
546
+ ```tsx
547
+ <Prompt
548
+ name="summary"
549
+ mode="run" // 'edit' | 'run'
550
+ onRunPrompt={async (prompt, options) => AI.fetch(prompt)}
551
+ commands={[{ name: 'translate', icon: 'languages', handler: (v) => `Translate:\n${v}` }]}
552
+ commandsTrigger="/" // default automatico quando commands e presente
553
+ attachments
554
+ statusItems={['tokensIn', 'tokensOut', 'contextPercent', 'duration']}
555
+ />
556
+ ```
557
+
558
+ 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.
559
+
560
+ `renderFallback`: quando l'AI non e configurata, `Prompt` monta un fallback testuale; passa `renderFallback` per personalizzarlo (riceve `{ name, label, required, onChange }`).
561
+
562
+ ---
563
+
564
+ ## UI Primitives
565
+
566
+ ### Alert
567
+ ```tsx
568
+ <Alert variant="success">Salvato!</Alert>
569
+ <Alert variant="warning" icon placement="fixed" timeout={3000}>Attenzione</Alert>
570
+ ```
571
+ `variant`: `'info' | 'success' | 'warning' | 'danger' | 'primary' | 'secondary' | 'light' | 'dark'`
572
+ `appearance`: `'default' | 'text'`
573
+ `placement`: `'inline' | 'fixed'`
574
+
575
+ ### Badge
576
+ ```tsx
577
+ <Badge variant="success">Attivo</Badge>
578
+ ```
579
+ `variant`: stessi valori di Alert
580
+
581
+ ### Button variants
582
+ ```tsx
583
+ <ActionButton variant="primary" label="Salva" icon="save" onClick={handleSave} />
584
+ <LoadingButton variant="primary" label="Invia" onClick={async (e, setMsg) => { await save(); setMsg('Salvato!') }} />
585
+ <BackLink label="Indietro" />
586
+ <GoSite href="https://..." label="Visita" />
587
+ ```
588
+
589
+ **IButton props:** `variant`, `label`, `icon`, `badge`, `disabled`, `ariaLabel`, `title`, `iconClassName`, `onClick`
590
+ **LoadingButton aggiunge:** `loading`, `loadingLabel` — `onClick` riceve `(e, setMessage?) => Promise<unknown>`
591
+
592
+ ### Card
593
+ ```tsx
594
+ <Card title="Titolo" header={<Actions />} footer={<Pagination />} loading={isLoading}>
595
+ {children}
596
+ </Card>
597
+ ```
598
+
599
+ ### Modal
600
+ ```tsx
601
+ <Modal title="Conferma" size="md" position="center" onClose={() => {}} onSave={handleSave} onDelete={handleDelete} motion="fade" allowFullscreen stackedBehind>
602
+ {content}
603
+ </Modal>
604
+ <ModalYesNo title="Sicuro?" onClose={() => {}} onYes={async () => true}>Eliminare il record?</ModalYesNo>
605
+ <ModalOk title="Completato!" onClose={() => {}}>Salvato.</ModalOk>
606
+ ```
607
+ `size`: `'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'fullscreen'`
608
+ `position`: `'center' | 'top' | 'left' | 'right' | 'bottom'`
609
+ `ModalYesNo` e `ModalOk` sono export separati: `Modal.YesNo` e `Modal.Ok` non esistono.
610
+ Tastiera: Esc chiude il modal in cima alla pila (`closeOnEscape`, default `true`).
611
+ 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 })`.
612
+
613
+ | Prop | Tipo | Note |
614
+ |---|---|---|
615
+ | `children` | `ReactNode` (required) | |
616
+ | `title` | `ReactNode` | |
617
+ | `header` / `footer` | `ReactNode \| false` | Custom header/footer |
618
+ | `size` / `position` | Come sopra | |
619
+ | `onClose` | `() => void` | |
620
+ | `onSave` / `onDelete` | `(e) => Promise<boolean>` | |
621
+ | `allowFullscreen` | `boolean` | Toggle fullscreen |
622
+ | `showCancel` | `boolean` | Default `true` |
623
+ | `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 |
624
+ | `closeOnBackdrop` | `boolean` | Default `true` |
625
+ | `closeOnEscape` | `boolean` | Default `true` — solo il modal in cima reagisce |
626
+ | `zIndex` | `number` | |
627
+ | `motion` | `MotionReference` | Preset animazione |
628
+ | Classi CSS header/title/subtitle/body/footer | `string` | |
629
+
630
+ ### Table
631
+ ```tsx
632
+ <Table
633
+ columns={[{ key: 'name', label: 'Nome', sort: true }]}
634
+ records={records}
635
+ onRowClick={(record) => {}}
636
+ selection="multiple"
637
+ sortable={{ field: 'name', dir: 'asc' }}
638
+ pagination={{ limit: 20 }}
639
+ onReorder={(records, meta) => {}}
640
+ onSelectionChange={(sel) => {}}
641
+ groupBy="status"
642
+ renderCell={(record, key, index) => <CustomCell />}
643
+ />
644
+ ```
645
+
646
+ | Prop | Tipo |
647
+ |---|---|
648
+ | `columns` | `TableHeaderProp[]` |
649
+ | `records` | `RecordArray` |
650
+ | `sortable` | `boolean \| OrderConfig` |
651
+ | `pagination` | `PaginationParams` |
652
+ | `selection` | `'single' \| 'multiple'` |
653
+ | `onRowClick` | `(record) => void` |
654
+ | `onReorder` | `(records, meta) => void` |
655
+ | `onSelectionChange` | `(selection) => void` |
656
+ | `renderCell` | `(record, key, index) => ReactNode` |
657
+ | `groupBy` | `string \| string[]` |
658
+ | `activeKey` | `string \| null` |
659
+ | `selectedKeys` | `string[]` |
660
+ | `header` / `footer` | `ReactNode` |
661
+ | `headerClassName` / `bodyClassName` / `footerClassName` / `scrollClassName` / `selectedClassName` / `heightClassName` | `string` |
662
+
663
+ ### Gallery
664
+ ```tsx
665
+ <Gallery
666
+ records={records}
667
+ overlays={[{ position: 'topRight', badge: { variant: 'success', children: 'New' } }]}
668
+ columns={3}
669
+ gap={2}
670
+ sortable={{ field: 'name', dir: 'asc' }}
671
+ pagination={{ limit: 20 }}
672
+ onRowClick={(record) => {}}
673
+ onSelectionChange={(sel) => {}}
674
+ header="Titolo"
675
+ groupBy="status"
676
+ />
677
+ ```
678
+
679
+ `position`: `'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' | 'middleLeft' | 'middleRight'`
680
+
681
+ | Prop | Tipo |
682
+ |---|---|
683
+ | `records` | `GalleryRecord[]` (`{ img?, thumbnail?, name?, width?, height? }`) |
684
+ | `columns` | `1 \| 2 \| 3 \| 4 \| 6` |
685
+ | `gap` | `0 \| 1 \| 2 \| 3 \| 4 \| 5` |
686
+ | `overlays` | `GalleryOverlay[]` |
687
+ | `sortable` | `boolean \| OrderConfig` |
688
+ | `pagination` | `PaginationParams` |
689
+ | `onRowClick` | `(record) => void` |
690
+ | `onSelectionChange` | `(selection) => void` |
691
+ | `groupBy` | `string \| string[]` |
692
+ | `selectedKeys` | `string[]` |
693
+ | `header` / `footer` | `ReactNode` |
694
+ | Classi CSS: `headerClassName`, `bodyClassName`, `footerClassName`, `scrollClassName`, `selectedClassName` | `string` |
695
+
696
+ ### Tab
697
+ ```tsx
698
+ import { Tab, TabItem } from '@llmnative/react'
699
+
700
+ <Tab defaultIndex={0} layout="top">
701
+ <TabItem label="Generale">{content1}</TabItem>
702
+ <TabItem label="SEO">{content2}</TabItem>
703
+ </Tab>
704
+ ```
705
+ `layout`: `'default' | 'top' | 'left' | 'right' | 'bottom'`
706
+ Le schede sono `<TabItem>`, export separato: `Tab.Item` non esiste.
707
+
708
+ ### Image
709
+ ```tsx
710
+ <Image src="..." fit="cover" position="center" width={400} height={300} />
711
+ ```
712
+ `fit`: `'cover' | 'contain' | 'fill' | 'scale-down' | 'none'`
713
+
714
+ ### Pagination (standalone)
715
+ ```tsx
716
+ <Pagination records={items} limit={20}>
717
+ {(pageRecords, offset) => pageRecords.map(r => <Row key={r._key} record={r} />)}
718
+ </Pagination>
719
+ ```
720
+
721
+ ### Percentage
722
+ ```tsx
723
+ <Percentage value={75} appearance="bar" variant="success" showText />
724
+ <Percentage value={0.75} appearance="circle" size={60} />
725
+ ```
726
+
727
+ | Prop | Tipo | Default |
728
+ |---|---|---|
729
+ | `value` | `number` | — |
730
+ | `max` | `number` | `100` |
731
+ | `min` | `number` | — |
732
+ | `appearance` | `'bar' \| 'circle'` | `'bar'` |
733
+ | `variant` | `ColorType` | — |
734
+ | `trackVariant` | `ColorType` | — |
735
+ | `thickness` | `number` | — (px) |
736
+ | `showText` | `boolean` | — |
737
+ | `size` | `number` | Diametro in modalità `'circle'` (px) |
738
+ | `fontSize` | `number` | — |
739
+ | `label` | `string` | — |
740
+
741
+ ### DescriptionList
742
+ 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>`.
743
+ ```tsx
744
+ <DescriptionList items={[{ label: 'Email', value: 'a@b.it' }, { label: 'Telefono', value: null }]} />
745
+ <DescriptionList layout="stacked" columns={4} truncate items={[{ label: 'Stato', value: <Badge variant="success">Spedito</Badge> }]} />
746
+ ```
747
+
748
+ | Prop | Tipo | Default |
749
+ |---|---|---|
750
+ | `items` | `DescriptionListItem[]` — `{ key?, label, value?, title? }` | — |
751
+ | `layout` | `'horizontal' \| 'stacked'` | `'horizontal'` (etichette a sinistra, impilate su mobile) |
752
+ | `columns` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `1` (solo `stacked`, dal breakpoint `lg`) |
753
+ | `labelWidth` | `string` (lunghezza CSS) | `'12rem'` (solo `horizontal`) |
754
+ | `emptyValue` | `ReactNode` | `'—'` per `null` / `undefined` / `''` (lo `0` resta) |
755
+ | `truncate` | `boolean` | `false`; un valore testuale diventa il suo `title` |
756
+ | Classi | `wrapperClassName`, `className`, `itemClassName`, `labelClassName`, `valueClassName` | tema `DescriptionList` |
757
+
758
+ ### Tree
759
+ 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.
760
+ ```tsx
761
+ <Tree nodes={treeFromPaths(paths)} defaultExpanded selectedId={path} onSelect={(node) => setPath(node.id)} ariaLabel="File" />
762
+ <Tree nodes={[{ id: 'a', label: 'Docs', children: [{ id: 'a/1', label: 'README.md', badge: <Badge>new</Badge> }] }]} />
763
+ ```
764
+ | Prop | Tipo | Note |
765
+ |---|---|---|
766
+ | `nodes` | `TreeNode[]` — `{ id, label, icon?, badge?, description?, children?, disabled? }` | `id` unico; con `children` è una cartella |
767
+ | `selectedId` + `onSelect` / `defaultSelectedId` | `string \| null` | selezione controllata o no |
768
+ | `expanded` + `onExpandedChange` / `defaultExpanded` | `string[]`; `defaultExpanded` anche `true` (tutto aperto) | apertura controllata o no |
769
+ | `selectableBranches` | `boolean` (default `false`) | anche le cartelle si selezionano |
770
+ | `ariaLabel` | `string` | nome accessibile |
771
+ | Classi | `wrapperClassName`, `className`, `itemClassName`, `selectedClassName` | tema `Tree` |
772
+
773
+ `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…).
774
+
775
+ ### Dropdown
776
+ ```tsx
777
+ <Dropdown trigger={<Icon name="menu" />} position="end">
778
+ <DropdownItem label="Modifica" icon="edit" onClick={() => {}} />
779
+ <DropdownItem label="Elimina" icon="trash" variant="danger" />
780
+ <DropdownDivider />
781
+ <DropdownHeader label="Azioni rapide" />
782
+ </Dropdown>
783
+ ```
784
+
785
+ Azioni secondarie di una voce (CR-107): `actions` mostra icone in fondo alla riga, al passaggio
786
+ del mouse o al focus (sempre sui dispositivi touch). Non selezionano la voce e lasciano il menu aperto
787
+ salvo `closeOnClick`.
788
+
789
+ ```tsx
790
+ <DropdownItem icon="messages-square" onClick={() => open(chat)}
791
+ actions={[{ icon: 'archive', label: 'Archivia', onClick: () => archive(chat) }]}>
792
+ {chat.title}
793
+ </DropdownItem>
794
+ ```
795
+
796
+ Per elenchi caricati dinamicamente usare `AsyncDropdown`: gestisce ricerca con debounce,
797
+ cancellazione delle richieste precedenti tramite `AbortSignal` e stati loading/empty/error.
798
+
799
+ ```tsx
800
+ <AsyncDropdown
801
+ trigger={<ActionButton label="Select user" />}
802
+ loadItems={(query, signal) => users.search(query, { signal })}
803
+ getItemId={user => user.id}
804
+ renderItem={user => user.name}
805
+ onSelect={user => setUser(user)}
806
+ selectedId={user?.id}
807
+ />
808
+ ```
809
+
810
+ ### ContextMenu
811
+ ```tsx
812
+ <ContextMenu trigger="/" searchable>
813
+ <ContextMenu.Heading>Comandi</ContextMenu.Heading>
814
+ <ContextMenu.Item label="Titolo" value="# " icon="heading" />
815
+ <ContextMenu.Separator />
816
+ <ContextMenu.Item label="Bold" value="**bold**" icon="bold" />
817
+ </ContextMenu>
818
+ ```
819
+ Funziona su qualsiasi `<textarea>` / `<input>` figlio. Trigger custom (`/`, `@`, `{`), filtro searchable, navigazione tastiera, floating positioning.
820
+
821
+ | Prop | Tipo | Note |
822
+ |---|---|---|
823
+ | `trigger` | `string` (required) | Es. `"/"`, `"@"`, `"{"` |
824
+ | `searchable` | `boolean` | Filtro testuale |
825
+ | `controlled` | `ContextMenuControlledState` | Stato controllato esterno |
826
+ | `onSelect` | `(item, context) => void` | |
827
+
828
+ **EditorCommand** (usato da `commands` prop su `RichText`, `CodeEditor`, `Prompt`):
829
+ ```ts
830
+ {
831
+ name: string;
832
+ description?: string;
833
+ icon?: string;
834
+ group?: string;
835
+ consumeSuffix?: string;
836
+ handler?: (context: TextCommandContext) => string | Promise<string>;
837
+ }
838
+ ```
839
+
840
+ `consumeSuffix` elimina una chiusura automatica adiacente durante la sostituzione del trigger,
841
+ utile quando un comando cambia famiglia sintattica (per esempio da `{{ ... }}` a `{% ... %}`).
842
+
843
+ ### Altre primitive
844
+ ```tsx
845
+ <Icon name="user" size={20} /> // usa provider icone configurato
846
+ <ImageAvatar src="..." title="Mario" alt="Mario" width={40} height={40} />
847
+ <Identicon value="acme/app" size={32} title="acme/app" /> // icona generata dal valore, stile GitHub
848
+ <Loader show={isLoading}>{content}</Loader> // indicatore al posto dei figli finché show è true
849
+ <Loader show>{null}</Loader> // solo indicatore (es. pagina in caricamento)
850
+ <Code language="tsx">{snippet}</Code> // syntax highlight
851
+ <GridSystem.Row><GridSystem.Col xs={12} md={6}>...</GridSystem.Col></GridSystem.Row>
852
+ <LocaleSwitcher locales={[{ code: 'it', label: 'Italiano' }]} />
853
+ <ThemeSwitcher />
854
+ <ProviderSwitcher />
855
+ <LayoutBuilder components={...} />
856
+ ```
857
+
858
+ ---
859
+
860
+ ## Blocks
861
+
862
+ ```tsx
863
+ <Breadcrumbs trail={[{ label: 'Home', href: '/' }, { label: 'Utenti' }]} separator="/" jsonLd />
864
+ <Brand url="/" label="MyApp" logo="/logo.svg" width={120} />
865
+ <Search onQueryChange={(e) => setQ(e.target.value)} />
866
+ <Notifications items={[...]} seeAllUrl="/notifications" onMarkAllRead={() => {}} onItemClick={(item) => markRead(item.id)} />
867
+ <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' }] }]} />
868
+ <Carousel images={[...]} />
869
+ <Toolbar items={[...]}>
870
+ {children}
871
+ </Toolbar>
872
+ <SideNav />
873
+ <ListCard records={records} onRowClick={(r) => {}} />
874
+ ```
875
+
876
+ **Brand:**
877
+ | Prop | Tipo |
878
+ |---|---|
879
+ | `url` | `string` |
880
+ | `label` | `string` |
881
+ | `logo` | `string` |
882
+ | `width` / `height` | `number` |
883
+ | `wrapperClassName` / `className` / `logoClassName` / `labelClassName` | `string` |
884
+
885
+ **SideNav:**
886
+ | Prop | Tipo |
887
+ |---|---|
888
+ | `items` | `SideNavItemDef[]` (in alternativa a `menuKey`) |
889
+ | `menuKey` | `string` — voci dal menu registrato in `<App>` |
890
+ | `bottomItems` | `SideNavItemDef[]` — voci fissate in fondo, sopra il footer e fuori dalla lista scorrevole (es. Impostazioni) |
891
+ | `header` / `footer` | `ReactNode` |
892
+ | `collapsed` + `onCollapsedChange` / `defaultCollapsed` | `boolean` — compressione controllata o no |
893
+ | `showIcons` / `showCollapseButton` | `boolean` (default `true`) |
894
+ | `embedded` | `boolean` — solo le voci, senza guscio (drawer, pannelli); `bottomItems` seguono le voci principali |
895
+
896
+ `SideNavItemDef`: `{ path, title?, icon?, group?, end?, badge?, children? }`. La voce attiva ha
897
+ `aria-current="page"`.
898
+
899
+ ```tsx
900
+ <SideNav
901
+ items={[{ path: '/dashboard', title: 'Dashboard', icon: 'home' }]}
902
+ bottomItems={[{ path: '/settings', title: 'Impostazioni', icon: 'settings' }]}
903
+ />
904
+ ```
905
+
906
+ **ImageAvatar:**
907
+ | Prop | Tipo |
908
+ |---|---|
909
+ | `src` | `string` (obbligatoria) |
910
+ | `width` / `height` | `number` |
911
+ | `title` / `alt` | `string` |
912
+ | `fit` | `AvatarFit` |
913
+ | `badge` | `BadgeDescriptor` |
914
+ | `feedback` | `ReactNode` |
915
+
916
+ **Identicon:** l'icona di un'entità senza immagine (repository, tenant, utente senza foto), come quelle di
917
+ GitHub: griglia 5×5 simmetrica, celle e tinta da un hash di `value`. Lo stesso valore dà sempre la stessa
918
+ icona; niente rete né storage. Default: `rounded-md bg-muted` (tema `Identicon.className`).
919
+ `identiconPattern(value)` restituisce celle e tinta, per chi la disegna altrove.
920
+ | Prop | Tipo |
921
+ |---|---|
922
+ | `value` | `string` (obbligatoria) |
923
+ | `size` | `number` (px, default 40) |
924
+ | `title` | `string`: nome accessibile (`role="img"`); senza, `aria-hidden` |
925
+ | `before` / `after` / `className` / `wrapperClassName` | come le altre primitive |
926
+
927
+ **Search:**
928
+ | Prop | Tipo |
929
+ |---|---|
930
+ | `onQueryChange` | `(e: React.ChangeEvent<HTMLInputElement>) => void` |
931
+
932
+ **Notifications:**
933
+ | Prop | Tipo |
934
+ |---|---|
935
+ | `items` | `NotificationItem[]` |
936
+ | `badge` | `BadgeProps` |
937
+ | `seeAllUrl` | `string` |
938
+ | `onMarkAllRead` | `() => void` |
939
+ | `onItemClick` | `(item, index) => void` — una voce aperta (es. per segnarla letta); il menu si chiude |
940
+ | `motion` | `MotionReference` |
941
+
942
+ `NotificationItem`: `{ id?, title, description?, url?, external?, time, icon, read?, actions? }`. `description` è la
943
+ riga sotto il titolo; senza `url` la voce si mostra e basta; con `external` l'`url` è un indirizzo aperto
944
+ in una nuova scheda (`<a target="_blank">`), altrimenti una rotta (`Link`). `actions`
945
+ (`NotificationAction[]`: `{ label, icon?, onClick, variant?: 'primary' | 'default', closeOnClick? }`) sono
946
+ pulsanti sotto la voce: non la aprono, mostrano il caricamento finché la promessa di `onClick` è in corso
947
+ e lasciano il menu aperto salvo `closeOnClick`.
948
+
949
+ **ListCard:**
950
+ | Prop | Tipo |
951
+ |---|---|
952
+ | `title` / `description` / `meta` | `ReactNode` |
953
+ | `badge` | `ReactNode` |
954
+ | `icon` | `string \| ReactNode` |
955
+ | `trailing` / `footer` / `children` | `ReactNode` |
956
+ | `href` | `string` |
957
+ | `external` / `active` / `disabled` / `dashed` / `compact` | `boolean` |
958
+ | `align` | `'start' \| 'center'` |
959
+ | `onClick` | `(e) => void` |
960
+ | Classi CSS: `titleClassName`, `descriptionClassName`, `metaClassName`, `badgeClassName`, `leadingClassName`, `bodyClassName`, `headerClassName`, `trailingClassName`, `footerClassName` | `string` |
961
+
962
+ **Dropdown:**
963
+ | Prop | Tipo |
964
+ |---|---|
965
+ | `trigger` | `string \| ReactNode \| DropdownTogglerProps` |
966
+ | `badge` | `BadgeProps` |
967
+ | `header` / `footer` | `ReactNode` |
968
+ | `defaultOpen` / `open` | `boolean` |
969
+ | `onOpenChange` | `(open) => void` |
970
+ | `position` | `'start' \| 'end'` |
971
+ | `placement` | `'auto' \| 'top' \| 'bottom'` |
972
+ | `strategy` | `'fixed' \| 'absolute'` |
973
+
974
+ // Menu — renderizza una sezione del menuConfig passato ad <App>
975
+ <Menu menuKey="main" /> // 'main' o qualsiasi chiave definita in MenuConfig
976
+ <Menu menuKey="topbar" className="nav flex-row gap-2" />
977
+ // NON usare context= — la prop si chiama menuKey
978
+
979
+ // AuthButton — login/logout con il provider auth configurato
980
+ <AuthButton />
981
+ ```
982
+
983
+ ---
984
+
985
+ ## Widgets
986
+
987
+ ### MarkdownReader
988
+ ```tsx
989
+ <MarkdownReader
990
+ content={markdownString}
991
+ onInternalLinkClick={(href, e) => { e.preventDefault(); navigate(href) }}
992
+ wrapperClassName="prose"
993
+ />
994
+ ```
995
+
996
+ ### Messaggi di chat: ChatMarkdown, ChatReasoning, ChatQuestions (CR-109)
997
+
998
+ Parti di un messaggio di un modello, da comporre nel proprio thread (anche accanto a `Chatbot`).
999
+
1000
+ ```tsx
1001
+ // Markdown GFM del messaggio: tabelle (scorrono dentro di sé in un pannello stretto), elenchi,
1002
+ // titoli, citazioni, task list, codice con evidenziazione (Prism, lazy) e pulsante Copia, link a
1003
+ // indirizzi in una nuova scheda (noopener). Sanitizzato: niente HTML grezzo, niente javascript:.
1004
+ // streaming: chiude ciò che la fine del testo lascia aperto (fence, intestazione di tabella,
1005
+ // **grassetto**, `codice`, link) così il layout non salta mentre arriva. Off per un messaggio finito.
1006
+ <ChatMarkdown content={text} streaming={running} onLinkClick={(href) => navigate(href)} />
1007
+ // tone="muted", highlight={false}, components (come react-markdown), className,
1008
+ // classNames={{ root, paragraph, heading, list, blockquote, tableWrapper, table, th, td, codeBlock, codeHeader, code, inlineCode, link }}
1009
+ // repairStreamingMarkdown(text) è la stessa riparazione, pura.
1010
+
1011
+ // Ragionamento: chiuso dietro "Ragionamento" / "Ha ragionato per 12 s"; aperto mentre arriva
1012
+ // (se l'utente non lo chiude) e richiuso alla fine. Contenuto con ChatMarkdown, tono attenuato.
1013
+ <ChatReasoning content={reasoning} streaming={thinking} duration={12} />
1014
+ // defaultOpen, open + onOpenChange (controllato), label, icon ('brain'), className, classNames={{ root, trigger, content }}
1015
+
1016
+ // Domande interattive (formato dello strumento `question` di opencode / elicitation MCP):
1017
+ <ChatQuestions
1018
+ questions={[
1019
+ { id: 'db', header: 'Database', question: 'Quale database?', recommended: 0,
1020
+ options: [{ label: 'Postgres', description: 'Relazionale' }, { label: 'SQLite' }] },
1021
+ { id: 'features', header: 'Funzioni', question: 'Quali funzioni?', multiple: true,
1022
+ options: [{ label: 'Auth' }, { label: 'Ricerca' }] },
1023
+ ]}
1024
+ onSubmit={async (answers) => send(answers)} // [{ id, header, values: string[] }] in ordine
1025
+ />
1026
+ // Una scheda per domanda (header) + Riepilogo e un solo Invia; opzioni numerate 1-2-3 o A-B-C
1027
+ // (numbering, altrimenti dedotto da etichette "A) …" / "1. …", che vengono tolte); consigliata
1028
+ // (recommended: indice da 0 o etichetta; anche il suffisso "(Recommended)"); multiple; "Altro" con
1029
+ // testo libero (allowOther, default true; per domanda `custom`); senza options è a testo libero.
1030
+ // Tastiera: numeri (o lettere con A-B-C) scelgono, frecce sinistra/destra tra le schede, su/giù tra
1031
+ // le opzioni, Spazio seleziona, Invio prosegue o invia (su una scelta singola prima la sceglie).
1032
+ // Ruoli tablist/tab/tabpanel, radiogroup/radio o group/checkbox.
1033
+ // Scelta singola: il click sceglie e passa alla domanda dopo. onSubmit che rifiuta lascia il form.
1034
+ // answers={[…]} mostra un insieme già risposto (sola lettura); disabled, autoFocus, submitLabel,
1035
+ // classNames={{ root, tabs, tab, panel, question, option, optionSelected, footer, summary }}
1036
+ ```
1037
+
1038
+ ### ImageEditor
1039
+ ```tsx
1040
+ <ImageEditor src="..." onSave={(dataUrl) => save(dataUrl)} />
1041
+ ```
1042
+
1043
+ `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.
1044
+
1045
+ ### TabDynamic
1046
+ Tab con lazy loading dei contenuti — gestione dinamica aggiunta/rimozione tab.
1047
+
1048
+ ---
1049
+
1050
+ ## AI Integration
1051
+
1052
+ ```ts
1053
+ import { useAIProvider, getAIModelCatalog, createAIProviderRegistry } from '@llmnative/react'
1054
+
1055
+ // Hooks
1056
+ const provider = useAIProvider() // provider di default
1057
+ const registry = useAIProviderRegistry() // tutti i provider AI registrati
1058
+ const catalog = getAIModelCatalog() // modelli disponibili
1059
+ // ogni modello: { id, provider, model, label, pricing? } — pricing in USD per 1M token
1060
+ // { input, output, currency: 'USD', source: 'provider' | 'models.dev' }; assente = prezzo
1061
+ // non trovato (il modello resta in lista). isFreeAIModel(m) → input e output a 0.
1062
+ // Il model picker di Prompt/Chatbot mostra "$in / $out", evidenzia i free, segnala i mancanti,
1063
+ // e raggruppa per provider (ChatbotModelOption.group = providerLabel) con header sticky.
1064
+
1065
+ // Provider adapter restituito da useAIProvider()
1066
+ const text = await provider.fetch("Scrivi un titolo per: {keyword}", { keyword: "React" })
1067
+ const json = await provider.json("Lista di 5 categorie per blog tech")
1068
+ const arr = await provider.array("5 tag per: machine learning")
1069
+
1070
+ // Provider custom
1071
+ class MyAIProvider implements AIProviderAdapter {
1072
+ async fetch(prompt: string, options?: AIRequestOptions): Promise<string> { ... }
1073
+ async json<T>(prompt: string, options?: AIRequestOptions): Promise<T> { ... }
1074
+ async array(prompt: string, options?: AIRequestOptions): Promise<string[]> { ... }
1075
+ }
1076
+
1077
+ // Factory per registri esterni
1078
+ const registry = createAIProviderRegistry(definitions)
1079
+ // Azioni di Chatbot (CR-107): con `content` aprono un menu; senza, sono pulsanti con `onClick`.
1080
+ // <Chatbot actions={[{ key: 'finish', icon: 'git-pull-request', label: 'Concludi', showLabel: true,
1081
+ // disabled: running, onClick: finish }]} … />
1082
+ ```
1083
+
1084
+ ### Identità dei modelli indipendente dal provider (CR-103)
1085
+
1086
+ ```ts
1087
+ const index = await loadModelIdentityIndex() // models.dev, un fetch per sessione
1088
+ canonicalModelName('DeepSeek V4.1 Flash (Alibaba Cloud)') // 'deepseek v4.1 flash'
1089
+ index.models['deepseek v4.1 flash'] // { key, name, family?, providers: { openrouter: [{ id, name }], … } }
1090
+ resolveModel(index, 'DeepSeek V4.1 Flash', ['openrouter', 'deepseek'])
1091
+ // → { provider: 'openrouter', model: 'deepseek/deepseek-v4.1-flash', ref: 'openrouter/deepseek/deepseek-v4.1-flash' }
1092
+ ```
1093
+
1094
+ Il nome canonico toglie data, Free, Latest e host o regione tra parentesi; una modalità
1095
+ ("(Thinking)", "(Reasoning)", "(Fast)") resta. Per ogni provider l'id preferito è quello stabile (non
1096
+ alias `~`, non free tier, non datato). I provider sono le chiavi di models.dev, le stesse di opencode.
1097
+ Puro: anche da `@llmnative/react/server`.
1098
+
1099
+ ### Chiavi tenute dal server (CR-102)
1100
+
1101
+ Per un'app che non mette mai le chiavi dei provider nel browser. Il browser conosce solo quali
1102
+ provider sono collegati; il proxy dell'app mette la chiave.
1103
+
1104
+ ```ts
1105
+ // Browser: gli stessi adapter, con un segnaposto dove andrebbe la chiave. Serve il proxy attivo.
1106
+ const registry = createServerCredentialAIRegistry(
1107
+ [{ id: 'anthropic' }, { id: 'cloudflare', accountId }, { id: 'openai-compatible', baseUrl }],
1108
+ { scope: companyId }, // lettere, cifre, - e _
1109
+ )
1110
+ Object.entries(registry).forEach(([id, adapter]) => setProvider('ai', id, adapter))
1111
+
1112
+ // Server (Node, nessuna dipendenza da React): nel proxy, prima di inoltrare.
1113
+ import { resolveServerCredentials } from '@llmnative/react/server'
1114
+ const resolved = await resolveServerCredentials({ url: target, headers },
1115
+ (scope, provider) => keysOf(scope)[provider]) // { id, apiKey, accountId?, baseUrl? } | undefined
1116
+ if (!resolved.ok) return reply(resolved.status, resolved.error) // 404 non collegato, 403 host estraneo
1117
+ await fetch(resolved.url, { headers: resolved.headers, ... })
1118
+ ```
1119
+
1120
+ La chiave va solo agli host del provider (`AI_PROVIDER_HOSTS`, il `baseUrl` di openai-compatible,
1121
+ l'account di Cloudflare): una destinazione diversa è rifiutata. `createProxyPlugin(route, log,
1122
+ lookup)` fa lo stesso nel proxy di sviluppo di Vite. Senza proxy, `proxyFetch` rifiuta di inviare
1123
+ un segnaposto. `createAIProviderRegistry` (chiavi nel browser) resta com'è.
1124
+
1125
+ 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`).
1126
+
1127
+ ---
1128
+
1129
+ ## Theme e icone
1130
+
1131
+ ```tsx
1132
+ const theme = useThemeController()
1133
+ theme.applyTheme('flat') // BuiltInThemeId ('default' | 'flat' | 'cyber' | 'vscode-dark-modern' | …) | custom
1134
+ theme.setMode('dark') // 'light' | 'dark' | 'system'
1135
+
1136
+ const icons = useIconController()
1137
+ icons.setProvider('phosphor') // 'lucide' | 'phosphor' | custom
1138
+ ```
1139
+
1140
+ ---
1141
+
1142
+ ## Head (SEO)
1143
+
1144
+ ```ts
1145
+ useHead({
1146
+ title: 'Pagina',
1147
+ description: '...',
1148
+ robots: 'noindex',
1149
+ canonical: 'https://...',
1150
+ og: { title: '...', image: '...' }
1151
+ })
1152
+ ```
1153
+
1154
+ ---
1155
+
1156
+ ## Global state (localStorage)
1157
+
1158
+ ```ts
1159
+ const vars = useGlobalVars()
1160
+ getGlobalVars('key')
1161
+ setGlobalVars({ key: 'value' })
1162
+ removeGlobalVars('key')
1163
+ ```
1164
+
1165
+ ---
1166
+
1167
+ ## Hooks completi
1168
+
1169
+ | Hook | Ritorna |
1170
+ |---|---|
1171
+ | `useDataProvider(name?)` | `DataProviderAdapter \| null` |
1172
+ | `useStorageProvider(name?)` | `StorageProviderAdapter \| null` |
1173
+ | `useAuthProvider(name?)` | `AuthProviderAdapter \| null` |
1174
+ | `useEmailProvider(name?)` | `EmailProviderAdapter \| null` |
1175
+ | `useAIProvider(name?)` | `AIProviderAdapter \| null` |
1176
+ | `useAIProviderRegistry()` | `Record<string, AIProviderAdapter>` |
1177
+ | `useCredentialsProvider()` | `CredentialsAdapter \| null` |
1178
+ | `useTheme()` | `Theme \| undefined` |
1179
+ | `useThemeController()` | `ThemeController` |
1180
+ | `useIconController()` | `IconController` |
1181
+ | `useAccessToken(clientID, renew?)` | `boolean` | Ottiene token per servizio esterno (true = ok) |
1182
+ | `useGlobalVars()` | `Record<string, unknown>` |
1183
+ | `useHead()` | `HeadController` |
1184
+ | `useDocumentHead()` | `HeadController` |
1185
+ | `useAssetsHead(title?)` | `void` |
1186
+ | `useLanguageHead(lang?)` | `void` |
1187
+ | `useSocialHead(config)` | `void` |
1188
+ | `usePaginationHead(config)` | `void` |
1189
+ | `usePwaHead(config)` | `void` |
1190
+ | `useSchemaOrgHead(config)` | `void` |
1191
+ | `useHeadLinks(links)` | `void` |
1192
+ | `useProxy()` | `(url, init?) => Promise<Response>` |
1193
+ | `useImage()` | `UseImageResult` |
1194
+ | `useI18n()` | `I18nController` |
1195
+ | `useMenu()` | Menu hook |
1196
+ | `useFormController(controller?)` | `FormController` |
1197
+ | `useFormActions()` | `FormController` |
1198
+ | `useApiProvider(pathname?)` | `ApiProviderAdapter \| null` |
1199
+ | `useSetProvider(service, driver?)` | `void` |
1200
+ | `useProvider(name)` | `ProviderConfigurationState` |
1201
+ | `useProviderSession()` | `ProviderSession` |
1202
+ | `useDropBoxConnect(config?)` | `DropBoxConnectResult` |
1203
+ | `useEnterMotion(baseStyle?, ref?, fallback?)` | `CSSProperties` |
1204
+ | `useMotionState(active, ref?, fallback?, baseStyle?)` | `CSSProperties` |
1205
+ | `useMotionEffect(ref?, fallback?)` | `ResolvedMotionEffect` |
1206
+ | `usePressMotion(hovered, baseStyle?, ref?, fallback?)` | `{ style, pressHandlers }` |
1207
+ | `useIconProvider()` | `IconProvider` |
1208
+
1209
+ ---
1210
+
1211
+ ## Tipi chiave
1212
+
1213
+ ```ts
1214
+ type RecordProps = Record<string, FieldValue>
1215
+ type FieldValue = string | number | boolean | null | undefined | Record<string, unknown> | unknown[]
1216
+ type RecordArray = RecordProps[]
1217
+
1218
+ type OrderConfig = { field: string; dir: 'asc' | 'desc' }
1219
+ type WhereClause = { field: string; op: '==' | '!=' | '<' | '<=' | '>' | '>=' | 'in' | 'not-in' | 'array-contains' | 'array-contains-any'; value: unknown }
1220
+
1221
+ interface UIProps {
1222
+ before?: React.ReactNode
1223
+ after?: React.ReactNode
1224
+ wrapperClassName?: string
1225
+ className?: string
1226
+ }
1227
+
1228
+ type UserProfile = { uid?: string; email?: string; displayName?: string; photoURL?: string; [key: string]: unknown }
1229
+ ```
1230
+
1231
+ ---
1232
+
1233
+ ## Utilities
1234
+
1235
+ ```ts
1236
+ import { cn, converter, Sanitizer, Order, path, fetchJson, fetchRest, fetchWithRetry } from '@llmnative/react'
1237
+ import { getKeywordIdeas, getGoogleTrendsData, getGoogleTrendsRelated } from '@llmnative/react'
1238
+
1239
+ cn('base', condition && 'extra', className) // tailwind-merge classname
1240
+ new Sanitizer().sanitize(html) // XSS sanitizer (non usare dangerouslySetInnerHTML)
1241
+ new Order().order(records, { field: 'name', dir: 'asc' }) // ordina record array
1242
+ fetchJson(url, init?) // fetch + JSON parse + error handling
1243
+ fetchRest(path, init?) // fetch REST con auth
1244
+ getKeywordIdeas(keyword) // Google Ads Keyword Ideas
1245
+ getGoogleTrendsData(keyword) // Google Trends interest over time
1246
+ getGoogleTrendsRelated(keyword) // Google Trends related queries
1247
+ ```
1248
+
1249
+ ---
1250
+
1251
+ ## Scaffold (npx create)
1252
+
1253
+ ```bash
1254
+ npx @llmnative/react create
1255
+ npx @llmnative/react create --yes --provider=mock --template=admin
1256
+ ```
1257
+
1258
+ Flag disponibili: `--name` `--provider=firebase|supabase|mock|custom` `--ai-provider` `--icon-provider` `--theme` `--template=blank|admin|crm|inventory|project`
1259
+
1260
+ **5 template disponibili:**
1261
+ | Template | Pagine incluse | Uso tipico |
1262
+ |---|---|---|
1263
+ | `blank` | home | App vuota — massima libertà |
1264
+ | `admin` | home, users, roles, settings | Dashboard admin/backoffice |
1265
+ | `crm` | home, companies, contacts, deals | CRM |
1266
+ | `inventory` | home + gestione inventario | Magazzino |
1267
+ | `project` | home + gestione progetti | PM tool |
1268
+
1269
+ **Struttura generata in-place nella current directory:**
1270
+ ```
1271
+ src/
1272
+ index.tsx ← App entry
1273
+ vite-env.d.ts
1274
+ conf/
1275
+ app.ts ← providers config letta da .env (VITE_PROVIDER, VITE_AI_PROVIDER...)
1276
+ menu.ts ← MenuConfig con path, title, icon, page (layout opzionale: c'è LayoutDefault)
1277
+ data/
1278
+ mockData.ts ← dati fake per MockDataProvider
1279
+ layouts/
1280
+ Default.tsx ← layout wrapper (header + sidebar + main + footer)
1281
+ sections/
1282
+ Header.tsx / Sidebar.tsx / PageHeader.tsx / Footer.tsx ← solo componenti pubblici (SideNav, AuthButton, ActionButton, Brand, Breadcrumbs)
1283
+ pages/
1284
+ home/HomePage.tsx
1285
+ [altre pagine dal template scelto]
1286
+ styles/
1287
+ globals.css ← @import '@llmnative/react/dist/index.css' + Tailwind override
1288
+ .env ← VITE_PROVIDER, VITE_THEME, chiavi API (non committare)
1289
+ .env.example ← stessa struttura, valori vuoti (committare)
1290
+ vite.config.ts / tsconfig.json / postcss.config.js / index.html
1291
+ ```
1292
+
1293
+ **Pattern conf/app.ts** (auto-generato, leggere per capire come funziona):
1294
+ ```ts
1295
+ import type { AppProvidersConfig } from '@llmnative/react'
1296
+ import { mockData } from '../data/mockData'
1297
+ const env = import.meta.env
1298
+
1299
+ export const providers: AppProvidersConfig = {
1300
+ mock: { data: mockData },
1301
+ ai: { openaiApiKey: env.VITE_OPENAI_API_KEY ?? '' }, // AI dentro providers, non prop <App>
1302
+ services: { data: 'mock', auth: 'googleAuth' },
1303
+ }
1304
+ ```
1305
+
1306
+ Driver per `VITE_PROVIDER`: `firebase` → `firestoreDb` + `firestorage` + `firebaseAuth`; `supabase` → `supabaseDb` + `supabaseStorage` + `supabaseAuth`; `mock` → `mock` + `googleAuth`. `index.tsx` passa `LayoutDefault={Default}` ad `<App>`.
1307
+
1308
+ ---
1309
+
1310
+ ## Gotchas — pitfall frequenti
1311
+
1312
+ | Errore | Causa | Fix |
1313
+ |---|---|---|
1314
+ | `aiConfig` prop non esiste su `<App>` | La config AI va dentro `providers.ai`, NON come prop separata | `providers={{ ai: { openaiApiKey } }}` |
1315
+ | `inputType` o `Input.Number`/`Input.Email` non esistono | Il tipo HTML è la prop `type` | `<Input type="number">`, `<Input type="email">`, `<Input type="date">` |
1316
+ | `Menu context=` non esiste | La prop si chiama `menuKey` | `<Menu menuKey="main" />` |
1317
+ | `SignInButton` non esiste | Rinominato | `<AuthButton />` |
1318
+ | `firebase: { config: ... }` errato | FirebaseConfig è flat | `firebase: { apiKey, authDomain, databaseURL, ... }` |
1319
+ | `google: { oAuth2: { clientId } }` errato | GoogleProviderConfig è flat | `google: { clientId, scope }` |
1320
+ | `children` su `Grid` | Grid non accetta children | Usa `form={<Fields />}` per il form inline |
1321
+ | `recordId` su `Form` | Prop non esiste | La chiave del record viene da URL automaticamente |
1322
+ | `MenuConfig` non importabile | Era type interno | Ora esportato: `import type { MenuConfig } from '@llmnative/react'` |
1323
+ | `GridCellContext` non importabile | Mancava dall'index | Ora esportato: `import type { GridCellContext } from '@llmnative/react'` |
1324
+
1325
+ ---
1326
+
1327
+ ## Dove cercare nel codice sorgente
1328
+
1329
+ | Cosa | File |
1330
+ |---|---|
1331
+ | Form | `src/components/widgets/Form.tsx` |
1332
+ | Grid | `src/components/widgets/Grid.tsx` |
1333
+ | Prompt | `src/components/widgets/Prompt.tsx` |
1334
+ | Campi form | `src/components/ui/fields/` |
1335
+ | Blocks (Menu, Toolbar, Notifications, Dropdown…) | `src/components/blocks/` |
1336
+ | Provider data Firebase (RTDB) | `src/providers/data/firebase.ts` |
1337
+ | Provider data Firestore | `src/providers/data/firestore.ts` |
1338
+ | Provider data Supabase | `src/providers/data/supabase.ts` |
1339
+ | Provider storage Firebase | `src/providers/storage/firebase.ts` |
1340
+ | Provider storage Supabase | `src/providers/storage/supabase.ts` |
1341
+ | Provider auth Firebase | `src/providers/auth/firebase/FirebaseAuthProvider.ts` |
1342
+ | Provider auth Supabase | `src/providers/auth/supabase/` |
1343
+ | Provider auth Google | `src/providers/auth/google/` |
1344
+ | Provider auth Dropbox | `src/providers/auth/dropbox/` |
1345
+ | Provider email Gmail | `src/providers/email/google/GmailEmailProvider.ts` |
1346
+ | Provider AI definitions | `src/providers/ai/` |
1347
+ | Provider credenziali | `src/providers/credentials/` |
1348
+ | Provider session | `src/providers/ProviderSession.ts` |
1349
+ | Proxy CORS | `src/providers/proxy.ts` |
1350
+ | Motion system | `src/motion.ts` |
1351
+ | I18n | `src/I18n.tsx` |
1352
+ | Dropbox integration | `src/providers/dropbox/` |
1353
+ | Interfaccia DataProvider | `src/providers/data/DataProvider.ts` |
1354
+ | Interfaccia StorageProvider | `src/providers/storage/StorageProvider.ts` |
1355
+ | Interfaccia AuthProvider | `src/providers/auth/AuthProvider.ts` |
1356
+ | Interfaccia EmailProvider | `src/providers/email/EmailProvider.ts` |
1357
+ | Interfaccia AIProvider | `src/providers/ai/AIProvider.ts` |
1358
+ | Registry provider | `src/providers/manifest.ts` |
1359
+ | Config provider | `src/providers/ProviderConfiguration.ts` |
1360
+ | SEO / Google Trends | `src/providers/seo/google/` |
1361
+ | Sistema tema | `src/Theme.tsx` |
1362
+ | Config runtime | `src/Config.tsx` |
1363
+ | Tipi TypeScript | `src/types/` |
1364
+ | Utilities pure | `src/libs/` |