diff --git a/DECISIONS.md b/DECISIONS.md index 4bbd6d6..e0ac021 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -30,6 +30,68 @@ Entrées **antéchronologiques** (la plus récente en haut). La date au format ` --- +## 2026-06-17 — Mixins SCSS partagés (`shared/styles/`) pour le CSS dupliqué + +- **Contexte** : du SCSS strictement identique était recopié entre features — + blocs des pages de détail (`.code`, socle `.example`, section de contenu, + liste), badge de rôle et carte `.practice` (aperçu des bonnes pratiques ↔ + page-support des phases). Refactor à rendu constant, sans toucher au markup + ni aux tokens. +- **Décision** : introduire un premier système de **partiels SCSS de mixins** + sous `frontend/src/app/shared/styles/` (`_detail.scss`, `_badge.scss`, + `_practice-card.scss`), importés par `@use '../../shared/styles/x' as *` dans + chaque composant et appelés via `@include`. Le mixin est inclus **sous le bloc + BEM local** (préfixe propre à la feature conservé) ; les variantes spécifiques + (`--avoid/--prefer`, bord coloré, `--done`) restent locales. Le mixin se + ré-expanse dans le scope de chaque composant → CSS émis et rendu identiques. +- **Options écartées** : (1) des **classes utilitaires globales** dans + `styles.scss` (façon `.cc-stagger`) — rejeté : changerait la portée + (global vs styles scopés par attribut) et imposerait des ajouts de classes + dans les templates ; le mixin garde le scoping et un CSS byte-identique ; + (2) configurer `stylePreprocessorOptions.includePaths` dans `angular.json` + pour raccourcir les imports — rejeté : modifier la config de build pendant un + refactor pur, pour un gain cosmétique sur des chemins relatifs courts + (`../../shared/styles/…`). +- **Point d'attention** : une déclaration placée **après** une règle imbriquée + issue d'un mixin déclenche la dépréciation Sass *mixed-declarations* ; placer + la déclaration **avant** le `@include` (cf. `.example` de design-pattern-detail). +- **Report** : l'item « chrome des hubs » (`.hero`, header, grille — items D-G + de l'audit, partie F) est **différé** : il porte sur les mêmes fichiers qu'une + feature « confettis » en cours (non commitée) et le `.hero` n'y est plus + identique d'un hub à l'autre. À reprendre sur un arbre propre une fois les + confettis intégrés. +- **Trace** : branche `refactor/extract-shared-frontend` — commits `c63baba` + (détail), `141e93c` (badge), `9d954cb` (carte pratique). + +## 2026-06-17 — Factorisation des pages de détail (nav séquentielle + utils) + +- **Contexte** : les 4 pages de détail (SOLID, Design Patterns, Claude Code, + phases de revue) dupliquaient à l'identique le bloc de navigation + précédent/suivant (template + ~70 lignes de SCSS chacune) et l'arithmétique + de résolution du voisin ; la persistance localStorage d'un `Set` coché était + répétée entre la checklist Code Review et les phases de revue. Refactor à + comportement constant, sans changement d'UI, de route, de slug ni de clé. +- **Décision** : trois extractions ciblées. (A) un composant présentationnel + partagé `shared/components/sequential-nav` (`SequentialNavComponent`, piloté + par `prev`/`next`/`routeBase`/`navLabel`, item `{slug,label,ariaLabel}`) ; + (B) `core/utils/checklist-storage.ts` (`loadCheckedSet`/`persistCheckedSet`, + clé fournie par l'appelant) ; (C) `core/utils/sequential-nav.ts` + (`neighborSlug`). Création du dossier `core/utils/` pour les fonctions pures. +- **Options écartées** : (1) un composant de détail générique unique (rejeté — + les templates de contenu divergent trop, ç'aurait été une abstraction « au cas + où ») ; (2) une classe de base abstraite pour la logique TS commune des détails + (rejeté — `inject()` + signals en classe de base peu idiomatiques ici, gain + faible vs. l'util pur) ; (3) factoriser aussi le « chrome » SCSS des hubs + (`.hero`, header, grilles) et les badges de rôle (différé — partage de SCSS + scopé via global/mixins plus exposé aux régressions visuelles, à traiter + séparément avec vérification de rendu). +- **Pourquoi** : supprimer ~390 lignes dupliquées (nav) + la logique localStorage + et d'index répétée, sans refactor du cœur ni dépendance nouvelle ; le rendu et + l'accessibilité (aria-labels, cible tactile, `:focus-visible`) restent + identiques. `npm run check` (lint + build) vert après chaque extraction. +- **Trace** : branche `refactor/extract-shared-frontend` — commits `b68e7da` + (A), `98ee1df` (B), `f05eb67` (C). + ## 2026-06-17 — Pivot de la direction artistique vers une DA festive et animée - **Contexte** : la DA « atelier / artisan » (papier, cuivre, teal, sobre, diff --git a/frontend/src/app/core/utils/checklist-storage.ts b/frontend/src/app/core/utils/checklist-storage.ts new file mode 100644 index 0000000..3b4f61f --- /dev/null +++ b/frontend/src/app/core/utils/checklist-storage.ts @@ -0,0 +1,20 @@ +/** + * Persistance d'un ensemble d'ids cochés dans le localStorage, partagée par les + * outils à cases à cocher (checklist Code Review, phases de revue). La clé est + * fournie par l'appelant (cf. convention `craftcode...`). + */ + +/** Lit l'ensemble d'ids cochés. Tolère un JSON corrompu / un mode privé. */ +export function loadCheckedSet(key: string): Set { + try { + const raw = localStorage.getItem(key); + return raw ? new Set(JSON.parse(raw)) : new Set(); + } catch { + return new Set(); + } +} + +/** Persiste l'ensemble d'ids cochés (sérialise le `Set` en tableau). */ +export function persistCheckedSet(key: string, ids: Set): void { + localStorage.setItem(key, JSON.stringify(Array.from(ids))); +} diff --git a/frontend/src/app/core/utils/sequential-nav.ts b/frontend/src/app/core/utils/sequential-nav.ts new file mode 100644 index 0000000..61b10d8 --- /dev/null +++ b/frontend/src/app/core/utils/sequential-nav.ts @@ -0,0 +1,19 @@ +/** + * Résolution du slug voisin dans un parcours séquentiel (précédent / suivant), + * partagée par les pages de détail (SOLID, Design Patterns, Claude Code, phases + * de revue). L'ordre du parcours est porté par la liste de slugs fournie. + */ + +/** + * Renvoie le slug voisin de `current` dans `slugs` (`delta` -1 = précédent, + * +1 = suivant), ou `undefined` si `current` est inconnu ou aux extrémités. + */ +export function neighborSlug( + slugs: readonly string[], + current: string, + delta: number +): string | undefined { + const index = slugs.indexOf(current); + if (index === -1) return undefined; + return slugs[index + delta]; +} diff --git a/frontend/src/app/features/best-practices/best-practices.component.scss b/frontend/src/app/features/best-practices/best-practices.component.scss index 5f247df..f2c23c0 100644 --- a/frontend/src/app/features/best-practices/best-practices.component.scss +++ b/frontend/src/app/features/best-practices/best-practices.component.scss @@ -1,3 +1,6 @@ +@use '../../shared/styles/badge' as *; +@use '../../shared/styles/practice-card' as *; + :host { display: block; max-width: 960px; @@ -98,35 +101,7 @@ } /* Badge de rôle — texte + teinte (jamais la couleur seule) */ -.badge { - display: inline-block; - padding: var(--cc-space-1) var(--cc-space-3); - border-radius: var(--cc-radius-pill); - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.02em; - white-space: nowrap; - - &--equipe { - background: var(--cc-role-equipe-tint); - color: var(--cc-role-equipe); - } - - &--auteur { - background: var(--cc-role-auteur-tint); - color: var(--cc-role-auteur); - } - - &--relecteur { - background: var(--cc-role-relecteur-tint); - color: var(--cc-role-relecteur); - } - - &--mixte { - background: var(--cc-role-mixte-tint); - color: var(--cc-role-mixte); - } -} +@include role-badge; /* Lien vers la page-support */ .support-link { @@ -143,63 +118,5 @@ } .practice { - border-radius: var(--cc-radius-md); - transition: - transform var(--cc-transition-bounce), - box-shadow var(--cc-transition); - - &:hover, - &:focus-within { - transform: translateY(-6px) scale(1.015); - box-shadow: var(--cc-shadow-2); - } - - &__top { - display: flex; - align-items: flex-start; - gap: var(--cc-space-2); - margin-bottom: var(--cc-space-2); - } - - &__icon { - color: var(--cc-primary); - flex-shrink: 0; - } - - &__title { - margin: 0; - font-size: var(--cc-fs-h3); - font-weight: 600; - color: var(--cc-ink); - line-height: var(--cc-lh-tight); - } - - &__accroche { - margin: 0 0 var(--cc-space-3); - color: var(--cc-ink-soft); - font-size: 0.9rem; - line-height: 1.5; - } - - &__why { - display: flex; - align-items: flex-start; - gap: var(--cc-space-2); - margin: 0; - padding-top: var(--cc-space-3); - border-top: 1px solid var(--cc-border); - color: var(--cc-ink-soft); - font-size: var(--cc-fs-small); - line-height: 1.45; - font-style: italic; - } - - &__why-icon { - font-size: 1.05rem; - width: 1.05rem; - height: 1.05rem; - color: var(--cc-accent); - flex-shrink: 0; - margin-top: 0.05rem; - } + @include practice-card; } diff --git a/frontend/src/app/features/claude-code-setup-detail/claude-code-setup-detail.component.html b/frontend/src/app/features/claude-code-setup-detail/claude-code-setup-detail.component.html index a12fdc5..4327836 100644 --- a/frontend/src/app/features/claude-code-setup-detail/claude-code-setup-detail.component.html +++ b/frontend/src/app/features/claude-code-setup-detail/claude-code-setup-detail.component.html @@ -56,32 +56,10 @@

- + } diff --git a/frontend/src/app/features/claude-code-setup-detail/claude-code-setup-detail.component.scss b/frontend/src/app/features/claude-code-setup-detail/claude-code-setup-detail.component.scss index adb7f93..f8e59d3 100644 --- a/frontend/src/app/features/claude-code-setup-detail/claude-code-setup-detail.component.scss +++ b/frontend/src/app/features/claude-code-setup-detail/claude-code-setup-detail.component.scss @@ -1,3 +1,5 @@ +@use '../../shared/styles/detail' as *; + :host { display: block; max-width: 960px; @@ -45,33 +47,11 @@ } .ccd-block { - margin-bottom: var(--cc-space-6); - - &__title { - display: flex; - align-items: center; - gap: var(--cc-space-2); - margin: 0 0 var(--cc-space-3); - font-size: clamp(1.2rem, 2.8vw, 1.4rem); - font-weight: 600; - color: var(--cc-ink); - - mat-icon { - color: var(--cc-accent); - } - } + @include detail-block; } .ccd-list { - margin: 0; - padding-left: var(--cc-space-5); - max-width: var(--cc-measure); - color: var(--cc-ink-soft); - line-height: var(--cc-lh-body); - - li { - margin-bottom: var(--cc-space-2); - } + @include detail-list; } /* Exemples concrets avec blocs de code */ @@ -81,93 +61,7 @@ } .example { - border-radius: var(--cc-radius-md); - - &__legende { - margin: 0 0 var(--cc-space-3); - color: var(--cc-ink-soft); - font-size: var(--cc-fs-small); - font-style: italic; - } -} - -.code { - margin: 0; - padding: var(--cc-space-4); - background: var(--cc-surface-2); - border: 1px solid var(--cc-border); - border-radius: var(--cc-radius-sm); - color: var(--cc-ink); - font-family: - 'SFMono-Regular', 'Cascadia Code', Consolas, 'Courier New', monospace; - font-size: 0.82rem; - line-height: 1.55; - overflow-x: auto; - white-space: pre; - tab-size: 2; -} - -/* Parcours séquentiel précédent / suivant */ -.topic-nav { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: var(--cc-space-3); - margin-top: var(--cc-space-7); - padding-top: var(--cc-space-4); - border-top: 1px solid var(--cc-border); -} - -.topic-nav__link { - display: inline-flex; - align-items: center; - gap: var(--cc-space-2); - min-height: 44px; - max-width: 100%; - padding: var(--cc-space-2) var(--cc-space-4); - border: 1px solid var(--cc-border); - border-radius: var(--cc-radius-md); - background: var(--cc-surface); - color: var(--cc-ink); - text-decoration: none; - transition: - border-color var(--cc-transition), - box-shadow var(--cc-transition), - transform var(--cc-transition-bounce); - - &:hover, - &:focus-visible { - border-color: var(--cc-primary); - box-shadow: var(--cc-shadow-2); - transform: translateY(-3px); - } - - &--next { - margin-left: auto; - text-align: right; - } - - mat-icon { - color: var(--cc-primary); - flex-shrink: 0; - } + @include detail-example; } -.topic-nav__meta { - display: flex; - flex-direction: column; - min-width: 0; -} - -.topic-nav__dir { - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.02em; - text-transform: uppercase; - color: var(--cc-ink-soft); -} - -.topic-nav__titre { - font-weight: 600; - line-height: var(--cc-lh-tight); -} +@include detail-code; diff --git a/frontend/src/app/features/claude-code-setup-detail/claude-code-setup-detail.component.ts b/frontend/src/app/features/claude-code-setup-detail/claude-code-setup-detail.component.ts index e7fdbc4..5247a4d 100644 --- a/frontend/src/app/features/claude-code-setup-detail/claude-code-setup-detail.component.ts +++ b/frontend/src/app/features/claude-code-setup-detail/claude-code-setup-detail.component.ts @@ -1,6 +1,6 @@ import { Component, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; @@ -10,16 +10,15 @@ import { BreadcrumbComponent, BreadcrumbItem, } from '../../shared/components/breadcrumb/breadcrumb.component'; +import { + SequentialNavComponent, + SequentialNavItem, +} from '../../shared/components/sequential-nav/sequential-nav.component'; +import { neighborSlug } from '../../core/utils/sequential-nav'; /** Ordre du parcours — dérivé de l'unique source CLAUDE_CODE_TOPICS. */ const TOPIC_SLUGS = CLAUDE_CODE_TOPICS.map((t) => t.slug); -/** Un sujet voisin pour le parcours séquentiel (précédent/suivant). */ -interface TopicLink { - slug: string; - titre: string; -} - /** * Page de détail d'un sujet de mise en place de Claude Code : définition, * pourquoi, exemples commentés, et comment le mettre en place. @@ -31,7 +30,12 @@ interface TopicLink { */ @Component({ selector: 'app-claude-code-setup-detail', - imports: [RouterLink, BreadcrumbComponent, MatCardModule, MatIconModule], + imports: [ + BreadcrumbComponent, + SequentialNavComponent, + MatCardModule, + MatIconModule, + ], templateUrl: './claude-code-setup-detail.component.html', styleUrl: './claude-code-setup-detail.component.scss', }) @@ -53,8 +57,12 @@ export class ClaudeCodeSetupDetailComponent { ]); /** Sujet précédent / suivant du parcours (undefined aux extrémités). */ - readonly prev = computed(() => this.neighbor(-1)); - readonly next = computed(() => this.neighbor(1)); + readonly prev = computed(() => + this.neighbor(-1, 'précédent') + ); + readonly next = computed(() => + this.neighbor(1, 'suivant') + ); constructor() { this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => { @@ -70,12 +78,10 @@ export class ClaudeCodeSetupDetailComponent { } /** Sujet voisin dans TOPIC_SLUGS (delta -1 = précédent, +1 = suivant). */ - private neighbor(delta: number): TopicLink | undefined { - const index = TOPIC_SLUGS.indexOf(this.slug()); - if (index === -1) return undefined; - const slug = TOPIC_SLUGS[index + delta]; + private neighbor(delta: number, sens: string): SequentialNavItem | undefined { + const slug = neighborSlug(TOPIC_SLUGS, this.slug(), delta); if (!slug) return undefined; const titre = CLAUDE_CODE_TOPICS.find((t) => t.slug === slug)?.titre ?? ''; - return { slug, titre }; + return { slug, label: titre, ariaLabel: `Sujet ${sens} : ${titre}` }; } } diff --git a/frontend/src/app/features/code-review/code-review.component.ts b/frontend/src/app/features/code-review/code-review.component.ts index add71a3..d52f45f 100644 --- a/frontend/src/app/features/code-review/code-review.component.ts +++ b/frontend/src/app/features/code-review/code-review.component.ts @@ -15,6 +15,10 @@ import { BreadcrumbComponent, BreadcrumbItem, } from '../../shared/components/breadcrumb/breadcrumb.component'; +import { + loadCheckedSet, + persistCheckedSet, +} from '../../core/utils/checklist-storage'; /** Clé de persistance de l'état coché dans le localStorage. */ const STORAGE_KEY = 'craftcode.code-review.checked'; @@ -54,7 +58,9 @@ export class CodeReviewComponent implements OnInit { readonly groups = signal([]); /** Ensemble des ids d'items cochés (source de vérité de l'UI). */ - private readonly checkedIds = signal>(this.loadChecked()); + private readonly checkedIds = signal>( + loadCheckedSet(STORAGE_KEY) + ); /** Nombre total d'items, tous groupes confondus. */ readonly total = computed(() => @@ -93,14 +99,14 @@ export class CodeReviewComponent implements OnInit { if (next.has(id)) next.delete(id); else next.add(id); this.checkedIds.set(next); - this.persist(next); + persistCheckedSet(STORAGE_KEY, next); } /** Décoche tout (réinitialise la checklist). */ reset(): void { const empty = new Set(); this.checkedIds.set(empty); - this.persist(empty); + persistCheckedSet(STORAGE_KEY, empty); } /** Regroupe les items par catégorie en préservant l'ordre d'arrivée. */ @@ -116,17 +122,4 @@ export class CodeReviewComponent implements OnInit { items: groupItems, })); } - - private loadChecked(): Set { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? new Set(JSON.parse(raw)) : new Set(); - } catch { - return new Set(); - } - } - - private persist(ids: Set): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(ids))); - } } diff --git a/frontend/src/app/features/design-pattern-detail/design-pattern-detail.component.html b/frontend/src/app/features/design-pattern-detail/design-pattern-detail.component.html index 23bddd8..7c3f41c 100644 --- a/frontend/src/app/features/design-pattern-detail/design-pattern-detail.component.html +++ b/frontend/src/app/features/design-pattern-detail/design-pattern-detail.component.html @@ -72,32 +72,10 @@

- + } diff --git a/frontend/src/app/features/design-pattern-detail/design-pattern-detail.component.scss b/frontend/src/app/features/design-pattern-detail/design-pattern-detail.component.scss index 02f647d..0607f46 100644 --- a/frontend/src/app/features/design-pattern-detail/design-pattern-detail.component.scss +++ b/frontend/src/app/features/design-pattern-detail/design-pattern-detail.component.scss @@ -1,3 +1,5 @@ +@use '../../shared/styles/detail' as *; + :host { display: block; max-width: 960px; @@ -51,21 +53,7 @@ } .dpd-block { - margin-bottom: var(--cc-space-6); - - &__title { - display: flex; - align-items: center; - gap: var(--cc-space-2); - margin: 0 0 var(--cc-space-3); - font-size: clamp(1.2rem, 2.8vw, 1.4rem); - font-weight: 600; - color: var(--cc-ink); - - mat-icon { - color: var(--cc-accent); - } - } + @include detail-block; > p { margin: 0; @@ -76,107 +64,13 @@ } .dpd-list { - margin: 0; - padding-left: var(--cc-space-5); - max-width: var(--cc-measure); - color: var(--cc-ink-soft); - line-height: var(--cc-lh-body); - - li { - margin-bottom: var(--cc-space-2); - } + @include detail-list; } /* Exemple de code */ .example { - border-radius: var(--cc-radius-md); border-left: 4px solid var(--cc-primary); - - &__legende { - margin: 0 0 var(--cc-space-3); - color: var(--cc-ink-soft); - font-size: var(--cc-fs-small); - font-style: italic; - } -} - -.code { - margin: 0; - padding: var(--cc-space-4); - background: var(--cc-surface-2); - border: 1px solid var(--cc-border); - border-radius: var(--cc-radius-sm); - color: var(--cc-ink); - font-family: - 'SFMono-Regular', 'Cascadia Code', Consolas, 'Courier New', monospace; - font-size: 0.82rem; - line-height: 1.55; - overflow-x: auto; - white-space: pre; - tab-size: 2; -} - -/* Parcours séquentiel précédent / suivant */ -.pattern-nav { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: var(--cc-space-3); - margin-top: var(--cc-space-7); - padding-top: var(--cc-space-4); - border-top: 1px solid var(--cc-border); + @include detail-example; } -.pattern-nav__link { - display: inline-flex; - align-items: center; - gap: var(--cc-space-2); - min-height: 44px; // cible tactile - max-width: 100%; - padding: var(--cc-space-2) var(--cc-space-4); - border: 1px solid var(--cc-border); - border-radius: var(--cc-radius-md); - background: var(--cc-surface); - color: var(--cc-ink); - text-decoration: none; - transition: - border-color var(--cc-transition), - box-shadow var(--cc-transition), - transform var(--cc-transition-bounce); - - &:hover, - &:focus-visible { - border-color: var(--cc-primary); - box-shadow: var(--cc-shadow-2); - transform: translateY(-3px); - } - - &--next { - margin-left: auto; - text-align: right; - } - - mat-icon { - color: var(--cc-primary); - flex-shrink: 0; - } -} - -.pattern-nav__meta { - display: flex; - flex-direction: column; - min-width: 0; -} - -.pattern-nav__dir { - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.02em; - text-transform: uppercase; - color: var(--cc-ink-soft); -} - -.pattern-nav__titre { - font-weight: 600; - line-height: var(--cc-lh-tight); -} +@include detail-code; diff --git a/frontend/src/app/features/design-pattern-detail/design-pattern-detail.component.ts b/frontend/src/app/features/design-pattern-detail/design-pattern-detail.component.ts index b2ce2b2..9757b2f 100644 --- a/frontend/src/app/features/design-pattern-detail/design-pattern-detail.component.ts +++ b/frontend/src/app/features/design-pattern-detail/design-pattern-detail.component.ts @@ -1,6 +1,6 @@ import { Component, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; @@ -10,16 +10,15 @@ import { BreadcrumbComponent, BreadcrumbItem, } from '../../shared/components/breadcrumb/breadcrumb.component'; +import { + SequentialNavComponent, + SequentialNavItem, +} from '../../shared/components/sequential-nav/sequential-nav.component'; +import { neighborSlug } from '../../core/utils/sequential-nav'; /** Ordre du parcours — dérivé de l'unique source DESIGN_PATTERNS. */ const PATTERN_SLUGS = DESIGN_PATTERNS.map((p) => p.slug); -/** Un patron voisin pour le parcours séquentiel (précédent/suivant). */ -interface PatternLink { - slug: string; - nom: string; -} - /** * Page de détail d'un design pattern : intention, problème, solution, exemple * de code, cas d'usage et pièges. @@ -31,7 +30,12 @@ interface PatternLink { */ @Component({ selector: 'app-design-pattern-detail', - imports: [RouterLink, BreadcrumbComponent, MatCardModule, MatIconModule], + imports: [ + BreadcrumbComponent, + SequentialNavComponent, + MatCardModule, + MatIconModule, + ], templateUrl: './design-pattern-detail.component.html', styleUrl: './design-pattern-detail.component.scss', }) @@ -53,8 +57,12 @@ export class DesignPatternDetailComponent { ]); /** Patron précédent / suivant du parcours (undefined aux extrémités). */ - readonly prev = computed(() => this.neighbor(-1)); - readonly next = computed(() => this.neighbor(1)); + readonly prev = computed(() => + this.neighbor(-1, 'précédent') + ); + readonly next = computed(() => + this.neighbor(1, 'suivant') + ); constructor() { this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => { @@ -70,12 +78,10 @@ export class DesignPatternDetailComponent { } /** Patron voisin dans PATTERN_SLUGS (delta -1 = précédent, +1 = suivant). */ - private neighbor(delta: number): PatternLink | undefined { - const index = PATTERN_SLUGS.indexOf(this.slug()); - if (index === -1) return undefined; - const slug = PATTERN_SLUGS[index + delta]; + private neighbor(delta: number, sens: string): SequentialNavItem | undefined { + const slug = neighborSlug(PATTERN_SLUGS, this.slug(), delta); if (!slug) return undefined; const nom = DESIGN_PATTERNS.find((p) => p.slug === slug)?.nom ?? ''; - return { slug, nom }; + return { slug, label: nom, ariaLabel: `Patron ${sens} : ${nom}` }; } } diff --git a/frontend/src/app/features/phase-guide/phase-guide.component.html b/frontend/src/app/features/phase-guide/phase-guide.component.html index 94c8913..f032dbb 100644 --- a/frontend/src/app/features/phase-guide/phase-guide.component.html +++ b/frontend/src/app/features/phase-guide/phase-guide.component.html @@ -65,32 +65,10 @@

{{ pratique.titre }}

- + } diff --git a/frontend/src/app/features/phase-guide/phase-guide.component.scss b/frontend/src/app/features/phase-guide/phase-guide.component.scss index c0be521..191d052 100644 --- a/frontend/src/app/features/phase-guide/phase-guide.component.scss +++ b/frontend/src/app/features/phase-guide/phase-guide.component.scss @@ -1,3 +1,6 @@ +@use '../../shared/styles/badge' as *; +@use '../../shared/styles/practice-card' as *; + :host { display: block; max-width: 960px; @@ -38,35 +41,7 @@ } /* Badge de rôle — mêmes tokens que la page d'aperçu */ -.badge { - display: inline-block; - padding: var(--cc-space-1) var(--cc-space-3); - border-radius: var(--cc-radius-pill); - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.02em; - white-space: nowrap; - - &--equipe { - background: var(--cc-role-equipe-tint); - color: var(--cc-role-equipe); - } - - &--auteur { - background: var(--cc-role-auteur-tint); - color: var(--cc-role-auteur); - } - - &--relecteur { - background: var(--cc-role-relecteur-tint); - color: var(--cc-role-relecteur); - } - - &--mixte { - background: var(--cc-role-mixte-tint); - color: var(--cc-role-mixte); - } -} +@include role-badge; /* Panneau de progression — collant sous la toolbar */ .progress-panel { @@ -104,65 +79,7 @@ } .practice { - border-radius: var(--cc-radius-md); - transition: - transform var(--cc-transition-bounce), - box-shadow var(--cc-transition); - - &:hover, - &:focus-within { - transform: translateY(-6px) scale(1.015); - box-shadow: var(--cc-shadow-2); - } - - &__top { - display: flex; - align-items: flex-start; - gap: var(--cc-space-2); - margin-bottom: var(--cc-space-2); - } - - &__icon { - color: var(--cc-primary); - flex-shrink: 0; - } - - &__title { - margin: 0; - font-size: var(--cc-fs-h3); - font-weight: 600; - color: var(--cc-ink); - line-height: var(--cc-lh-tight); - } - - &__accroche { - margin: 0 0 var(--cc-space-3); - color: var(--cc-ink-soft); - font-size: 0.9rem; - line-height: 1.5; - } - - &__why { - display: flex; - align-items: flex-start; - gap: var(--cc-space-2); - margin: 0; - padding-top: var(--cc-space-3); - border-top: 1px solid var(--cc-border); - color: var(--cc-ink-soft); - font-size: var(--cc-fs-small); - line-height: 1.45; - font-style: italic; - } - - &__why-icon { - font-size: 1.05rem; - width: 1.05rem; - height: 1.05rem; - color: var(--cc-accent); - flex-shrink: 0; - margin-top: 0.05rem; - } + @include practice-card; /* Pratique cochée : titre barré */ &--done { @@ -172,69 +89,3 @@ } } } - -/* Parcours séquentiel précédent / suivant */ -.phase-nav { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: var(--cc-space-3); - margin-top: var(--cc-space-7); - padding-top: var(--cc-space-4); - border-top: 1px solid var(--cc-border); -} - -.phase-nav__link { - display: inline-flex; - align-items: center; - gap: var(--cc-space-2); - min-height: 44px; // cible tactile - max-width: 100%; - padding: var(--cc-space-2) var(--cc-space-4); - border: 1px solid var(--cc-border); - border-radius: var(--cc-radius-md); - background: var(--cc-surface); - color: var(--cc-ink); - text-decoration: none; - transition: - border-color var(--cc-transition), - box-shadow var(--cc-transition), - transform var(--cc-transition-bounce); - - &:hover, - &:focus-visible { - border-color: var(--cc-primary); - box-shadow: var(--cc-shadow-2); - transform: translateY(-3px); - } - - // Le suivant reste collé à droite même quand le précédent est absent. - &--next { - margin-left: auto; - text-align: right; - } - - mat-icon { - color: var(--cc-primary); - flex-shrink: 0; - } -} - -.phase-nav__meta { - display: flex; - flex-direction: column; - min-width: 0; -} - -.phase-nav__dir { - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.02em; - text-transform: uppercase; - color: var(--cc-ink-soft); -} - -.phase-nav__titre { - font-weight: 600; - line-height: var(--cc-lh-tight); -} diff --git a/frontend/src/app/features/phase-guide/phase-guide.component.ts b/frontend/src/app/features/phase-guide/phase-guide.component.ts index 6a5c5cb..8072387 100644 --- a/frontend/src/app/features/phase-guide/phase-guide.component.ts +++ b/frontend/src/app/features/phase-guide/phase-guide.component.ts @@ -1,6 +1,6 @@ import { Component, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; @@ -13,6 +13,15 @@ import { BreadcrumbComponent, BreadcrumbItem, } from '../../shared/components/breadcrumb/breadcrumb.component'; +import { + SequentialNavComponent, + SequentialNavItem, +} from '../../shared/components/sequential-nav/sequential-nav.component'; +import { + loadCheckedSet, + persistCheckedSet, +} from '../../core/utils/checklist-storage'; +import { neighborSlug } from '../../core/utils/sequential-nav'; /** Table de correspondance slug de phase → `numero` de section. */ const SLUG_TO_NUMERO: Record = { @@ -30,12 +39,6 @@ const SLUG_TO_NUMERO: Record = { */ const PHASE_SLUGS = Object.keys(SLUG_TO_NUMERO); -/** Une phase voisine pour le parcours séquentiel (précédent/suivant). */ -interface PhaseLink { - slug: string; - titre: string; -} - /** * Page-support d'une phase de revue : lecture (titre + accroche + « pourquoi ») * ET case à cocher par pratique, avec progression sauvegardée par phase. @@ -47,8 +50,8 @@ interface PhaseLink { @Component({ selector: 'app-phase-guide', imports: [ - RouterLink, BreadcrumbComponent, + SequentialNavComponent, MatCheckboxModule, MatCardModule, MatIconModule, @@ -91,8 +94,12 @@ export class PhaseGuideComponent { ]); /** Phase précédente / suivante du parcours (undefined aux extrémités). */ - readonly prev = computed(() => this.neighbor(-1)); - readonly next = computed(() => this.neighbor(1)); + readonly prev = computed(() => + this.neighbor(-1, 'précédente') + ); + readonly next = computed(() => + this.neighbor(1, 'suivante') + ); constructor() { // Réactif au paramètre `:phase` : couvre le chargement initial ET la @@ -108,7 +115,7 @@ export class PhaseGuideComponent { } this.slug.set(slug); this.section.set(section); - this.checkedIds.set(this.loadChecked(slug)); + this.checkedIds.set(loadCheckedSet(this.storageKey(slug))); }); } @@ -122,26 +129,24 @@ export class PhaseGuideComponent { if (next.has(id)) next.delete(id); else next.add(id); this.checkedIds.set(next); - this.persist(this.slug(), next); + persistCheckedSet(this.storageKey(this.slug()), next); } /** Décoche tout (réinitialise la phase). */ reset(): void { const empty = new Set(); this.checkedIds.set(empty); - this.persist(this.slug(), empty); + persistCheckedSet(this.storageKey(this.slug()), empty); } /** Phase voisine dans PHASE_SLUGS (delta -1 = précédent, +1 = suivant). */ - private neighbor(delta: number): PhaseLink | undefined { - const index = PHASE_SLUGS.indexOf(this.slug()); - if (index === -1) return undefined; - const slug = PHASE_SLUGS[index + delta]; + private neighbor(delta: number, sens: string): SequentialNavItem | undefined { + const slug = neighborSlug(PHASE_SLUGS, this.slug(), delta); if (!slug) return undefined; const numero = SLUG_TO_NUMERO[slug]; const titre = CODE_REVIEW_SECTIONS.find((s) => s.numero === numero)?.titre ?? ''; - return { slug, titre }; + return { slug, label: titre, ariaLabel: `Phase ${sens} : ${titre}` }; } private resolveSection(slug: string): Section | undefined { @@ -153,17 +158,4 @@ export class PhaseGuideComponent { private storageKey(slug: string): string { return `craftcode.phase.${slug}.checked`; } - - private loadChecked(slug: string): Set { - try { - const raw = localStorage.getItem(this.storageKey(slug)); - return raw ? new Set(JSON.parse(raw)) : new Set(); - } catch { - return new Set(); - } - } - - private persist(slug: string, ids: Set): void { - localStorage.setItem(this.storageKey(slug), JSON.stringify(Array.from(ids))); - } } diff --git a/frontend/src/app/features/solid-detail/solid-detail.component.html b/frontend/src/app/features/solid-detail/solid-detail.component.html index 7200de7..20a65c1 100644 --- a/frontend/src/app/features/solid-detail/solid-detail.component.html +++ b/frontend/src/app/features/solid-detail/solid-detail.component.html @@ -4,7 +4,9 @@
- +

{{ p.nomFr }}

{{ p.nomEn }}

@@ -79,32 +81,10 @@

- + } diff --git a/frontend/src/app/features/solid-detail/solid-detail.component.scss b/frontend/src/app/features/solid-detail/solid-detail.component.scss index 7a5ad66..fffe7a4 100644 --- a/frontend/src/app/features/solid-detail/solid-detail.component.scss +++ b/frontend/src/app/features/solid-detail/solid-detail.component.scss @@ -1,3 +1,5 @@ +@use '../../shared/styles/detail' as *; + :host { display: block; max-width: 960px; @@ -65,21 +67,7 @@ } .sd-block { - margin-bottom: var(--cc-space-6); - - &__title { - display: flex; - align-items: center; - gap: var(--cc-space-2); - margin: 0 0 var(--cc-space-3); - font-size: clamp(1.2rem, 2.8vw, 1.4rem); - font-weight: 600; - color: var(--cc-ink); - - mat-icon { - color: var(--cc-accent); - } - } + @include detail-block; > p { margin: 0; @@ -90,15 +78,7 @@ } .sd-list { - margin: 0; - padding-left: var(--cc-space-5); - max-width: var(--cc-measure); - color: var(--cc-ink-soft); - line-height: var(--cc-lh-body); - - li { - margin-bottom: var(--cc-space-2); - } + @include detail-list; } /* Exemples « à éviter / à préférer » avec blocs de code */ @@ -109,7 +89,7 @@ } .example { - border-radius: var(--cc-radius-md); + @include detail-example; &__title { display: flex; @@ -120,13 +100,6 @@ font-weight: 600; } - &__legende { - margin: 0 0 var(--cc-space-3); - color: var(--cc-ink-soft); - font-size: var(--cc-fs-small); - font-style: italic; - } - // Indices doublés (icône + couleur de bord), jamais la couleur seule. &--avoid { border-left: 4px solid var(--cc-warn); @@ -145,83 +118,4 @@ } } -.code { - margin: 0; - padding: var(--cc-space-4); - background: var(--cc-surface-2); - border: 1px solid var(--cc-border); - border-radius: var(--cc-radius-sm); - color: var(--cc-ink); - font-family: - 'SFMono-Regular', 'Cascadia Code', Consolas, 'Courier New', monospace; - font-size: 0.82rem; - line-height: 1.55; - overflow-x: auto; - white-space: pre; - tab-size: 2; -} - -/* Parcours séquentiel précédent / suivant */ -.principle-nav { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: var(--cc-space-3); - margin-top: var(--cc-space-7); - padding-top: var(--cc-space-4); - border-top: 1px solid var(--cc-border); -} - -.principle-nav__link { - display: inline-flex; - align-items: center; - gap: var(--cc-space-2); - min-height: 44px; // cible tactile - max-width: 100%; - padding: var(--cc-space-2) var(--cc-space-4); - border: 1px solid var(--cc-border); - border-radius: var(--cc-radius-md); - background: var(--cc-surface); - color: var(--cc-ink); - text-decoration: none; - transition: - border-color var(--cc-transition), - box-shadow var(--cc-transition), - transform var(--cc-transition-bounce); - - &:hover, - &:focus-visible { - border-color: var(--cc-primary); - box-shadow: var(--cc-shadow-2); - transform: translateY(-3px); - } - - &--next { - margin-left: auto; - text-align: right; - } - - mat-icon { - color: var(--cc-primary); - flex-shrink: 0; - } -} - -.principle-nav__meta { - display: flex; - flex-direction: column; - min-width: 0; -} - -.principle-nav__dir { - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.02em; - text-transform: uppercase; - color: var(--cc-ink-soft); -} - -.principle-nav__titre { - font-weight: 600; - line-height: var(--cc-lh-tight); -} +@include detail-code; diff --git a/frontend/src/app/features/solid-detail/solid-detail.component.ts b/frontend/src/app/features/solid-detail/solid-detail.component.ts index 647b6c2..4c7ed78 100644 --- a/frontend/src/app/features/solid-detail/solid-detail.component.ts +++ b/frontend/src/app/features/solid-detail/solid-detail.component.ts @@ -1,6 +1,6 @@ import { Component, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; @@ -10,16 +10,15 @@ import { BreadcrumbComponent, BreadcrumbItem, } from '../../shared/components/breadcrumb/breadcrumb.component'; +import { + SequentialNavComponent, + SequentialNavItem, +} from '../../shared/components/sequential-nav/sequential-nav.component'; +import { neighborSlug } from '../../core/utils/sequential-nav'; /** Ordre du parcours — dérivé de l'unique source SOLID_PRINCIPLES. */ const PRINCIPLE_SLUGS = SOLID_PRINCIPLES.map((p) => p.slug); -/** Un principe voisin pour le parcours séquentiel (précédent/suivant). */ -interface PrincipleLink { - slug: string; - nomFr: string; -} - /** * Page de détail d'un principe SOLID : définition, pourquoi, exemples de code * à éviter / à préférer, et comment le respecter. @@ -31,7 +30,12 @@ interface PrincipleLink { */ @Component({ selector: 'app-solid-detail', - imports: [RouterLink, BreadcrumbComponent, MatCardModule, MatIconModule], + imports: [ + BreadcrumbComponent, + SequentialNavComponent, + MatCardModule, + MatIconModule, + ], templateUrl: './solid-detail.component.html', styleUrl: './solid-detail.component.scss', }) @@ -53,8 +57,12 @@ export class SolidDetailComponent { ]); /** Principe précédent / suivant du parcours (undefined aux extrémités). */ - readonly prev = computed(() => this.neighbor(-1)); - readonly next = computed(() => this.neighbor(1)); + readonly prev = computed(() => + this.neighbor(-1, 'précédent') + ); + readonly next = computed(() => + this.neighbor(1, 'suivant') + ); constructor() { this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => { @@ -70,12 +78,10 @@ export class SolidDetailComponent { } /** Principe voisin dans PRINCIPLE_SLUGS (delta -1 = précédent, +1 = suivant). */ - private neighbor(delta: number): PrincipleLink | undefined { - const index = PRINCIPLE_SLUGS.indexOf(this.slug()); - if (index === -1) return undefined; - const slug = PRINCIPLE_SLUGS[index + delta]; + private neighbor(delta: number, sens: string): SequentialNavItem | undefined { + const slug = neighborSlug(PRINCIPLE_SLUGS, this.slug(), delta); if (!slug) return undefined; const nomFr = SOLID_PRINCIPLES.find((p) => p.slug === slug)?.nomFr ?? ''; - return { slug, nomFr }; + return { slug, label: nomFr, ariaLabel: `Principe ${sens} : ${nomFr}` }; } } diff --git a/frontend/src/app/shared/components/sequential-nav/sequential-nav.component.html b/frontend/src/app/shared/components/sequential-nav/sequential-nav.component.html new file mode 100644 index 0000000..81c1ed5 --- /dev/null +++ b/frontend/src/app/shared/components/sequential-nav/sequential-nav.component.html @@ -0,0 +1,28 @@ + diff --git a/frontend/src/app/shared/components/sequential-nav/sequential-nav.component.scss b/frontend/src/app/shared/components/sequential-nav/sequential-nav.component.scss new file mode 100644 index 0000000..fd55b43 --- /dev/null +++ b/frontend/src/app/shared/components/sequential-nav/sequential-nav.component.scss @@ -0,0 +1,70 @@ +:host { + display: block; + // Reprend le retrait qui séparait jusqu'ici la nav du contenu de la page. + margin-top: var(--cc-space-7); +} + +/* Parcours séquentiel précédent / suivant */ +.seq-nav { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: var(--cc-space-3); + padding-top: var(--cc-space-4); + border-top: 1px solid var(--cc-border); +} + +.seq-nav__link { + display: inline-flex; + align-items: center; + gap: var(--cc-space-2); + min-height: 44px; // cible tactile + max-width: 100%; + padding: var(--cc-space-2) var(--cc-space-4); + border: 1px solid var(--cc-border); + border-radius: var(--cc-radius-md); + background: var(--cc-surface); + color: var(--cc-ink); + text-decoration: none; + transition: + border-color var(--cc-transition), + box-shadow var(--cc-transition), + transform var(--cc-transition-bounce); + + &:hover, + &:focus-visible { + border-color: var(--cc-primary); + box-shadow: var(--cc-shadow-2); + transform: translateY(-3px); + } + + // Le suivant reste collé à droite même quand le précédent est absent. + &--next { + margin-left: auto; + text-align: right; + } + + mat-icon { + color: var(--cc-primary); + flex-shrink: 0; + } +} + +.seq-nav__meta { + display: flex; + flex-direction: column; + min-width: 0; +} + +.seq-nav__dir { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--cc-ink-soft); +} + +.seq-nav__titre { + font-weight: 600; + line-height: var(--cc-lh-tight); +} diff --git a/frontend/src/app/shared/components/sequential-nav/sequential-nav.component.ts b/frontend/src/app/shared/components/sequential-nav/sequential-nav.component.ts new file mode 100644 index 0000000..5c17e86 --- /dev/null +++ b/frontend/src/app/shared/components/sequential-nav/sequential-nav.component.ts @@ -0,0 +1,37 @@ +import { Component, Input } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; + +/** Un maillon du parcours séquentiel (précédent ou suivant). */ +export interface SequentialNavItem { + slug: string; + label: string; + /** Libellé d'accessibilité complet (« Principe précédent : … »). */ + ariaLabel: string; +} + +/** + * Navigation « précédent / suivant » d'un parcours séquentiel, partagée par les + * pages de détail (SOLID, Design Patterns, Claude Code, phases de revue). Pilotée + * par `prev`/`next` (undefined aux extrémités) ; `routeBase` est le segment de + * route commun (`/solid`…) auquel le `slug` du maillon est concaténé. + */ +@Component({ + selector: 'app-sequential-nav', + imports: [RouterLink, MatIconModule], + templateUrl: './sequential-nav.component.html', + styleUrl: './sequential-nav.component.scss', +}) +export class SequentialNavComponent { + /** Segment de route commun, ex. `/solid` (concaténé au `slug`). */ + @Input({ required: true }) routeBase!: string; + + /** Libellé d'accessibilité de la balise `