Source unique des composants
Décision
@sia-ui/react-web est la source des composants web. Le registre installable n'est plus une implémentation concurrente : il devient une projection de react-web, produite au build.
Jusqu'ici le dépôt contenait deux implémentations web sans lien entre elles :
packages/registry/templates/— 88 composants copiés chez l'utilisateur, sans dépendance, avec leur proprecn,formatDateetformatCurrency, et un unique fichier CSS. Aucun n'importe@sia-ui/*.packages/react-web/— composants distribués comme package, avec un CSS par composant.
Maintenir les deux revient à écrire chaque composant deux fois. Et le registre ne mène nulle part côté mobile : on ne copie pas un <span> dans une <View>.
Architecture visée
Un composant n'est écrit qu'une fois, dans react-web. Il sort par deux canaux :
- package —
pnpm add @sia-ui/react-web, mis à jour par semver; - registre —
sia-ui add badge, copié chez l'utilisateur qui en devient propriétaire.
Ce que les deux plateformes partagent — et ce qu'elles ne partagent pas
Il serait tentant de placer un contrat de props par composant dans un paquet commun, que chaque implémentation étendrait. Une forme de props ne traverse pas les plateformes : le web étend HTMLAttributes et prend className, le natif étend ViewProps et prend style; un tooltip web se place avec position: fixed, un tooltip natif avec un calque.
Chaque composant déclare donc ses props chez lui, comme le fait shadcn, et comme l'exige le canal de copie : un contrat importé obligerait à installer un paquet pour obtenir un type.
Ce qui se partage réellement :
| Quoi | Où | Pourquoi |
|---|---|---|
| Le vocabulaire — ton, taille, variante | @sia-ui/tokens | 22 composants s'en servent, le natif s'en servira |
| Le comportement — machines à états, placement, calculs | @sia-ui/headless | identique des deux côtés, c'est le vrai partage |
| Le client HTTP | @sia-ui/api | aucun rapport avec le rendu |
| Le contrat de formulaire | @sia-ui/headless | aucun rapport avec le rendu |
| La forme des props | nulle part | elle est propre à chaque plateforme |
État actuel
@sia-ui/react-web existe désormais comme package du workspace : il est installé, typé, construit et couvert par la CI. Il exporte neuf composants.
| Composant | État |
|---|---|
Badge | exporté |
Divider | exporté |
Label | exporté |
Skeleton | exporté |
Switch | exporté |
Text | exporté |
Drawer | exporté |
Slider | exporté |
Tooltip | exporté |
DateTimePicker | attend Calendar, Input et Timer |
Form | attend Button; dépend de react-hook-form et @hookform/resolvers/zod |
Field | pas d'implémentation, seulement un styles.css et une story |
DateTimePicker, Form et Field restent exclus de src/index.ts et du tsconfig.json tant que leurs dépendances internes n'existent pas.
Portal, usePortal et useOutsideClick ne vivent pas ici mais dans @sia-ui/react : ce sont des primitives de runtime, utiles à une application comme à ce package. De même, cn vient de @sia-ui/utils. Un composant ne réimplémente jamais ce qu'une couche inférieure expose déjà.
Cas de Form et Field
Ces deux composants importent react-hook-form et @hookform/resolvers/zod. Le parti pris du projet est de se brancher sur ces librairies, pas d'en dépendre : Field doit rester entièrement contrôlé — name, value, onChange, onBlur, error, ref — et l'adaptateur vers react-hook-form doit vivre à côté, optionnel. À reprendre avant de les réintégrer.
Migration
L'ordre de travail est mécanique, composant par composant :
- écrire le contrat dans
packages/core/src/<composant>.tss'il n'existe pas, et l'exporter depuissrc/index.ts; - porter le template du registre vers
packages/react-web/src/components/, en typant les props sur le contrat; - extraire ses règles de
templates/styles/sia-ui.cssvers unstyles.csspropre au composant; - exporter le composant depuis
react-web/src/index.tset ajouter sa story; - une fois la projection en place, supprimer le template manuel du registre.
Tant que la projection n'existe pas, les deux implémentations coexistent. Les stories de react-web sont préfixées Package · react-web/ pour éviter les collisions d'identifiants avec celles du registre.
Distribution : pas de projection
Le registre a d'abord été pensé sans aucune dépendance, ce qui imposait de générer les templates : inliner le vocabulaire de @sia-ui/tokens, le runtime de @sia-ui/react, et réécrire chaque import. Un générateur a été écrit et fonctionnait — la projection compilait avec "paths": {}, sans qu'aucun @sia-ui/* soit résolvable.
Il a été abandonné. Cette contrainte n'était pas nécessaire : shadcn lui-même ne la respecte pas, son button.tsx importe @radix-ui/react-slot et class-variance-authority, et registry.json porte depuis toujours un champ dependencies pour les paquets npm.
Un template a donc le droit d'importer @sia-ui/tokens, @sia-ui/utils, @sia-ui/headless et @sia-ui/react. La CLI copie le fichier source tel quel et annonce les dépendances à installer. Aucun code n'est généré, aucun n'est dupliqué.
La CLI
sia-ui init écrit sia-ui.json et la feuille de style
sia-ui list liste les entrées, marquées [pkg] quand elles viennent du package
sia-ui add <nom...> installe, avec ses dépendances de registre
sia-ui add <nom> --overwrite écrase une copie existanteElle lit désormais sia-ui.json — components, lib, styles — au lieu d'écrire en dur dans src/, et affiche les paquets npm à installer :
add app/ui/Label/index.tsx
add app/ui/Label/styles.css
À installer :
pnpm add @sia-ui/headless @sia-ui/react @sia-ui/tokens @sia-ui/utilsLe fichier installé est exactement le fichier source de react-web, octet pour octet. C'est ce que permet le droit d'importer @sia-ui/*, et c'est ce qui garantit qu'il n'y a plus qu'une implémentation.
Le schéma des entrées
Chaque entrée porte maintenant :
| Champ | Rôle |
|---|---|
version | version de l'entrée, indépendante de celle du package |
status | experimental, stable, deprecated ou removed |
source | package (copié depuis react-web) ou template (écrit à la main) |
dependencies | paquets npm annoncés par la CLI, jamais installés d'office |
La CLI refuse une entrée removed, prévient sur une entrée deprecated et affiche son remplacement.
Pourquoi 0.1.0 et pas 1.0.0
Toutes les entrées et tous les paquets sont à 0.1.0. Rien n'est publié : en semver, le 0. majeur veut dire « l'API n'est pas figée, une rupture peut arriver en mineure ».
C'est ce qu'il faut ici, parce que plusieurs décisions sont encore ouvertes et qu'elles seront chacune une rupture :
- les sept composants qui existent des deux côtés, à arbitrer;
- le sort de
ConfigProvider; FieldetForm, à détacher dereact-hook-form;- les 85 entrées qui passeront de
templateàpackage.
Passer en 1.0.0 maintenant reviendrait à s'engager sur une API qu'on sait devoir changer, et à brûler 2.0.0, 3.0.0, 4.0.0 sur des décisions internes que personne n'a encore consommées.
1.0.0 est le premier engagement public : il vient en phase 7, quand la migration est finie et les arbitrages rendus.
Migration : faite
Les 88 entrées historiques ont rejoint packages/react-web/src/components/. packages/registry/templates/ n'existe plus.
| Avant | Après | |
|---|---|---|
| Entrées du registre | 88 template + 2 package | 87, toutes package |
Composants dans react-web | 9 | 88 |
| Feuille de style | un fichier de 202 lignes | 68 feuilles par composant + une commune |
Fichiers copiés pour crud-page | 12, dont 3 de bibliothèque | 9 composants, 0 bibliothèque |
Déplacer par étapes n'a pas servi
L'ordre imposé par le graphe de dépendances ne valait que pour un déplacement composant par composant. En les déplaçant tous ensemble, les imports voisins se résolvent entre eux à l'arrivée : l'ordre n'a plus d'objet.
Le découpage de la feuille de style
609 règles, réparties automatiquement : une règle part avec un composant si lui seul peut la revendiquer, d'après les classes que son fichier cite. Sinon elle reste dans src/styles/shared.css — variables de thème, animations partagées, ajustements responsives transverses.
571 règles ont trouvé un composant, 146 sont communes. Les 38 restantes appartenaient à slider, tooltip et drawer, dont la version de react-web a été retenue : leur CSS est remplacé, pas perdu.
L'invariant a été vérifié : aucune règle du registre n'a disparu sans arbitrage explicite.
Les arbitrages
| Composant | Retenu | Pourquoi |
|---|---|---|
slider, tooltip, drawer | react-web | déjà câblés sur @sia-ui/headless |
badge, divider, skeleton, switch | registre | versions plus riches — tons, chargement |
field, date-time-picker | registre | les brouillons de react-web étaient incomplets |
text | supprimé | doublon de typography, plus complet |
Les fonctions que le registre avait en plus ont été récupérées plutôt que perdues : minDistance est descendu dans useSliderState, showValue, formatValue, startLabel et endLabel dans le contrat ISliderProps, et TooltipProps reste exporté comme alias avec un content facultatif.
Ce qui reste dehors
Form est exclu du barrel et du typage : il importe react-hook-form et @hookform/resolvers/zod en dur, ce que la règle du projet interdit. Il attend d'être repris en composant contrôlé, avec l'adaptateur à côté.
Règle
Un composant web ne s'écrit plus dans packages/registry/templates/. Une contribution qui ajoute un template sans passer par react-web est à refuser.