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