diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9da9db9e..7ae2c4b5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,12 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr
### Added
+- **`ng add @4sh/ui-kit-schematics` pose l'arborescence d'assets maison** (FSHSP-164). `src/assets/` ne recevait qu'un `assets-map.json` vide, et seulement si `ui-image` faisait partie des composants copiés : le consommateur héritait de composants qui résolvent `assets/img/{marque}/{type}/…` sans un seul dossier pour les accueillir, ni rien qui dise où déposer une police ou un favicon. La fondation pose désormais `fonts/{police,icon}/` et `img/{common,brand1,brand2,brand3}/{jpg,png,svg}/` (les dossiers vides portent un `.gitkeep` — ni git ni un `Tree` de schematic ne livre un dossier sans fichier), un `src/assets/README.md` qui documente la convention et le format de l'`assets-map.json`, et l'`assets-map.json` lui-même quels que soient les composants choisis. Les sous-dossiers `light/`/`dark/` ne sont pas échafaudés : ils sont l'exception, et le README dit où les créer.
+ - **`src/assets` est enfin déclaré au builder** (`assets` de la cible `build`, et de `test` quand elle est en Karma), en s'AJOUTANT au `public/` d'`ng new` plutôt qu'en le remplaçant. Sans cette entrée, tout ce que le kit résout sous `/assets/` répondait 404 dans l'application — `ui-image` tombant alors sur son placeholder sans rien dire de la cause. Les cibles `storybook`/`build-storybook` reprennent maintenant les assets de `build` au lieu de recoder la leur : la preview était le seul endroit où `/assets/` était servi, et elle y perdait au passage le `public/` du projet. Le `README.md` et les `.gitkeep` sont exclus du build (`ignore`).
+ - **Deux fichiers sont fournis, le reste est un emplacement vide** : les **drapeaux** SVG (`img/common/svg/flags/`), génériques et codés en dur par la story `ui-input-group` — sans eux, son indicatif téléphonique affichait une image cassée chez le consommateur — et un **favicon** placeholder, sur lequel le ` ` de l'`index.html` est pointé, uniquement s'il porte encore le `favicon.ico` d'`ng new` (jamais par-dessus un `href` choisi par le projet). L'ancien `public/favicon.ico` est laissé sur le disque, simplement plus référencé.
+ - **Aucun fichier de police n'est livré**, ni aucune fixture de démo : 2,2 Mo de `.ttf` de marque (dans un format qui n'est même pas celui que notre doc recommande) et 428 Ko de visuels de test arriveraient chez le consommateur comme du bruit à supprimer. À la place, un scaffold `src/styles/vendors/_fonts.scss` — mixin `variable-font`, exemple commenté, déjà `@use` par `main.scss`, et qui n'émet rien tant que rien n'est décommenté — plus la recette complète dans `src/assets/README.md`.
+ - Corrigé au passage : les chemins d'assets du scaffold `src/styles/variables.scss` pointaient vers `../assets/images/`, un dossier qui n'a jamais existé (l'arborescence dit `img/`), et il manquait `$path-police`.
+
- **Les champs à boîte acceptent un mode `floatLabel`, avec ses trois variantes** (FSHSP-157). Le libellé n'avait qu'une place possible, au-dessus du champ : un formulaire dense n'avait pas d'autre levier que de le retirer et de rabattre l'information sur le `placeholder`, qui disparaît à la première frappe et n'est pas un libellé pour un lecteur d'écran. `floatLabel` le fait descendre **dans** le champ, où il tient le rôle du placeholder, et remonter au focus, dès qu'une valeur est présente, tant qu'un panneau ouvert retient le champ (un déclencheur comme `ui-datepicker` confie le focus à son calendrier sans cesser d'être actif), ou sur autofill du navigateur (qui ne déclenche aucun évènement de saisie). Trois positions hautes, alignées sur celles de PrimeNG : `over` (au-dessus de la boîte), `in` (bande réservée en haut de la boîte, qui grandit d'autant pour que la rangée du contrôle garde sa hauteur) et `on` (à cheval sur le trait, qu'il entaille). Disponible sur les huit champs bâtis sur le shell `ui-field` : `ui-input`, `ui-textarea`, `ui-select`, `ui-autocomplete`, `ui-input-number`, `ui-input-mask`, `ui-input-tags` et `ui-datepicker` (qui le transmet à son déclencheur). L'input est porté par `BaseFormField`, donc un champ écrit sur cette base l'hérite sans code supplémentaire. Non renseigné, rien ne change : le libellé classique reste le défaut, sur tous les champs.
- Le libellé flottant reste un `` natif, marqueur `required` compris : il n'intercepte pas le pointeur au repos (le clic va au curseur de saisie) et le récupère une fois remonté. Seuls sa position et son échelle sont animées, jamais une `font-size` : le trajet ne provoque aucune reprise de mise en page, et la transition est coupée sous `prefers-reduced-motion`.
- Aucun recouvrement de mise en page : `over` réserve la bande où le libellé monte, `on` la moitié qui dépasse du trait, `in` grandit la boîte. Deux champs collés verticalement ne se chevauchent pas.
@@ -31,7 +37,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr
### Changed
-- **`ui-datepicker` : la plage sélectionnée (`range`) se lit comme une seule zone continue** (FSHSP-166). Elle était peinte sur la cellule elle-même — fond en dégradé mi-cellule sur les deux extrémités, filets haut et bas en bordure — ce qui la faisait *encadrer* la sélection plutôt que la porter, avec deux débordements visibles : les filets couraient sur toute la largeur des cellules de début et de fin, donc au-delà de la moitié réellement remplie, et la pastille des jours de début/fin, dont la bordure transparente s'ajoutait à sa taille (`content-box`), dépassait la hauteur de la bande. La bande est désormais une couche à part, exactement de la hauteur d'une pastille et arrêtée au centre des deux extrémités : plus rien ne peut la dépasser, ni en hauteur ni sur les côtés. Les filets sont retirés, et seuls les deux bouts d'une ligne de semaine sont arrondis — sur le retrait qu'une pastille laisse dans sa colonne, pas sur le bord de la cellule, pour qu'une plage finissant sur la dernière colonne s'aligne avec la rangée du dessus au lieu de s'arrêter une demi-gouttière trop tôt. Et une date de début dont la fin n'est pas encore choisie n'affiche plus de demi-bande partant vers une extrémité qui n'existe pas : la pastille reste seule jusqu'au second clic. Deux nouveaux réglages, `--ui-datepicker-range-background` et `--ui-datepicker-range-color`, permettent de re-skinner la bande et le chiffre qu'elle porte (voir la table « Theming » de la doc).
+- **`ui-datepicker` : la plage sélectionnée (`range`) se lit comme une seule zone continue** (FSHSP-166). Elle était peinte sur la cellule elle-même — fond en dégradé mi-cellule sur les deux extrémités, filets haut et bas en bordure — ce qui la faisait _encadrer_ la sélection plutôt que la porter, avec deux débordements visibles : les filets couraient sur toute la largeur des cellules de début et de fin, donc au-delà de la moitié réellement remplie, et la pastille des jours de début/fin, dont la bordure transparente s'ajoutait à sa taille (`content-box`), dépassait la hauteur de la bande. La bande est désormais une couche à part, exactement de la hauteur d'une pastille et arrêtée au centre des deux extrémités : plus rien ne peut la dépasser, ni en hauteur ni sur les côtés. Les filets sont retirés, et seuls les deux bouts d'une ligne de semaine sont arrondis — sur le retrait qu'une pastille laisse dans sa colonne, pas sur le bord de la cellule, pour qu'une plage finissant sur la dernière colonne s'aligne avec la rangée du dessus au lieu de s'arrêter une demi-gouttière trop tôt. Et une date de début dont la fin n'est pas encore choisie n'affiche plus de demi-bande partant vers une extrémité qui n'existe pas : la pastille reste seule jusqu'au second clic. Deux nouveaux réglages, `--ui-datepicker-range-background` et `--ui-datepicker-range-color`, permettent de re-skinner la bande et le chiffre qu'elle porte (voir la table « Theming » de la doc).
- **La boîte de `ui-field` est désormais enveloppée dans un `.ui-field-control`** (FSHSP-157). C'est le contexte de positionnement du libellé flottant, et il est rendu dans les deux modes plutôt que conditionnellement, pour que le DOM d'un champ ne dépende pas de l'option. Aucun impact visuel ni sur les sélecteurs publics ; un consommateur qui aurait écrit du CSS sur l'enchaînement direct `.ui-field > .ui-field-box` doit passer par le descendant.
- **`ui-label` tronque son texte quand il est contraint** au lieu de déborder (`text-overflow: ellipsis` sur `.ui-label-text`, `max-width: 100%` sur la racine). Sans contrainte de largeur, le comportement est inchangé : le texte passe à la ligne comme avant.
@@ -40,12 +46,12 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr
### Fixed
-- **En thème sombre, les jours situés *dans* une plage `ui-datepicker` étaient illisibles** (FSHSP-166). Le chiffre prenait `--form-high-content-default`, qui bascule au quasi-blanc en sombre, alors que le fond de la bande est le même lavande clair dans les deux thèmes : blanc sur lavande, soit un contraste d'environ 1,3:1. Il prend maintenant le token de contenu apparié à ce fond (`--informative-highlightlow-content-default`, identique en clair et en sombre), ce qui remonte le contraste à ~4,7:1 (AA). Le survol et le focus d'un jour de la plage basculent sur la pastille pleine `informative.highlightHigh`, elle aussi stable d'un thème à l'autre — l'ancien fond neutre virait au gris foncé en sombre, sous un chiffre désormais foncé.
+- **En thème sombre, les jours situés _dans_ une plage `ui-datepicker` étaient illisibles** (FSHSP-166). Le chiffre prenait `--form-high-content-default`, qui bascule au quasi-blanc en sombre, alors que le fond de la bande est le même lavande clair dans les deux thèmes : blanc sur lavande, soit un contraste d'environ 1,3:1. Il prend maintenant le token de contenu apparié à ce fond (`--informative-highlightlow-content-default`, identique en clair et en sombre), ce qui remonte le contraste à ~4,7:1 (AA). Le survol et le focus d'un jour de la plage basculent sur la pastille pleine `informative.highlightHigh`, elle aussi stable d'un thème à l'autre — l'ancien fond neutre virait au gris foncé en sombre, sous un chiffre désormais foncé.
- **`ui-datepicker` en mode `timeOnly` ignorait `hourFormat` et `dateFormat`** (FSHSP-163). L'affichage formatait directement via `Intl` en `timeStyle: 'short'` sur la locale résolue, sans jamais consulter ces deux inputs : `hourFormat="24"` (le défaut) n'avait aucun effet — l'heure basculait en AM/PM dès que la locale résolue en avait un par défaut (ex. `en-US`) — et un `dateFormat` custom n'avait aucune prise sur ce mode. `hourFormat` est maintenant respecté (`hour12` forcé en conséquence, jamais laissé au défaut de la locale), et `dateFormat`, quand fourni, s'applique aussi en `timeOnly` (symétrique de son usage en `date`/`month`).
- **La saisie clavier de `ui-datepicker` (`allowInput`) pouvait mélanger les segments jour/mois/année après une suppression** (FSHSP-118). Le masque re-dérive l'affichage à chaque frappe depuis le flux brut des chiffres tapés ; une vérification de bornes (1-31, 1-12) — pensée pour rejeter un chiffre de tête invalide en cours de frappe — s'appliquait aussi après une suppression, où elle pouvait sauter un chiffre encore valide et décaler tout ce qui suit d'un cran vers le mauvais segment (le jour hérite d'un chiffre du mois, etc.) ; l'année, seule sans borne, n'était jamais concernée — d'où l'observation qu'elle seule se supprimait « proprement ». Cette vérification est maintenant désactivée quand la frappe raccourcit le texte (suppression), et rétablie dès qu'elle le rallonge. Une segmentation en place pleinement fiable quel que soit le point d'édition (clic au milieu du champ, par ex.) reste un chantier plus large, non couvert ici.
- **Effacer une date au clavier (`allowInput`) se bloquait dès que jour et mois étaient complets** (FSHSP-118). Le "/" auto-inséré entre deux segments place le curseur juste après lui, et un Retour arrière à cette position supprimait ce séparateur cosmétique plutôt qu'un chiffre — séparateur aussitôt réinséré au rendu suivant, donnant l'impression que la touche ne fait plus rien (ex. `20/08/2020` s'effaçait normalement jusqu'à `20/08/`, puis restait bloqué indéfiniment). Une suppression en fin de champ positionne maintenant le curseur juste avant ce séparateur, pas après, pour que le Retour arrière suivant retire le dernier chiffre du segment.
-- **Corriger un segment d'une date déjà saisie (ex. juste le mois) décalait tout ce qui suit** (FSHSP-118), les deux points précédents n'y suffisant pas : re-dériver l'intégralité du texte à chaque frappe (le principe même de l'auto-"/") n'a de sens que pour *construire* une date depuis un champ vide, jamais pour en corriger une déjà valide en place. Le masque se désactive désormais dès qu'une valeur existe (saisie complétée ou déjà présente au chargement) : la frappe redevient alors un champ texte ordinaire — aucun reformatage en direct, aucun risque de mélange de segments — et seul le parsing au blur/Entrée s'applique, déjà tolérant à un séparateur quelconque. Il se réactive de lui-même une fois le champ vidé, pour guider à nouveau la construction de la prochaine date — y compris en vidant le champ à la main puis en retapant aussitôt, sans passer par le blur ou par la croix : le vidage est désormais commité dès que le texte lu est vide, pas seulement au blur/Entrée.
+- **Corriger un segment d'une date déjà saisie (ex. juste le mois) décalait tout ce qui suit** (FSHSP-118), les deux points précédents n'y suffisant pas : re-dériver l'intégralité du texte à chaque frappe (le principe même de l'auto-"/") n'a de sens que pour _construire_ une date depuis un champ vide, jamais pour en corriger une déjà valide en place. Le masque se désactive désormais dès qu'une valeur existe (saisie complétée ou déjà présente au chargement) : la frappe redevient alors un champ texte ordinaire — aucun reformatage en direct, aucun risque de mélange de segments — et seul le parsing au blur/Entrée s'applique, déjà tolérant à un séparateur quelconque. Il se réactive de lui-même une fois le champ vidé, pour guider à nouveau la construction de la prochaine date — y compris en vidant le champ à la main puis en retapant aussitôt, sans passer par le blur ou par la croix : le vidage est désormais commité dès que le texte lu est vide, pas seulement au blur/Entrée.
- **Le placeholder auto-dérivé de `ui-datepicker` restait numérique (« jj/mm/aaaa ») avec un `dateFormat` custom**, alors que le champ n'affiche ni n'accepte ce format-là dans ce cas — un placeholder qui décrit une saisie que le parser va rejeter. Il reprend maintenant la sortie du `dateFormat` fourni pour une date d'illustration (ex. « Nov 22, 2023 »), cohérent avec ce que le champ affiche et attend réellement. Le hint `aria-describedby` (« Format attendu : … »), dérivé du même placeholder, en profite aussi.
- **`ui-datepicker` : taper l'heure juste après l'année (`showTime`) pouvait corrompre l'année affichée** (FSHSP-118). Le segment année, volontairement sans borne (`1-12`/`1-31` s'appliquent au jour/mois, pas à elle), ne déclenchait jamais l'insertion de son propre séparateur (l'espace avant l'heure) une fois ses 4 chiffres tapés — un suivi de position réservé aux segments bornés. L'heure tapée ensuite s'accolait donc directement à l'année (ex. `08/07/2026` + `10` tapé → `08/07/202610`, relu comme une année à 6 chiffres). Le suivi de position du moteur de masque partagé (`mask-engine.ts`, utilisé aussi par `ui-input-mask`) couvre maintenant tout segment, borné ou non ; la prévisualisation en direct attend en plus que l'heure soit complète avant de la commiter.
- **`ui-datepicker` : la saisie tapée en `range`/`multiple` pouvait couper une date en plein milieu si son `dateFormat` custom contenait le caractère du séparateur** (FSHSP-118). Le découpage retenait la première occurrence littérale de `" - "` (`range`) ou `", "` (`multiple`) dans le texte tapé, sans vérifier qu'elle délimitait bien deux dates plutôt que d'appartenir à l'une d'elles (ex. un `dateFormat` ISO contenant un tiret). Chaque occurrence candidate doit désormais faire parser valablement le texte qui la précède comme une date avant d'être retenue comme frontière.
diff --git a/projects/ui-kit-schematics/README.fr.md b/projects/ui-kit-schematics/README.fr.md
index 52b1f710..20ef1288 100644
--- a/projects/ui-kit-schematics/README.fr.md
+++ b/projects/ui-kit-schematics/README.fr.md
@@ -115,6 +115,36 @@ Non repris : les liens `parameters.design` vers notre fichier Figma — vous ne
pouvez pas l'ouvrir, ils sont retirés à la copie. Remettez votre `node-id` si
vous en avez un.
+### Assets (polices, images, favicon)
+
+La fondation pose l'arborescence d'assets maison sous `src/assets/`, et la déclare
+au builder pour qu'elle soit servie sous `/assets/`, le préfixe que `ui-image`
+construit (`assets/img/{marque}/{type}/…`) et que la story `ui-input-group` code en
+dur pour les drapeaux de son indicatif. Le `public/` d'`ng new` continue d'être
+servi à côté.
+
+```
+src/assets/
+├── favicon.png ← placeholder, câblé dans votre index.html
+├── assets-map.json ← l'index des images locales que lit ui-image (posé vide)
+├── fonts/{police,icon}/
+└── img/{common,brand1,brand2,brand3}/{jpg,png,svg}/
+```
+
+Deux fichiers sont fournis, tout le reste est un emplacement vide : les
+**drapeaux** (génériques, et sans eux la story d'indicatif téléphonique répond 404) et un **favicon** placeholder, sur lequel le ` ` de votre
+`index.html` est pointé, uniquement s'il porte encore le `favicon.ico` d'`ng
+new`, jamais par-dessus un href que vous avez choisi. L'ancien
+`public/favicon.ico` reste sur le disque, simplement plus référencé.
+
+**Aucun fichier de police n'est livré.** Les tokens ne font que _nommer_ les
+familles (`--fontfamily-base`), chacune terminée par une pile système : un projet
+sans police embarquée tombe donc sur le sans-serif de l'OS, pas sur le serif du
+navigateur. Déclarez les vôtres dans `src/styles/vendors/_fonts.scss`, créé avec
+le mixin `variable-font`, un exemple commenté, et déjà `@use` par `main.scss` ; il
+n'émet rien tant que vous n'avez rien décommenté. Même raisonnement pour les
+fixtures de démo de `ui-image` : c'est notre démo, pas votre fondation.
+
### Système de grille (Gridaflex)
Question posée juste après le choix des composants : le projet utilise-t-il
diff --git a/projects/ui-kit-schematics/README.md b/projects/ui-kit-schematics/README.md
index 9523a75a..3ab07f44 100644
--- a/projects/ui-kit-schematics/README.md
+++ b/projects/ui-kit-schematics/README.md
@@ -111,6 +111,30 @@ Not carried over: the `parameters.design` links to our Figma file — you cannot
open it, so it is stripped at copy time. Put your own `node-id` back if you have
one.
+### Assets (fonts, images, favicon)
+
+The foundation lays the house asset tree under `src/assets/`, and declares it to
+the builder so it is served under `/assets/`, the prefix `ui-image` resolves
+(`assets/img/{brand}/{type}/…`) and the one the `ui-input-group` story hardcodes
+for its dial-code flags. The `public/` folder `ng new` created keeps being served
+alongside it.
+
+```
+src/assets/
+├── favicon.png ← placeholder, wired into your index.html
+├── assets-map.json ← the local-image index ui-image reads (starts empty)
+├── fonts/{police,icon}/
+└── img/{common,brand1,brand2,brand3}/{jpg,png,svg}/
+```
+
+**No font file ships.** The tokens only _name_ families (`--fontfamily-base`),
+each ending in a system stack, so a project with no embedded font falls back to
+the OS sans-serif rather than the browser serif. Declare yours in
+`src/styles/vendors/_fonts.scss`, created with the `variable-font` mixin, a
+commented example, and already `@use`d by `main.scss`; it emits nothing until you
+uncomment something. Same reasoning for the `ui-image` demo fixtures: they are our
+demo, not your foundation.
+
### Grid system (Gridaflex)
Asked, right after you pick your components: whether the project uses
diff --git a/projects/ui-kit-schematics/src/ng-add/files/main.scss b/projects/ui-kit-schematics/src/ng-add/files/main.scss
index a9e1339c..e3cec787 100644
--- a/projects/ui-kit-schematics/src/ng-add/files/main.scss
+++ b/projects/ui-kit-schematics/src/ng-add/files/main.scss
@@ -19,6 +19,10 @@
// serif du navigateur. Gardez cette queue de pile quand vous surchargez.
// -----------------------------------------------------------------------------
+// ✏️ Vos polices : `@font-face` + surcharge de `--fontfamily-*`. Vide par
+// défaut — voir le fichier et `src/assets/README.md`.
+@use "vendors/fonts";
+
// 🔒 Tokens générés — régénérés par `npm run tokens:build`, jamais édités à la main.
@use "ui-kit/generated/tokens";
diff --git a/projects/ui-kit-schematics/src/ng-add/files/variables.scss b/projects/ui-kit-schematics/src/ng-add/files/variables.scss
index 4a487613..47fc51ba 100644
--- a/projects/ui-kit-schematics/src/ng-add/files/variables.scss
+++ b/projects/ui-kit-schematics/src/ng-add/files/variables.scss
@@ -12,11 +12,13 @@
// -----------------------------------------------------------------------------
/** Chemins des images */
-$path-img: "../assets/images/" !default;
-$path-jpg: "#{$path-img}jpg/" !default;
-$path-png: "#{$path-img}png/" !default;
-$path-svg: "#{$path-img}svg/" !default;
+$path-img: "../assets/img/" !default;
+$path-common: "#{$path-img}common/" !default;
+$path-jpg: "#{$path-common}jpg/" !default;
+$path-png: "#{$path-common}png/" !default;
+$path-svg: "#{$path-common}svg/" !default;
/** Chemins des polices */
$path-font: "../assets/fonts/" !default;
+$path-police: "#{$path-font}police/" !default;
$path-icon: "#{$path-font}icon/" !default;
diff --git a/projects/ui-kit-schematics/src/ng-add/index.ts b/projects/ui-kit-schematics/src/ng-add/index.ts
index 0800d55a..f34b926c 100644
--- a/projects/ui-kit-schematics/src/ng-add/index.ts
+++ b/projects/ui-kit-schematics/src/ng-add/index.ts
@@ -15,7 +15,7 @@ import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'
import { confirm } from '@inquirer/prompts';
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
import { updateWorkspace } from '@schematics/angular/utility/workspace';
-import { readFileSync, readdirSync } from 'node:fs';
+import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import type { Schema } from './schema';
import { addDependency, addNpmScript, readPackageJson } from '../utils/package-json';
@@ -25,6 +25,7 @@ import {
docsPipelineDir,
mcpServerDir,
prettierConfigDir,
+ projectAssetsDir,
stylesFoundationDir,
} from '../utils/component-registry';
import { readKitManifestInfo } from '../utils/kit-manifest';
@@ -72,9 +73,9 @@ function copyStylesFoundationRule(): Rule {
};
}
-/** Scaffolds `main.scss` / `variables.scss` : le point d'entrée global du projet.
- * Créés une seule fois — ce sont des fichiers du consommateur dès la première
- * copie, jamais réécrasés ensuite (FSHSP-109). */
+const FONTS_MODULE = 'vendors/fonts';
+const FONTS_PATH = 'src/styles/vendors/_fonts.scss';
+
function createStyleScaffolds(): Rule {
return (tree: Tree, context: SchematicContext) => {
const filesDir = join(__dirname, 'files');
@@ -83,7 +84,136 @@ function createStyleScaffolds(): Rule {
if (tree.exists(targetPath)) continue;
tree.create(targetPath, readFileSync(join(filesDir, name), 'utf8'));
}
- context.logger.info('✔ Scaffolds src/styles/main.scss et src/styles/variables.scss créés.');
+ if (!tree.exists(FONTS_PATH)) {
+ tree.create(FONTS_PATH, readFileSync(join(filesDir, 'vendors', '_fonts.scss'), 'utf8'));
+ }
+ importInMainScss(tree, FONTS_MODULE, '// ✏️ Vos polices (`@font-face` + `--fontfamily-*`).');
+
+ context.logger.info(
+ `✔ Scaffolds ${MAIN_SCSS_PATH}, src/styles/variables.scss et ${FONTS_PATH} créés.`,
+ );
+ return tree;
+ };
+}
+
+/** Racine des assets du projet. Servie sous `/assets/` par le builder (entrée
+ * ajoutée à `angular.json` par {@link updateAngularJson}) : c'est ce chemin-là
+ * que `ui-image` et les stories du kit résolvent. */
+const ASSETS_ROOT = 'src/assets';
+/** Index des images locales lues par `ui-image`. Posé vide : le composant ne
+ * peut pas deviner l'arborescence d'un projet, et un nom absent affiche son
+ * placeholder tokenisé plutôt que de casser la compilation. */
+const ASSETS_MAP_PATH = `${ASSETS_ROOT}/assets-map.json`;
+/** Marques telles que `BrandService` les émet — `brand1` est le défaut. */
+const ASSET_BRANDS = ['common', 'brand1', 'brand2', 'brand3'];
+/** Types de fichier, chacun pouvant contenir un sous-dossier `light/`/`dark/`
+ * quand le visuel a des variantes de mode (voir `resolvePath` de `ui-image`).
+ * Ces deux-là ne sont PAS échafaudés : ils sont l'exception, pas la règle, et
+ * les poser partout ferait 24 `.gitkeep` au lieu de 12, le README les décrit. */
+const ASSET_IMAGE_TYPES = ['jpg', 'png', 'svg'];
+
+/**
+ * Squelette de l'arborescence : un `Tree` de schematic ne porte que des
+ * fichiers, et git ne versionne pas un dossier vide. Le `.gitkeep` est donc la
+ * seule façon de livrer un emplacement, à supprimer dès qu'on y dépose
+ * quelque chose.
+ */
+function projectAssetDirs(): string[] {
+ const dirs = ['fonts/police', 'fonts/icon'];
+ for (const brand of ASSET_BRANDS) {
+ for (const type of ASSET_IMAGE_TYPES) dirs.push(`img/${brand}/${type}`);
+ }
+ return dirs;
+}
+
+/**
+ * Pose l'arborescence d'assets maison sous `src/assets/`.
+ *
+ * Avant, `ng add` n'y écrivait qu'un `assets-map.json` vide, et seulement si
+ * `ui-image` faisait partie des composants copiés : le consommateur héritait
+ * d'un composant qui résout `assets/img/{marque}/{type}/…` sans un seul dossier
+ * pour l'accueillir, ni rien qui dise où déposer une police ou un favicon.
+ *
+ * Trois natures de fichier, trois traitements :
+ * - le SQUELETTE (`.gitkeep`), calculé ici — voir {@link projectAssetDirs} ;
+ * - les fichiers TRANSPOSABLES tels quels (drapeaux, favicon placeholder),
+ * copiés depuis les assets du package, en binaire;
+ * - le README et l'`assets-map.json`, scaffolds destinés à être édités.
+ *
+ * `create()` uniquement, jamais `overwrite()` : tout ce qui est ici appartient
+ * au consommateur dès la première pose. Un `ng add` rejoué complète les trous
+ * sans rien écraser.
+ */
+function copyProjectAssetsRule(): Rule {
+ return (tree: Tree, context: SchematicContext) => {
+ const create = (targetPath: string, content: string | Buffer) => {
+ if (tree.exists(targetPath)) return;
+ tree.create(targetPath, content);
+ };
+
+ for (const dir of projectAssetDirs()) create(`${ASSETS_ROOT}/${dir}/.gitkeep`, '');
+
+ const root = projectAssetsDir();
+ const copyAll = (srcDir: string, targetDir: string) => {
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
+ const full = join(srcDir, entry.name);
+ if (entry.isDirectory()) copyAll(full, `${targetDir}/${entry.name}`);
+ else create(`${targetDir}/${entry.name}`, readFileSync(full));
+ }
+ };
+ if (existsSync(root)) copyAll(root, ASSETS_ROOT);
+
+ create(ASSETS_MAP_PATH, '{}\n');
+ create(
+ `${ASSETS_ROOT}/README.md`,
+ readFileSync(join(__dirname, 'files', 'assets', 'README.md'), 'utf8'),
+ );
+
+ context.logger.info(
+ `✔ Arborescence d'assets posée (${ASSETS_ROOT}/ : fonts, img, favicon — voir son README).`,
+ );
+ return tree;
+ };
+}
+
+/**
+ * Bascule le ` ` de chaque `index.html` d'application sur le
+ * favicon placeholder de l'arborescence maison.
+ *
+ * Conservateur par construction : on ne réécrit QUE le `favicon.ico` d'`ng new`.
+ * Un `href` déjà personnalisé est le choix du projet, et le réécrire lui
+ * remplacerait sa marque par notre placeholder — l'inverse du service rendu.
+ *
+ * Le `public/favicon.ico` d'`ng new` n'est pas supprimé : c'est un fichier du
+ * consommateur, qui peut vouloir le garder (ou y revenir). Il devient seulement
+ * non référencé — le README le signale.
+ */
+const NG_NEW_FAVICON_LINK = / ]*\brel=(["'])(?:shortcut\s+)?icon\1[^>]*>/gi;
+const NG_NEW_FAVICON_HREF = /\bhref=(["'])\.?\/?favicon\.ico\1/i;
+
+function retargetFaviconRule(): Rule {
+ return (tree: Tree, context: SchematicContext) => {
+ let retargeted = 0;
+ for (const path of applicationIndexPaths(tree)) {
+ const buffer = tree.read(`/${path}`);
+ if (!buffer) continue;
+ const content = buffer.toString('utf8');
+ const updated = content.replace(NG_NEW_FAVICON_LINK, (link) =>
+ NG_NEW_FAVICON_HREF.test(link)
+ ? link
+ .replace(NG_NEW_FAVICON_HREF, 'href="assets/favicon.png"')
+ .replace(/\btype=(["'])[^"']*\1/i, 'type="image/png"')
+ : link,
+ );
+ if (updated === content) continue;
+ tree.overwrite(`/${path}`, updated);
+ retargeted++;
+ }
+ if (retargeted) {
+ context.logger.info(
+ '✔ Favicon : pointé sur assets/favicon.png (placeholder, à remplacer). L\'ancien public/favicon.ico n\'est plus référencé.',
+ );
+ }
return tree;
};
}
@@ -227,6 +357,37 @@ function updateAngularJson(): Rule {
if (targetName === 'test' && !acceptsStyleOptions(target)) continue;
const options = (target.options ??= {});
+ // Arborescence d'assets maison (FSHSP-164), servie sous `/assets/` :
+ // c'est ce préfixe que `ui-image` construit (`assets/img/{marque}/…`)
+ // et que la story `ui-input-group` code en dur pour ses drapeaux.
+ // Sans cette entrée, `ng new` ne sert que `public/` et tout ce que le
+ // kit résout sous `/assets/` répond 404 — le composant tombant alors
+ // sur son placeholder, sans rien dire de la cause.
+ //
+ // AJOUTÉE à l'existant, jamais substituée : `public/` reste servi.
+ const assets = ((options['assets'] as JsonValue[] | undefined) ?? []).slice();
+ const hasAssetsRoot = assets.some((asset) =>
+ typeof asset === 'string'
+ ? asset === ASSETS_ROOT
+ : !!asset &&
+ typeof asset === 'object' &&
+ !Array.isArray(asset) &&
+ asset['input'] === ASSETS_ROOT,
+ );
+ if (!hasAssetsRoot) {
+ assets.push({
+ glob: '**/*',
+ input: ASSETS_ROOT,
+ output: './assets/',
+ // Le README documente l'arborescence pour le développeur, et les
+ // `.gitkeep` ne tiennent que des dossiers vides : ni l'un ni les
+ // autres n'ont à être servis en production. `ignore` est accepté
+ // par le builder d'application ET par celui de Storybook.
+ ignore: ['README.md', '**/.gitkeep'],
+ });
+ }
+ options['assets'] = assets;
+
const styles = ((options['styles'] as string[] | undefined) ?? []).slice();
for (const entry of [
'src/styles/main.scss',
@@ -399,9 +560,10 @@ function scaffoldStorybook(): Rule {
hasUiImage ? preview.replace(UI_IMAGE_MARKER_RE, '') : preview.replace(UI_IMAGE_BLOCK_RE, ''),
);
- // `ui-image` résout ses images dans cette map. Vide, ses stories affichent
- // le placeholder plutôt que de faire échouer la compilation de la preview.
- if (hasUiImage) create('src/assets/assets-map.json', '{}\n');
+ // La map que `preview.ts` importe dans son bloc `` n'est plus
+ // créée ici : elle fait partie de l'arborescence d'assets, posée par
+ // `copyProjectAssetsRule` quels que soient les composants copiés
+ // (FSHSP-164). Elle est donc là avant que cette règle ne tourne.
context.logger.info('✔ Configuration Storybook posée (storybook/).');
return tree;
@@ -442,7 +604,12 @@ function addStorybookTargets(): Rule {
const shared = {
configDir: 'storybook',
browserTarget: `${name}:build`,
- assets: [{ glob: '**/*', input: 'src/assets', output: './assets/' }],
+ // Repris de `build`, pas réécrit : `updateAngularJson` y a déjà ajouté
+ // `src/assets` (FSHSP-164) et normalisé les `output`. L'entrée était
+ // codée en dur ici, ce qui rendait la preview le SEUL endroit où
+ // `/assets/` était servi — et faisait disparaître le `public/`
+ // d'`ng new` des stories qui s'en servaient.
+ assets: build.options?.['assets'],
styles: build.options?.['styles'],
stylePreprocessorOptions: build.options?.['stylePreprocessorOptions'],
compodoc: true,
@@ -479,6 +646,51 @@ function firstApplicationName(tree: Tree): string | null {
return null;
}
+/**
+ * `index.html` de chaque application, lu dans `angular.json` plutôt que
+ * supposé à `src/index.html` : l'option `index` peut être déplacée, et elle
+ * accepte aussi bien une chaîne qu'un objet `{ input, output }`.
+ *
+ * Elle peut aussi être ABSENTE — c'est même le cas d'`ng new` depuis Angular 22,
+ * qui laisse le builder appliquer son défaut, `{sourceRoot}/index.html` (lui-même
+ * `{root}/src` par défaut). Sans ce repli, un projet fraîchement créé n'a aucun
+ * `index.html` à nos yeux. `index: false` (pas de page générée) ne compte pas.
+ *
+ * `architect` ou `targets` : les deux clés sont valides dans `angular.json` et
+ * `updateWorkspace` accepte l'une comme l'autre — un parse maison doit donc
+ * regarder les deux, sans quoi un workspace en `targets` ne trouverait rien.
+ */
+function applicationIndexPaths(tree: Tree): string[] {
+ const buffer = tree.read('/angular.json');
+ if (!buffer) return [];
+ type Target = { options?: { index?: string | false | { input?: string } } };
+ type Project = {
+ projectType?: string;
+ root?: string;
+ sourceRoot?: string;
+ architect?: Record;
+ targets?: Record;
+ };
+ const workspace = JSON.parse(buffer.toString('utf8')) as { projects?: Record };
+ const paths: string[] = [];
+ for (const project of Object.values(workspace.projects ?? {})) {
+ if (project.projectType !== 'application') continue;
+ const index = (project.architect ?? project.targets)?.['build']?.options?.index;
+ if (index === false) continue;
+ if (typeof index === 'string') {
+ paths.push(index);
+ continue;
+ }
+ if (index?.input) {
+ paths.push(index.input);
+ continue;
+ }
+ const sourceRoot = project.sourceRoot ?? [project.root, 'src'].filter(Boolean).join('/');
+ paths.push(`${sourceRoot}/index.html`);
+ }
+ return paths;
+}
+
/**
* Plage de version d'Angular déclarée par le projet (`^22.1.0`), telle quelle.
*
@@ -711,7 +923,7 @@ const GRIDAFLEX_VERSION = '^1.0.0';
const GRIDAFLEX_SETTINGS_PATH = 'src/styles/vendors/_gridaflex-settings.scss';
const MAIN_SCSS_PATH = 'src/styles/main.scss';
/** Résolu par l'includePath `src/styles` déjà écrit dans `angular.json`. */
-const GRIDAFLEX_USE = '@use "vendors/gridaflex-settings";';
+const GRIDAFLEX_MODULE = 'vendors/gridaflex-settings';
/**
* Réponse à la question Gridaflex : l'option la tranche sans prompt (usage
@@ -737,22 +949,29 @@ async function resolveGridaflexChoice(
}
/**
- * Ajoute le `@use` des réglages en TÊTE des `@use` de `main.scss` : configurer
- * un module Sass (`with (…)`) n'est possible que s'il n'a pas déjà été chargé.
- * Idempotent : un `ng add` rejoué ne duplique pas la ligne.
+ * Ajoute un `@use` en TÊTE des `@use` de `main.scss`.
+ *
+ * En tête, et pas en queue, parce que Gridaflex l'exige : configurer un module
+ * Sass (`with (…)`) n'est possible que s'il n'a pas déjà été chargé. Les autres
+ * couches s'en accommodent (un `@font-face` n'a pas d'ordre de cascade), donc
+ * une seule règle d'insertion pour tout le monde.
+ *
+ * Idempotent : un `ng add` rejoué ne duplique pas la ligne. Le scaffold
+ * `main.scss` porte déjà ces `@use` — cette fonction sert les projets installés
+ * avant, dont `main.scss` (fichier du consommateur) n'est jamais réécrasé.
*/
-function importGridaflexInMainScss(tree: Tree): 'added' | 'already-there' | 'no-main' {
+function importInMainScss(
+ tree: Tree,
+ moduleId: string,
+ comment: string,
+): 'added' | 'already-there' | 'no-main' {
const buffer = tree.read(`/${MAIN_SCSS_PATH}`);
if (!buffer) return 'no-main';
const content = buffer.toString('utf8');
- if (content.includes('vendors/gridaflex-settings')) return 'already-there';
+ if (content.includes(moduleId)) return 'already-there';
const lines = content.split('\n');
- const block = [
- '// ✏️ Grille Gridaflex : colonnes, breakpoints et gouttières.',
- GRIDAFLEX_USE,
- '',
- ];
+ const block = [comment, `@use "${moduleId}";`, ''];
const firstUse = lines.findIndex((line) => /^\s*@use\b/.test(line));
const charset = lines.findIndex((line) => /^\s*@charset\b/.test(line));
let at = firstUse !== -1 ? firstUse : charset !== -1 ? charset + 1 : 0;
@@ -789,13 +1008,17 @@ function setupGridaflexRule(options: Schema): Rule {
readFileSync(join(__dirname, 'files', 'vendors', '_gridaflex-settings.scss'), 'utf8'),
);
}
- const imported = importGridaflexInMainScss(tree);
+ const imported = importInMainScss(
+ tree,
+ GRIDAFLEX_MODULE,
+ '// ✏️ Grille Gridaflex : colonnes, breakpoints et gouttières.',
+ );
addDependency(tree, 'gridaflex', GRIDAFLEX_VERSION, 'dependencies');
if (imported === 'no-main') {
context.logger.warn(
`Gridaflex posé (${GRIDAFLEX_SETTINGS_PATH}) mais ${MAIN_SCSS_PATH} est introuvable : ` +
- `ajoutez-y \`${GRIDAFLEX_USE}\` avant vos autres \`@use\`.`,
+ `ajoutez-y \`@use "${GRIDAFLEX_MODULE}";\` avant vos autres \`@use\`.`,
);
return tree;
}
@@ -871,6 +1094,11 @@ export function ngAdd(options: Schema): Rule {
const foundation = [
copyStylesFoundationRule(),
createStyleScaffolds(),
+ // Avant `updateAngularJson`, qui déclare `src/assets` au builder : la
+ // règle qui pose l'arborescence et celle qui la fait servir se lisent
+ // ainsi dans l'ordre où elles prennent effet.
+ copyProjectAssetsRule(),
+ retargetFaviconRule(),
copyPrettierConfigRule(),
copyTokensPipeline(),
// La chaîne de doc ne sert qu'aux MDX copiés : sans eux, ce sont deux
diff --git a/projects/ui-kit-schematics/src/utils/component-registry.ts b/projects/ui-kit-schematics/src/utils/component-registry.ts
index 758d4e9e..6216a258 100644
--- a/projects/ui-kit-schematics/src/utils/component-registry.ts
+++ b/projects/ui-kit-schematics/src/utils/component-registry.ts
@@ -97,6 +97,15 @@ export function mcpServerDir(): string {
return join(ASSETS_ROOT, 'mcp-server');
}
+/**
+ * Assets de projet transposables tels quels: drapeaux SVG et
+ * favicon placeholder. Ni les polices ni les fixtures de démo n'en font partie
+ * : voir `scripts/schematics-assets.build.mjs` pour le pourquoi.
+ */
+export function projectAssetsDir(): string {
+ return join(ASSETS_ROOT, 'project-assets');
+}
+
/**
* `.prettierrc`/`.prettierignore` du kit (FSHSP-140) : posés tels quels chez
* le consommateur pour que `ng generate …:update` compare deux copies
diff --git a/projects/ui-kit/base/ui-icon/ui-icon.stories.ts b/projects/ui-kit/base/ui-icon/ui-icon.stories.ts
index a913fc5f..baf6231b 100644
--- a/projects/ui-kit/base/ui-icon/ui-icon.stories.ts
+++ b/projects/ui-kit/base/ui-icon/ui-icon.stories.ts
@@ -3,7 +3,7 @@ import { applicationConfig, moduleMetadata } from '@storybook/angular';
import { UiIcon, UiIconFamilyScope, provideUiIconFamilies } from '@4sh/ui-kit/base/ui-icon';
const meta: Meta = {
- title: 'Components/ui/ui-icon',
+ title: 'Components/ui/base/ui-icon',
component: UiIcon,
decorators: [moduleMetadata({ imports: [UiIcon] })],
parameters: { layout: 'centered' },
diff --git a/projects/ui-kit/base/ui-image/ui-image.stories.ts b/projects/ui-kit/base/ui-image/ui-image.stories.ts
index 33ba41b8..e3c47bd9 100644
--- a/projects/ui-kit/base/ui-image/ui-image.stories.ts
+++ b/projects/ui-kit/base/ui-image/ui-image.stories.ts
@@ -90,7 +90,7 @@ class StorybookWrapper {
// --- 4. CONFIGURATION ---
const meta: Meta = {
- title: 'Components/ui/ui-image',
+ title: 'Components/ui/base/ui-image',
component: StorybookWrapper,
decorators: [
applicationConfig({
diff --git a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts
index 5889dcc0..efc62c6b 100644
--- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts
+++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.stories.ts
@@ -642,8 +642,17 @@ export const YearPicker: Story = {
// Plusieurs mois côte à côte (numberOfMonths).
export const TwoMonths: Story = {
- render: story(new Date(2026, 6, 8)),
- args: { inline: true, numberOfMonths: 2, label: 'Deux mois' },
+ render: () => ({
+ props: { model: new Date(2026, 6, 8), inline: true, numberOfMonths: 2, label: 'Deux mois', valueType: 'date', dateFormat: demoDateFormat },
+ template: ` `,
+ }),
+ parameters: { layout: 'centered' },
};
// Barre de boutons personnalisée via le template #buttonbar (contexte todayCallback / clearCallback).
diff --git a/projects/ui-kit/informative/ui-toast/ui-toast.stories.ts b/projects/ui-kit/informative/ui-toast/ui-toast.stories.ts
index c6ef1fbd..0f9cc4b4 100644
--- a/projects/ui-kit/informative/ui-toast/ui-toast.stories.ts
+++ b/projects/ui-kit/informative/ui-toast/ui-toast.stories.ts
@@ -363,19 +363,18 @@ export const Default: Story = {
render: (args) => ({
props: args,
template: `
-
-
-
+
`,
}),
+ parameters: { layout: 'centered' },
};
/** Usage nominal : un bouton pousse un toast via le service. */
diff --git a/scripts/schematics-assets.build.mjs b/scripts/schematics-assets.build.mjs
index c04ae90c..def897c6 100644
--- a/scripts/schematics-assets.build.mjs
+++ b/scripts/schematics-assets.build.mjs
@@ -314,6 +314,52 @@ function main() {
docFiles++;
}
+ const logoImages = ['storybook-projet-logo.png', 'storybook-projet-logo-white.png'];
+ for (const name of logoImages) {
+ const src = join(ROOT, 'storybook/public', name);
+ if (!existsSync(src)) continue;
+ mkdirSync(join(ASSETS, 'storybook/public'), { recursive: true });
+ copyFileSync(src, join(ASSETS, 'storybook/public', name));
+ docFiles++;
+ }
+
+ // Assets de projet (FSHSP-164) : la part de `src/assets/` qui vaut TELLE
+ // QUELLE chez le consommateur. Le SQUELETTE de l'arborescence, lui, est
+ // écrit par `ng-add` (`PROJECT_ASSET_DIRS`) — un dossier vide n'existe ni
+ // dans un `Tree` de schematic, ni dans git.
+ //
+ // Ce qui part d'ici est ce qu'un projet consommateur ne peut pas se donner
+ // lui-même sans copier nos fichiers à la main :
+ // - les drapeaux SVG, référencés en dur par la story `ui-input-group`
+ // (`assets/img/common/svg/flags/{code}.svg`) et réutilisables tels quels
+ // par n'importe quel champ téléphone international ;
+ // - le favicon, posé en PLACEHOLDER (cf. `ng new` et son favicon Angular)
+ // pour que le slot de l'arborescence maison soit câblé, pas juste décrit.
+ //
+ // Ce qui NE part PAS d'ici, volontairement :
+ // - les polices (2,2 Mo de `.ttf` DM Sans + Inter) : typographie de marque,
+ // licence à porter par le projet, et `.ttf` n'est pas le format web que
+ // notre propre doc recommande (`.woff2`). Le consommateur reçoit le
+ // dossier et le scaffold `_fonts.scss` qui sait les déclarer, pas nos
+ // fichiers ;
+ // - les fixtures `test-*.{png,jpg,svg}` des stories `ui-image` (428 Ko de
+ // camions et de dégradés) : de la démo, pas de la fondation. Elles
+ // arriveraient chez le consommateur comme du bruit à supprimer.
+ let projectAssetFiles = 0;
+ const projectAssetsDest = join(ASSETS, 'project-assets');
+ const flagsSrc = join(ROOT, 'src/assets/img/common/svg/flags');
+ if (existsSync(flagsSrc)) {
+ projectAssetFiles += copyTree(flagsSrc, join(projectAssetsDest, 'img/common/svg/flags'), (n) =>
+ n.endsWith('.svg'),
+ );
+ }
+ const faviconSrc = join(ROOT, 'src/assets/favicon.png');
+ if (existsSync(faviconSrc)) {
+ mkdirSync(projectAssetsDest, { recursive: true });
+ copyFileSync(faviconSrc, join(projectAssetsDest, 'favicon.png'));
+ projectAssetFiles++;
+ }
+
// Serveur MCP compagnon (FSHSP-115) : bundlé (esbuild, un seul fichier ESM,
// zéro dépendance) puis copié tel quel — c'est CE dossier que `ng-add`
// recopiera chez le consommateur (`.ui-kit-mcp/`), jamais publié à part.
@@ -330,7 +376,8 @@ function main() {
`[schematics-assets] ${componentCount} composant(s), ${sharedCount} base(s) partagée(s), ` +
`${styleFiles} fichier(s) de style, ${tokenFiles} fichier(s) de pipeline de tokens, ` +
`${docFiles} fichier(s) de chaîne de doc, ${mcpFiles} fichier(s) de serveur MCP, ` +
- `${prettierFiles} fichier(s) Prettier → ${relative(ROOT, ASSETS)}`,
+ `${prettierFiles} fichier(s) Prettier, ${projectAssetFiles} asset(s) de projet ` +
+ `→ ${relative(ROOT, ASSETS)}`,
);
}