@woodylab/uims
v0.1.0-beta.23
Published
UIMS — User Interface Management System per Payload CMS 3 + Next.js: separa il contenuto dalla presentazione (ViewMode), temi/palette/stili configurabili da admin.
Readme
@woodylab/uims
User Interface Management System per Payload CMS 3 e Next.js (App Router).
UIMS separa il contenuto dalla sua forma. Il contenuto — pagine, menu, relazioni — resta in Payload.
La forma — viewMode, stili, temi, variabili — vive in sei file db.*.json versionati col progetto e
si modifica dallo Studio. A runtime il motore risolve il primo attraverso la seconda e produce
descrittori, che i componenti React disegnano senza sapere da dove vengano i dati.
Cambiare l'aspetto di qualcosa non è quindi mai una modifica al contenuto, e viceversa.
Questo file copre l'installazione, il cablaggio e i punti in cui il motore si estende. Il dettaglio per area del sorgente — cosa contiene ogni cartella e perché è divisa così — sta nella documentazione estesa.
Il pacchetto spedisce altri due documenti, dentro dist/ e quindi presenti in ogni installazione:
| File | Sottopercorso | Contiene |
|---|---|---|
| uims.md | @woodylab/uims/uims.md | le regole: entità, vincoli, e i guasti che non danno nessun errore |
| uims-workflow.md | @woodylab/uims/uims-workflow.md | l'ordine delle cose: la sequenza a runtime e a build, la catena causa-effetto, e una procedura per ogni tipo di intervento |
Si rimandano, non si copiano: una copia diverge al primo aggiornamento.
Requisiti
Pacchetto ESM-only, subpath-only (nessun export root ., per separare codice server e client RSC).
| Peer dependency | Versione |
| --- | --- |
| payload | ^3.54.0 |
| @payloadcms/richtext-lexical | ^3.54.0 |
| next | ^15.2.3 o ^16 |
| react | ^19 |
| tailwindcss · @tailwindcss/postcss | ^4 — obbligatorio, vedi sotto |
| tailwindcss-animate | ^1.0.7 |
Migra a Tailwind 4 PRIMA di installare. Con la 3 in albero
npm ifallisce conERESOLVEe il pacchetto non entra nemmeno: il blocco dell'init è la seconda rete, non la prima.Tailwind 4 è obbligatorio. Il
styles.cssscaffoldato punta gli store con@source, che in Tailwind 3 non esiste. Se alla radice c'è untailwind.config.*— o unatailwindcss@3fra le dipendenze — l'init si ferma senza scrivere niente e ti dà il comando:npx @tailwindcss/upgrade. In 4 la configurazione vive nel CSS, e quel file non serve più.Se invece Tailwind manca del tutto, l'init lo installa da sé, col gestore che legge dal tuo lockfile, e scrive un
postcss.config.mjsse non ce n'è uno. Nel pacchetto non può stare: gira nella tua pipeline PostCSS e scansiona i tuoi sorgenti, quindi annidato sotto il nostronode_modulesil tuopostcss.confignon lo risolverebbe.
Installazione
npm install @woodylab/uims@beta
npx woodylab-uims init # scaffold: rotte Next + glue consumer (uimsContext, styles.css, .uims/ generato) + primo utente dev
npx woodylab-uims css # rigenera il CSS del tema in .uims/ (in dev e prebuild gira da sé)Chiedila per nome, finché siamo qui. Il pacchetto esce come prerelease, e per npm una versione con suffisso sta fuori dagli intervalli:
^0.1.0non la prende, e non la prenderà nemmeno quando uscirà una beta più recente.@betaè l'etichetta che si sposta da sola sull'ultima, ed è il modo giusto di seguirla. Quando la superficie si ferma esce una0.1.0stabile e questo capoverso sparisce.initcopia i template una volta (salta i file esistenti,--forceper sovrascrivere).
L'ordine non è invertibile. Gli store
db.*.jsoncheinitcrea sono import statici: devono esistere prima che il bundler risolva gli import, quindiinitviene prima di qualunque build. È un errore rumoroso, ma manda a sbattere la build.
Se non basta, i file da creare a mano sono in Rotte da costruire.
Setup, in cinque passi
Plugin in
payload.config.ts(il tema vive nello Studio, non in Payload — nessuna opzione da passare):import { uimsPlugin } from '@woodylab/uims/plugin' export default buildConfig({ collections: [Users], plugins: [uimsPlugin({ collections: { pages: true, media: true } })], })Opzioni:
collections:{pages?,media?}·viewModeNames·triggerNames·animationNames·eventNames·effectNames·relationTargets·domainViewModes·adminBarBlocks·forms·formSubmissions·defaultToEmail(il form è del pacchetto ed è sempre registrato) ·site(la metà AMBIENTALE dell'identità:url,indexable,robotsDisallow— l'editoriale vive nel globalsettings, tab SEO) ·disabled.Le tre collection sono fabbriche con default sovrascrivibili.
collections: { pages, media, users }accettatrue(il default) oppure un oggetto che lo sostituisce chiave per chiave. Le stesse funzioni sono esportate —makeUimsPages,makeUimsMedia,makeUimsUsers,makeUimsForms,makeUimsFormSubmissions— così per aggiungere invece che sostituire si parte dal default:makeUimsUsers({ fields: [...makeUimsUsers().fields, { name: 'nickname', type: 'text' }] })Nessuna entra se il progetto ha già una collection con quello slug: la sua vince, perché due con lo stesso nome non fanno partire Payload.
Le
*Namessono ciò che l'admin propone nelle tendine, non ciò che esiste. Omesse, i trigger cadono sui nomi diuimsTriggers; le altre restano vuote finché non gliele passi — ed è voluto: sono nomi del tuo progetto, non del pacchetto.Payload non gestisce il tema: viewMode, stili, temi e variabili vivono negli store dello Studio (
db.*.json). Nel pacchetto non esistono collectionview-modes/stylesné globaltheme/theme-vars.Env:
DATABASE_URI(Mongo),PAYLOAD_SECRET(≥32 char),NEXT_PUBLIC_SERVER_URL(o su VercelVERCEL_PROJECT_PRODUCTION_URL).Il pacchetto non legge
process.env. Le variabili che lo riguardano le legge il TUO progetto e gliele passa: sono nomi tuoi, in una firma che li dichiara, diversi per due istanze nello stesso processo e verificabili senza mutare un globale. Fa eccezioneNODE_ENV, che non è configurazione ma la modalità di build.Sono tutte e otto FACOLTATIVE. Un progetto che non ne dichiara nessuna funziona: ognuna ha un default, ed è quello giusto per il caso normale. Si dichiarano per scavalcarlo, non per accenderlo —
.envche non le contiene è la configurazione corretta, non una dimenticata.| Variabile | Dove la passi | A cosa serve | Assente → | |---|---|---|---| |
STUDIO_OPEN|studioRoute({ open })| scavalca il gate di auth dello Studio — solo in sviluppo | lo Studio chiede il login | |WOODY_DB_VIEWMODES·WOODY_DB_STYLES·WOODY_DB_BASEVARS·WOODY_DB_THEMES·WOODY_DB_ANIMATIONS·WOODY_DB_EVENTS|studioRoute({ paths })·seedDbJson({ paths })| dove stanno gli store | idb.*.jsondella working directory; dichiarata solo la prima, le altre cinque la seguono nella sua cartella | |GOOGLE_FONTS_API_KEY|studioRoute({ googleFontsApiKey })| catalogo font ufficiale | il metadata endpoint pubblico, poi la lista interna |Lo scaffold crea
src/studioOptions.ts, l'unico punto in cui il progetto legge il proprio ambiente per lo Studio: le sette rotte lo spandono e basta.Tema attivo & rendering —
ACTIVE_THEMEinsrc/themes.ts(un tema per progetto, code-owned: si cambia da codice/Studio); il layout consuma@/uimsTheme(thin wrapper subuildActiveTheme): importa idb.*.jsone ne ricavaactiveVarsCss()(var del tema attivo) estylesCss()(regole.us-*).Stili — in
styles.css(Tailwind v4):@import "tailwindcss"+@sourceverso gli store dello Studio (generati dainit), così Tailwind genera esattamente le classi in uso (minimale in prod, niente safelist né validazione):@source "../../../db.viewmodes.json"; @source "../../../db.styles.json";Le var del tema e le
.us-*sono iniettate inlayout.tsxda@/uimsThemein un<style id="uims-theme-vars">.Seed & deploy — il seed gira a BUILD-TIME, poi è no-op a runtime (cold-start snelli):
onInit(consumer) e seed del plugin sono gated dietroRUN_SEED: a runtime (env assente) no-op.- script
build-seed+build = build:seed && next build: il seed crea/allinea glidb.*.jsone il contenuto una volta; se fallisce,&&aborta → prod resta sul deploy buono. - ⚠️ su Vercel
RUN_SEEDsolo nello step di build, MAI a runtime. - primo
npm run dev(impostaRUN_SEED) → seed → login su/admin= sito pronto col temauims; gestisci la forma su/admin/studio.
Entrypoint
| Subpath | Contenuto | Lato |
| --- | --- | --- |
| @woodylab/uims/plugin | uimsPlugin(options) — collection (form compreso) + global + onInit/seed + admin-view Studio | Server |
| @woodylab/uims/config | Collections, globals, blocks (factory makeBlockX), fields, hooks | Server |
| @woodylab/uims/engine | createUIMSContext(…, viewModesDb, stylesDb, activeTheme), uimsResolvers, resolver, mapper | Server |
| @woodylab/uims/theme | buildActiveTheme({ baseVars, themes, styles, activeTheme }) → { active, varsCss, stylesCss, themeVarsMap } + buildThemeVars | Server |
| @woodylab/uims/react | Componenti, uimsComponents, LayoutRenderer, BlockRenderer, provider eventi, uimsEventHandlers, uimsTriggers (le cause) e uimsActions (le conseguenze), defaultReducers/defaultWiring, lo store (useSliceState, useSliceControl), PAGE_EFFECT_NAMES | Client |
| @woodylab/uims/utils | getCachedGlobal, iconsList, generateCssVars/generateStyleRules — server-only (tira next/cache) | Server |
| @woodylab/uims/utils/generateCssVars | solo le funzioni PURE (generateCssVars, generateStyleRules): importabile anche da un componente client | Client-safe |
| @woodylab/uims/next | uimsRootMetadata, uimsPageMetadata, uimsSitemap, uimsRobots — metadati, sitemap e robots per le convenzioni di Next (adattatori di tre righe nel progetto) | Server |
| @woodylab/uims/studio · /next · /server · /adminView · /StudioNavLink · /PublicSiteNavLink | studioRoute(entity, opts), handler, la admin-view e le due voci di navigazione | Server/Client |
| @woodylab/uims/admin | AdminBar (editor in pagina): AdminBar e i suoi pezzi (AdminSectionTree, AdminSection), store ed eventi (ADMIN_EVENTS), describeBlocks (schema dei blocchi), applyDraft | Client |
| @woodylab/uims/blocks | builder per comporre blocchi UIMS nei tuoi seeder (text, action, richDoc, navigation, card, section…): ritornano plain object pronti per payload.create/update | Server |
| @woodylab/uims/baselines | i default con cui nasce un progetto: i cataloghi da cui init genera gli store dello Studio (viewModeDefaults, baselineStyles, effectDefaults, baseVarsDefaults, themePalettes) più il registro temi (themes, themeByName, themeOptions) | Server |
| @woodylab/uims/themes · /palettes · /view-modes · /styles · /base-vars · /effects | le stesse baseline, una chiave per sottopercorso: utile per importarne una senza tirarsi dietro le altre. Per il codice nuovo va bene /baselines, che non promette il nome dei file interni | Server |
| @woodylab/uims/dal · /icons | accesso dati cachato · catalogo icone | Server |
| @woodylab/uims/uims.md · /uims-workflow.md | le regole e l'ordine delle cose. Non si importano: si leggono dal filesystem risolvendo il sottopercorso | — |
Nessun wildcard, e non è pigrizia al contrario. La exports elenca 28 sottopercorsi uno per
uno. Un ./* avrebbe risparmiato la lista e reso pubblico ogni file interno: da quel momento
qualunque riorganizzazione delle cartelle sarebbe un breaking change per chi ha installato. I
percorsi dist/... sono incapsulati: non usarli.
In sviluppo (dentro questo repo) si consuma via
tsconfig.paths, che il wildcard ce l'ha. Sviluppare e installare quindi non sono lo stesso percorso — ed è la differenza che non dà nessun errore finché non la scopre chi installa. Due controlli la coprono:check-package-paths.mjsriconcilia le due liste, egate.shcostruisce un progetto ex novo, installa il tarball, lanciainite compila.
Il
dist/è minificato. Il codice non è open source ma finisce comunque sul disco di chi installa: la minificazione lo rende non consultabile per comodità, non protetto. Tre vincoli sono verificati dallo script di build perché rompono il pacchetto se saltano:'use client'resta (è il confine RSC), i nomi delle proprietà non si offuscano mai (uimsComponentsè cercato per stringa dai viewMode), e un file resta un file (la export map punta dentrodist/).
Cos'è UIMS
Separa il contenuto (i dati) dal come viene mostrato (la presentazione). Il "come" è il ViewMode: lega un dato a un componente React, a classi Tailwind, a un tag HTML e a degli slot (content-projection).
Principi:
- Colori sempre semantici: i ViewMode usano token (
bg-background,text-primary,bg-secondary,text-accent,border-secondary…), il cui valore viene dalla palette del tema attivo. Mai classi-colore raw. - Un tema = un insieme di variabili + stili: la base (
db.base-vars.json) contiene la totalità delle variabili modificabili (colori light/dark, font, scala/tipografia, motion, componenti); un tema (db.themes.json) è un override della stessa lista. Il tema attivo è la costanteACTIVE_THEME(code-owned). Un tema ridefinisce solo ciò che vuole diverso; il resto eredita dalla base. - Fonte di verità = gli store dello Studio (i
db.*.json, versionati). Si editano dallo Studio (/admin/studio), non dal pannello Payload. In dev la scrittura è live (fs); in prod sono read-only (si aggiornano via commit + redeploy).
Pipeline: Dato → ViewMode (da db.viewmodes.json, risolto per NOME nel tema attivo→uims fallback) → contesto UIMS (registry, dati iniettati) → componente → renderer. Le classi le genera Tailwind via @source sugli store (db.viewmodes.json/db.styles.json) — niente safelist né validazione.
Lo Studio — /admin/studio
La superficie di gestione di tutta la forma dell'interfaccia. È una admin-view Payload custom (gated dall'auth admin), registrata zero-config dal plugin, con UI propria (fuori dalla chrome Payload). Gestisce sei store (una rotta per lista, raggruppamento e dettaglio client-side) + i font:
| Tab | Store | Cosa gestisci |
| --- | --- | --- |
| ViewModes | db.viewmodes.json | raggruppati per component. Per ognuno: component, tag, className, styles[] (riferimenti per nome), slots e themeStyles (override per-tema strutturato: className/styles/slots per ogni tema a sistema). |
| Styles | db.styles.json | raggruppati per categoria. { name, categoria, css?, classes? }. |
| Themes | db.themes.json | i temi (palette con toggle light/dark, font con anteprima live, vars di override). |
| Base Vars | db.base-vars.json | la base completa: palette (light/dark) + font + scala/tipografia + motion + componenti; il tema le sovrascrive. |
| Animations | db.animations.json | le ricette di animazione, in tre momenti: initial prima del trigger, la transizione, ending dopo. Le classi vengono dagli stili dello Studio → Tailwind le genera dallo stesso file. |
| Events | db.events.json | il catalogo dei nomi che l'interfaccia propone nelle tendine. Non è l'elenco di cosa risponde: un handler può esistere senza essere dichiarato, e un nome può essere dichiarato senza handler (gli eventi di stato, che tratta il reducer). |
| Fonts | — (endpoint) | lista Google Fonts live (/api/studio/fonts): Webfonts API se GOOGLE_FONTS_API_KEY, altrimenti il metadata endpoint pubblico; cachata, con fallback. Selettore con anteprima. |
Perché file e non collection. Due ragioni, e la seconda è quella decisiva. Il percorso di render non fa una query per sapere che aspetto ha una Card: gli store sono import statici. E Tailwind sa leggere un file con
@source, un database no — è da lì che nasce l'assenza di safelist. Spostare la forma in database significherebbe reintrodurla il giorno stesso.
CRUD completo (create/update via Salva, Elimina per viewMode/style/tema). Gli endpoint /api/studio/* sono scaffoldati dall'init e gated (ruoli ADMIN/WEBADMIN/DESIGNER); la logica sta in studioRoute(entity, { config, data }) del pacchetto.
La AdminBar — l'editor in pagina
Lo Studio gestisce la forma (viewMode, stili, temi) dall'area admin. L'AdminBar gestisce le istanze — questa sezione, questo testo, questa classe — stando sulla pagina, con la pagina come anteprima. Si vede solo a utente autenticato: per un visitatore non rende nulla di proprio e il markup resta identico a com'era senza di lei.
Tre slot, e la loro composizione è un GLOBAL (AdminBar, gruppo UIMS in Payload):
| Slot | Default | Cosa ci puoi mettere |
| --- | --- | --- |
| trigger | la matita (in basso a sinistra) | qualunque blocco: di norma un action che dichiara admin:toggle nei suoi uiState.events |
| bar | l'albero degli elementi della pagina | adminSectionTree, o i tuoi blocchi |
| sectionDrawer | la gestione dell'elemento selezionato | adminSection, o i tuoi blocchi |
Slot vuoto = default: finchè non componi niente, la barra funziona come descritto qui. Comporre è opt-in e si fa solo da Payload — la barra non edita sè stessa, o sarebbe l'unico modo di rendersi inutilizzabile senza avere più l'interfaccia per rimediare.
Il form viene dallo SCHEMA, non dal dato: i campi si leggono dalla definizione Payload del blocco (tab Section · Container · Content, e dentro ognuno UI · UIManager), quindi compare anche ciò che non è mai stato valorizzato. Cliccando un elemento nella pagina il pannello apre da solo la catena di tab e evidenzia la card giusta.
Mentre modifichi, la pagina riflette (testo e classi d'istanza, a qualunque profondità): non è editing in-line — il campo sta nel drawer — ed è reversibile: Annulla o chiusura riportano tutto com'era. Cio' che cambia il componente (viewMode, struttura) si vede al salvataggio, quando è il server a renderlo.
Salvare è un evento: il bottone chiede admin:save, chi ha i canali scrive, e l'esito torna come admin:saved / admin:saveFailed; sul successo la pagina si rigenera dal server.
⚠️
admin:è un namespace RISERVATO. Gli eventiadmin:*vengono inoltrati dal kernel del sito allo store dell'editor: se chiami così un tuo evento, piloti l'AdminBar senza volerlo. Per i tuoi usa un namespace tuo (shop:,app:, il nome del progetto).
⚠️ I viewMode del tema
admindevono puntare a componenti CLIENT. Gli slot dell'editor si rendono nel browser: un componente server in quel sottoalbero non attraversa il confine e lo slot arriva vuoto, in silenzio. Per questo il pacchetto fornisce i gemelli client (AdminSlot,AdminText,AdminAction,AdminSectionTree,AdminSection).
Cosa scaffolda l'init (tutto cablato, niente da montare a mano):
| File | Ruolo |
| --- | --- |
| src/adminBridge.tsx | come si legge e si scrive il dato (quale entità, quale rotta): è tuo, non del pacchetto |
| src/uimsAdminContext.ts | il contesto UIMS del tema admin (stesso motore, altro tema) + resolveAdminSlots |
| src/app/api/uims/schemas/route.ts | la forma dei blocchi per il form (~190 KB, chiesti una volta; auth-gated) |
| layout.tsx | <UIMSEventsWrapper> sopra <AdminBridge> — l'ordine conta: gli eventi dei blocchi admin passano dal kernel |
Cosa NON fa (ancora): aggiungere o togliere blocchi figlio; modificare richText, immagini e relazioni (restano in Payload); header/footer (si modificano dai rispettivi global); i campi condizionali si vedono sempre (la condition di Payload è una funzione, e non sopravvive al descrittore).
Stato iniziale, dopo init e il primo build
Gli store db.*.json li crea npx woodylab-uims init (a scaffold-time, dai default del pacchetto: sono import statici, devono esistere prima del compile). I contenuti — menù, header, footer, pagine — sono seed del progetto, dichiarati in app-seeds.ts: il pacchetto offre contenuti neutri di partenza (menuIniziale, headerIniziale, footerIniziale) ma non decide. init lancia il primo seed una volta; da lì in poi si semina solo dal pannello «Seed forzato» (solo ADMIN). Nessun boot semina niente, mai — e l'onInit del progetto resta suo, il plugin non lo tocca. Trovi già pronto:
Seed forzato (contenuti ri-seminabili) — dichiara per entità di dominio in
src/app-seeds.ts(menus,forms,blocks,sections,pages,globals) e passa le liste al plugin conseedForcepiùcache: { revalidateTag, revalidatePath }(danext/cache, nella TUA config): compaiono un pannello nella dashboard (solo ADMIN — per gli altri non esiste) e la portaPOST /api/uims/seed-force. Upsert per chiave, mai potatura; la vocetuttosemina nell'ordine di dipendenza (menus → forms → blocks → sections → pages → globals) e le relazioni si dichiarano con{ $seedRef: { collection, name } }, risolte in id dal runner. Con le liste vuote non esiste niente. Le regole che i tipi non dicono sono commentate dentroapp-seeds.ts.
- ViewMode del tema
uims: completi — ogni componente (Card,Section,Container,Header,Footer,Figure,Button*,Accordion,Swiper, …) ha giàclassName/styles/slots. Non vanno ricreati né ri-stilati da zero: si editano dallo Studio. - Tema
uimsattivo (ACTIVE_THEME): palette e font indb.themes.json; stili riusabili indb.styles.json. - Header, footer e menù iniziali come utility pronte da dichiarare nei seed; eventi/effetti baseline — il contenuto resta in Payload (dominio).
- Pagine demo + la superficie protetta
/uims/manage(hub).
La regola: si CLONA, non si edita ciò che il pacchetto spedisce
⚠️
updateripristina i viewMode del pacchetto, interi. Un viewMode che il pacchetto spedisce è suo: se lo modifichi nello Studio, il primonpx woodylab-uims updaterimette la sua versione —className,styles, slot ecomponentcompresi. Non è un difetto del comando, è cosa vuol dire «aggiornare»: se il pacchetto non potesse ripristinare ciò che spedisce, non potrebbe correggere niente per nessuno.Quindi: clona. Duplica il viewMode con un nome tuo —
CardFeature→CardOfferta— e vesti quello. I nomi che il pacchetto non conosce non li tocca nessuno.Lo stesso vale per il materiale agentico: le regole (
.claude/uims/regole.md) e le skill (.claude/skills/uims*) sono del pacchetto eupdatele riallinea a ogni versione — le tue istruzioni stanno inCLAUDE.md, che è tuo e nessun comando tocca (cita le regole con@.claude/uims/regole.md).La garanzia più forte è un tema tuo:
updatenon tocca in nessuno store ciò che appartiene a un tema che il pacchetto non gestisce. Se stai costruendo un'identità che deve restare tua, mettila lì, e non serve fidarsi di nessuna regola.Vale solo per i viewMode e gli stili che il pacchetto SPEDISCE. Quelli che aggiungi tu — nomi nuovi, temi tuoi — restano dove sono, sempre.
E dentro quello spazio: si edita dallo Studio, non si ricrea da zero
La forma (viewMode/stili/tema/variabili) si modifica nello Studio; il codice (i default del pacchetto) è la base da cui gli store nascono. Quando sviluppi:
| Vuoi… | Fai | NON fare |
| --- | --- | --- |
| Cambiare l'aspetto di un componente | Clona il suo ViewMode con un nome tuo e vesti quello | editare il ViewMode del pacchetto |
| Cambiare colori/font/scala del tema | Base Vars (default condivisi) o Themes (override del tema) nello Studio | hardcodare valori nei ViewMode |
| Aggiungere una pagina | Componi blocchi che referenziano ViewMode esistenti per nome (in Payload Pages) | inventare stili nuovi inline |
| Aggiungere un componente/variante tua | uiComponents (component custom) + domainViewModes → Estendere | toccare il pacchetto o i ViewMode uims |
| Dati di dominio | Tua collection in relationTargets + una sezione con righe (tab Data) | hardcodare dati nei ViewMode |
Un ViewMode/blocco si visualizza risolvendo il suo ViewMode (UIMS.resolve), non ridisegnandolo: se un componente "sembra vuoto", il problema è il contenuto/slot o il ViewMode giusto per nome, non uno stile mancante.
Stili riusabili — db.styles.json
Gli stili nominati vivono in db.styles.json (tab Styles), richiamati dai ViewMode per NOME (campo styles, o token us-<nome> in una className). Ogni stile è { name, categoria, css?, classes? } con tre nature:
classes→ utility Tailwind (es.text-xs,rounded-2xl): generate da Tailwind che fa@sourcesudb.styles.json; il resolver le espande nelclassNamedel ViewMode/slot/istanza.css→ CSS grezzo (es. gradienti convar(--color-*)): genera la regola.us-<name>. Usa ivar(--color-*)→ si ri-stila da sé al cambio tema.classesche cita altrius-*→ una composizione:bgGridèus-surfacePrimary us-patternGrid. L'espansione è ricorsiva, quindi arriva fino alle utility vere.
Quale delle prime due, e non è un gusto. tailwind-merge sa risolvere un conflitto fra due utility — l'ultima vince — ma contro una regola .us-* è cieco. Quindi classes ovunque le utility bastino, css solo dove non arrivano (ombre composite, color-mix, pseudo-elementi) — e lì ogni stile occupa un asse solo, perché nessuno lo protegge dal collidere.
I quattro livelli — design token 3+1
L'aspetto passa per quattro strati, e nessuno ripete quello sotto:
① variabile --text-xs: 0.75rem il VALORE (palette / base-vars)
② selettore text-xs il MECCANISMO (Tailwind, via @theme)
③ vocabolario us-textXs = "text-xs" il NOSTRO NOME (uno per valore d'asse)
④ ruolo us-eyebrow = "us-textXs us-weightSemibold …" il NOME LEGGIBILE (composizione)Si chiama 3+1: i primi tre sono il sistema di token, il quarto è d'appoggio — togli il quarto e tutto continua a funzionare, togli il terzo e crolla tutto.
Il terzo livello è organizzato per asse — superficie, inchiostro, bordo, raggio, elevazione, trama, luce, rango tipografico, movimento — e uno stile occupa un asse solo. Ogni asse copre la propria scala per intero (2xs · xs · sm · md · lg · xl · 2xl, la stessa ovunque); un modificatore invece non si genera: l'opacità è già di Tailwind, e si compone al volo (us-borderW1 border-primary/50).
Il quarto livello è ciò che permette a chi inserisce contenuto di scegliere «una polaroid» senza sapere cos'è un box-shadow.
Chi possiede cosa: motore, Studio, progetto
| Ambito | Package @woodylab/uims | Studio (db.*.json) | Consumer (il tuo progetto) |
| --- | --- | --- | --- |
| ViewMode / stili / temi / base-vars | ✅ default (seed if-missing) | ✅ fonte di verità (editi qui) | scegli ACTIVE_THEME; estendi via domainViewModes |
| Rendering del tema | ✅ helper @woodylab/uims/theme | i valori | il layout consuma l'helper (@/uimsTheme) |
| Contenuto Payload: pagine, chrome, menu, eventi, effetti, relazioni | ✅ engine + seed baseline | — | pagine e dati tuoi |
| Rotte Next (layout / pagine / preview / studio) | scaffold (init) | — | ne possiedi i file |
| Infrastruttura: DB, storage media, hosting, env, ACL | — | — | ✅ tua |
Esempio: comporre una pagina
Costruire una pagina significa comporre blocchi, ognuno associato a un ViewMode esistente scelto da un menu a tendina (in Payload Pages). Non si scrive CSS: lo stile lo porta il ViewMode.
[
{
"blockType": "sections",
"section": { "viewMode": "Section", "content": [] },
"container": { "viewMode": "Container", "content": [] },
"content": [
{
"blockType": "content", // resolver: contenitore
"viewMode": "Card", // aspetto: già stilato per il tema attivo
"slot": "content", // dove va nel Container
"content": [
{ "blockType": "text", "viewMode": "Text", "slot": "header", "text": "Chi siamo" },
{ "blockType": "text", "viewMode": "Text", "slot": "content", "text": "Studio di design con base a Milano." },
{
"blockType": "action",
"viewMode": "ButtonPill",
"slot": "footer",
"action": { "actionType": "link", "actionTarget": "/contatti", "target": "_self" },
"content": [ { "blockType": "text", "viewMode": "ButtonLabel", "text": "Contattaci" } ]
}
]
}
]
}
]Ogni blocco porta blockType (quale resolver lo interpreta), viewMode (quale aspetto, per nome), slot (in quale fessura del genitore va) e il contenuto. Il motore risolve ogni viewMode per nome → componente + classi + slot; il renderer disegna l'albero. In tutto il JSON non compare una classe CSS: cambiare aspetto = scegliere un altro ViewMode. Lo stesso contenuto, cambiando ACTIVE_THEME (o editando un tema nello Studio), si ri-stila da solo.
Comportamento e stato — il gruppo uiState
Ogni blocco riceve, senza doverlo dichiarare, un gruppo uiState: è lì che vive tutto ciò che il
blocco fa invece di essere. Quattro cose, e sono quattro perché rispondono a domande diverse.
| Chiave | Domanda | Forma |
| --- | --- | --- |
| events | «quando succede X, annuncia Y» | { trigger, event, payload?, scope?, await?, successEvent?, errorEvent? } |
| effects | «quando succede X, fai Y» | { trigger, animate? / event? / run?, when?, payload?, scope? } |
| bindings | «questa prop segue una chiave dello stato» | { prop, path, mode?, when?, transform?, scope? } |
| store · nodeId | «questo nodo ha uno stato proprio» | interruttore + id con cui la slice viene indirizzata |
Gli indirizzi. Lo scope decide sia la chiave nello store sia chi riceve l'evento: vuoto =
tutti · self = solo questo nodo · #<nodeId> = un nodo preciso. Vale da entrambi i lati — chi
ascolta deve dichiarare self per sentire un evento indirizzato. È la ragione per cui tre accordion
identici nella stessa pagina non si svegliano tutti quando l'evento riguarda uno solo.
Le slice sono dichiarate, non dedotte. Quale stato espone un tipo di blocco è un dato
(config/slices/blockSlices.ts), non una riga dentro il componente: l'admin lo può mostrare e lo
store conosce tipo e proprietario invece di avere solo un valore. Il vocabolario è di cinque parole,
ed è deliberatamente piccolo perché la stessa parola con due significati non dà nessun errore:
| Slice | Tipo | Blocchi |
| --- | --- | --- |
| open | boolean | collapsible |
| active | string — quale figlio | accordion, tabs |
| pressed | boolean | toggle |
| value | json — il valore di un gruppo | toggleGroup |
| index | number — una posizione | swiper |
Ci si sveglia solo per le proprie chiavi. Un nodo dichiara quali chiavi guarda e si sottoscrive a quelle; un nodo che ne guarda zero — quello con la sola animazione — non si sottoscrive affatto.
I token nel payload. Il payload è opaco (il kernel non lo interpreta), ma i suoi valori-stringa
possono portare {{event.*}}, {{query.*}}, {{node.*}}, {{now}}, risolti quando l'evento parte.
Un token intero restituisce il valore vero preservandone il tipo; dentro una frase produce una
stringa.
In produzione
- Plugin —
uimsPlugin({ collections:{pages:true} }). (package: engine + seed contenuto; il tema è negli store.) - Env di runtime —
DATABASE_URI,PAYLOAD_SECRET(≥32), URL pubblico. ⚠️RUN_SEEDMAI a runtime. (consumer.) - DB — istanza tua (es. Mongo Atlas) + Network Access che ammetta il provider. (consumer.)
- Build = seed + next build — il seed gira una volta a build (crea gli
db.*.jsonse assenti, allinea il contenuto). (package fornisce lo script; tu lo colleghi.) - Store del tema — i
db.*.jsonsono versionati: in prod sono read-only, si aggiornano editando nello Studio in dev e ri-deployando (commit → build). (consumer.) - Cache / ISR — gestita dal pacchetto (pagine statiche, invalidazione on-change via hook). (package.)
- Sicurezza / ACL — restringi ciò che è permissivo (allowlist DB, ruoli di
/uims/managee dello Studio). (consumer.)
Requisito: moduleResolution
Il tuo tsconfig.json deve avere "moduleResolution": "bundler" (oppure node16/nodenext).
Non è pignoleria: il pacchetto non espone main né types alla radice — ha solo exports, e i
tipi stanno dentro le sue condizioni ("./react": { "types": "./dist/react/index.d.ts", … }).
TypeScript legge exports soltanto con quelle tre modalità. Con il vecchio "node" ignora il
campo, cerca main/types alla radice, non li trova, e ti dà Cannot find module
'@woodylab/uims/react' or its corresponding type declarations — mentre a runtime il bundler
risolve benissimo. Sembra un bug del pacchetto; è una riga del tuo tsconfig.
Rotte da costruire (se init non basta)
Un plugin Payload non può iniettare rotte Next: init le copia una volta. Idea comune: getPayload → UIMS.resolve(block) → renderer.
Glue (una volta)
src/uimsContext.ts—createUIMSContext(payload, uiComponents, resolvers, viewModesDb, stylesDb, ACTIVE_THEME). Iniettadb.viewmodes.json(viewMode) edb.styles.json(stili) come moduli (bundlati, zero fs nel render). Punto d'aggancio per componenti/resolver custom.src/uimsTheme.ts— thin wrapper subuildActiveTheme(importadb.base-vars.json/db.themes.json/db.styles.json+ACTIVE_THEME); esponeactiveVarsCss/stylesCss/themeVarsMap.src/uimsEventsWrapper.tsx,src/app-events.ts,src/utilities/deployId.ts.
app/(frontend)/layout.tsx — legge header/footer (CACHATI: unstable_cache + DEPLOY_ID + tag global_<slug>); emette `${activeVarsCss()}\n${stylesCss()}` in <style id="uims-theme-vars">; risolve header/footer con UIMS.resolve + BlockRenderer; avvolge children in UIMSEventsWrapper. Non legge più global theme/theme-vars.
app/(frontend)/page.tsx e [slug]/page.tsx — ISR on-demand (niente force-dynamic/revalidate a tempo): pagine statiche, rigenerate solo al cambio di un oggetto in pagina (query cachata, tag pages_<slug>); layout via LayoutRenderer, meta via generateMeta. Invalidazione dagli hook del pacchetto.
⚠️ L'hub sta a
/uims/manage, non a/uims: in Next un segmento STATICO batte il dinamico[slug], quindi una rotta montata su/uimsoscurerebbe l'eventuale pagina pubblica con quello slug — in silenzio, con un redirect al login. Vale per qualunque rotta statica tu aggiunga.
app/(protected)/ — gate unico sul layout (payload.auth → ruoli fieldRoles). Ospita /uims/manage (hub). Base grafica dagli store del tema (via @/uimsTheme).
app/api/studio/*/route.ts — le 5 rotte dello Studio (viewmodes/styles/themes/base-vars/fonts), thin: export const { GET, PUT, DELETE } = studioRoute('<entity>', { config, data }) (dove data = lo store importato come modulo; fonts è GET-only, no store).
app/(frontend)/styles.css — Tailwind v4: @import "tailwindcss" + @source "../../../db.viewmodes.json" / "../../../db.styles.json" + @custom-variant dark + @theme (mappa i token) + @import CSS terze parti.
Tema & variabili (base-vars ⊕ themes)
db.base-vars.json= il cablaggio delle dipendenze che il pacchetto si porta dietro, e nient'altro:swiperThemeColor,accordionDuration,accordionEasing. Radix e Swiper leggono variabili CSS proprie, e nessun tema può saperlo. Ogni altra chiave — movimento, ombra, superfici native — porta il proprio ripiego dentrogenerateCssVars: si scrive nel tema solo per scostarsi.db.themes.json= i temi; ognuno hapalette+fonts+vars. A render:buildThemeVarsfondebase ⊕ palette ⊕ fonts ⊕ vars→ il tema vince.La palette ha quaranta chiavi: dieci ruoli, ognuno con la variante scura e con il proprio inchiostro (
primaryInkColor). Fondo e inchiostro sono un incarico solo: chi compone scriveus-surfacePrimarye riceve tutti e due, e non può sbagliare l'accoppiata perché non ha modo di separarla.Sui colori non c'è eredità. Un tema dichiara la palette intera: un ruolo omesso non degrada, sparisce —
--color-<ruolo>resta indefinito e chi lo usa non dipinge niente. È severo di proposito: un tema è un insieme di incarichi, e un incarico vacante si deve vedere subito.Tema attivo =
ACTIVE_THEME(costante, un tema per progetto): scelta code-owned (si cambia da codice/Studio). A render il layout emette le var del tema attivo nel<head>; non c'è switch di TEMA esposto al client.Da non confondere con il componente
ThemeSwitch, che il pacchetto spedisce: quello commuta light/dark (documentElement.dataset.theme+localStorage), che è una dimensione della palette. Cambiare tema resta una scelta di progetto.La differenziazione strutturale fra temi (raggi, bordi, ombre, spaziature, tipografia) non sta nel tema: sta nei
themeStylesdei viewMode, indicizzati per nome di tema. È quello che tiene leggero un catalogo di temi — altrimenti ogni tema sarebbe una copia della style guide.classNameper-istanza: ogni blocco compositivo ha, accanto alviewMode, un campoclassNamefuso (twMerge) col ViewMode → l'istanza vince. Ammette Tailwind (generate via@sourcesugli store), valori arbitrari[...]e i tokenus-<nome>(riferimento a uno stile per nome → espanso a render).
La codebase, in dieci aree
Il sorgente non è diviso per tipo di file — non c'è una components/, una hooks/, una
types/ — ma per il momento in cui una cosa entra in gioco. La conseguenza pratica: per sapere
dove va un file nuovo non serve chiedersi che tipo di file è, serve chiedersi quando serve.
| Area | Cosa ci vive |
| --- | --- |
| src/engine/ | legge il dato e lo traduce in descrittori. Gira sul server. Dentro: resolver (dal blocco al descrittore), mapper (i dati negli slot), styling (dal viewMode alle classi), dal (lettura cachata) |
| src/react/ | prende i descrittori e li disegna. L'area più grande: i componenti, più state, events, triggers, actions |
| src/config/ | la configurazione Payload: blocchi, campi, collection, global, hook. È la superficie che vede chi scrive contenuto |
| src/studio/ | l'editor della forma, servito come vista dell'admin |
| src/admin/ | la AdminBar: modificare le istanze stando sopra il sito pubblico |
| src/baselines/ | i valori da cui init scrive i sei store. Non sono runtime: il motore legge gli store, non questi file |
| src/plugin/ · src/cli/ | il punto (unico) in cui il pacchetto entra in un progetto, e ciò che scaffolda |
| src/theme/ · src/utils/ | le variabili complete di un tema; CSS var, cache dei global, colori, icone |
La regola che regge il resto è una: engine non importa da react, e react non importa da
engine se non i tipi. Il primo non sa che esiste un browser, il secondo non sa da dove viene il
dato. È anche il motivo per cui i tre file di stile sono usciti da resolver/: la dipendenza fra le
sottocartelle deve restare a senso unico (styling ← mapper ← resolver).
Estendere (consumer-side)
Il motore è VUOTO. Quello che passi è tutto quello che c'è. Il pacchetto non si mette niente da
solo: esporta i suoi default come oggetti, e a cablarli è il tuo progetto — nei file che init
scaffolda, dove restano in chiaro. Così si legge cosa è attivo, in conflitto vinci tu, e — la parte
che nessun merge nascosto concede — puoi togliere un default
(const { navigation, ...resolvers } = uimsResolvers).
| cosa | default del pacchetto | dove componi | dove lo passi |
|---|---|---|---|
| componenti | uimsComponents | uimsContext.ts | createUIMSContext(payload, uiComponents, …) |
| resolver | uimsResolvers | uimsContext.ts | createUIMSContext(…, resolvers, …) |
| handler | uimsEventHandlers | app-events.ts | <UIMSEventProvider handlers={…}> |
| trigger (le cause) | uimsTriggers | app-triggers.ts | <UIMSEventProvider triggers={…}> |
| action (le conseguenze) | uimsActions | app-actions.ts | <UIMSEventProvider actions={…}> |
| middleware | — | app-events.ts | kernel.use(fn) — trasforma restituendo l'evento, annulla restituendo null |
| reducer / wiring | defaultReducers · defaultWiring | app-state.ts | <UIStateProvider reducers={…} config={{ wiring }}> |
| effetti (store) | — | app-state.ts | <UIStateProvider effects={…}> · kernel.registerEffect(nome, fn) |
Ogni riga di quella tabella è un file che ti ritrovi nel progetto, non un comportamento nascosto nel
pacchetto. Il rovescio, da sapere: un canale che non passi resta spento, in silenzio. È il punto —
ed è anche il motivo per cui non conviene cancellare righe dallo scaffold senza sapere cosa fanno:
...defaultWiring porta ui:setSlice, il canale con cui Tabs, Accordion, Toggle e Swiper pubblicano
il proprio stato quando hanno la «gestione store».
Componente
import { UIMSPrimitive } from '@woodylab/uims/react'
export const Etichetta = ({ ui, data, ...rest }: any) =>
<UIMSPrimitive ui={ui} {...rest}>{data?.text}</UIMSPrimitive>
// uimsContext.ts: const uiComponents = { ...uimsComponents, Etichetta }Regola: se renderizza <Slot> (nodi server) non marcarlo 'use client'.
Resolver (logica a resolve-time; chiave = blockType)
import { resolveViewMode, uimsResolvers } from '@woodylab/uims/engine'
export async function greetingResolver(block, context) {
const r = await resolveViewMode(block, context)
r.data = { ...(r.data || {}), text: `Ciao ${block?.name || 'mondo'}!` }
return r
}
// uimsContext.ts: const resolvers = { ...uimsResolvers, greeting: greetingResolver }Evento (kernel) — app-events.ts: export const customHandlers = { 'cart:add': async (e) => ({ status:'ok' }) }. Dispatch: useUIMSEvents().dispatch({ type:'cart:add', payload }); da admin, il gruppo uiState.events dell'istanza (vedi sotto).
⚠️
actionTypevalelink·submit·none.eventescrollnon esistono più: erano COMPORTAMENTI travestiti da destinazioni, e sono passati inuiState. Seminarli non dà nessun errore — scrive un dato che nessun ramo riconosce, e l'elemento non appare.
Il criterio di cosa merita un handler è netto: ci sta quello che lo store non può fare. Scorrere, mettere a fuoco, copiare, stampare sono effetti sul browser. Tutto ciò che si esprime come «una chiave cambia e qualcosa si ri-stila» non è un handler: è un reducer più un binding.
Trigger — la CAUSA. Riceve il nodo reso (se è relazionale), i suoi params (dato
d'istanza) e un fire; non sa cosa viene dopo — lo decide la conseguenza che lo nomina.
// app-triggers.ts
import { uimsTriggers } from '@woodylab/uims/react'
export const customTriggers = {
// ambient: la finestra è scrollata oltre `params.px`. Scatta al CAMBIO di soglia, non a ogni scroll.
'on:scrollPast': ({ params, fire }) => {
const soglia = Number(params?.px ?? 500)
let oltre: boolean | null = null
const guarda = () => {
const ora = window.scrollY >= soglia
if (ora !== oltre) { oltre = ora; fire({ oltre: ora, y: window.scrollY }) }
}
guarda(); window.addEventListener('scroll', guarda, { passive: true })
return () => window.removeEventListener('scroll', guarda)
},
}
export const mergedTriggers = { ...uimsTriggers, ...customTriggers }Nel dato del blocco: uiState: { effects: [{ trigger:'on:scrollPast', params:{ px:300 }, dispatch:'ui:set', payload:{ key:'oltre', value:true } }] }.
I dieci trigger spediti: on:viewportEnter · on:viewportLeave · on:viewportProgress
(relazionali — params: threshold come frazione dell'elemento, once, root/rootMargin;
un solo IntersectionObserver per nodo e opzioni, non uno per trigger) · on:click · on:change
· on:submit · on:hover · on:mount · on:stateChange (una chiave dello store cambia) ·
on:event (bus — params.eventName).
Action — la CONSEGUENZA, gemella del trigger e altrettanto aperta. Le quattro spedite:
| Action | Cosa fa |
|---|---|
| animate | applica una ricetta di db.animations.json (i tre momenti) |
| emit | annuncia sul bus: chiunque può ascoltare |
| dispatch | comanda: uno-a-uno, e con await l'esito torna come successEvent/errorEvent |
| run | invoca un effetto registrato per nome — e gli passa il nodo, che un messaggio sul bus non porta |
// app-actions.ts
import { uimsActions } from '@woodylab/uims/react'
export const mergedActions = { ...uimsActions, log: ({ config, node }) => console.debug(config, node) }Entrambi i registri si potano, non solo si estendono: quello che passi al provider è l'insieme attivo. Un progetto che non usa i trigger di viewport li toglie e non ne paga il codice. Le chiavi però sono quelle che il dato già nomina: rinominarne una rompe contenuto già inserito.
ViewMode di dominio — passali al plugin (theme consumer), seedati nello store col resto:
const CardPromo = { name:'CardPromo', component:'Card', className:'rounded-xl bg-info p-6',
styles:['borderPrimary'], slots:[{ name:'content', className:'p-4' }] }
uimsPlugin({ domainViewModes: [CardPromo] })Relazioni
Blocchi + resolver dedicati: reference (un documento citato in un layout), sectionRef (una sezione riusabile), navigation (un menu). I dati entrano dalle righe data di global, pagina e sezione; i blocchi li leggono con repeatFor e dataPathValue ({ "prop": "percorso" }).
- Dati di DOMINIO: dichiara le tue collection con
relationTargets: ['articles', …]: diventano sorgenti delle righe dati e bersagli direference. Per listare una collection usa una sezione con righe. - Il wiring viewMode è per NOME (campo text): i resolver risolvono gli slot del wrapper dal registry (
db.viewmodes.json), non da relazioni.
Entità nel pannello Payload
Payload gestisce il contenuto/dominio, non il tema (le collection/global del tema non esistono nel pacchetto):
- Collection:
blocks,sections,menus(+formseform-submissions, sempre; +pages,mediaeusers, opt-in dalle opzioni del plugin). Global:settings,header,footer,AdminBar. - NON in Payload:
view-modes/styles(collection),theme/theme-vars(global) → Studio (db.*.json). - Neanche
eventseanimations: erano collection, ora sono i due storedb.events.jsonedb.animations.json. Il catalogo delle ricette riferiva per nome stili che vivono nei file — metà artefatto da una parte e metà dall'altra, e una modifica in admin non compariva in nessun diff.
Note
- Studio = DEV-ONLY: in produzione la admin-view
/admin/studionon è registrata e gli endpoint/api/studio/*rispondono 404 (tutti i metodi). Si edita in dev e si versionano idb.*.json→ commit + redeploy. - Superficie protetta
/uims: l'hub/uims/manage(gate su ruolifieldRoles). Il Playground non fa parte del comportamento base ed è escluso dallo scaffold. - Il motore non legge
process.env(eccettoNODE_ENV, che non è configurazione ma la modalità di build). Nemmeno i reducer se li installa:UIStateProvidernon ha default, e a cablarli èapp-state.tsnel tuo progetto — dove si vedono, si estendono e si possono togliere. Reducer,Wiring,EffecteMutationsono contratto pubblico, perché è contro quei tipi che scrivi la tua logica di dominio. La loro forma è stabile per scelta.Mutationporta sempre unakey, anche quando porta unpath: ilpathè previsto per scrivere su una foglia (sections.hero.title), che oggi non è implementata — chi la produrrà dovrà comunque valorizzarekeycon la radice del percorso. Il campo è dichiarato prima della pubblicazione proprio perché aggiungerlo dopo cambierebbe una forma già in mano a chi ha installato.- I dodici punti di estensione: componenti · resolver · viewMode · stili · temi · blocchi · campi · trigger · action · handler · middleware · reducer/effetti. Nessuno richiede di modificare il pacchetto, e nessuno usa decoratori o convenzioni magiche: sono oggetti.
Licenza
@woodylab/uims è distribuito sotto la Prosperity Public License 3.0.0 (il testo è nel file
LICENSE). In una frase: per uso personale e non commerciale è gratis, senza limiti; per uso
commerciale hai trenta giorni di prova, poi serve un accordo con l'autore.
- Gratis, per sempre: progetti personali, studio, ricerca, hobby, enti no-profit, scuole, PA.
- Commerciale: qualunque uso in un'attività economica — il sito di un cliente, il sito dell'azienda, uno strumento interno. Trenta giorni per provare, poi una licenza commerciale.
- La licenza commerciale è per sito in produzione, annuale, con gli aggiornamenti inclusi. Scrivi a [email protected]: prezzo e termini si concordano al primo contatto.
- Contribuire non è uso commerciale: chi manda correzioni o aggiunte sotto una licenza permissiva (MIT, Apache 2.0, Blue Oak, BSD) resta nel gratuito.
Non c'è nessun controllo tecnico: il codice su npm è leggibile e la licenza è un patto sulla parola. Il testo dice esattamente cosa è gratis e cosa no, così nessuno può dire di non saperlo.
