chore(ui): stage local sprint changes
Co-Authored-By: Claude Opus 4.6 <[email protected]>
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 electron-rare contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,73 @@
|
||||
# electron-rare.github.io
|
||||
|
||||
## Overview
|
||||
TODO: Describe project goals and context.
|
||||
|
||||
## Installation
|
||||
TODO: Add setup steps.
|
||||
|
||||
## Usage
|
||||
TODO: Add run/deploy instructions.
|
||||
|
||||
## Architecture
|
||||
TODO: Document components and data flow.
|
||||
|
||||
## Testing & Quality
|
||||
TODO: Document lint/test/build commands.
|
||||
|
||||
## Performance & Optimization
|
||||
TODO: Add known hotspots and optimization strategy.
|
||||
|
||||
## Security
|
||||
TODO: Add threat surfaces and hardening notes.
|
||||
|
||||
## Roadmap
|
||||
TODO: Add near-term milestones.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- CHANTIER:AUDIT START -->
|
||||
## Audit & Execution Plan (2026-03-10)
|
||||
|
||||
### Snapshot
|
||||
- Priority: `P2`
|
||||
- Tech profile: `node`
|
||||
- Workflows: `yes`
|
||||
- Tests: `yes`
|
||||
- Debt markers: `0`
|
||||
- Source files: `15`
|
||||
|
||||
### Corrections Prioritaires
|
||||
- [ ] Normaliser scripts npm lint/test/build
|
||||
- [ ] Ajouter/fiabiliser les commandes de vérification automatiques.
|
||||
- [ ] Clore les points bloquants avant optimisation avancée.
|
||||
|
||||
### Optimisation
|
||||
- [ ] Identifier le hotspot principal et mesurer avant/après.
|
||||
- [ ] Réduire la complexité des modules les plus touchés.
|
||||
|
||||
### Mémoire chantier
|
||||
- Control plane: `/Users/electron/.codex/memories/electron_rare_chantier`
|
||||
- Repo card: `/Users/electron/.codex/memories/electron_rare_chantier/REPOS/electron-rare.github.io.md`
|
||||
|
||||
<!-- CHANTIER:AUDIT END -->
|
||||
@@ -1,14 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 72" role="img" aria-label="Electron rare lockup placeholder">
|
||||
<defs>
|
||||
<linearGradient id="gg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#08f7ff"/>
|
||||
<stop offset="100%" stop-color="#ff3adf"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="316" height="68" rx="14" fill="#0a1220" stroke="#324667"/>
|
||||
<rect x="12" y="12" width="48" height="48" rx="12" fill="#101c37" stroke="#31527d"/>
|
||||
<path d="M24 26H40V32H30V36H39" fill="none" stroke="url(#gg)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M43 26H49C54 26 56 28 56 32C56 36 54 38 49 38H43V26Z" fill="none" stroke="#8dff5a" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="72" y="33" fill="#f4f8ff" font-size="15" font-family="Source Sans 3, Segoe UI, sans-serif" font-weight="700" letter-spacing="1.8">ELECTRON RARE</text>
|
||||
<text x="72" y="50" fill="#9fb3d3" font-size="10" font-family="Source Sans 3, Segoe UI, sans-serif" letter-spacing="1.6">UNSTABLE BY DESIGN</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 700" role="img" aria-label="Electron rare logo lockup"><image href="/assets/brand/logo-lockup.png" width="1400" height="700" preserveAspectRatio="xMidYMid meet"/></svg>
|
||||
|
Before Width: | Height: | Size: 227 B |
@@ -1,14 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-label="Electron rare mark placeholder">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#08f7ff"/>
|
||||
<stop offset="100%" stop-color="#ff3adf"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="6" y="6" width="84" height="84" rx="20" fill="#0a1220" stroke="#324667" stroke-width="2"/>
|
||||
<path d="M24 30H56V40H34V48H54" fill="none" stroke="url(#g)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M62 30H72C80 30 84 34 84 42C84 50 80 54 72 54H62V30Z" fill="none" stroke="#8dff5a" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="24" cy="30" r="3" fill="#08f7ff"/>
|
||||
<circle cx="54" cy="48" r="3" fill="#ff3adf"/>
|
||||
<circle cx="84" cy="42" r="3" fill="#8dff5a"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 854 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Electron rare logo mark"><image href="/assets/brand/logo-mark.png" width="1024" height="1024" preserveAspectRatio="xMidYMid meet"/></svg>
|
||||
|
Before Width: | Height: | Size: 225 B |
@@ -0,0 +1,66 @@
|
||||
# Plan P2 Exécutable (2026-03-03)
|
||||
|
||||
## Objectif P2
|
||||
Monter la landing d’un niveau premium sans casser la conversion actuelle: `System Map` riche, mini-widgets instrumentation avancés, pages cas dédiées SEO.
|
||||
|
||||
## Cadre
|
||||
- Horizon: 14 jours
|
||||
- Owners: `Frontend`, `UX/CRO`, `QA`, `SEO/Analytics`
|
||||
- Status: `TODO | IN_PROGRESS | BLOCKED | DONE`
|
||||
- Priorité: `P2`
|
||||
|
||||
## Lot A - System Map enrichie (J1-J4)
|
||||
|
||||
| ID | Task | Owner | Dépendances | Sortie attendue | Acceptance criteria | Status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| P2-A1 | Enrichir la map avec noeuds/liaisons métier (Power/MCU/RF/Audio/Test) | Frontend | Aucune | Map interactive en 1 colonne | Noeud actif + liens hot + tabs Inputs/Outputs/Risques | DONE |
|
||||
| P2-A2 | Ajouter vues interfaces par noeud (I2C/SPI/UART/Analog/RF/Power) | Frontend | P2-A1 | Chips interfaces + details contextuels | Chaque noeud affiche ses interfaces propres | DONE |
|
||||
| P2-A3 | Ajouter mode mobile touch-first (hit area + lisibilité) | Frontend | P2-A1 | UX mobile robuste | Cibles tactiles >= 40px, aucun overlap 390px | DONE |
|
||||
| P2-A4 | QA accessibilité map (tab/focus/aria) | QA | P2-A1 | Rapport QA map | Navigation clavier complète, rôles ARIA valides | DONE |
|
||||
|
||||
## Lot B - Widgets scope/bode approfondis (J5-J9)
|
||||
|
||||
| ID | Task | Owner | Dépendances | Sortie attendue | Acceptance criteria | Status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| P2-B1 | Scope v2: 3 états (idle/hover/active) + lecture explicative | Frontend | P2-A4 | Module scope plus crédible | Chaque état change visuel + texte impact décision | DONE |
|
||||
| P2-B2 | Bode v2: curseur fréquence + point de coupure + message implication | Frontend | P2-B1 | Mini-plot décisionnel | Affiche gain, zone passante, warning hors marge | DONE |
|
||||
| P2-B3 | Reduced-motion strict sur widgets | Frontend | P2-B1 | Animations dégradées proprement | Avec `prefers-reduced-motion`, pas d’animation auto | DONE |
|
||||
| P2-B4 | Validation perf animation (desktop + mobile) | QA | P2-B1,P2-B2 | Rapport perf P2 | Aucun stutter visible, interaction fluide au scroll | DONE |
|
||||
|
||||
## Lot C - Pages cas dédiées SEO (J10-J14)
|
||||
|
||||
| ID | Task | Owner | Dépendances | Sortie attendue | Acceptance criteria | Status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| P2-C1 | Créer template page cas (`/cas/[slug]`) | Frontend | P2-A2 | Gabarit réutilisable | Hero cas + contexte + intervention + impact + CTA | DONE |
|
||||
| P2-C2 | Publier 3 cas (Studio Core / Design Produit / R&D Sonore) | UX/CRO | P2-C1 | 3 pages prêtes indexation | Chaque page contient preuve + résultat + CTA contact | DONE |
|
||||
| P2-C3 | Maillage interne landing -> pages cas -> contact | SEO/Analytics | P2-C2 | Flux navigation SEO | Liens internes valides, aucun lien mort | DONE |
|
||||
| P2-C4 | Meta/OG/schema par cas + QA indexabilité | SEO/Analytics | P2-C2 | SEO on-page final cas | title/meta/OG + schema Article/CreativeWork valides | DONE |
|
||||
|
||||
## Livrables P2
|
||||
- System Map premium et accessible
|
||||
- Modules Scope/Bode v2 orientés décision
|
||||
- 3 pages cas SEO-ready et maillées
|
||||
|
||||
## Rapports P2
|
||||
- `docs/p2-lot-a-accessibilite-map-2026-03-03.md`
|
||||
- `docs/p2-lot-b-widgets-perf-2026-03-03.md`
|
||||
- `docs/p2-lot-c-seo-cas-2026-03-03.md`
|
||||
- `docs/qa-artifacts/p2-qa-report.json`
|
||||
|
||||
## Risques P2
|
||||
|
||||
| Risque | Impact | Probabilité | Mitigation | Owner |
|
||||
|---|---|---|---|---|
|
||||
| Sur-design au détriment de la lisibilité | Élevé | Moyen | Revue mobile 390 + contraste avant merge | UX/CRO |
|
||||
| Widgets trop lourds | Moyen | Moyen | Limiter animation active + reduced-motion | Frontend |
|
||||
| Délais contenus cas | Élevé | Moyen | Gabarit unique + collecte assets J10 | UX/CRO |
|
||||
| Régression tracking funnel | Élevé | Faible | Rejouer `sprint2-qa.cjs` + smoke events | SEO/Analytics |
|
||||
|
||||
## Definition of Done P2
|
||||
- [x] `npm run build` vert
|
||||
- [x] `npm run typecheck` vert (0 erreur)
|
||||
- [x] System Map validée mobile + clavier
|
||||
- [x] Scope/Bode v2 validés reduced-motion
|
||||
- [x] 3 pages cas publiées et liées depuis landing
|
||||
- [x] Meta/OG/schema cas validés
|
||||
- [x] Tracking funnel toujours intact
|
||||
@@ -0,0 +1,20 @@
|
||||
# P2 Lot A - QA Map Mobile + Accessibilite (2026-03-03)
|
||||
|
||||
## Scope
|
||||
- System Map section `offre-systeme`
|
||||
- Mobile 390x844
|
||||
- Navigation clavier et roles tab
|
||||
|
||||
## Resultats
|
||||
- `system-map-scroll` present
|
||||
- Hit areas noeuds >= 40px: OK
|
||||
- Overlap noeuds: 0
|
||||
- Roles tabs details: 3 tabs detectes
|
||||
- Focus clavier: parcours valide avec focus sur noeuds Power/MCU/RF/Audio/Test
|
||||
|
||||
## Preuves
|
||||
- Rapport brut: `docs/qa-artifacts/p2-qa-report.json`
|
||||
- Capture mobile: `docs/qa-artifacts/p2-lot-a-mobile-390.png`
|
||||
|
||||
## Verdict
|
||||
- PASS (Lot A)
|
||||
@@ -0,0 +1,16 @@
|
||||
# P2 Lot B - Widgets Scope/Bode v2 + Reduced Motion (2026-03-03)
|
||||
|
||||
## Scope
|
||||
- `LabNotes` (scope states idle/hover/active, bode avec cutoff)
|
||||
- Test reduced motion
|
||||
|
||||
## Resultats
|
||||
- Scope v2: etats explicites + message impact decision
|
||||
- Bode v2: slider, marker, cutoff, implication normalisee
|
||||
- Reduced motion: transitions neutralisees (`transitionDuration ~ 0.001s`)
|
||||
|
||||
## Preuves
|
||||
- Rapport brut: `docs/qa-artifacts/p2-qa-report.json` (`lotB.reducedMotion`)
|
||||
|
||||
## Verdict
|
||||
- PASS (Lot B)
|
||||
@@ -0,0 +1,29 @@
|
||||
# P2 Lot C - Pages Cas SEO + Maillage (2026-03-03)
|
||||
|
||||
## Routes publiees
|
||||
- `/cas/studio-core/`
|
||||
- `/cas/design-produit/`
|
||||
- `/cas/rd-sonore/`
|
||||
|
||||
## SEO checks
|
||||
Pour chaque page cas:
|
||||
- `title`: OK
|
||||
- `meta description`: OK
|
||||
- `og:title` + `og:description`: OK
|
||||
- canonical: OK
|
||||
- schema `CreativeWork`: OK
|
||||
- lien retour contact `/#contact`: OK
|
||||
|
||||
## Maillage
|
||||
- Landing -> cas: liens `Voir le cas complet`
|
||||
- Cas -> contact: CTA `Envoyer un brief`
|
||||
|
||||
## Tracking
|
||||
- Contrat DOM `engagement_case_opened`: present sur landing
|
||||
- Contrat funnel Sprint 2: `npm run tracking:check` PASS
|
||||
|
||||
## Preuves
|
||||
- `docs/qa-artifacts/p2-qa-report.json`
|
||||
|
||||
## Verdict
|
||||
- PASS (Lot C)
|
||||
|
After Width: | Height: | Size: 668 KiB |
@@ -0,0 +1,192 @@
|
||||
{
|
||||
"generatedAt": "2026-03-03T08:15:15.122Z",
|
||||
"lotA": {
|
||||
"hasScrollContainer": true,
|
||||
"horizontalOverflow": false,
|
||||
"svgMinWidth": "420px",
|
||||
"minNodeHitArea": true,
|
||||
"overlapsCount": 0,
|
||||
"screenshot": "docs/qa-artifacts/p2-lot-a-mobile-390.png",
|
||||
"keyboard": {
|
||||
"tabRoleCount": 3,
|
||||
"focusLog": [
|
||||
{
|
||||
"tag": "A",
|
||||
"role": null,
|
||||
"aria": null,
|
||||
"text": "Aller au contenu"
|
||||
},
|
||||
{
|
||||
"tag": "A",
|
||||
"role": null,
|
||||
"aria": null,
|
||||
"text": "ELECTRONICS MISSION STUDIO"
|
||||
},
|
||||
{
|
||||
"tag": "A",
|
||||
"role": null,
|
||||
"aria": null,
|
||||
"text": "#a-propos"
|
||||
},
|
||||
{
|
||||
"tag": "A",
|
||||
"role": null,
|
||||
"aria": null,
|
||||
"text": "#preuves"
|
||||
},
|
||||
{
|
||||
"tag": "A",
|
||||
"role": null,
|
||||
"aria": null,
|
||||
"text": "#contact"
|
||||
},
|
||||
{
|
||||
"tag": "BUTTON",
|
||||
"role": null,
|
||||
"aria": null,
|
||||
"text": "Mode contraste"
|
||||
},
|
||||
{
|
||||
"tag": "g",
|
||||
"role": "button",
|
||||
"aria": "Bloc Power",
|
||||
"text": "Power"
|
||||
},
|
||||
{
|
||||
"tag": "g",
|
||||
"role": "button",
|
||||
"aria": "Bloc MCU",
|
||||
"text": "MCU"
|
||||
},
|
||||
{
|
||||
"tag": "g",
|
||||
"role": "button",
|
||||
"aria": "Bloc RF",
|
||||
"text": "RF"
|
||||
},
|
||||
{
|
||||
"tag": "g",
|
||||
"role": "button",
|
||||
"aria": "Bloc Audio",
|
||||
"text": "Audio"
|
||||
},
|
||||
{
|
||||
"tag": "g",
|
||||
"role": "button",
|
||||
"aria": "Bloc Test",
|
||||
"text": "Test"
|
||||
},
|
||||
{
|
||||
"tag": "A",
|
||||
"role": null,
|
||||
"aria": null,
|
||||
"text": "Envoyer un brief"
|
||||
},
|
||||
{
|
||||
"tag": "A",
|
||||
"role": null,
|
||||
"aria": null,
|
||||
"text": "Voir les preuves"
|
||||
},
|
||||
{
|
||||
"tag": "BUTTON",
|
||||
"role": null,
|
||||
"aria": null,
|
||||
"text": "Power"
|
||||
},
|
||||
{
|
||||
"tag": "BUTTON",
|
||||
"role": null,
|
||||
"aria": null,
|
||||
"text": "MCU"
|
||||
},
|
||||
{
|
||||
"tag": "BUTTON",
|
||||
"role": null,
|
||||
"aria": null,
|
||||
"text": "RF"
|
||||
},
|
||||
{
|
||||
"tag": "BUTTON",
|
||||
"role": null,
|
||||
"aria": null,
|
||||
"text": "Audio"
|
||||
},
|
||||
{
|
||||
"tag": "BUTTON",
|
||||
"role": null,
|
||||
"aria": null,
|
||||
"text": "Test"
|
||||
},
|
||||
{
|
||||
"tag": "BUTTON",
|
||||
"role": "tab",
|
||||
"aria": null,
|
||||
"text": "JE DECOUVRE"
|
||||
},
|
||||
{
|
||||
"tag": "BUTTON",
|
||||
"role": "tab",
|
||||
"aria": null,
|
||||
"text": "JE SUIS PRO"
|
||||
},
|
||||
{
|
||||
"tag": "BUTTON",
|
||||
"role": null,
|
||||
"aria": null,
|
||||
"text": "Suivant"
|
||||
},
|
||||
{
|
||||
"tag": "A",
|
||||
"role": null,
|
||||
"aria": null,
|
||||
"text": "Decrire votre idee (2 minutes)"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"lotB": {
|
||||
"reducedMotion": {
|
||||
"available": true,
|
||||
"waveTransition": "0.001s",
|
||||
"markerTransition": "0.001s"
|
||||
}
|
||||
},
|
||||
"lotC": {
|
||||
"pages": [
|
||||
{
|
||||
"slug": "studio-core",
|
||||
"hasTitle": true,
|
||||
"hasDesc": true,
|
||||
"hasOgTitle": true,
|
||||
"hasOgDesc": true,
|
||||
"hasCanonical": true,
|
||||
"hasSchemaCreativeWork": true,
|
||||
"hasContactLink": true
|
||||
},
|
||||
{
|
||||
"slug": "design-produit",
|
||||
"hasTitle": true,
|
||||
"hasDesc": true,
|
||||
"hasOgTitle": true,
|
||||
"hasOgDesc": true,
|
||||
"hasCanonical": true,
|
||||
"hasSchemaCreativeWork": true,
|
||||
"hasContactLink": true
|
||||
},
|
||||
{
|
||||
"slug": "rd-sonore",
|
||||
"hasTitle": true,
|
||||
"hasDesc": true,
|
||||
"hasOgTitle": true,
|
||||
"hasOgDesc": true,
|
||||
"hasCanonical": true,
|
||||
"hasSchemaCreativeWork": true,
|
||||
"hasContactLink": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"tracking": {
|
||||
"hasCaseOpenedContractInDom": true
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 721 KiB |
|
After Width: | Height: | Size: 917 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 926 KiB |
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"generatedAt": "2026-03-02T21:20:15.542Z",
|
||||
"baseUrl": "http://127.0.0.1:4323/",
|
||||
"responsive": [
|
||||
{
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"name": "390x844"
|
||||
},
|
||||
"heroVisible": true,
|
||||
"contactVisible": true,
|
||||
"diagramScrollableMobile": null,
|
||||
"screenshot": "docs/qa-artifacts/responsive-390x844.png"
|
||||
},
|
||||
{
|
||||
"viewport": {
|
||||
"width": 768,
|
||||
"height": 1024,
|
||||
"name": "768x1024"
|
||||
},
|
||||
"heroVisible": true,
|
||||
"contactVisible": true,
|
||||
"diagramScrollableMobile": null,
|
||||
"screenshot": "docs/qa-artifacts/responsive-768x1024.png"
|
||||
},
|
||||
{
|
||||
"viewport": {
|
||||
"width": 1024,
|
||||
"height": 768,
|
||||
"name": "1024x768"
|
||||
},
|
||||
"heroVisible": true,
|
||||
"contactVisible": true,
|
||||
"diagramScrollableMobile": null,
|
||||
"screenshot": "docs/qa-artifacts/responsive-1024x768.png"
|
||||
},
|
||||
{
|
||||
"viewport": {
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"name": "1440x900"
|
||||
},
|
||||
"heroVisible": true,
|
||||
"contactVisible": true,
|
||||
"diagramScrollableMobile": null,
|
||||
"screenshot": "docs/qa-artifacts/responsive-1440x900.png"
|
||||
}
|
||||
],
|
||||
"accessibility": {
|
||||
"keyboardFocusSteps": [
|
||||
{
|
||||
"tag": "A",
|
||||
"id": null,
|
||||
"className": "sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-50 focus:",
|
||||
"text": "Aller au contenu"
|
||||
},
|
||||
{
|
||||
"tag": "A",
|
||||
"id": null,
|
||||
"className": "figma-lab-nav-title",
|
||||
"text": "ELECTRONICS MISSION STUDIO"
|
||||
},
|
||||
{
|
||||
"tag": "A",
|
||||
"id": null,
|
||||
"className": "figma-lab-nav-link site-navlink",
|
||||
"text": "#a-propos"
|
||||
},
|
||||
{
|
||||
"tag": "A",
|
||||
"id": null,
|
||||
"className": "figma-lab-nav-link site-navlink",
|
||||
"text": "#preuves"
|
||||
},
|
||||
{
|
||||
"tag": "A",
|
||||
"id": null,
|
||||
"className": "figma-lab-nav-link site-navlink is-active",
|
||||
"text": "#contact"
|
||||
},
|
||||
{
|
||||
"tag": "BUTTON",
|
||||
"id": null,
|
||||
"className": "site-contrast-toggle",
|
||||
"text": "Mode contraste"
|
||||
},
|
||||
{
|
||||
"tag": "g",
|
||||
"id": null,
|
||||
"className": null,
|
||||
"text": "Power"
|
||||
},
|
||||
{
|
||||
"tag": "g",
|
||||
"id": null,
|
||||
"className": null,
|
||||
"text": "MCU"
|
||||
},
|
||||
{
|
||||
"tag": "g",
|
||||
"id": null,
|
||||
"className": null,
|
||||
"text": "RF"
|
||||
},
|
||||
{
|
||||
"tag": "g",
|
||||
"id": null,
|
||||
"className": null,
|
||||
"text": "Audio"
|
||||
},
|
||||
{
|
||||
"tag": "g",
|
||||
"id": null,
|
||||
"className": null,
|
||||
"text": "Test"
|
||||
},
|
||||
{
|
||||
"tag": "A",
|
||||
"id": null,
|
||||
"className": "figma-lab-pill figma-lab-pill--primary",
|
||||
"text": "Envoyer un brief"
|
||||
},
|
||||
{
|
||||
"tag": "A",
|
||||
"id": null,
|
||||
"className": "figma-lab-pill figma-lab-pill--secondary",
|
||||
"text": "Voir les preuves"
|
||||
},
|
||||
{
|
||||
"tag": "BUTTON",
|
||||
"id": null,
|
||||
"className": "hero-domain-tag is-active",
|
||||
"text": "Power"
|
||||
}
|
||||
],
|
||||
"audienceTabCount": 6,
|
||||
"reducedMotionSignalsHidden": true
|
||||
},
|
||||
"tracking": {
|
||||
"requiredEvents": [
|
||||
"experiment_variant_exposed",
|
||||
"funnel_brief_generated",
|
||||
"funnel_brief_copied",
|
||||
"funnel_contact_submitted"
|
||||
],
|
||||
"present": [
|
||||
"experiment_variant_exposed",
|
||||
"funnel_brief_generated",
|
||||
"funnel_brief_copied",
|
||||
"funnel_contact_submitted"
|
||||
],
|
||||
"missing": [],
|
||||
"counts": {
|
||||
"gtm.js": 1,
|
||||
"experiment_variant_exposed": 1,
|
||||
"gtm.dom": 1,
|
||||
"gtm.load": 1,
|
||||
"engagement_scroll_depth": 3,
|
||||
"engagement_section_view": 2,
|
||||
"funnel_brief_generated": 1,
|
||||
"cta_hero_contact": 1,
|
||||
"funnel_contact_submitted": 2,
|
||||
"contact_mode_selected": 1,
|
||||
"funnel_brief_copied": 1
|
||||
}
|
||||
},
|
||||
"copyVariants": {
|
||||
"queryCto": "cto",
|
||||
"storageFallback": "cto",
|
||||
"defaultWithoutQueryOrStorage": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
# Refonte Roadmap -> Plan Sprint Exécutable (2 x 7 jours)
|
||||
|
||||
## Résumé
|
||||
Transformer `docs/refonte-roadmap.md` en plan d’exécution opérationnel sur 14 jours, découpé en 2 sprints de 7 jours, avec:
|
||||
- tâches ordonnées et dépendances
|
||||
- owners par rôle (`Frontend`, `UX/CRO`, `QA`, `SEO/Analytics`)
|
||||
- livrables vérifiables
|
||||
- critères d’acceptation mesurables
|
||||
- suivi des risques + blocages
|
||||
|
||||
Objectif: donner un document directement exécutable par un autre agent/ingénieur, sans décision restante.
|
||||
|
||||
---
|
||||
|
||||
## 1) Contexte & objectifs
|
||||
- Accueillir une audience néophyte puis monter en technicité jusqu’au niveau CTO/BE.
|
||||
- Renforcer la perception premium et le positionnement system artist.
|
||||
- Maximiser la conversion via contact simple/pro et brief copiable.
|
||||
- Garantir une lisibilité mobile forte (390/768/1024/1440).
|
||||
- Prouver l’exécution par des cas concrets, livrables et instrumentation UI.
|
||||
|
||||
---
|
||||
|
||||
## 2) Cadre d’exécution
|
||||
|
||||
### Conventions
|
||||
- `Sprint length`: 7 jours
|
||||
- `Cadence`: daily check + review fin de sprint
|
||||
- `Owners`: rôles génériques
|
||||
- `Status`: `TODO | IN_PROGRESS | BLOCKED | DONE`
|
||||
- `Priority`: `P0 | P1 | P2`
|
||||
|
||||
### Gouvernance
|
||||
- Toute tâche doit inclure une sortie attendue et un acceptance criteria testable.
|
||||
- Toute tâche `BLOCKED` doit avoir une mitigation documentée dans le registre des risques.
|
||||
- Le passage Sprint 1 -> Sprint 2 nécessite validation des livrables Sprint 1.
|
||||
|
||||
---
|
||||
|
||||
## 3) Sprint 1 (J1-J7) - Finalisation UX + conversion
|
||||
|
||||
### Objectif sprint
|
||||
Finaliser UX visible + conversion directe.
|
||||
|
||||
### Tableau de tâches
|
||||
|
||||
| ID | Priorité | Task | Owner | Dépendances | Sortie attendue | Acceptance criteria | Status |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| S1-01 | P0 | Harmoniser tous les blocs au style hero (typo, alignements, contraste) | Frontend | Aucune | UI homogène sur toutes les sections `.circuit-board` | Rendu cohérent desktop/mobile, pas de régression layout | DONE |
|
||||
| S1-02 | P0 | Verrouiller Hero final (copy, CTA, schéma plus petit, fond carnet) | Frontend | S1-01 | Hero final conforme direction validée | Hero correspond exactement au wording validé | DONE |
|
||||
| S1-03 | P0 | Contact prioritaire simple/pro (templates, copier, envoi) | Frontend | S1-01 | Form contact à double mode opérationnel | Switch simple/pro fonctionnel + brief prérempli | DONE |
|
||||
| S1-04 | P0 | Suppression totale mention Malt (UI + structured data + CTA) | Frontend | S1-03 | Références Malt supprimées dans `src` | `rg -n "malt|Malt" src` ne retourne rien | DONE |
|
||||
| S1-05 | P1 | Cas & preuves: ajouter vrai visuel anonymisé de livrable | UX/CRO | S1-02 | Asset réel intégré dans section Cas/Preuves | Section Cas contient asset concret + légende | DONE |
|
||||
| S1-06 | P1 | Ajustement micro-copy conversion (CTA et aides contextuelles) | UX/CRO | S1-02, S1-03 | Copy conversion harmonisée | CTA primaire unique clair au-dessus de la ligne de flottaison | DONE |
|
||||
|
||||
### Livrables Sprint 1
|
||||
- Landing visuellement finalisée
|
||||
- Contact orienté conversion
|
||||
- Preuve de livrable intégrée
|
||||
|
||||
---
|
||||
|
||||
## 4) Sprint 2 (J8-J14) - QA, SEO, tracking, release
|
||||
|
||||
### Objectif sprint
|
||||
Sécuriser release: QA, analytics, SEO, performance.
|
||||
|
||||
### Tableau de tâches
|
||||
|
||||
| ID | Priorité | Task | Owner | Dépendances | Sortie attendue | Acceptance criteria | Status |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| S2-01 | P0 | QA responsive 390/768/1024/1440 | QA | Sprint 1 validé | Rapport QA responsive | Check-list screenshotée + 0 bug bloquant | DONE |
|
||||
| S2-02 | P0 | QA accessibilité (focus, clavier, contraste, reduced motion) | QA | S2-01 | Rapport accessibilité | Tous éléments interactifs accessibles clavier | DONE |
|
||||
| S2-03 | P0 | Smoke test tracking GTM/dataLayer | SEO/Analytics | S2-01 | Trace des events en live | Events clés observés (`experiment_variant_exposed`, `funnel_brief_generated`, `funnel_brief_copied`, `funnel_contact_submitted`) | DONE |
|
||||
| S2-04 | P1 | KPI framework GA4 (table de pilotage) | SEO/Analytics | S2-03 | Document KPI exploitable | Doc KPI avec définitions + baseline | DONE |
|
||||
| S2-05 | P1 | SEO on-page final (titles/meta/OG/anchors coherence) | SEO/Analytics | S2-01 | Vérification SEO finale | Validation manuelle + aucun lien mort interne | DONE |
|
||||
| S2-06 | P1 | Release checklist et go/no-go | QA | S2-02, S2-03, S2-05 | Décision release documentée | DoD signé + build vert | DONE |
|
||||
|
||||
### Livrables Sprint 2
|
||||
- Rapport QA complet
|
||||
- Validation tracking
|
||||
- Go/No-Go release
|
||||
|
||||
---
|
||||
|
||||
## 5) Backlog post-release (P2/P3)
|
||||
|
||||
Reference execution: `docs/p2-execution-plan-2026-03-03.md`
|
||||
|
||||
### P2
|
||||
| Item | Valeur attendue | Effort | Prérequis |
|
||||
|---|---|---|---|
|
||||
| System map enrichie (interfaces détaillées) | Crédibilité technique renforcée | M | Stabilité UI Sprint 1 |
|
||||
| Widgets scope/bode approfondis | Perception instrumentation premium | M | QA perf animations |
|
||||
| Pages dédiées par cas | Meilleure conversion et partage | L | Assets cas + copy validée |
|
||||
|
||||
### P3
|
||||
| Item | Valeur attendue | Effort | Prérequis |
|
||||
|---|---|---|---|
|
||||
| SEO avancé case-study (schema dédié) | Acquisition organique long terme | M | Pages cas en place |
|
||||
| QA automatisée (perf/accessibilité/tracking) | Réduction du risque release | L | Pipeline CI documenté |
|
||||
|
||||
---
|
||||
|
||||
## 6) Registre risques & mitigation
|
||||
|
||||
| Risque | Impact | Probabilité | Mitigation | Owner |
|
||||
|---|---|---|---|---|
|
||||
| Dérive visuelle vs lisibilité | Élevé | Moyen | Revue contraste/typographie sur 390/768/desktop | UX/CRO |
|
||||
| Régression mobile contact | Élevé | Moyen | Tests ciblés contact + templates simple/pro | QA |
|
||||
| Tracking incomplet | Élevé | Moyen | Smoke test dataLayer + vérif GTM avant go-live | SEO/Analytics |
|
||||
| Surcharge animations | Moyen | Moyen | Contrôle reduced-motion + optimisation animations actives | Frontend |
|
||||
|
||||
---
|
||||
|
||||
## 7) Definition of Done release
|
||||
- [x] build OK
|
||||
- [x] responsive QA validé
|
||||
- [x] accessibilité validée
|
||||
- [x] tracking validé en live
|
||||
- [x] aucune mention Malt
|
||||
- [x] footer avec LinkedIn uniquement
|
||||
- [x] contenu Hero/Contact conforme version finale
|
||||
|
||||
---
|
||||
|
||||
## 8) Changements interfaces/APIs/types publics
|
||||
Aucun changement d’API externe prévu.
|
||||
La transformation porte sur documentation exécutable (`docs/refonte-roadmap.md`) et gouvernance du delivery.
|
||||
|
||||
---
|
||||
|
||||
## 9) Cas de test et scénarios
|
||||
1. `?copy=public` puis `?copy=cto` -> variante affichée + event exposition.
|
||||
2. Contact mode `simple` -> template simple + copy brief + send mail tracké.
|
||||
3. Contact mode `pro` -> template pro + copy brief + send mail tracké.
|
||||
4. Navigation mobile 390 -> Hero lisible, schéma non bloquant, contact accessible.
|
||||
5. `prefers-reduced-motion` actif -> rotations/pulses réduits ou stoppés.
|
||||
|
||||
---
|
||||
|
||||
## 10) Hypothèses et defaults verrouillés
|
||||
- Horizon: 14 jours en `2 sprints (7 + 7)`.
|
||||
- Owners en rôles génériques (`Frontend`, `UX/CRO`, `QA`, `SEO/Analytics`).
|
||||
- Priorité immédiate: conversion + QA release.
|
||||
- Le document final reste en français, style opérationnel, sans section marketing longue.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Sprint 2 - Go/No-Go Release (2026-03-02)
|
||||
|
||||
## Gates
|
||||
- Build: PASS (`npm run build`)
|
||||
- Typecheck: PASS (0 erreur)
|
||||
- QA responsive: PASS (`docs/sprint2-qa-report-2026-03-02.md`)
|
||||
- QA accessibilite: PASS (`docs/sprint2-qa-report-2026-03-02.md`)
|
||||
- Tracking smoke: PASS (`docs/sprint2-tracking-smoke-2026-03-02.md`)
|
||||
- SEO on-page: PASS (`docs/sprint2-seo-onpage-2026-03-02.md`)
|
||||
- Mention Malt dans `src`: PASS (`rg -n "malt|Malt" src` -> aucun resultat)
|
||||
- Footer: LinkedIn uniquement -> PASS
|
||||
|
||||
## Decision
|
||||
- GO
|
||||
|
||||
## Conditions post-release
|
||||
- Monitorer GA4 sur 7 jours: exposition variante, copy rate, submit rate.
|
||||
- Prioriser backlog P2 selon signaux de conversion.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Sprint 2 - KPI Framework GA4 (Baseline)
|
||||
|
||||
## Objectif pilotage
|
||||
Mesurer exposition de variante, progression funnel brief, et conversion contact.
|
||||
|
||||
## KPI coeur
|
||||
1. Exposition variante
|
||||
- Event: `experiment_variant_exposed`
|
||||
- Dimension: `copy_variant`, `copy_source`, `audience_mode`
|
||||
- Usage: verifier repartition `public` vs `cto`.
|
||||
|
||||
2. Generation de brief
|
||||
- Event: `funnel_brief_generated`
|
||||
- Dimension: `source`, `product`, `constraint`, `deadline`
|
||||
- Usage: mesurer adoption du configurator.
|
||||
|
||||
3. Copie de brief
|
||||
- Event: `funnel_brief_copied`
|
||||
- Dimension: `source`, `brief_chars`
|
||||
- Usage: indicateur d'intention qualifiee.
|
||||
|
||||
4. Soumission contact
|
||||
- Event: `funnel_contact_submitted`
|
||||
- Dimension: `source`, `brief_chars`
|
||||
- Usage: conversion finale.
|
||||
|
||||
## Ratios cibles (tableau de bord)
|
||||
- `brief_copy_rate = funnel_brief_copied / funnel_brief_generated`
|
||||
- `contact_submit_rate = funnel_contact_submitted / experiment_variant_exposed`
|
||||
- `qualified_submit_rate = funnel_contact_submitted(source=contact_form) / funnel_contact_submitted(total)`
|
||||
|
||||
## Baseline technique (smoke)
|
||||
- Presence des 4 events: OK (voir `docs/sprint2-tracking-smoke-2026-03-02.md`).
|
||||
@@ -0,0 +1,27 @@
|
||||
# Sprint 2 - Rapport QA (2026-03-02)
|
||||
|
||||
## Portee
|
||||
- Responsive: 390x844, 768x1024, 1024x768, 1440x900
|
||||
- Accessibilite: navigation clavier, focus visibles, reduced motion
|
||||
- Source de preuve: `docs/qa-artifacts/sprint2-qa-report.json`
|
||||
|
||||
## Resultats responsive
|
||||
- 390x844: OK (`Hero` visible, `#contact` accessible)
|
||||
- 768x1024: OK
|
||||
- 1024x768: OK
|
||||
- 1440x900: OK
|
||||
|
||||
Captures:
|
||||
- `docs/qa-artifacts/responsive-390x844.png`
|
||||
- `docs/qa-artifacts/responsive-768x1024.png`
|
||||
- `docs/qa-artifacts/responsive-1024x768.png`
|
||||
- `docs/qa-artifacts/responsive-1440x900.png`
|
||||
|
||||
## Resultats accessibilite
|
||||
- Navigation clavier: OK (skip link -> nav -> contrast toggle -> hero actions)
|
||||
- Elements interactifs via tablist/tab presents: `audienceTabCount = 6`
|
||||
- `prefers-reduced-motion`: OK (`signalDot` masques)
|
||||
|
||||
## Statut
|
||||
- 0 bug bloquant observe sur le scope Sprint 2.
|
||||
- Gate QA Sprint 2: PASS.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Sprint 2 - SEO On-Page Validation (2026-03-02)
|
||||
|
||||
## Checks effectues
|
||||
- Build statique genere: `dist/index.html`.
|
||||
- Balises meta presentes: `title`, `description`, canonical, OG, Twitter.
|
||||
- Structured data present: `WebSite`, `Organization`, `Person`.
|
||||
- Coherence ancres internes: aucun lien mort interne detecte.
|
||||
|
||||
## Verification anchors
|
||||
- Script de verification sur `dist/index.html`.
|
||||
- Resultat: `missing: []`.
|
||||
|
||||
## Statut
|
||||
- Validation SEO on-page manuelle: PASS.
|
||||
- Aucun blocage SEO release sur le perimetre de la landing.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Sprint 2 - Tracking Smoke Test (2026-03-02)
|
||||
|
||||
## Environnement
|
||||
- URL testee: `http://127.0.0.1:4323/`
|
||||
- Build: `npm run build` -> OK
|
||||
- Contrat tracking statique: `npm run tracking:check` -> OK
|
||||
|
||||
## Events cles verifies (dataLayer)
|
||||
- `experiment_variant_exposed` -> OK
|
||||
- `funnel_brief_generated` -> OK
|
||||
- `funnel_brief_copied` -> OK
|
||||
- `funnel_contact_submitted` -> OK
|
||||
|
||||
Preuve:
|
||||
- `docs/qa-artifacts/sprint2-qa-report.json` (`tracking.present`, `tracking.missing=[]`)
|
||||
|
||||
## Scenarios verifies
|
||||
1. `?copy=public` puis `?copy=cto` -> variante appliquee et exposee.
|
||||
2. Contact mode `simple` -> brief copiable + event copy.
|
||||
3. Contact mode `pro` -> brief copiable + event contact submit.
|
||||
|
||||
## Notes
|
||||
- Script utilise: `scripts/sprint2-qa.cjs`
|
||||
@@ -26,6 +26,7 @@
|
||||
"@storybook/addon-vitest": "^9.1.5",
|
||||
"@storybook/react-vite": "^9.1.5",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"playwright": "^1.58.2",
|
||||
"storybook": "^9.1.5",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
@@ -7678,6 +7679,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
Original prompt: [$develop-web-game](/Users/cils/.codex/skills/develop-web-game/SKILL.md) preuves / mini expérience à transformer en mini jeux
|
||||
|
||||
- Initialized web-game skill workflow for converting LabNotes into playable mini-games.
|
||||
- Planned additions: game goals, score/state loops, deterministic hook window.advanceTime(ms), and window.render_game_to_text output.
|
||||
- In progress: refactor Hero/Contact/LabNotes CSS modules and UX stabilization requested in preceding prompts.
|
||||
|
||||
## Update 1
|
||||
- Refactored Hero to dedicated CSS Module (`Hero.module.css`) with a single visual intent: oscilloscope overlay + simplified schematic styling.
|
||||
- Converted SVG node interactions to real `<button>` controls (inside `foreignObject`) for better accessibility.
|
||||
- Refactored Contact and LabNotes to CSS Modules (`Contact.module.css`, `LabNotes.module.css`).
|
||||
- Contact now has explicit user feedback messages for copy/send actions.
|
||||
- LabNotes transformed into 3 playable mini-games with score and goal loops.
|
||||
- Added `window.render_game_to_text` and `window.advanceTime(ms)` from LabNotes for deterministic testability.
|
||||
|
||||
## Update 2
|
||||
- Removed legacy DA variant CSS block (`v1` to `v11`) from `src/styles/global.css` and locked runtime DA variant to `v12` in `BaseLayout.astro`.
|
||||
- Build passes after cleanup.
|
||||
- Ran web-game Playwright client (`.tmp-web-game-client.mjs` copied from skill script) against local preview.
|
||||
- Captured artifacts in `output/web-game/` (`shot-0.png`, `shot-1.png`, `state-0.json`, `state-1.json`, `errors-1.json`).
|
||||
- `render_game_to_text` and `advanceTime(ms)` are present and returning deterministic game state.
|
||||
- Known issue: hydration warning persists on Contact textarea (`caret-color` mismatch), likely influenced by client-side style injection before hydration.
|
||||
|
||||
## TODO / Next agent
|
||||
- Investigate hydration warning source by testing with a clean browser profile and without extensions.
|
||||
- Decide whether to keep `.tmp-web-game-client.mjs` in repo or move to scripts/ with docs.
|
||||
- Optional: add explicit game controls keyboard mapping for automated action payloads (left/right/space hooks).
|
||||
@@ -0,0 +1,153 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const baseUrl = 'http://127.0.0.1:4323/';
|
||||
const outDir = path.join(process.cwd(), 'docs', 'qa-artifacts');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
lotA: {},
|
||||
lotB: {},
|
||||
lotC: {},
|
||||
tracking: {}
|
||||
};
|
||||
|
||||
{
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
||||
const page = await context.newPage();
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
await page.locator('#offre-systeme').scrollIntoViewIfNeeded();
|
||||
|
||||
const mapStats = await page.evaluate(() => {
|
||||
const scroll = document.querySelector('.system-map-scroll');
|
||||
const svg = document.querySelector('.system-map-svg');
|
||||
const rects = Array.from(document.querySelectorAll('.system-map-node')).map((n) => ({
|
||||
w: Number(n.getAttribute('width') || 0),
|
||||
h: Number(n.getAttribute('height') || 0),
|
||||
x: Number(n.getAttribute('x') || 0),
|
||||
y: Number(n.getAttribute('y') || 0)
|
||||
}));
|
||||
|
||||
const overlaps = [];
|
||||
for (let i = 0; i < rects.length; i += 1) {
|
||||
for (let j = i + 1; j < rects.length; j += 1) {
|
||||
const a = rects[i];
|
||||
const b = rects[j];
|
||||
const ix = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
|
||||
const iy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
|
||||
if (ix * iy > 0) overlaps.push([i, j]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasScrollContainer: !!scroll,
|
||||
horizontalOverflow: scroll ? scroll.scrollWidth > scroll.clientWidth : false,
|
||||
svgMinWidth: svg ? getComputedStyle(svg).minWidth : null,
|
||||
minNodeHitArea: rects.every((r) => r.w >= 40 && r.h >= 40),
|
||||
overlapsCount: overlaps.length
|
||||
};
|
||||
});
|
||||
|
||||
const shotPath = path.join(outDir, 'p2-lot-a-mobile-390.png');
|
||||
await page.screenshot({ path: shotPath, fullPage: true });
|
||||
|
||||
report.lotA = { ...mapStats, screenshot: 'docs/qa-artifacts/p2-lot-a-mobile-390.png' };
|
||||
await context.close();
|
||||
}
|
||||
|
||||
{
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
await page.locator('#offre-systeme').scrollIntoViewIfNeeded();
|
||||
|
||||
const focusLog = [];
|
||||
for (let i = 0; i < 22; i += 1) {
|
||||
await page.keyboard.press('Tab');
|
||||
const active = await page.evaluate(() => {
|
||||
const el = document.activeElement;
|
||||
if (!el) return null;
|
||||
return {
|
||||
tag: el.tagName,
|
||||
role: el.getAttribute('role'),
|
||||
aria: el.getAttribute('aria-label'),
|
||||
text: (el.textContent || '').trim().slice(0, 42)
|
||||
};
|
||||
});
|
||||
focusLog.push(active);
|
||||
}
|
||||
|
||||
const tabRoles = await page.locator('#offre-systeme [role="tablist"] [role="tab"]').count();
|
||||
|
||||
report.lotA.keyboard = {
|
||||
tabRoleCount: tabRoles,
|
||||
focusLog
|
||||
};
|
||||
|
||||
await context.close();
|
||||
|
||||
const reduced = await browser.newContext({ viewport: { width: 1280, height: 900 }, reducedMotion: 'reduce' });
|
||||
const reducedPage = await reduced.newPage();
|
||||
await reducedPage.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
await reducedPage.locator('#lab-notes').scrollIntoViewIfNeeded();
|
||||
|
||||
const reducedStats = await reducedPage.evaluate(() => {
|
||||
const wave = document.querySelector('.proof-scope-wave');
|
||||
const marker = document.querySelector('.proof-bode-marker');
|
||||
if (!wave || !marker) return { available: false };
|
||||
const waveStyle = getComputedStyle(wave);
|
||||
const markerStyle = getComputedStyle(marker);
|
||||
return {
|
||||
available: true,
|
||||
waveTransition: waveStyle.transitionDuration,
|
||||
markerTransition: markerStyle.transitionDuration
|
||||
};
|
||||
});
|
||||
|
||||
report.lotB.reducedMotion = reducedStats;
|
||||
await reduced.close();
|
||||
}
|
||||
|
||||
{
|
||||
const pages = ['studio-core', 'design-produit', 'rd-sonore'];
|
||||
const lotCChecks = [];
|
||||
|
||||
for (const slug of pages) {
|
||||
const file = path.join(process.cwd(), 'dist', 'cas', slug, 'index.html');
|
||||
const html = fs.readFileSync(file, 'utf8');
|
||||
lotCChecks.push({
|
||||
slug,
|
||||
hasTitle: /<title>/.test(html),
|
||||
hasDesc: /meta name="description"/.test(html),
|
||||
hasOgTitle: /meta property="og:title"/.test(html),
|
||||
hasOgDesc: /meta property="og:description"/.test(html),
|
||||
hasCanonical: /rel="canonical"/.test(html),
|
||||
hasSchemaCreativeWork: /"@type":"CreativeWork"/.test(html),
|
||||
hasContactLink: /href="\/#contact"/.test(html)
|
||||
});
|
||||
}
|
||||
|
||||
report.lotC = { pages: lotCChecks };
|
||||
}
|
||||
|
||||
{
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
await page.locator('#cas').scrollIntoViewIfNeeded();
|
||||
const hasContract = await page.evaluate(() => !!document.querySelector('a[data-track=\"engagement_case_opened\"]'));
|
||||
report.tracking = {
|
||||
hasCaseOpenedContractInDom: hasContract
|
||||
};
|
||||
|
||||
await context.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
const outputPath = path.join(outDir, 'p2-qa-report.json');
|
||||
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2));
|
||||
console.log(outputPath);
|
||||
})();
|
||||
@@ -0,0 +1,173 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const baseUrl = 'http://127.0.0.1:4323/';
|
||||
const outDir = path.join(process.cwd(), 'docs', 'qa-artifacts');
|
||||
|
||||
const requiredEvents = [
|
||||
'experiment_variant_exposed',
|
||||
'funnel_brief_generated',
|
||||
'funnel_brief_copied',
|
||||
'funnel_contact_submitted'
|
||||
];
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
baseUrl,
|
||||
responsive: [],
|
||||
accessibility: {},
|
||||
tracking: {},
|
||||
copyVariants: {}
|
||||
};
|
||||
|
||||
const viewportMatrix = [
|
||||
{ width: 390, height: 844, name: '390x844' },
|
||||
{ width: 768, height: 1024, name: '768x1024' },
|
||||
{ width: 1024, height: 768, name: '1024x768' },
|
||||
{ width: 1440, height: 900, name: '1440x900' }
|
||||
];
|
||||
|
||||
for (const vp of viewportMatrix) {
|
||||
const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height } });
|
||||
const page = await context.newPage();
|
||||
await page.goto(baseUrl + '?copy=public', { waitUntil: 'networkidle' });
|
||||
|
||||
const heroVisible = await page.locator('h1').first().isVisible();
|
||||
const contactVisible = await page.locator('#contact').isVisible();
|
||||
const diagramScrollableMobile = vp.width <= 390
|
||||
? await page.evaluate(() => {
|
||||
const node = document.querySelector('.hero-diagram-scroll, .diagramScroll');
|
||||
if (!node) return null;
|
||||
return node.scrollWidth > node.clientWidth;
|
||||
})
|
||||
: null;
|
||||
|
||||
const shotName = `responsive-${vp.name}.png`;
|
||||
await page.screenshot({ path: path.join(outDir, shotName), fullPage: true });
|
||||
|
||||
report.responsive.push({
|
||||
viewport: vp,
|
||||
heroVisible,
|
||||
contactVisible,
|
||||
diagramScrollableMobile,
|
||||
screenshot: `docs/qa-artifacts/${shotName}`
|
||||
});
|
||||
|
||||
await context.close();
|
||||
}
|
||||
|
||||
{
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
await page.goto(baseUrl + '?copy=public', { waitUntil: 'networkidle' });
|
||||
|
||||
const focusTags = [];
|
||||
for (let i = 0; i < 14; i += 1) {
|
||||
await page.keyboard.press('Tab');
|
||||
const active = await page.evaluate(() => {
|
||||
const el = document.activeElement;
|
||||
if (!el) return null;
|
||||
return {
|
||||
tag: el.tagName,
|
||||
id: el.id || null,
|
||||
className: typeof el.className === 'string' ? el.className.slice(0, 80) : null,
|
||||
text: (el.textContent || '').trim().slice(0, 60)
|
||||
};
|
||||
});
|
||||
focusTags.push(active);
|
||||
}
|
||||
|
||||
const hasAudienceTabs = await page.locator('[role="tablist"] [role="tab"]').count();
|
||||
|
||||
await context.close();
|
||||
|
||||
const reducedContext = await browser.newContext({ reducedMotion: 'reduce', viewport: { width: 1280, height: 900 } });
|
||||
const reducedPage = await reducedContext.newPage();
|
||||
await reducedPage.goto(baseUrl + '?copy=public', { waitUntil: 'networkidle' });
|
||||
const reducedMotionOk = await reducedPage.evaluate(() => {
|
||||
const dots = Array.from(document.querySelectorAll('.signalDot'));
|
||||
if (!dots.length) return true;
|
||||
return dots.every((dot) => getComputedStyle(dot).display === 'none');
|
||||
});
|
||||
await reducedContext.close();
|
||||
|
||||
report.accessibility = {
|
||||
keyboardFocusSteps: focusTags,
|
||||
audienceTabCount: hasAudienceTabs,
|
||||
reducedMotionSignalsHidden: reducedMotionOk
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
await page.goto(baseUrl + '?copy=public', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(700);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: async () => {}
|
||||
},
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /copier le brief/i }).first().click();
|
||||
await page.getByRole('link', { name: /envoyer le brief/i }).click();
|
||||
await page.getByRole('tab', { name: /version pro/i }).click();
|
||||
await page.getByRole('button', { name: /copier le brief/i }).nth(1).click();
|
||||
await page.getByRole('link', { name: /envoyer par mail/i }).click();
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const eventNames = await page.evaluate(() => {
|
||||
const dl = window.dataLayer || [];
|
||||
return dl.map((e) => e && e.event).filter(Boolean);
|
||||
});
|
||||
|
||||
const counts = eventNames.reduce((acc, ev) => {
|
||||
acc[ev] = (acc[ev] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
report.tracking = {
|
||||
requiredEvents,
|
||||
present: requiredEvents.filter((ev) => eventNames.includes(ev)),
|
||||
missing: requiredEvents.filter((ev) => !eventNames.includes(ev)),
|
||||
counts
|
||||
};
|
||||
|
||||
await context.close();
|
||||
}
|
||||
|
||||
{
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(baseUrl + '?copy=cto', { waitUntil: 'networkidle' });
|
||||
const ctoVariant = await page.evaluate(() => document.documentElement.getAttribute('data-copy-variant'));
|
||||
|
||||
await page.evaluate(() => localStorage.setItem('er_copy_variant', 'cto'));
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
const storageVariant = await page.evaluate(() => document.documentElement.getAttribute('data-copy-variant'));
|
||||
|
||||
await page.evaluate(() => localStorage.removeItem('er_copy_variant'));
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
const defaultVariant = await page.evaluate(() => document.documentElement.getAttribute('data-copy-variant'));
|
||||
|
||||
report.copyVariants = {
|
||||
queryCto: ctoVariant,
|
||||
storageFallback: storageVariant,
|
||||
defaultWithoutQueryOrStorage: defaultVariant
|
||||
};
|
||||
|
||||
await context.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
const outputPath = path.join(outDir, 'sprint2-qa-report.json');
|
||||
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2));
|
||||
console.log(outputPath);
|
||||
})();
|
||||
@@ -1,7 +1,10 @@
|
||||
import { motion } from 'motion/react';
|
||||
import { CtaDualRail } from '@/components/ui/cta-dual-rail';
|
||||
import { useCopyContent } from '@/lib/copy';
|
||||
|
||||
export function About() {
|
||||
const { copy, variant } = useCopyContent();
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
id="a-propos"
|
||||
@@ -16,22 +19,16 @@ export function About() {
|
||||
<div className="circuit-title-row">
|
||||
<span className="circuit-node" aria-hidden="true" />
|
||||
<h2 id="about-title" className="m-0 text-3xl md:text-4xl">
|
||||
A propos / cadre d'intervention
|
||||
{copy.about.title}
|
||||
</h2>
|
||||
<span className="circuit-pinline" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="section-lead mb-0 mt-3">
|
||||
Vous sollicitez un expert quand un produit electronique demande une execution sur mesure, rapide et fiable.
|
||||
</p>
|
||||
<p className="mb-0 mt-3 max-w-3xl studio-muted">
|
||||
Je suis <strong>Clement Saillant</strong>, fondateur de <strong>L'electron rare</strong>. J'accompagne
|
||||
des equipes produit sur des enjeux a forte contrainte: architecture systeme, prototype fonctionnel et
|
||||
passage en production.
|
||||
</p>
|
||||
<p className="mb-0 mt-3 max-w-3xl studio-muted">
|
||||
La methode combine pilotage agile en sprints de 2 semaines, arbitrages techniques documentes et
|
||||
synchronisation business pour tenir vos delais sans sacrifier la robustesse.
|
||||
</p>
|
||||
<p className="section-lead mb-0 mt-3">{copy.about.paragraphs[0]}</p>
|
||||
{copy.about.paragraphs.slice(1).map((paragraph) => (
|
||||
<p key={paragraph} className="mb-0 mt-3 max-w-3xl studio-muted">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="mt-4 rounded-xl border border-[color:var(--line)] p-3">
|
||||
<ul className="m-0 grid gap-2 p-0 md:grid-cols-2">
|
||||
@@ -42,7 +39,7 @@ export function About() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<CtaDualRail className="mt-4" label="Cadrer votre mission sur mesure" />
|
||||
<CtaDualRail className="mt-4" label={variant === 'cto' ? 'Cadrer votre mission electronique' : 'Cadrer votre mission sur mesure'} />
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import * as React from 'react';
|
||||
import { TRACK_EVENTS, trackEvent } from '@/lib/tracking';
|
||||
|
||||
type AudienceMode = 'discover' | 'pro';
|
||||
|
||||
type NeedItem = {
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
const MODE_STORAGE_KEY = 'er_audience_mode';
|
||||
|
||||
const MODE_COPY: Record<
|
||||
AudienceMode,
|
||||
{
|
||||
kicker: string;
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
}
|
||||
> = {
|
||||
discover: {
|
||||
kicker: 'JE DECOUVRE',
|
||||
title: 'Vous avez une idee. Je la transforme en prototype qui dit la verite.',
|
||||
paragraphs: [
|
||||
"Beaucoup d'idees restent dans un carnet parce que l'electronique semble etre un mur. Je transforme ce mur en chemin: on clarifie l'usage, on structure le systeme, on construit un premier prototype, puis on verifie avec des tests concrets.",
|
||||
'Vous avancez avec une trajectoire lisible: ce qu\'on construit, ce qu\'on teste, ce qu\'on decide. On passe de l\'imaginaire au reel, sans jargon inutile et sans zones floues.',
|
||||
"Vous repartez avec un prototype credible, des preuves mesurables, et un plan clair pour la suite."
|
||||
],
|
||||
ctaLabel: 'Decrire votre idee (2 minutes)',
|
||||
ctaHref: '#contact'
|
||||
},
|
||||
pro: {
|
||||
kicker: 'JE SUIS PRO',
|
||||
title: 'Pour CTO et equipes hardware: reduction du risque systeme, mesuree et actionnable.',
|
||||
paragraphs: [
|
||||
'Quand le blocage est deja technique, le format est direct: audit rapide, plan d\'attaque, instrumentation, mesures, decisions tranchees.',
|
||||
'Perimetre traite: power integrity, signal integrity, EMI/EMC pre-conformite, RF, audio, thermique, testabilite, Go/NoGo et handoff DFM.',
|
||||
'Livrables transmis: architecture, plan de test, rapport de mesures, priorisation risque x valeur, trajectoire industrialisation.'
|
||||
],
|
||||
ctaLabel: 'Partager votre contrainte #1',
|
||||
ctaHref: '#contact'
|
||||
}
|
||||
};
|
||||
|
||||
const NEEDS: Record<AudienceMode, NeedItem[]> = {
|
||||
discover: [
|
||||
{ title: 'Objet autonome', body: 'Je veux un objet sur batterie qui tienne plusieurs jours avec recharge USB-C.' },
|
||||
{ title: 'Objet connecte', body: 'Je veux un produit Bluetooth/Wi-Fi pilotable depuis une application mobile.' },
|
||||
{ title: 'Prototype demo', body: 'Je veux une demo fiable pour client ou investisseur, pas juste un concept.' },
|
||||
{ title: 'Objet sonore', body: 'Je veux un systeme audio sans souffle ni buzz, stable dans le temps.' },
|
||||
{ title: 'Capteurs terrain', body: 'Je veux mesurer des capteurs et exploiter les donnees dans des conditions reelles.' },
|
||||
{ title: 'Moteur/actionneur', body: 'Je veux piloter un moteur ou une pompe sans a-coups et sans surchauffe.' },
|
||||
{ title: 'Robustesse usage', body: 'Je veux que le prototype tienne les vibrations, la temperature et l\'usage intensif.' },
|
||||
{ title: 'Du croquis au reel', body: 'Je veux un plan clair: quoi fabriquer, quoi tester et dans quel ordre avancer.' }
|
||||
],
|
||||
pro: [
|
||||
{ title: 'Resets sous charge', body: 'Reset sur pics de charge/radio/moteur: PI, transitoires et brown-out a verrouiller.' },
|
||||
{ title: 'Bruit analog/audio', body: 'Niveau de bruit hors spec: masse, decouplage, routage, filtres et preuves de mesure.' },
|
||||
{ title: 'Autonomie degradee', body: 'Consommation trop elevee: profiling, budget energie, arbitrages MCU/RF.' },
|
||||
{ title: 'EMI pre-scan KO', body: 'Echec pre-conformite: identifier les sources, corriger vite, revalider proprement.' },
|
||||
{ title: 'RF instable', body: 'Portee faible/desensibilisation: integration antenne, coexistence, matching.' },
|
||||
{ title: 'Bus fragile', body: 'I2C/SPI/UART instables: timings, SI, pull-ups, marges et strategie debug.' },
|
||||
{ title: 'Variabilite cartes', body: 'Resultats heterogenes entre unites: robustesse, tolerances, plan de test.' },
|
||||
{ title: 'Proto vers prod', body: 'Handoff industrialisation: DFM, testabilite, Go/NoGo et documentation exploitable.' }
|
||||
]
|
||||
};
|
||||
|
||||
function isAudienceMode(value: string): value is AudienceMode {
|
||||
return value === 'discover' || value === 'pro';
|
||||
}
|
||||
|
||||
function shuffleItems<T>(items: T[]): T[] {
|
||||
const next = [...items];
|
||||
for (let i = next.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const tmp = next[i];
|
||||
next[i] = next[j];
|
||||
next[j] = tmp;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function usePrefersReducedMotion() {
|
||||
const [reduce, setReduce] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const onChange = () => setReduce(media.matches);
|
||||
onChange();
|
||||
media.addEventListener?.('change', onChange) ?? media.addListener(onChange);
|
||||
return () => media.removeEventListener?.('change', onChange) ?? media.removeListener(onChange);
|
||||
}, []);
|
||||
|
||||
return reduce;
|
||||
}
|
||||
|
||||
function useRotatingNeeds(items: NeedItem[], pageSize: number, intervalMs: number) {
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
const [order, setOrder] = React.useState<NeedItem[]>(items);
|
||||
const [index, setIndex] = React.useState(0);
|
||||
const [paused, setPaused] = React.useState(false);
|
||||
|
||||
const itemsRef = React.useRef(items);
|
||||
React.useEffect(() => {
|
||||
itemsRef.current = items;
|
||||
}, [items]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setOrder(shuffleItems(items));
|
||||
setIndex(0);
|
||||
}, [items]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (prefersReducedMotion || paused || order.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setIndex((prev) => {
|
||||
const next = prev + pageSize;
|
||||
if (next >= order.length) {
|
||||
setOrder(shuffleItems(itemsRef.current));
|
||||
return 0;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, intervalMs);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [order.length, paused, pageSize, intervalMs, prefersReducedMotion]);
|
||||
|
||||
const current = React.useMemo(() => {
|
||||
if (order.length === 0) {
|
||||
return [] as NeedItem[];
|
||||
}
|
||||
const doubled = [...order, ...order];
|
||||
return doubled.slice(index, index + pageSize);
|
||||
}, [order, index, pageSize]);
|
||||
|
||||
const next = React.useCallback(() => {
|
||||
setIndex((prev) => {
|
||||
const nextIndex = prev + pageSize;
|
||||
if (nextIndex >= order.length) {
|
||||
return 0;
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
}, [order.length, pageSize]);
|
||||
|
||||
return { current, next, paused, setPaused, prefersReducedMotion } as const;
|
||||
}
|
||||
|
||||
export function AudienceModeSwitch() {
|
||||
const [mode, setMode] = React.useState<AudienceMode>('discover');
|
||||
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const fromQuery = (params.get('audience') || '').toLowerCase().trim();
|
||||
if (isAudienceMode(fromQuery)) {
|
||||
setMode(fromQuery);
|
||||
try {
|
||||
window.localStorage.setItem(MODE_STORAGE_KEY, fromQuery);
|
||||
} catch (_error) {
|
||||
// Ignore localStorage access errors.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fromStorage = (window.localStorage.getItem(MODE_STORAGE_KEY) || '').toLowerCase().trim();
|
||||
if (isAudienceMode(fromStorage)) {
|
||||
setMode(fromStorage);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore localStorage access errors.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateMode = React.useCallback((nextMode: AudienceMode) => {
|
||||
setMode(nextMode);
|
||||
document.documentElement.setAttribute('data-audience-mode', nextMode);
|
||||
try {
|
||||
window.localStorage.setItem(MODE_STORAGE_KEY, nextMode);
|
||||
} catch (_error) {
|
||||
// Ignore localStorage access errors.
|
||||
}
|
||||
trackEvent(TRACK_EVENTS.audienceModeSwitch, {
|
||||
destination: '#audience',
|
||||
selected_mode: nextMode
|
||||
});
|
||||
}, []);
|
||||
|
||||
const copy = MODE_COPY[mode];
|
||||
const needs = NEEDS[mode];
|
||||
const { current, next, setPaused, prefersReducedMotion } = useRotatingNeeds(needs, 3, 4800);
|
||||
|
||||
const [scanKey, setScanKey] = React.useState(0);
|
||||
const currentKey = React.useMemo(() => current.map((item) => item.title).join('|'), [current]);
|
||||
|
||||
React.useEffect(() => {
|
||||
document.documentElement.setAttribute('data-audience-mode', mode);
|
||||
}, [mode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (prefersReducedMotion) {
|
||||
return;
|
||||
}
|
||||
setScanKey((value) => value + 1);
|
||||
}, [mode, currentKey, prefersReducedMotion]);
|
||||
|
||||
return (
|
||||
<section className="section-anchor audience-switch mt-5" id="audience" aria-labelledby="audience-title">
|
||||
<div className="circuit-board audience-switch-board rounded-2xl p-5 md:p-6">
|
||||
<div className="audience-switch-header">
|
||||
<div className="audience-scope" role="tablist" aria-label="Mode de lecture">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'discover'}
|
||||
className={`audience-scope-btn ${mode === 'discover' ? 'is-active' : ''}`}
|
||||
onClick={() => updateMode('discover')}
|
||||
>
|
||||
<span className={`audience-led ${mode === 'discover' ? 'is-on' : ''}`} aria-hidden="true" />
|
||||
<span className="audience-scope-label">JE DECOUVRE</span>
|
||||
</button>
|
||||
<span className="audience-scope-divider" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'pro'}
|
||||
className={`audience-scope-btn ${mode === 'pro' ? 'is-active' : ''}`}
|
||||
onClick={() => updateMode('pro')}
|
||||
>
|
||||
<span className={`audience-led ${mode === 'pro' ? 'is-on' : ''}`} aria-hidden="true" />
|
||||
<span className="audience-scope-label">JE SUIS PRO</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="audience-mode-kicker">{copy.kicker} · lecture progressive</p>
|
||||
</div>
|
||||
|
||||
<h2 id="audience-title" className="m-0 text-3xl md:text-4xl">
|
||||
{copy.title}
|
||||
</h2>
|
||||
|
||||
<div className="audience-copy mt-3">
|
||||
{copy.paragraphs.map((paragraph) => (
|
||||
<p key={paragraph} className="mb-0 mt-3 max-w-3xl studio-muted">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="audience-needs mt-4"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onFocus={() => setPaused(true)}
|
||||
onBlur={() => setPaused(false)}
|
||||
>
|
||||
{!prefersReducedMotion ? <div key={scanKey} className="audience-scanline" aria-hidden="true" /> : null}
|
||||
<div className="audience-needs-head">
|
||||
<p className="audience-needs-title m-0">Exemples de besoins concrets</p>
|
||||
<div className="audience-needs-actions">
|
||||
<button type="button" className="audience-next" onClick={next}>
|
||||
Suivant
|
||||
</button>
|
||||
<span className="audience-needs-hint">
|
||||
{prefersReducedMotion ? 'Rotation auto desactivee' : 'Rotation reguliere · pause au survol'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audience-needs-grid" aria-live="polite">
|
||||
{current.map((item, idx) => (
|
||||
<article key={`${item.title}-${idx}`} className="audience-need-card">
|
||||
<p className="audience-need-title m-0">{item.title}</p>
|
||||
<p className="audience-need-body m-0 mt-2">{item.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audience-cta-row mt-4">
|
||||
<a href={copy.ctaHref} className="figma-lab-pill">
|
||||
{copy.ctaLabel}
|
||||
</a>
|
||||
<a href="#lab-notes" className="figma-lab-pill">
|
||||
Voir des preuves
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
|
||||
import { CASE_STUDIES } from '@/content/caseStudies';
|
||||
|
||||
type CaseItem = {
|
||||
slug: string;
|
||||
title: string;
|
||||
trace: string;
|
||||
context: string;
|
||||
intervention: string;
|
||||
result: string;
|
||||
linkLabel?: string;
|
||||
linkHref?: string;
|
||||
event?: (typeof TRACK_EVENTS)[keyof typeof TRACK_EVENTS];
|
||||
destination?: string;
|
||||
};
|
||||
|
||||
const CASES: CaseItem[] = [
|
||||
...CASE_STUDIES.map((item) => ({
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
trace: item.trace,
|
||||
context: item.context,
|
||||
intervention: item.intervention,
|
||||
result: item.result
|
||||
}))
|
||||
];
|
||||
|
||||
const GITHUB_REFERENCES = [
|
||||
'https://github.com/KomplexKapharnaum/KXKM_ESP32_Audio_Battery_hardware',
|
||||
'https://github.com/KomplexKapharnaum/LEDcurtain_hardware',
|
||||
'https://github.com/KomplexKapharnaum/kxkm_Ve.direct'
|
||||
] as const;
|
||||
|
||||
export function CaseProofs() {
|
||||
return (
|
||||
<section id="cas" className="section-anchor mt-5" aria-labelledby="cases-title">
|
||||
<div className="circuit-board rounded-2xl p-5 md:p-6">
|
||||
<div className="circuit-title-row">
|
||||
<span className="circuit-node" aria-hidden="true" />
|
||||
<h2 id="cases-title" className="m-0 text-3xl md:text-4xl">
|
||||
Cas & preuves d'execution
|
||||
</h2>
|
||||
<span className="circuit-pinline" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p className="section-lead mb-0 mt-3">
|
||||
Trois cas maximum, lecture scan-friendly: contrainte, intervention, resultat.
|
||||
</p>
|
||||
|
||||
<div className="case-grid mt-4" role="list" aria-label="Cas d'usage">
|
||||
{CASES.map((item) => (
|
||||
<article key={item.title} className="case-card" role="listitem">
|
||||
<p className="case-trace">{item.trace}</p>
|
||||
<h3 className="case-title">{item.title}</h3>
|
||||
<p className="case-line">
|
||||
<strong>Contexte:</strong> {item.context}
|
||||
</p>
|
||||
<p className="case-line">
|
||||
<strong>Intervention:</strong> {item.intervention}
|
||||
</p>
|
||||
<p className="case-line">
|
||||
<strong>Resultat:</strong> {item.result}
|
||||
</p>
|
||||
<p className="mb-0 mt-2">
|
||||
<a
|
||||
href={`/cas/${item.slug}`}
|
||||
{...trackAttrs(TRACK_EVENTS.engagementCaseOpened, `/cas/${item.slug}`)}
|
||||
className="case-link"
|
||||
>
|
||||
Voir le cas complet
|
||||
</a>
|
||||
</p>
|
||||
{item.linkHref && item.linkLabel && item.event && item.destination ? (
|
||||
<p className="mb-0 mt-2">
|
||||
<a
|
||||
href={item.linkHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...trackAttrs(item.event, item.destination)}
|
||||
className="case-link"
|
||||
>
|
||||
{item.linkLabel}
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
.highlight {
|
||||
border: 1px solid rgba(124, 255, 203, 0.28);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(180deg, rgba(6, 24, 18, 0.8), rgba(5, 18, 14, 0.84));
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #99f1c9;
|
||||
}
|
||||
|
||||
.modeSwitch {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
|
||||
.modeBtn {
|
||||
border: 1px solid rgba(124, 255, 203, 0.3);
|
||||
background: rgba(4, 20, 14, 0.62);
|
||||
color: #d9ffea;
|
||||
border-radius: 999px;
|
||||
padding: 0.32rem 0.72rem;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modeBtnActive {
|
||||
border-color: rgba(146, 255, 213, 0.8);
|
||||
background: rgba(18, 57, 42, 0.88);
|
||||
}
|
||||
|
||||
.modeHint {
|
||||
margin: 0.45rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.brief {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.briefKicker {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #a2f2ce;
|
||||
}
|
||||
|
||||
.briefInput {
|
||||
margin-top: 0.48rem;
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(122, 255, 197, 0.3);
|
||||
background: rgba(4, 18, 13, 0.74);
|
||||
color: #e7fff3;
|
||||
padding: 0.7rem;
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 0.58rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid rgba(124, 255, 203, 0.3);
|
||||
background: rgba(4, 20, 14, 0.66);
|
||||
color: #e2fff1;
|
||||
border-radius: 999px;
|
||||
padding: 0.34rem 0.78rem;
|
||||
font-size: 0.74rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
border-color: rgba(146, 255, 213, 0.8);
|
||||
background: rgba(18, 57, 42, 0.9);
|
||||
}
|
||||
|
||||
.feedback {
|
||||
margin-top: 0.48rem;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.56rem;
|
||||
font-size: 0.78rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.feedbackSuccess {
|
||||
color: #d9ffea;
|
||||
background: rgba(16, 58, 42, 0.72);
|
||||
border-color: rgba(124, 255, 203, 0.35);
|
||||
}
|
||||
|
||||
.feedbackError {
|
||||
color: #ffe9e9;
|
||||
background: rgba(94, 30, 30, 0.68);
|
||||
border-color: rgba(255, 134, 134, 0.44);
|
||||
}
|
||||
@@ -1,17 +1,105 @@
|
||||
import * as React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { CtaDualRail } from '@/components/ui/cta-dual-rail';
|
||||
import type { CtaLink } from '@/lib/types';
|
||||
import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
|
||||
import { TRACK_EVENTS, trackEvent } from '@/lib/tracking';
|
||||
import { useCopyContent } from '@/lib/copy';
|
||||
import styles from './Contact.module.css';
|
||||
|
||||
const tertiary: CtaLink = {
|
||||
label: 'Bandcamp (Electron Fou)',
|
||||
href: 'https://lelectron-fou.bandcamp.com/',
|
||||
event: TRACK_EVENTS.outboundBandcampContact,
|
||||
destination: 'bandcamp.com',
|
||||
external: true
|
||||
};
|
||||
type ContactMode = 'simple' | 'pro';
|
||||
type FeedbackState = { kind: 'idle' | 'success' | 'error'; message: string };
|
||||
|
||||
export function Contact() {
|
||||
const { copy } = useCopyContent();
|
||||
const [mode, setMode] = React.useState<ContactMode>('simple');
|
||||
const [brief, setBrief] = React.useState('');
|
||||
const [feedback, setFeedback] = React.useState<FeedbackState>({ kind: 'idle', message: '' });
|
||||
|
||||
const simpleBrief = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
`${copy.mission.planLabels.briefTitle} - Version simple`,
|
||||
'Idee produit: ',
|
||||
'A quoi ca sert: ',
|
||||
'Contrainte principale: ',
|
||||
'Delai souhaite: '
|
||||
].join('\n'),
|
||||
[copy.mission.planLabels.briefTitle]
|
||||
);
|
||||
|
||||
const proBrief = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
`${copy.mission.planLabels.briefTitle} - Version pro`,
|
||||
'Stack / architecture actuelle: ',
|
||||
'Symptomes observes: ',
|
||||
'Contraintes (CEM, conso, cout, delai): ',
|
||||
'Mesures deja faites: ',
|
||||
'Objectif de validation: '
|
||||
].join('\n'),
|
||||
[copy.mission.planLabels.briefTitle]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const saved = window.localStorage.getItem('er_mission_brief');
|
||||
if (saved && saved.trim()) {
|
||||
setBrief(saved);
|
||||
return;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore localStorage errors.
|
||||
}
|
||||
setBrief(simpleBrief);
|
||||
}, [simpleBrief]);
|
||||
|
||||
const applyMode = React.useCallback(
|
||||
(nextMode: ContactMode) => {
|
||||
setMode(nextMode);
|
||||
const template = nextMode === 'simple' ? simpleBrief : proBrief;
|
||||
setBrief(template);
|
||||
setFeedback({ kind: 'idle', message: '' });
|
||||
try {
|
||||
localStorage.setItem('er_mission_brief', template);
|
||||
} catch (_error) {
|
||||
// Ignore localStorage errors.
|
||||
}
|
||||
trackEvent('contact_mode_selected', {
|
||||
destination: '#contact',
|
||||
selected_mode: nextMode
|
||||
});
|
||||
},
|
||||
[proBrief, simpleBrief]
|
||||
);
|
||||
|
||||
const copyBrief = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(brief);
|
||||
setFeedback({ kind: 'success', message: 'Brief copie dans le presse-papiers.' });
|
||||
trackEvent(TRACK_EVENTS.funnelBriefCopied, {
|
||||
destination: '#contact',
|
||||
source: 'contact_brief',
|
||||
brief_chars: brief.length
|
||||
});
|
||||
} catch (_error) {
|
||||
setFeedback({ kind: 'error', message: 'Impossible de copier automatiquement. Copiez le texte manuellement.' });
|
||||
}
|
||||
}, [brief]);
|
||||
|
||||
const mailHref = React.useMemo(() => {
|
||||
const subject = encodeURIComponent(copy.contact.mailSubject);
|
||||
const body = encodeURIComponent(brief);
|
||||
return `mailto:[email protected]?subject=${subject}&body=${body}`;
|
||||
}, [brief, copy.contact.mailSubject]);
|
||||
|
||||
const onSendMail = React.useCallback(() => {
|
||||
setFeedback({ kind: 'success', message: 'Client mail ouvert avec votre brief pre-rempli.' });
|
||||
trackEvent(TRACK_EVENTS.funnelContactSubmitted, {
|
||||
destination: 'mailto:[email protected]',
|
||||
source: 'contact_form',
|
||||
brief_chars: brief.length
|
||||
});
|
||||
}, [brief.length]);
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
id="contact"
|
||||
@@ -26,44 +114,71 @@ export function Contact() {
|
||||
<div className="circuit-title-row">
|
||||
<span className="circuit-node circuit-node--magenta" aria-hidden="true" />
|
||||
<h2 id="contact-title" className="m-0 text-3xl md:text-4xl">
|
||||
Demarrer une mission
|
||||
{copy.contact.title}
|
||||
</h2>
|
||||
<span className="circuit-pinline" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p className="section-lead mb-0 mt-3">
|
||||
Point d'entree mission: perimetre, contraintes et priorites de delivery.
|
||||
</p>
|
||||
<p className="section-lead mb-0 mt-3">{copy.contact.lead}</p>
|
||||
|
||||
<p className="mb-0 mt-3 max-w-3xl studio-muted">
|
||||
Missions ciblees: architecture electronique, prototype sur mesure, optimisation systeme et accompagnement
|
||||
produit. Le cadrage initial valide objectifs, risques et temporalite.
|
||||
</p>
|
||||
<p className="mb-0 mt-2 text-sm studio-muted">
|
||||
Canal prioritaire: LinkedIn DM. Option achat/prestation: Malt. Premier retour sous 24-48h selon charge.
|
||||
</p>
|
||||
<p className="mb-0 mt-3 max-w-3xl studio-muted">{copy.contact.helpText}</p>
|
||||
|
||||
<CtaDualRail className="mt-4" label="Choisir le canal de contact mission" />
|
||||
<div className={`${styles.highlight} mt-4`}>
|
||||
<p className={styles.kicker}>Formulaire de contact (prioritaire)</p>
|
||||
<div className={styles.modeSwitch} role="tablist" aria-label="Version formulaire">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'simple'}
|
||||
className={`${styles.modeBtn} ${mode === 'simple' ? styles.modeBtnActive : ''}`}
|
||||
onClick={() => applyMode('simple')}
|
||||
>
|
||||
Version simple
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'pro'}
|
||||
className={`${styles.modeBtn} ${mode === 'pro' ? styles.modeBtnActive : ''}`}
|
||||
onClick={() => applyMode('pro')}
|
||||
>
|
||||
Version pro
|
||||
</button>
|
||||
</div>
|
||||
<p className={styles.modeHint}>
|
||||
{mode === 'simple'
|
||||
? "Simple: decrivez l'idee, l'usage et la contrainte principale."
|
||||
: 'Pro: ajoutez stack, symptomes et mesures deja effectuees.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="mb-0 mt-3">
|
||||
<a
|
||||
href={tertiary.href}
|
||||
target={tertiary.external ? '_blank' : undefined}
|
||||
rel={tertiary.external ? 'noopener noreferrer' : undefined}
|
||||
{...trackAttrs(tertiary.event, tertiary.destination)}
|
||||
className="inline-flex min-h-9 items-center gap-2 font-semibold studio-link underline-offset-4"
|
||||
>
|
||||
<span className="circuit-node circuit-node--magenta" aria-hidden="true" />
|
||||
{tertiary.label}
|
||||
</a>
|
||||
</p>
|
||||
<div className={styles.brief}>
|
||||
<p className={styles.briefKicker}>{copy.contact.formKicker}</p>
|
||||
<textarea
|
||||
className={styles.briefInput}
|
||||
value={brief}
|
||||
onChange={(e) => setBrief(e.target.value)}
|
||||
rows={7}
|
||||
suppressHydrationWarning
|
||||
style={{ caretColor: 'inherit' }}
|
||||
/>
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.btn} onClick={copyBrief}>
|
||||
{copy.contact.copyBrief}
|
||||
</button>
|
||||
<a href={mailHref} className={`${styles.btn} ${styles.btnPrimary}`} onClick={onSendMail}>
|
||||
{copy.contact.sendByMail}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul aria-label="Repères de confiance" className="mb-0 mt-4 pl-5 studio-muted">
|
||||
<li>Prerequis mission: objectif cible, contexte technique, contrainte principale</li>
|
||||
<li>Mode operatoire: sprint de 2 semaines avec jalons de validation</li>
|
||||
<li>Livrables: note de cadrage, priorisation et plan de delivery</li>
|
||||
<li>References publiques disponibles pour pre-qualification</li>
|
||||
</ul>
|
||||
{feedback.kind !== 'idle' ? (
|
||||
<p className={`${styles.feedback} ${feedback.kind === 'success' ? styles.feedbackSuccess : styles.feedbackError}`} aria-live="polite">
|
||||
{feedback.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<CtaDualRail className="mt-4" label="LinkedIn direct (optionnel)" />
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
|
||||
@@ -31,10 +31,10 @@ const steps: FlowStep[] = [
|
||||
{
|
||||
label: 'Sprint 02',
|
||||
description: 'Prototype operationnel, tests terrain et arbitrages de delivery',
|
||||
href: '#projets',
|
||||
href: '#lab-notes',
|
||||
icon: 'cable',
|
||||
event: TRACK_EVENTS.ctaHeroContact,
|
||||
destination: '#projets'
|
||||
destination: '#lab-notes'
|
||||
},
|
||||
{
|
||||
label: 'Activation',
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
.root {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.oscilloscope {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.oscilloscopeSvg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.oscTrack {
|
||||
transform: translateX(1480px);
|
||||
animation: scopeTrackSweep 12s linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.oscWave {
|
||||
fill: none;
|
||||
stroke: rgba(120, 255, 166, 0.5);
|
||||
stroke-width: 2.6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.oscCore {
|
||||
fill: none;
|
||||
stroke: rgba(173, 255, 200, 0.98);
|
||||
stroke-width: 1.1;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.heroGrid {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.heroCard {
|
||||
width: 100%;
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(14px, 2.4vw, 28px);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgba(121, 255, 206, 0.48);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(5, 20, 15, 0.88), rgba(6, 18, 14, 0.92)),
|
||||
url('/assets/da/openai/hero-carnet-labo-open-v2.webp') center/cover no-repeat,
|
||||
linear-gradient(180deg, rgba(6, 29, 20, 0.94), rgba(5, 22, 16, 0.94));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(145, 255, 215, 0.12),
|
||||
0 20px 52px rgba(3, 11, 8, 0.42);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.66rem;
|
||||
color: #97e6c0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0.55rem 0 0;
|
||||
text-align: center;
|
||||
font-size: clamp(1.8rem, 4.6vw, 3.15rem);
|
||||
line-height: 1.02;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.copy {
|
||||
margin: 0.78rem auto 0;
|
||||
max-width: 78ch;
|
||||
text-align: justify;
|
||||
text-justify: inter-word;
|
||||
font-size: clamp(0.92rem, 1.5vw, 1.05rem);
|
||||
line-height: 1.58;
|
||||
color: #dbffee;
|
||||
padding: 0.62rem 0.72rem;
|
||||
border-radius: 0.68rem;
|
||||
border: 1px solid rgba(124, 255, 203, 0.26);
|
||||
background: linear-gradient(180deg, rgba(6, 24, 18, 0.82), rgba(5, 18, 14, 0.86));
|
||||
}
|
||||
|
||||
.schema {
|
||||
margin-top: 0.84rem;
|
||||
border-radius: 0.78rem;
|
||||
border: 1px solid rgba(163, 255, 224, 0.42);
|
||||
background:
|
||||
radial-gradient(640px 280px at 12% 14%, rgba(96, 216, 255, 0.1), transparent 62%),
|
||||
linear-gradient(180deg, rgba(6, 15, 19, 0.9), rgba(5, 11, 15, 0.94));
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.schemaScroll {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border-radius: 0.62rem;
|
||||
}
|
||||
|
||||
.schemaScroll::before,
|
||||
.schemaScroll::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 22px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.schemaScroll::before {
|
||||
left: 0;
|
||||
background: linear-gradient(90deg, rgba(4, 10, 15, 0.9), transparent);
|
||||
}
|
||||
|
||||
.schemaScroll::after {
|
||||
right: 0;
|
||||
background: linear-gradient(270deg, rgba(4, 10, 15, 0.9), transparent);
|
||||
}
|
||||
|
||||
.schemaSvg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.path {
|
||||
fill: none;
|
||||
stroke: rgba(136, 255, 188, 0.34);
|
||||
stroke-width: 2.9;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 420;
|
||||
stroke-dashoffset: 420;
|
||||
animation: traceDraw 900ms ease forwards;
|
||||
}
|
||||
|
||||
.pathActive {
|
||||
stroke: rgba(156, 255, 192, 0.96);
|
||||
filter: drop-shadow(0 0 10px rgba(117, 255, 166, 0.58));
|
||||
}
|
||||
|
||||
.signalDot {
|
||||
fill: #a6ffc3;
|
||||
filter:
|
||||
drop-shadow(0 0 7px rgba(118, 255, 168, 0.72))
|
||||
drop-shadow(0 0 16px rgba(118, 255, 168, 0.38));
|
||||
}
|
||||
|
||||
.nodeRect {
|
||||
stroke-width: 1.8;
|
||||
fill: rgba(16, 38, 28, 0.9);
|
||||
stroke: rgba(128, 255, 181, 0.5);
|
||||
transition: 170ms ease;
|
||||
}
|
||||
|
||||
.nodeActive {
|
||||
filter:
|
||||
drop-shadow(0 0 11px rgba(118, 255, 166, 0.42))
|
||||
drop-shadow(0 0 24px rgba(118, 255, 166, 0.2));
|
||||
}
|
||||
|
||||
.nodeMagic {
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
|
||||
.nodeLabel {
|
||||
fill: #deffe9;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.014em;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
font-family: "IBM Plex Mono", ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.nodeLabelLarge {
|
||||
fill: #ecfff3;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
text-shadow:
|
||||
0 0 8px rgba(126, 255, 173, 0.52),
|
||||
0 0 18px rgba(126, 255, 173, 0.22);
|
||||
}
|
||||
|
||||
.nodeButton {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nodeButton:focus-visible {
|
||||
outline: 2px solid rgba(180, 255, 213, 0.95);
|
||||
outline-offset: -2px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.scrollHint {
|
||||
margin: 0.36rem 0 0;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #b8eaff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 0.42rem;
|
||||
border-top: 1px solid rgba(145, 255, 215, 0.24);
|
||||
padding-top: 0.38rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metaKicker {
|
||||
margin: 0;
|
||||
color: #95e6bf;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metaLine {
|
||||
margin: 0.18rem 0 0;
|
||||
font-size: 0.73rem;
|
||||
color: #d7ffe8;
|
||||
}
|
||||
|
||||
.valueRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.42rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.valuePill {
|
||||
min-height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(123, 255, 188, 0.32);
|
||||
border-radius: 999px;
|
||||
padding: 0.14rem 0.66rem;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
font-family: "IBM Plex Mono", ui-monospace, Menlo, Consolas, monospace;
|
||||
background: rgba(7, 28, 20, 0.74);
|
||||
}
|
||||
|
||||
.ctaRow {
|
||||
margin-top: 0.7rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.48rem;
|
||||
}
|
||||
|
||||
.cta {
|
||||
min-height: 2.2rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.2rem 0.84rem;
|
||||
border: 1px solid rgba(128, 255, 191, 0.34);
|
||||
color: #dfffee;
|
||||
background: rgba(5, 25, 18, 0.68);
|
||||
}
|
||||
|
||||
.ctaPrimary {
|
||||
border-color: rgba(139, 255, 197, 0.74);
|
||||
background: linear-gradient(180deg, rgba(13, 55, 38, 0.94), rgba(10, 45, 31, 0.92));
|
||||
color: #ecfff6;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.schemaSvg {
|
||||
min-width: 1040px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.scrollHint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.oscTrack,
|
||||
.path {
|
||||
animation: none;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
.signalDot,
|
||||
.oscilloscope {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scopeTrackSweep {
|
||||
0% {
|
||||
transform: translateX(1480px);
|
||||
opacity: 0;
|
||||
}
|
||||
8% {
|
||||
opacity: 0.82;
|
||||
}
|
||||
90% {
|
||||
opacity: 0.76;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-700px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes traceDraw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,181 +1,351 @@
|
||||
import * as React from 'react';
|
||||
import { useId } from 'react';
|
||||
import type { CtaLink } from '@/lib/types';
|
||||
import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
|
||||
import { useCopyContent } from '@/lib/copy';
|
||||
import styles from './Hero.module.css';
|
||||
|
||||
const heroCtas: CtaLink[] = [
|
||||
{
|
||||
label: 'Profil expert',
|
||||
href: '#a-propos',
|
||||
event: TRACK_EVENTS.ctaHeroProfile,
|
||||
destination: '#a-propos'
|
||||
},
|
||||
{
|
||||
label: 'Demarrer mission',
|
||||
label: 'Envoyer un brief',
|
||||
href: '#contact',
|
||||
event: TRACK_EVENTS.ctaHeroContact,
|
||||
destination: '#contact'
|
||||
},
|
||||
{
|
||||
label: "Voir les cas d'usage",
|
||||
href: '#projets',
|
||||
label: 'Voir les preuves',
|
||||
href: '#lab-notes',
|
||||
event: TRACK_EVENTS.ctaHeroProjects,
|
||||
destination: '#projets'
|
||||
destination: '#lab-notes'
|
||||
}
|
||||
];
|
||||
|
||||
const HERO_TITLE_WORDS = ['Conception', 'electronique', 'sur mesure'];
|
||||
type EdgeKey = 'power-mcu' | 'power-rf' | 'mcu-rf' | 'mcu-audio' | 'audio-test' | 'rf-test';
|
||||
|
||||
const HERO_VARIANT_ASSETS: Record<
|
||||
string,
|
||||
{
|
||||
routingDesktopWebp: string;
|
||||
routingMobileWebp: string;
|
||||
routingDesktop: string;
|
||||
routingMobile: string;
|
||||
instrumentDesktopWebp: string;
|
||||
instrumentMobileWebp: string;
|
||||
instrumentDesktop: string;
|
||||
instrumentMobile: string;
|
||||
notebookDesktopWebp: string;
|
||||
notebookMobileWebp: string;
|
||||
notebookDesktop: string;
|
||||
notebookMobile: string;
|
||||
routingCaption: string;
|
||||
instrumentCaption: string;
|
||||
}
|
||||
> = {
|
||||
v12: {
|
||||
routingDesktopWebp: '/assets/da/openai/hero-pcb-routing-map-v2.webp',
|
||||
routingMobileWebp: '/assets/da/openai/hero-pcb-routing-map-mobile-low-noise-v2.webp',
|
||||
routingDesktop: '/assets/da/openai/hero-pcb-routing-map-v2.png',
|
||||
routingMobile: '/assets/da/openai/hero-pcb-routing-map-mobile-low-noise-v2.png',
|
||||
instrumentDesktopWebp: '/assets/da/openai/hero-instrument-panel-v2.webp',
|
||||
instrumentMobileWebp: '/assets/da/openai/hero-instrument-panel-mobile-low-noise-v2.webp',
|
||||
instrumentDesktop: '/assets/da/openai/hero-instrument-panel-v2.png',
|
||||
instrumentMobile: '/assets/da/openai/hero-instrument-panel-mobile-low-noise-v2.png',
|
||||
notebookDesktopWebp: '/assets/da/openai/hero-carnet-labo-open-v2.webp',
|
||||
notebookMobileWebp: '/assets/da/openai/carnet-labo-ouvert.webp',
|
||||
notebookDesktop: '/assets/da/openai/hero-carnet-labo-open-v2.png',
|
||||
notebookMobile: '/assets/da/openai/carnet-labo-ouvert.png',
|
||||
routingCaption: 'Architecture pcb',
|
||||
instrumentCaption: 'Appareil de mesure (asset)'
|
||||
}
|
||||
const EDGES: { key: EdgeKey; d: string }[] = [
|
||||
{ key: 'power-mcu', d: 'M520 104 C455 136, 390 164, 280 188' },
|
||||
{ key: 'power-rf', d: 'M520 104 C620 136, 705 154, 780 170' },
|
||||
{ key: 'mcu-rf', d: 'M410 234 C500 214, 565 212, 630 224' },
|
||||
{ key: 'mcu-audio', d: 'M280 278 C235 322, 228 348, 252 370' },
|
||||
{ key: 'rf-test', d: 'M780 282 C786 324, 786 348, 760 370' },
|
||||
{ key: 'audio-test', d: 'M400 414 C500 430, 560 430, 610 414' }
|
||||
];
|
||||
|
||||
type SchematicBlock = {
|
||||
id: 'power' | 'mcu' | 'rf' | 'audio' | 'test';
|
||||
label: string;
|
||||
risk: string;
|
||||
deliverable: string;
|
||||
target: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
function resolveDaVariant() {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'v12';
|
||||
const SCHEMATIC_BLOCKS: SchematicBlock[] = [
|
||||
{
|
||||
id: 'power',
|
||||
label: 'Votre idee de genie',
|
||||
risk: "L'alimentation peut faire tomber tout le systeme.",
|
||||
deliverable: 'Plan alim stable + points de mesure clairs.',
|
||||
target: 'offre-systeme',
|
||||
x: 390,
|
||||
y: 20,
|
||||
w: 260,
|
||||
h: 84
|
||||
},
|
||||
{
|
||||
id: 'mcu',
|
||||
label: 'Plan realiste',
|
||||
risk: 'Le coeur logique peut ralentir ou planter.',
|
||||
deliverable: 'Plan de pilotage clair + priorites firmware.',
|
||||
target: 'process-etats',
|
||||
x: 150,
|
||||
y: 190,
|
||||
w: 260,
|
||||
h: 88
|
||||
},
|
||||
{
|
||||
id: 'rf',
|
||||
label: 'Votre produit magique',
|
||||
risk: 'La connexion sans fil peut devenir instable.',
|
||||
deliverable: 'Regles radio + validation de portee.',
|
||||
target: 'lab-notes',
|
||||
x: 630,
|
||||
y: 170,
|
||||
w: 300,
|
||||
h: 112
|
||||
},
|
||||
{
|
||||
id: 'audio',
|
||||
label: 'Premiere version',
|
||||
risk: 'Le son peut introduire du bruit ou de la saturation.',
|
||||
deliverable: 'Chaine audio propre + tests simples.',
|
||||
target: 'lab-notes',
|
||||
x: 100,
|
||||
y: 370,
|
||||
w: 300,
|
||||
h: 88
|
||||
},
|
||||
{
|
||||
id: 'test',
|
||||
label: 'Amelioration agile',
|
||||
risk: 'Sans validation, le risque projet reste flou.',
|
||||
deliverable: 'Rapport go/no-go simple et actionnable.',
|
||||
target: 'contact',
|
||||
x: 610,
|
||||
y: 370,
|
||||
w: 300,
|
||||
h: 88
|
||||
}
|
||||
];
|
||||
|
||||
const rootVariant = document.documentElement.getAttribute('data-da-variant') || '';
|
||||
if (HERO_VARIANT_ASSETS[rootVariant]) {
|
||||
return rootVariant;
|
||||
}
|
||||
function usePrefersReducedMotion() {
|
||||
const [reduce, setReduce] = React.useState(false);
|
||||
|
||||
return 'v12';
|
||||
React.useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const onChange = () => setReduce(media.matches);
|
||||
onChange();
|
||||
media.addEventListener?.('change', onChange) ?? media.addListener(onChange);
|
||||
return () => media.removeEventListener?.('change', onChange) ?? media.removeListener(onChange);
|
||||
}, []);
|
||||
|
||||
return reduce;
|
||||
}
|
||||
|
||||
function Signal({ pathId, speed = 220, count = 2 }: { pathId: string; speed?: number; count?: number }) {
|
||||
const reduceMotion = usePrefersReducedMotion();
|
||||
const circleRefs = React.useRef<Array<SVGCircleElement | null>>([]);
|
||||
const rafRef = React.useRef<number | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (reduceMotion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = document.getElementById(pathId) as SVGPathElement | null;
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalLength = path.getTotalLength();
|
||||
let last = performance.now();
|
||||
const offsets = Array.from({ length: count }, (_, i) => (totalLength * i) / count);
|
||||
|
||||
const tick = (now: number) => {
|
||||
const dt = (now - last) / 1000;
|
||||
last = now;
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
offsets[i] = (offsets[i] + speed * dt) % totalLength;
|
||||
const point = path.getPointAtLength(offsets[i]);
|
||||
const circle = circleRefs.current[i];
|
||||
if (circle) {
|
||||
circle.setAttribute('cx', point.x.toFixed(2));
|
||||
circle.setAttribute('cy', point.y.toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
};
|
||||
}, [pathId, speed, count, reduceMotion]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<circle
|
||||
key={`${pathId}-${i}`}
|
||||
ref={(el) => {
|
||||
circleRefs.current[i] = el;
|
||||
}}
|
||||
r="3.7"
|
||||
className={styles.signalDot}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Hero() {
|
||||
const [assets, setAssets] = React.useState(HERO_VARIANT_ASSETS.v12);
|
||||
const uid = useId();
|
||||
const { copy } = useCopyContent();
|
||||
const [activeBlock, setActiveBlock] = React.useState<SchematicBlock['id']>('power');
|
||||
const [hoveredBlock, setHoveredBlock] = React.useState<SchematicBlock['id'] | null>(null);
|
||||
const heroTitleWords = React.useMemo(() => copy.hero.title.split(' ').filter(Boolean), [copy.hero.title]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const variant = resolveDaVariant();
|
||||
setAssets(HERO_VARIANT_ASSETS[variant] || HERO_VARIANT_ASSETS.v12);
|
||||
const currentBlock = React.useMemo(() => {
|
||||
const id = hoveredBlock || activeBlock;
|
||||
return SCHEMATIC_BLOCKS.find((block) => block.id === id) || SCHEMATIC_BLOCKS[0];
|
||||
}, [activeBlock, hoveredBlock]);
|
||||
|
||||
const hotEdges = React.useMemo(() => {
|
||||
const id = currentBlock.id;
|
||||
if (id === 'power') return new Set<EdgeKey>(['power-mcu', 'power-rf']);
|
||||
if (id === 'mcu') return new Set<EdgeKey>(['power-mcu', 'mcu-rf', 'mcu-audio']);
|
||||
if (id === 'rf') return new Set<EdgeKey>(['power-rf', 'mcu-rf', 'rf-test']);
|
||||
if (id === 'audio') return new Set<EdgeKey>(['mcu-audio', 'audio-test']);
|
||||
return new Set<EdgeKey>(['rf-test', 'audio-test']);
|
||||
}, [currentBlock.id]);
|
||||
|
||||
const goToBlock = React.useCallback((block: SchematicBlock) => {
|
||||
setActiveBlock(block.id);
|
||||
const target = document.getElementById(block.target);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="hero-title" className="section-anchor relative overflow-hidden pt-4 md:pt-6" id="top">
|
||||
<div className="figma-lab-hero-grid">
|
||||
<article className="figma-lab-left">
|
||||
<h1 id="hero-title" className="figma-lab-title">
|
||||
{HERO_TITLE_WORDS.map((word, index) => (
|
||||
<span key={word} className="hero-title-word" style={{ ['--word-index' as string]: String(index) }}>
|
||||
{word}
|
||||
</span>
|
||||
<section aria-labelledby="hero-title" className={`section-anchor relative overflow-hidden pt-4 md:pt-6 ${styles.root}`} id="top">
|
||||
<div className={styles.oscilloscope} aria-hidden="true">
|
||||
<svg viewBox="0 0 1440 520" preserveAspectRatio="none" className={styles.oscilloscopeSvg}>
|
||||
<defs>
|
||||
<linearGradient id="heroScopeBeam" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="rgba(121, 255, 171, 0)" />
|
||||
<stop offset="28%" stopColor="rgba(121, 255, 171, 0.18)" />
|
||||
<stop offset="52%" stopColor="rgba(121, 255, 171, 0.6)" />
|
||||
<stop offset="100%" stopColor="rgba(121, 255, 171, 0)" />
|
||||
</linearGradient>
|
||||
<filter id="heroScopeGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="2.6" result="blurred" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blurred" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<g className={styles.oscTrack}>
|
||||
<rect x="-20" y="0" width="620" height="520" fill="url(#heroScopeBeam)" />
|
||||
<path
|
||||
className={styles.oscWave}
|
||||
filter="url(#heroScopeGlow)"
|
||||
d="M 20 260 L 52 260 L 88 220 L 112 305 L 142 186 L 170 322 L 206 230 L 236 278 L 266 150 L 298 336 L 332 246 L 360 270 L 390 198 L 426 326 L 460 236 L 490 262 L 524 205 L 560 296 L 596 252"
|
||||
/>
|
||||
<path
|
||||
className={styles.oscCore}
|
||||
d="M 20 260 L 52 260 L 88 220 L 112 305 L 142 186 L 170 322 L 206 230 L 236 278 L 266 150 L 298 336 L 332 246 L 360 270 L 390 198 L 426 326 L 460 236 L 490 262 L 524 205 L 560 296 L 596 252"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroGrid}>
|
||||
<article className={styles.heroCard}>
|
||||
<p className={styles.kicker}>SYSTEM ARTIST · ELECTRONICS MISSION STUDIO</p>
|
||||
<h1 id="hero-title" className={styles.title}>
|
||||
{heroTitleWords.map((word, index) => (
|
||||
<span key={`${word}-${index}`}> {word}</span>
|
||||
))}
|
||||
</h1>
|
||||
|
||||
<p className="figma-lab-copy hero-copy-reveal">
|
||||
Studio premium: cadrage, prototype, validation terrain et delivery en sprints de 2 semaines.
|
||||
</p>
|
||||
<p className="figma-lab-copy figma-lab-copy--mono hero-console-line">
|
||||
CH1 business / CH2 architecture / DMM risque / REV sprint 02
|
||||
</p>
|
||||
<p className={styles.copy}>{copy.hero.body}</p>
|
||||
|
||||
<div className="figma-lab-cta-row">
|
||||
<a href={heroCtas[0].href} {...trackAttrs(heroCtas[0].event, heroCtas[0].destination)} className="figma-lab-pill">
|
||||
{heroCtas[0].label}
|
||||
</a>
|
||||
<a href={heroCtas[1].href} {...trackAttrs(heroCtas[1].event, heroCtas[1].destination)} className="figma-lab-pill">
|
||||
{heroCtas[1].label}
|
||||
</a>
|
||||
<a href={heroCtas[2].href} {...trackAttrs(heroCtas[2].event, heroCtas[2].destination)} className="figma-lab-pill">
|
||||
{heroCtas[2].label}
|
||||
</a>
|
||||
</div>
|
||||
<p className="figma-lab-cta-help">Parcours recommande: profil, cas d'usage, puis mission.</p>
|
||||
<section className={styles.schema} aria-label="Schema systeme interactif">
|
||||
<div className={styles.schemaScroll}>
|
||||
<svg viewBox="0 0 1040 480" className={styles.schemaSvg} role="img" aria-label="Schema vivant electronique">
|
||||
{EDGES.map((edge) => (
|
||||
<path
|
||||
key={edge.key}
|
||||
id={`${uid}-edge-${edge.key}`}
|
||||
d={edge.d}
|
||||
className={`${styles.path} ${hotEdges.has(edge.key) ? styles.pathActive : ''}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
<section aria-label="Notes de bench" className="figma-lab-notes">
|
||||
<p className="figma-lab-notes-title">Notes de bench & valeur business</p>
|
||||
<ul className="figma-lab-notes-list">
|
||||
<li>Fait: contraintes I/O et EMI mesurees. Decision: architecture cible verrouillee.</li>
|
||||
<li>Fait: prototype sprint 01 stable. Decision: backlog priorise par valeur client.</li>
|
||||
<li>Fait: validation terrain sprint 02. Impact: reduction du risque planning delivery.</li>
|
||||
</ul>
|
||||
{EDGES.map((edge) =>
|
||||
hotEdges.has(edge.key) ? (
|
||||
<Signal key={`sig-${edge.key}`} pathId={`${uid}-edge-${edge.key}`} speed={220} count={2} />
|
||||
) : null
|
||||
)}
|
||||
|
||||
{SCHEMATIC_BLOCKS.map((block) => {
|
||||
const isActive = currentBlock.id === block.id;
|
||||
const isMagic = block.id === 'rf';
|
||||
return (
|
||||
<g key={block.id}>
|
||||
<rect
|
||||
x={block.x}
|
||||
y={block.y}
|
||||
width={block.w}
|
||||
height={block.h}
|
||||
rx={14}
|
||||
className={`${styles.nodeRect} ${isActive ? styles.nodeActive : ''} ${isMagic ? styles.nodeMagic : ''}`}
|
||||
/>
|
||||
<text
|
||||
x={block.x + block.w / 2}
|
||||
y={block.y + block.h / 2 + 1}
|
||||
className={`${styles.nodeLabel} ${isMagic ? styles.nodeLabelLarge : ''}`}
|
||||
>
|
||||
{block.label}
|
||||
</text>
|
||||
<foreignObject x={block.x} y={block.y} width={block.w} height={block.h}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.nodeButton}
|
||||
onMouseEnter={() => setHoveredBlock(block.id)}
|
||||
onMouseLeave={() => setHoveredBlock(null)}
|
||||
onFocus={() => setHoveredBlock(block.id)}
|
||||
onBlur={() => setHoveredBlock(null)}
|
||||
onClick={() => goToBlock(block)}
|
||||
aria-label={`Bloc ${block.label}. Ce qui peut bloquer: ${block.risk}. Ce qu'on livre: ${block.deliverable}`}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
>
|
||||
<span className={styles.srOnly}>{block.label}</span>
|
||||
</button>
|
||||
</foreignObject>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
<p className={styles.scrollHint}>Sur mobile: faites glisser le schema horizontalement.</p>
|
||||
|
||||
<div className={styles.meta} aria-live="polite">
|
||||
<p className={styles.metaKicker}>Bloc actif: {currentBlock.label}</p>
|
||||
<p className={styles.metaLine}>
|
||||
<strong>Ce qui peut bloquer:</strong> {currentBlock.risk}
|
||||
</p>
|
||||
<p className={styles.metaLine}>
|
||||
<strong>Ce qu'on livre:</strong> {currentBlock.deliverable}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<figure className="figma-lab-notebook">
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" type="image/webp" srcSet={assets.notebookMobileWebp} />
|
||||
<source media="(max-width: 767px)" srcSet={assets.notebookMobile} />
|
||||
<source type="image/webp" srcSet={assets.notebookDesktopWebp} />
|
||||
<img
|
||||
src={assets.notebookDesktop}
|
||||
alt="Carnet de laboratoire electronique avec schema et mesures"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={1200}
|
||||
height={800}
|
||||
/>
|
||||
</picture>
|
||||
</figure>
|
||||
</article>
|
||||
<div className={styles.valueRow} role="list" aria-label="Points cle">
|
||||
<span className={styles.valuePill} role="listitem">
|
||||
Objectif clair
|
||||
</span>
|
||||
<span className={styles.valuePill} role="listitem">
|
||||
Prototype fiable
|
||||
</span>
|
||||
<span className={styles.valuePill} role="listitem">
|
||||
Plan de suite
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<aside className="figma-lab-right" aria-label="Assets labo electronique">
|
||||
<figure className="figma-lab-asset-card">
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" type="image/webp" srcSet={assets.routingMobileWebp} />
|
||||
<source media="(max-width: 767px)" srcSet={assets.routingMobile} />
|
||||
<source type="image/webp" srcSet={assets.routingDesktopWebp} />
|
||||
<img
|
||||
src={assets.routingDesktop}
|
||||
alt="Routing PCB avec traces et vias"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
width={1200}
|
||||
height={700}
|
||||
/>
|
||||
</picture>
|
||||
<figcaption>{assets.routingCaption}</figcaption>
|
||||
</figure>
|
||||
<figure className="figma-lab-asset-card">
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" type="image/webp" srcSet={assets.instrumentMobileWebp} />
|
||||
<source media="(max-width: 767px)" srcSet={assets.instrumentMobile} />
|
||||
<source type="image/webp" srcSet={assets.instrumentDesktopWebp} />
|
||||
<img
|
||||
src={assets.instrumentDesktop}
|
||||
alt="Affichage oscilloscope de mesure"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={1200}
|
||||
height={700}
|
||||
/>
|
||||
</picture>
|
||||
<figcaption>{assets.instrumentCaption}</figcaption>
|
||||
</figure>
|
||||
</aside>
|
||||
<div className={styles.ctaRow}>
|
||||
{heroCtas.map((cta, index) => (
|
||||
<a
|
||||
key={cta.label}
|
||||
href={cta.href}
|
||||
{...trackAttrs(cta.event, cta.destination)}
|
||||
className={`${styles.cta} ${index === 0 ? styles.ctaPrimary : ''}`}
|
||||
>
|
||||
{cta.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,75 +1,254 @@
|
||||
import * as React from 'react';
|
||||
|
||||
type NodeTab = 'inputs' | 'outputs' | 'risques';
|
||||
|
||||
const NODE_W = 100;
|
||||
const NODE_H = 40;
|
||||
|
||||
type SystemNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
svgLabel?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
ifaces: string[];
|
||||
inputs: string[];
|
||||
outputs: string[];
|
||||
risques: string[];
|
||||
};
|
||||
|
||||
const SYSTEM_NODES: SystemNode[] = [
|
||||
{
|
||||
id: 'alim',
|
||||
label: 'Alimentation',
|
||||
x: 8,
|
||||
y: 18,
|
||||
ifaces: ['Power'],
|
||||
inputs: ['Source batterie/secteur', 'Contraintes rendement', 'Budget thermique'],
|
||||
outputs: ['Rails stables', 'Marge dynamique', 'Plan decouplage'],
|
||||
risques: ['Ringing', 'Chute transitoire', 'EMI conduite']
|
||||
},
|
||||
{
|
||||
id: 'capteurs',
|
||||
label: 'Capteurs',
|
||||
x: 145,
|
||||
y: 18,
|
||||
ifaces: ['Analog', 'I2C', 'SPI'],
|
||||
inputs: ['Signal analogique', 'Fenetre acquisition', 'Calibration'],
|
||||
outputs: ['Data propre', 'Filtres initiaux', 'Events valides'],
|
||||
risques: ['Bruit analogique', 'Derive thermique', 'Aliasing']
|
||||
},
|
||||
{
|
||||
id: 'bus',
|
||||
label: 'Bus I2C/SPI/UART',
|
||||
svgLabel: 'I2C/SPI/UART',
|
||||
x: 282,
|
||||
y: 18,
|
||||
ifaces: ['I2C', 'SPI', 'UART'],
|
||||
inputs: ['Topologie bus', 'Frequence cible', 'Charge lineaire'],
|
||||
outputs: ['Synchronisation stable', 'Trames tracees', 'Budget latence'],
|
||||
risques: ['Contention', 'Reflexions', 'Timeout protocole']
|
||||
},
|
||||
{
|
||||
id: 'radio',
|
||||
label: 'Radio RF',
|
||||
x: 282,
|
||||
y: 98,
|
||||
ifaces: ['RF'],
|
||||
inputs: ['Bandes frequences', 'Duty cycle', 'Coexistence locale'],
|
||||
outputs: ['Lien stable', 'RSSI/PER traces', 'Budget antenne'],
|
||||
risques: ['Desense', 'Interferences', 'Portee degradee']
|
||||
},
|
||||
{
|
||||
id: 'dfm',
|
||||
label: 'Mecanique / DFM',
|
||||
x: 145,
|
||||
y: 98,
|
||||
ifaces: [],
|
||||
inputs: ['Contraintes encombre', 'Assemblage', 'Volume serie'],
|
||||
outputs: ['Placement valide', 'Regles fab', 'Check industrialisation'],
|
||||
risques: ['Empilement impossible', 'Rework cher', 'Dissipation insuffisante']
|
||||
}
|
||||
];
|
||||
|
||||
// Paths stop 8 px before target edge to leave room for arrowhead
|
||||
type Conn = { from: string; to: string; d: string };
|
||||
const CONNECTIONS: Conn[] = [
|
||||
{ from: 'alim', to: 'capteurs', d: 'M 108 38 H 137' },
|
||||
{ from: 'capteurs', to: 'bus', d: 'M 245 38 H 274' },
|
||||
{ from: 'bus', to: 'radio', d: 'M 332 58 V 90' },
|
||||
{ from: 'capteurs', to: 'dfm', d: 'M 195 58 V 90' },
|
||||
{ from: 'dfm', to: 'radio', d: 'M 245 118 H 274' }
|
||||
];
|
||||
|
||||
const ALL_INTERFACES = ['Power', 'Analog', 'I2C', 'SPI', 'UART', 'RF'] as const;
|
||||
|
||||
const TABS: { id: NodeTab; label: string }[] = [
|
||||
{ id: 'inputs', label: 'Inputs' },
|
||||
{ id: 'outputs', label: 'Outputs' },
|
||||
{ id: 'risques', label: 'Risques' }
|
||||
];
|
||||
|
||||
export function IdentityLab() {
|
||||
const [activeNodeId, setActiveNodeId] = React.useState('alim');
|
||||
const [activeTab, setActiveTab] = React.useState<NodeTab>('inputs');
|
||||
const node = SYSTEM_NODES.find((n) => n.id === activeNodeId) ?? SYSTEM_NODES[0];
|
||||
|
||||
const tabValues: Record<NodeTab, string[]> = {
|
||||
inputs: node.inputs,
|
||||
outputs: node.outputs,
|
||||
risques: node.risques
|
||||
};
|
||||
|
||||
function handleSelect(id: string) {
|
||||
setActiveNodeId(id);
|
||||
setActiveTab('inputs');
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
id="identite"
|
||||
aria-labelledby="identity-title"
|
||||
className="section-anchor mt-5"
|
||||
>
|
||||
<section id="identite" aria-labelledby="identity-title" className="section-anchor mt-5">
|
||||
<div className="circuit-board rounded-2xl p-5 md:p-6">
|
||||
<div className="circuit-title-row">
|
||||
<span className="circuit-node circuit-node--magenta" aria-hidden="true" />
|
||||
<h2 id="identity-title" className="m-0 text-3xl md:text-4xl">
|
||||
Systemes et identite de marque
|
||||
Offre en system map
|
||||
</h2>
|
||||
<span className="circuit-pinline" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p className="mb-0 mt-3 max-w-3xl studio-muted">
|
||||
Cette section expose la grammaire visuelle du studio: signal technique, lisibilite commerciale et signature
|
||||
premium orientee mission.
|
||||
Niveaux systeme + interfaces: cliquez un noeud pour lire les inputs, outputs et risques reels de delivery.
|
||||
</p>
|
||||
|
||||
<div className="identity-lab-grid mt-4">
|
||||
<article className="logo-lab-card">
|
||||
<p className="logo-lab-kicker">Marque 01</p>
|
||||
<h3 className="logo-lab-title">ER monogramme PCB</h3>
|
||||
<svg viewBox="0 0 360 140" className="logo-lab-svg" role="img" aria-label="Electron rare monogramme PCB">
|
||||
<rect x="8" y="8" width="344" height="124" rx="14" className="logo-lab-bg" />
|
||||
<path d="M58 40 H128 V60 H78 V74 H124" className="logo-lab-trace" />
|
||||
<path d="M150 40 H214 Q248 40 248 66 Q248 92 214 92 H150 V40" className="logo-lab-trace" />
|
||||
<circle cx="58" cy="40" r="4" className="logo-lab-node" />
|
||||
<circle cx="124" cy="74" r="4" className="logo-lab-node logo-lab-node--alt" />
|
||||
<circle cx="248" cy="66" r="4" className="logo-lab-node" />
|
||||
<text x="54" y="112" className="logo-lab-wordmark">
|
||||
ELECTRON RARE
|
||||
</text>
|
||||
</svg>
|
||||
<p className="logo-lab-meta">Positionnement: precision technique</p>
|
||||
</article>
|
||||
<div className="system-map-layout mt-4">
|
||||
{/* ── SVG canvas ─────────────────────────────────── */}
|
||||
<div className="system-map-canvas">
|
||||
<div className="system-map-scroll">
|
||||
<svg
|
||||
viewBox="0 0 390 148"
|
||||
className="system-map-svg"
|
||||
role="img"
|
||||
aria-label="Carte systeme electronique"
|
||||
>
|
||||
<defs>
|
||||
{/* Arrow marker — fill driven by CSS `color` on the path */}
|
||||
<marker
|
||||
id="sm-arrow"
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="7"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
markerUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M 0 1 L 7 4 L 0 7 Z" fill="currentColor" />
|
||||
</marker>
|
||||
|
||||
<article className="logo-lab-card">
|
||||
<p className="logo-lab-kicker">Marque 02</p>
|
||||
<h3 className="logo-lab-title">Boucle de controle</h3>
|
||||
<svg viewBox="0 0 360 140" className="logo-lab-svg" role="img" aria-label="Electron rare boucle de controle">
|
||||
<rect x="8" y="8" width="344" height="124" rx="14" className="logo-lab-bg" />
|
||||
<rect x="48" y="48" width="50" height="44" rx="8" className="logo-lab-block" />
|
||||
<rect x="155" y="48" width="50" height="44" rx="8" className="logo-lab-block" />
|
||||
<rect x="262" y="48" width="50" height="44" rx="8" className="logo-lab-block" />
|
||||
<path d="M98 70 H155 M205 70 H262 M287 92 V104 H73 V92" className="logo-lab-link" />
|
||||
<polygon points="145,66 155,70 145,74" className="logo-lab-arrow" />
|
||||
<polygon points="252,66 262,70 252,74" className="logo-lab-arrow" />
|
||||
<text x="28" y="28" className="logo-lab-wordmark">
|
||||
ELECTRON RARE
|
||||
</text>
|
||||
</svg>
|
||||
<p className="logo-lab-meta">Positionnement: pilotage par feedback</p>
|
||||
</article>
|
||||
{/* Glow filter for active node */}
|
||||
<filter id="sm-node-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="3.5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<article className="logo-lab-card">
|
||||
<p className="logo-lab-kicker">Marque 03</p>
|
||||
<h3 className="logo-lab-title">Stamp technique</h3>
|
||||
<svg viewBox="0 0 360 140" className="logo-lab-svg" role="img" aria-label="Electron rare stamp technique">
|
||||
<rect x="8" y="8" width="344" height="124" rx="14" className="logo-lab-bg" />
|
||||
<rect x="34" y="34" width="292" height="72" rx="8" className="logo-lab-stamp" />
|
||||
<text x="50" y="65" className="logo-lab-wordmark">
|
||||
ELECTRON RARE
|
||||
</text>
|
||||
<text x="50" y="89" className="logo-lab-subline">
|
||||
UNSTABLE BY DESIGN
|
||||
</text>
|
||||
<circle cx="294" cy="70" r="7" className="logo-lab-node logo-lab-node--amber" />
|
||||
</svg>
|
||||
<p className="logo-lab-meta">Positionnement: label industriel sur mesure</p>
|
||||
{CONNECTIONS.map((conn) => {
|
||||
const hot = conn.from === activeNodeId || conn.to === activeNodeId;
|
||||
return (
|
||||
<path
|
||||
key={`${conn.from}-${conn.to}`}
|
||||
d={conn.d}
|
||||
className={`system-map-link ${hot ? 'is-hot' : ''}`}
|
||||
markerEnd="url(#sm-arrow)"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{SYSTEM_NODES.map((item) => {
|
||||
const active = item.id === activeNodeId;
|
||||
const cx = item.x + NODE_W / 2;
|
||||
const cy = item.y + NODE_H / 2;
|
||||
return (
|
||||
<g
|
||||
key={item.id}
|
||||
className="system-map-node-group"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Noeud ${item.label}`}
|
||||
aria-pressed={active}
|
||||
filter={active ? 'url(#sm-node-glow)' : undefined}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelect(item.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<rect
|
||||
x={item.x}
|
||||
y={item.y}
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
rx={7}
|
||||
className={`system-map-node ${active ? 'is-active' : ''}`}
|
||||
/>
|
||||
<text
|
||||
x={cx}
|
||||
y={cy + 4}
|
||||
textAnchor="middle"
|
||||
className={`system-map-label ${active ? 'is-active' : ''}`}
|
||||
>
|
||||
{item.svgLabel ?? item.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Interface badges — highlighted for active node */}
|
||||
<div className="system-map-interfaces">
|
||||
{ALL_INTERFACES.map((iface) => (
|
||||
<span
|
||||
key={iface}
|
||||
className={`system-map-interface ${node.ifaces.includes(iface) ? 'is-active' : ''}`}
|
||||
>
|
||||
{iface}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Detail panel ───────────────────────────────── */}
|
||||
<article className="system-map-panel">
|
||||
<p className="system-map-panel-kicker">Noeud actif</p>
|
||||
<h3 key={activeNodeId} className="system-map-panel-title">
|
||||
{node.label}
|
||||
</h3>
|
||||
|
||||
<div className="system-map-tabs" role="tablist" aria-label="Details noeud">
|
||||
{TABS.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === id}
|
||||
className={`system-map-tab ${activeTab === id ? 'is-active' : ''}`}
|
||||
onClick={() => setActiveTab(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ul key={`${activeNodeId}-${activeTab}`} className="system-map-details">
|
||||
{tabValues[activeTab].map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
.board {
|
||||
border-color: rgba(119, 255, 198, 0.48);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.module {
|
||||
border: 1px solid rgba(122, 255, 197, 0.22);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(5, 22, 16, 0.8), rgba(5, 17, 13, 0.84));
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #9cf5cf;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0.28rem 0 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.switches {
|
||||
margin-top: 0.54rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.switch {
|
||||
border: 1px solid rgba(122, 255, 197, 0.28);
|
||||
border-radius: 999px;
|
||||
background: rgba(5, 20, 14, 0.7);
|
||||
color: #dcffee;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.22rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switchActive {
|
||||
border-color: rgba(146, 255, 213, 0.84);
|
||||
background: rgba(18, 57, 42, 0.9);
|
||||
}
|
||||
|
||||
.scope,
|
||||
.bode {
|
||||
margin-top: 0.6rem;
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(122, 255, 197, 0.24);
|
||||
background: rgba(4, 16, 12, 0.82);
|
||||
}
|
||||
|
||||
.scopeGrid {
|
||||
fill: none;
|
||||
stroke: rgba(118, 255, 168, 0.14);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.scopeWave {
|
||||
fill: none;
|
||||
stroke: rgba(156, 255, 192, 0.94);
|
||||
stroke-width: 2.2;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
filter: drop-shadow(0 0 6px rgba(118, 255, 168, 0.45));
|
||||
}
|
||||
|
||||
.copy {
|
||||
margin: 0.6rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.impact {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: #dfffee;
|
||||
}
|
||||
|
||||
.sliderLabel {
|
||||
margin-top: 0.52rem;
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.slider {
|
||||
margin-top: 0.35rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bodeCurve {
|
||||
fill: none;
|
||||
stroke: rgba(156, 255, 192, 0.94);
|
||||
stroke-width: 2.2;
|
||||
filter: drop-shadow(0 0 5px rgba(118, 255, 168, 0.4));
|
||||
}
|
||||
|
||||
.bodeCutoff {
|
||||
stroke: rgba(247, 196, 88, 0.76);
|
||||
stroke-width: 1.2;
|
||||
stroke-dasharray: 3 3;
|
||||
}
|
||||
|
||||
.bodeMarker {
|
||||
fill: rgba(205, 255, 225, 0.95);
|
||||
stroke: rgba(118, 255, 168, 0.8);
|
||||
stroke-width: 1.1;
|
||||
}
|
||||
|
||||
.bodePanel {
|
||||
margin-top: 0.52rem;
|
||||
border: 1px solid rgba(122, 255, 197, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: rgba(5, 17, 13, 0.74);
|
||||
font-size: 0.8rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.log {
|
||||
margin: 0.58rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 0.46rem;
|
||||
}
|
||||
|
||||
.logItem {
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(122, 255, 197, 0.2);
|
||||
background: rgba(5, 16, 12, 0.68);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.statusOk {
|
||||
border-color: rgba(132, 255, 184, 0.38);
|
||||
}
|
||||
|
||||
.statusWarn {
|
||||
border-color: rgba(248, 210, 111, 0.42);
|
||||
}
|
||||
|
||||
.logTitle {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #d9ffeb;
|
||||
}
|
||||
|
||||
.logDetail {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,122 +1,278 @@
|
||||
import { motion } from 'motion/react';
|
||||
import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
|
||||
import * as React from 'react';
|
||||
import { CtaDualRail } from '@/components/ui/cta-dual-rail';
|
||||
import styles from './LabNotes.module.css';
|
||||
|
||||
type LabEntry = {
|
||||
date: string;
|
||||
title: string;
|
||||
intent: string;
|
||||
system: string;
|
||||
build: string;
|
||||
test: string;
|
||||
result: string;
|
||||
href: string;
|
||||
event: (typeof TRACK_EVENTS)[keyof typeof TRACK_EVENTS];
|
||||
destination: string;
|
||||
external?: boolean;
|
||||
type ScopeMode = 'charge' | 'ripple' | 'ringing';
|
||||
type ScopeState = 'idle' | 'hover' | 'active';
|
||||
type LogStatus = 'ok' | 'warn';
|
||||
|
||||
const MODE_LABEL: Record<ScopeMode, string> = {
|
||||
charge: 'charge step',
|
||||
ripple: 'ripple',
|
||||
ringing: 'ringing'
|
||||
};
|
||||
|
||||
const entries: LabEntry[] = [
|
||||
{
|
||||
date: '2026-02-25',
|
||||
title: 'Sprint 01 - Navigation et lisibilite commerciale',
|
||||
intent: 'Reduire la friction de lecture pour un decideur en scan rapide.',
|
||||
system: 'Signal scroll -> section active -> priorite CTA.',
|
||||
build: 'Header sticky, progress line et hierarchie copy mobile/desktop.',
|
||||
test: 'Verification 390/768/1024/1440, clavier et focus-visible.',
|
||||
result: 'Temps de comprehension hero/nav reduit, parcours de contact clarifie.',
|
||||
href: '#top',
|
||||
event: TRACK_EVENTS.ctaHeroProfile,
|
||||
destination: '#top'
|
||||
},
|
||||
{
|
||||
date: '2026-02-27',
|
||||
title: 'Sprint 02 - Boucle conversion mission',
|
||||
intent: 'Rendre explicite la proposition sur mesure et la methode agile.',
|
||||
system: 'CTA profil expert -> mission -> cas d usage.',
|
||||
build: 'Micro-copy de preuves, labels actionnables et sequence commerciale.',
|
||||
test: 'Controle tracking GA4/GTM + consistence des labels de sections.',
|
||||
result: 'Decision de prise de contact mieux guidee sans changer le contrat events.',
|
||||
href: '#contact',
|
||||
event: TRACK_EVENTS.ctaHeroContact,
|
||||
destination: '#contact'
|
||||
},
|
||||
{
|
||||
date: '2026-03-01',
|
||||
title: 'Sprint 03 - Preuves publiques et qualification',
|
||||
intent: 'Renforcer la credibilite avant premier echange commercial.',
|
||||
system: 'Cas d usage + timeline + references externes verifiables.',
|
||||
build: 'Integration Bandcamp, GitHub et liens experts dans le parcours.',
|
||||
test: 'Sortants verifies, conformite nofollow/noopener et UX mobile.',
|
||||
result: 'Bloc confiance plus robuste pour qualifier une mission premium.',
|
||||
href: 'https://lelectron-fou.bandcamp.com/',
|
||||
event: TRACK_EVENTS.outboundBandcampProject,
|
||||
destination: 'bandcamp.com',
|
||||
external: true
|
||||
}
|
||||
const SCOPE_TEXT: Record<ScopeMode, string> = {
|
||||
charge: 'Step de charge: verifier la chute transitoire et le temps de recuperation rail.',
|
||||
ripple: 'Ripple: verifier bruit de fond alim et impact ADC/codec.',
|
||||
ringing: 'Ringing: verifier amortissement et stabilite de la boucle de puissance.'
|
||||
};
|
||||
|
||||
const LOG_ITEMS = [
|
||||
{ id: 'esd', label: 'ESD', status: 'ok' as const, detail: 'Pre-check IEC, pas de reset systeme.' },
|
||||
{ id: 'emi', label: 'EMI pre-scan', status: 'warn' as const, detail: 'Pic harmonique 3 a corriger via filtrage.' },
|
||||
{ id: 'therm', label: 'Thermique', status: 'ok' as const, detail: 'Marge > 12C sur scenario charge nominale.' }
|
||||
];
|
||||
|
||||
const SCOPE_TARGETS: ScopeMode[] = ['charge', 'ripple', 'ringing'];
|
||||
const BODE_TARGETS = [120, 480, 1200, 3200, 6400, 12000] as const;
|
||||
|
||||
function buildScopePath(mode: ScopeMode, state: ScopeState) {
|
||||
const scale = state === 'active' ? 1.1 : state === 'hover' ? 1.06 : 1;
|
||||
const base = 40;
|
||||
if (mode === 'ripple') {
|
||||
const amp = 6 * scale;
|
||||
return `M0 ${base} C20 ${base - amp} 40 ${base + amp} 60 ${base} C80 ${base - amp} 100 ${base + amp} 120 ${base} C140 ${base - amp} 160 ${base + amp} 180 ${base} C200 ${base - amp} 220 ${base + amp} 240 ${base}`;
|
||||
}
|
||||
if (mode === 'ringing') {
|
||||
const top = Math.max(16, Math.round(22 - (scale - 1) * 22));
|
||||
const bottom = Math.min(92, Math.round(88 + (scale - 1) * 22));
|
||||
return `M0 60 L44 60 L52 ${top} L58 ${bottom} L64 30 L70 74 L78 44 L88 60 L240 60`;
|
||||
}
|
||||
const stepHigh = Math.max(22, Math.round(30 - (scale - 1) * 20));
|
||||
return `M0 62 L48 62 L48 ${stepHigh} L86 ${stepHigh} L86 47 L124 47 L124 56 L240 56`;
|
||||
}
|
||||
|
||||
export function LabNotes() {
|
||||
const [mode, setMode] = React.useState<ScopeMode>('charge');
|
||||
const [scopeState, setScopeState] = React.useState<ScopeState>('idle');
|
||||
const [scopeScore, setScopeScore] = React.useState(0);
|
||||
const [scopeMessage, setScopeMessage] = React.useState('Objectif: alignez la forme demandee puis validez.');
|
||||
|
||||
const [freq, setFreq] = React.useState(3200);
|
||||
const [bodeScore, setBodeScore] = React.useState(0);
|
||||
const [bodeMessage, setBodeMessage] = React.useState('Objectif: caler la frequence sur la cible.');
|
||||
|
||||
const [triageAnswers, setTriageAnswers] = React.useState<Record<string, LogStatus>>({
|
||||
esd: 'ok',
|
||||
emi: 'ok',
|
||||
therm: 'ok'
|
||||
});
|
||||
const [triageScore, setTriageScore] = React.useState(0);
|
||||
const [triageMessage, setTriageMessage] = React.useState('Objectif: attribuez le bon statut a chaque test.');
|
||||
|
||||
const [virtualTime, setVirtualTime] = React.useState(0);
|
||||
|
||||
const scopeTarget = SCOPE_TARGETS[Math.floor(virtualTime / 5000) % SCOPE_TARGETS.length];
|
||||
const bodeTarget = BODE_TARGETS[Math.floor(virtualTime / 4000) % BODE_TARGETS.length];
|
||||
const totalScore = scopeScore + bodeScore + triageScore;
|
||||
|
||||
const bodeDb = React.useMemo(() => {
|
||||
const ratio = freq / 3200;
|
||||
const db = -10 * Math.log10(1 + ratio * ratio);
|
||||
return Math.max(-40, Math.min(3, db));
|
||||
}, [freq]);
|
||||
|
||||
const cutoffRatio = (Math.log10(3200) - Math.log10(20)) / (Math.log10(100000) - Math.log10(20));
|
||||
const cutoffX = 12 + cutoffRatio * 216;
|
||||
const bodeCurve = 'M12 20 C52 20 80 22 112 28 C142 34 164 42 182 52 C198 60 212 66 228 72';
|
||||
const markerRatio = (Math.log10(freq) - Math.log10(20)) / (Math.log10(100000) - Math.log10(20));
|
||||
const markerX = 12 + markerRatio * 216;
|
||||
const markerY = Math.max(20, Math.min(72, 22 + ((Math.abs(bodeDb) / 40) * 54)));
|
||||
|
||||
const validateScopeGame = React.useCallback(() => {
|
||||
if (mode === scopeTarget && scopeState === 'active') {
|
||||
setScopeScore((prev) => prev + 1);
|
||||
setScopeMessage('Succes: forme validee, point ajoute.');
|
||||
return;
|
||||
}
|
||||
setScopeMessage(`Pas valide. Cible: ${MODE_LABEL[scopeTarget]} + mode actif.`);
|
||||
}, [mode, scopeTarget, scopeState]);
|
||||
|
||||
const validateBodeGame = React.useCallback(() => {
|
||||
const diff = Math.abs(freq - bodeTarget);
|
||||
if (diff <= Math.max(60, bodeTarget * 0.08)) {
|
||||
setBodeScore((prev) => prev + 1);
|
||||
setBodeMessage('Succes: frequence verrouillee dans la marge.');
|
||||
return;
|
||||
}
|
||||
setBodeMessage(`Trop loin de la cible (${bodeTarget} Hz). Ajustez le curseur.`);
|
||||
}, [freq, bodeTarget]);
|
||||
|
||||
const setTriage = React.useCallback((id: string, status: LogStatus) => {
|
||||
setTriageAnswers((prev) => ({ ...prev, [id]: status }));
|
||||
}, []);
|
||||
|
||||
const validateTriageGame = React.useCallback(() => {
|
||||
const correct = LOG_ITEMS.every((item) => triageAnswers[item.id] === item.status);
|
||||
if (correct) {
|
||||
setTriageScore((prev) => prev + 1);
|
||||
setTriageMessage('Succes: triage valide, rapport exploitable.');
|
||||
return;
|
||||
}
|
||||
setTriageMessage('Triage incomplet: verifiez les statuts ESD/EMI/Thermique.');
|
||||
}, [triageAnswers]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setVirtualTime((prev) => prev + 1000);
|
||||
}, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const renderGameToText = () =>
|
||||
JSON.stringify({
|
||||
mode: 'lab-mini-games',
|
||||
coordinate_system: 'scope/bode SVG origin=(0,0) top-left, x right, y down',
|
||||
score: { total: totalScore, scope: scopeScore, bode: bodeScore, triage: triageScore },
|
||||
scope: { target: scopeTarget, selected: mode, state: scopeState },
|
||||
bode: { target_hz: bodeTarget, selected_hz: freq, gain_db: Number(bodeDb.toFixed(2)) },
|
||||
triage: triageAnswers,
|
||||
virtual_time_ms: virtualTime
|
||||
});
|
||||
|
||||
const advanceTime = (ms: number) => {
|
||||
setVirtualTime((prev) => prev + Math.max(0, ms));
|
||||
};
|
||||
|
||||
(window as typeof window & { render_game_to_text?: () => string; advanceTime?: (ms: number) => void }).render_game_to_text = renderGameToText;
|
||||
(window as typeof window & { render_game_to_text?: () => string; advanceTime?: (ms: number) => void }).advanceTime = advanceTime;
|
||||
|
||||
return () => {
|
||||
delete (window as typeof window & { render_game_to_text?: () => string }).render_game_to_text;
|
||||
delete (window as typeof window & { advanceTime?: (ms: number) => void }).advanceTime;
|
||||
};
|
||||
}, [totalScore, scopeScore, bodeScore, triageScore, scopeTarget, mode, scopeState, bodeTarget, freq, bodeDb, triageAnswers, virtualTime]);
|
||||
|
||||
return (
|
||||
<section id="lab-notes" aria-labelledby="lab-notes-title" className="section-anchor mt-5">
|
||||
<div className="circuit-board rounded-2xl p-5 md:p-6">
|
||||
<div className={`circuit-board rounded-2xl p-5 md:p-6 ${styles.board}`}>
|
||||
<div className="circuit-title-row">
|
||||
<span className="circuit-node circuit-node--green" aria-hidden="true" />
|
||||
<h2 id="lab-notes-title" className="m-0 text-3xl md:text-4xl">
|
||||
Lab notes / journal d'execution
|
||||
Preuves / mini jeux
|
||||
</h2>
|
||||
<span className="circuit-pinline" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p className="section-lead mb-0 mt-3">
|
||||
Debrief sprint par sprint: faits techniques, decisions prises et impact sur la mission.
|
||||
Trois mini jeux: scope, bode et triage validation. Score total: <strong>{totalScore}</strong>
|
||||
</p>
|
||||
|
||||
<p className="mb-0 mt-3 max-w-3xl studio-muted">
|
||||
Journal de fabrication: chaque note suit le protocole
|
||||
<code> Intent -> System -> Build -> Test -> Result</code>.
|
||||
</p>
|
||||
|
||||
<div className="lab-notes-grid mt-4">
|
||||
{entries.map((entry, index) => (
|
||||
<motion.article
|
||||
key={`${entry.date}-${entry.title}`}
|
||||
className="lab-note-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.4, delay: index * 0.06 }}
|
||||
>
|
||||
<p className="lab-note-date">{entry.date}</p>
|
||||
<h3 className="lab-note-title">{entry.title}</h3>
|
||||
<dl className="lab-note-flow">
|
||||
<dt>Intent</dt>
|
||||
<dd>{entry.intent}</dd>
|
||||
<dt>System</dt>
|
||||
<dd>{entry.system}</dd>
|
||||
<dt>Build</dt>
|
||||
<dd>{entry.build}</dd>
|
||||
<dt>Test</dt>
|
||||
<dd>{entry.test}</dd>
|
||||
<dt>Result</dt>
|
||||
<dd>{entry.result}</dd>
|
||||
</dl>
|
||||
<p className="mb-0 mt-3">
|
||||
<a
|
||||
href={entry.href}
|
||||
target={entry.external ? '_blank' : undefined}
|
||||
rel={entry.external ? 'noopener noreferrer' : undefined}
|
||||
{...trackAttrs(entry.event, entry.destination)}
|
||||
className="lab-note-link"
|
||||
<div className={`${styles.grid} mt-4`}>
|
||||
<article className={styles.module}>
|
||||
<p className={styles.kicker}>Mini jeu 01</p>
|
||||
<h3 className={styles.title}>Scope stabilizer</h3>
|
||||
<p className={styles.copy}>Cible: {MODE_LABEL[scopeTarget]} + mode actif.</p>
|
||||
<div className={styles.switches}>
|
||||
{(['charge', 'ripple', 'ringing'] as ScopeMode[]).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={`${styles.switch} ${mode === item ? styles.switchActive : ''}`}
|
||||
onClick={() => setMode(item)}
|
||||
>
|
||||
Ouvrir note reliee
|
||||
</a>
|
||||
</p>
|
||||
</motion.article>
|
||||
))}
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.switch} ${scopeState === 'active' ? styles.switchActive : ''}`}
|
||||
onClick={() => setScopeState((prev) => (prev === 'active' ? 'idle' : 'active'))}
|
||||
>
|
||||
{scopeState === 'active' ? 'mode actif' : 'activer lecture'}
|
||||
</button>
|
||||
<button type="button" className={styles.switch} onClick={validateScopeGame}>
|
||||
valider
|
||||
</button>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 240 100"
|
||||
className={styles.scope}
|
||||
role="img"
|
||||
aria-label="Waveform scope"
|
||||
onPointerEnter={() => setScopeState((prev) => (prev === 'active' ? 'active' : 'hover'))}
|
||||
onPointerLeave={() => setScopeState((prev) => (prev === 'active' ? 'active' : 'idle'))}
|
||||
>
|
||||
<path d="M0 20 H240 M0 40 H240 M0 60 H240 M0 80 H240" className={styles.scopeGrid} />
|
||||
<path d={buildScopePath(mode, scopeState)} className={styles.scopeWave} />
|
||||
</svg>
|
||||
<p className={styles.copy}>{SCOPE_TEXT[mode]}</p>
|
||||
<p className={styles.impact}>{scopeMessage}</p>
|
||||
</article>
|
||||
|
||||
<article className={styles.module}>
|
||||
<p className={styles.kicker}>Mini jeu 02</p>
|
||||
<h3 className={styles.title}>Bode lock</h3>
|
||||
<p className={styles.copy}>Cible: {bodeTarget} Hz</p>
|
||||
<label className={styles.sliderLabel} htmlFor="bode-slider">
|
||||
Frequence: {freq} Hz
|
||||
</label>
|
||||
<input
|
||||
id="bode-slider"
|
||||
type="range"
|
||||
min={20}
|
||||
max={100000}
|
||||
step={20}
|
||||
value={freq}
|
||||
onChange={(e) => setFreq(Number(e.target.value))}
|
||||
className={styles.slider}
|
||||
/>
|
||||
<button type="button" className={styles.switch} onClick={validateBodeGame}>
|
||||
verrouiller
|
||||
</button>
|
||||
<svg viewBox="0 0 240 84" className={styles.bode} role="img" aria-label="Bode response">
|
||||
<path d="M12 20 H228 M12 40 H228 M12 60 H228 M12 78 H228" className={styles.scopeGrid} />
|
||||
<path d={bodeCurve} className={styles.bodeCurve} />
|
||||
<line x1={cutoffX} y1="16" x2={cutoffX} y2="78" className={styles.bodeCutoff} />
|
||||
<circle cx={markerX} cy={markerY} r="4.2" className={styles.bodeMarker} />
|
||||
</svg>
|
||||
<div className={styles.bodePanel}>
|
||||
<p className="m-0">Gain estime: {bodeDb.toFixed(1)} dB</p>
|
||||
<p className="m-0">Implication: {freq < 3200 * 0.8 ? 'zone passante utile' : freq <= 3200 * 1.2 ? 'debut attenuation' : 'hors marge'}</p>
|
||||
<p className="m-0">{bodeMessage}</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className={styles.module}>
|
||||
<p className={styles.kicker}>Mini jeu 03</p>
|
||||
<h3 className={styles.title}>Validation triage</h3>
|
||||
<ol className={styles.log}>
|
||||
{LOG_ITEMS.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`${styles.logItem} ${item.status === 'ok' ? styles.statusOk : styles.statusWarn}`}
|
||||
>
|
||||
<p className={styles.logTitle}>{item.label}</p>
|
||||
<p className={styles.logDetail}>{item.detail}</p>
|
||||
<div className={styles.switches}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.switch} ${triageAnswers[item.id] === 'ok' ? styles.switchActive : ''}`}
|
||||
onClick={() => setTriage(item.id, 'ok')}
|
||||
>
|
||||
ok
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.switch} ${triageAnswers[item.id] === 'warn' ? styles.switchActive : ''}`}
|
||||
onClick={() => setTriage(item.id, 'warn')}
|
||||
>
|
||||
warn
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<button type="button" className={styles.switch} onClick={validateTriageGame}>
|
||||
evaluer triage
|
||||
</button>
|
||||
<p className={styles.impact}>{triageMessage}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<CtaDualRail className="mt-5" label="Passer du journal a une mission active" />
|
||||
<CtaDualRail className="mt-5" label="Passer des mini jeux au plan de mission" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import * as React from 'react';
|
||||
import { TRACK_EVENTS, trackAttrs, trackEvent } from '@/lib/tracking';
|
||||
import { useCopyContent } from '@/lib/copy';
|
||||
|
||||
type ProductType = 'iot' | 'audio' | 'rf' | 'indus';
|
||||
type Constraint = 'emi' | 'autonomie' | 'cout' | 'delai';
|
||||
type Deadline = '2-4' | '4-8' | '8+';
|
||||
|
||||
const PRODUCT_LABELS: Record<ProductType, string> = {
|
||||
iot: 'IoT / objet connecte',
|
||||
audio: 'Audio embarque',
|
||||
rf: 'RF / sans fil',
|
||||
indus: 'Systeme industriel'
|
||||
};
|
||||
|
||||
const CONSTRAINT_LABELS: Record<Constraint, string> = {
|
||||
emi: 'EMI / CEM',
|
||||
autonomie: 'Autonomie',
|
||||
cout: 'Cout BOM',
|
||||
delai: 'Delai'
|
||||
};
|
||||
|
||||
const DEADLINE_LABELS: Record<Deadline, string> = {
|
||||
'2-4': '2 a 4 semaines',
|
||||
'4-8': '4 a 8 semaines',
|
||||
'8+': '8+ semaines'
|
||||
};
|
||||
|
||||
function recommendedPlan(
|
||||
recommendations: {
|
||||
emiQuick: string;
|
||||
emiStandard: string;
|
||||
autonomie: string;
|
||||
cout: string;
|
||||
rf: string;
|
||||
defaultPlan: string;
|
||||
},
|
||||
product: ProductType,
|
||||
constraint: Constraint,
|
||||
deadline: Deadline
|
||||
) {
|
||||
const quick = deadline === '2-4';
|
||||
if (constraint === 'emi') {
|
||||
return quick ? recommendations.emiQuick : recommendations.emiStandard;
|
||||
}
|
||||
if (constraint === 'autonomie') {
|
||||
return recommendations.autonomie;
|
||||
}
|
||||
if (constraint === 'cout') {
|
||||
return recommendations.cout;
|
||||
}
|
||||
if (product === 'rf') {
|
||||
return recommendations.rf;
|
||||
}
|
||||
return recommendations.defaultPlan;
|
||||
}
|
||||
|
||||
export function MissionConfigurator() {
|
||||
const { copy } = useCopyContent();
|
||||
const missionCopy = copy.mission;
|
||||
const [product, setProduct] = React.useState<ProductType>('iot');
|
||||
const [constraint, setConstraint] = React.useState<Constraint>('emi');
|
||||
const [deadline, setDeadline] = React.useState<Deadline>('4-8');
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const hasSentInitialGenerated = React.useRef(false);
|
||||
|
||||
const briefText = React.useMemo(() => {
|
||||
return [
|
||||
missionCopy.planLabels.briefTitle,
|
||||
`${missionCopy.planLabels.productLine}: ${PRODUCT_LABELS[product]}`,
|
||||
`${missionCopy.planLabels.constraintLine}: ${CONSTRAINT_LABELS[constraint]}`,
|
||||
`${missionCopy.planLabels.deadlineLine}: ${DEADLINE_LABELS[deadline]}`,
|
||||
`${missionCopy.planLabels.planLine}: ${recommendedPlan(missionCopy.recommendations, product, constraint, deadline)}`
|
||||
].join('\n');
|
||||
}, [missionCopy, product, constraint, deadline]);
|
||||
|
||||
const copyBrief = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(briefText);
|
||||
localStorage.setItem('er_mission_brief', briefText);
|
||||
trackEvent(TRACK_EVENTS.funnelBriefCopied, {
|
||||
destination: '#mission-config',
|
||||
source: 'mission_config',
|
||||
brief_chars: briefText.length
|
||||
});
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
} catch (_error) {
|
||||
setCopied(false);
|
||||
}
|
||||
}, [briefText]);
|
||||
|
||||
const saveBrief = React.useCallback(() => {
|
||||
try {
|
||||
localStorage.setItem('er_mission_brief', briefText);
|
||||
} catch (_error) {
|
||||
// Ignore localStorage errors.
|
||||
}
|
||||
trackEvent(TRACK_EVENTS.funnelContactSubmitted, {
|
||||
destination: '#contact',
|
||||
source: 'mission_config',
|
||||
brief_chars: briefText.length
|
||||
});
|
||||
}, [briefText]);
|
||||
|
||||
React.useEffect(() => {
|
||||
trackEvent(TRACK_EVENTS.funnelBriefGenerated, {
|
||||
destination: '#mission-config',
|
||||
source: hasSentInitialGenerated.current ? 'mission_config_update' : 'mission_config_initial',
|
||||
product,
|
||||
constraint,
|
||||
deadline,
|
||||
brief_chars: briefText.length
|
||||
});
|
||||
hasSentInitialGenerated.current = true;
|
||||
}, [briefText, product, constraint, deadline]);
|
||||
|
||||
return (
|
||||
<section id="mission-config" className="section-anchor mission-config mt-5" aria-labelledby="mission-config-title">
|
||||
<div className="circuit-board rounded-2xl p-5 md:p-6">
|
||||
<div className="circuit-title-row">
|
||||
<span className="circuit-node circuit-node--green" aria-hidden="true" />
|
||||
<h2 id="mission-config-title" className="m-0 text-3xl md:text-4xl">
|
||||
{missionCopy.title}
|
||||
</h2>
|
||||
<span className="circuit-pinline" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="section-lead mb-0 mt-3">{missionCopy.subtitle}</p>
|
||||
|
||||
<div className="mission-config-grid mt-4">
|
||||
<fieldset className="mission-fieldset">
|
||||
<legend>{missionCopy.labels.productType}</legend>
|
||||
<div className="mission-options">
|
||||
{(Object.keys(PRODUCT_LABELS) as ProductType[]).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`mission-chip ${product === key ? 'is-active' : ''}`}
|
||||
onClick={() => setProduct(key)}
|
||||
>
|
||||
{PRODUCT_LABELS[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="mission-fieldset">
|
||||
<legend>{missionCopy.labels.constraint}</legend>
|
||||
<div className="mission-options">
|
||||
{(Object.keys(CONSTRAINT_LABELS) as Constraint[]).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`mission-chip ${constraint === key ? 'is-active' : ''}`}
|
||||
onClick={() => setConstraint(key)}
|
||||
>
|
||||
{CONSTRAINT_LABELS[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="mission-fieldset">
|
||||
<legend>{missionCopy.labels.deadline}</legend>
|
||||
<div className="mission-options">
|
||||
{(Object.keys(DEADLINE_LABELS) as Deadline[]).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`mission-chip ${deadline === key ? 'is-active' : ''}`}
|
||||
onClick={() => setDeadline(key)}
|
||||
>
|
||||
{DEADLINE_LABELS[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div className="mission-plan mt-4">
|
||||
<p className="mission-plan-kicker">{missionCopy.planLabels.recommended}</p>
|
||||
<p className="mission-plan-copy">{recommendedPlan(missionCopy.recommendations, product, constraint, deadline)}</p>
|
||||
<pre className="mission-plan-brief" aria-label="Brief genere">
|
||||
{briefText}
|
||||
</pre>
|
||||
<div className="mission-plan-actions">
|
||||
<button type="button" className="mission-cta mission-cta--secondary" onClick={copyBrief}>
|
||||
{copied ? missionCopy.planLabels.copiedBrief : missionCopy.planLabels.copyBrief}
|
||||
</button>
|
||||
<a
|
||||
href="#contact"
|
||||
className="mission-cta mission-cta--primary"
|
||||
onClick={saveBrief}
|
||||
{...trackAttrs(TRACK_EVENTS.ctaHeroContact, '#contact')}
|
||||
>
|
||||
{missionCopy.planLabels.sendBrief}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { CtaDualRail } from '@/components/ui/cta-dual-rail';
|
||||
|
||||
type Deliverable = {
|
||||
title: string;
|
||||
detail: string;
|
||||
impact: string;
|
||||
};
|
||||
|
||||
const DELIVERABLES: Deliverable[] = [
|
||||
{
|
||||
title: 'Architecture',
|
||||
detail: 'Carte systeme, interfaces critiques, risques et arbitrages assumes.',
|
||||
impact: 'Vision partagee entre produit, tech et execution.'
|
||||
},
|
||||
{
|
||||
title: 'Prototype instrumente',
|
||||
detail: 'Points de mesure, logs et protocoles de test prevus des le build.',
|
||||
impact: 'Apprendre vite avec des faits, pas des suppositions.'
|
||||
},
|
||||
{
|
||||
title: 'Validation',
|
||||
detail: 'Criteres Go/NoGo, rapport actionnable et priorisation risque x valeur.',
|
||||
impact: 'Decisions rapides, defendables et tracables.'
|
||||
},
|
||||
{
|
||||
title: 'Handoff',
|
||||
detail: 'Documentation transmissible, testabilite et trajectoire vers la production.',
|
||||
impact: 'Passage propre vers BE/CTO/industrialisation.'
|
||||
}
|
||||
];
|
||||
|
||||
export function OfferDeliverables() {
|
||||
return (
|
||||
<section id="offre-systeme" className="section-anchor mt-5" aria-labelledby="offer-title">
|
||||
<div className="circuit-board rounded-2xl p-5 md:p-6">
|
||||
<div className="circuit-title-row">
|
||||
<span className="circuit-node circuit-node--green" aria-hidden="true" />
|
||||
<h2 id="offer-title" className="m-0 text-3xl md:text-4xl">
|
||||
Offre & livrables
|
||||
</h2>
|
||||
<span className="circuit-pinline" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p className="section-lead mb-0 mt-3">
|
||||
Ce que vous recevez est simple a lire, utile a decider, et exploitable en execution.
|
||||
</p>
|
||||
|
||||
<div className="offer-grid mt-4" role="list" aria-label="Livrables mission">
|
||||
{DELIVERABLES.map((item) => (
|
||||
<article key={item.title} className="offer-card" role="listitem">
|
||||
<p className="offer-card-title">{item.title}</p>
|
||||
<p className="offer-card-detail">{item.detail}</p>
|
||||
<p className="offer-card-impact">Impact: {item.impact}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="offer-signature mb-0 mt-4">
|
||||
Pas une carte de plus. Une partition technique: chaque mesure produit une decision.
|
||||
</p>
|
||||
|
||||
<CtaDualRail className="mt-4" label="Transformer ces livrables en mission" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as React from 'react';
|
||||
|
||||
type StateKey = 's01' | 's02';
|
||||
|
||||
type ProcessState = {
|
||||
key: StateKey;
|
||||
label: string;
|
||||
title: string;
|
||||
inputs: string[];
|
||||
outputs: string[];
|
||||
goNoGo: string;
|
||||
};
|
||||
|
||||
const STATES: ProcessState[] = [
|
||||
{
|
||||
key: 's01',
|
||||
label: 'Sprint 01',
|
||||
title: 'Architecture & risques',
|
||||
inputs: ['Brief produit', 'Contraintes business', 'Contexte technique existant'],
|
||||
outputs: ['Archi cible', 'Risk map priorisee', 'Plan de test initial'],
|
||||
goNoGo: 'GO si architecture validee et risques critiques couverts par plan de mesure.'
|
||||
},
|
||||
{
|
||||
key: 's02',
|
||||
label: 'Sprint 02',
|
||||
title: 'Prototype & tests',
|
||||
inputs: ['Archi validee', 'BOM cible', 'Jeu de tests de validation'],
|
||||
outputs: ['Proto fonctionnel', 'Resultats mesure/test', 'Decision production ou iteration'],
|
||||
goNoGo: 'GO si performances tenues et ecarts documentes avec actions de mitigation.'
|
||||
}
|
||||
];
|
||||
|
||||
export function ProcessStateMachine() {
|
||||
const [active, setActive] = React.useState<StateKey>('s01');
|
||||
const current = STATES.find((state) => state.key === active) || STATES[0];
|
||||
|
||||
return (
|
||||
<section id="process-etats" className="section-anchor mt-5" aria-labelledby="process-title">
|
||||
<div className="circuit-board rounded-2xl p-5 md:p-6">
|
||||
<div className="circuit-title-row">
|
||||
<span className="circuit-node circuit-node--magenta" aria-hidden="true" />
|
||||
<h2 id="process-title" className="m-0 text-3xl md:text-4xl">
|
||||
Process en state machine
|
||||
</h2>
|
||||
<span className="circuit-pinline" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p className="section-lead mb-0 mt-3">
|
||||
Cliquez un etat pour voir les entrees/sorties et le critere go-no-go associe.
|
||||
</p>
|
||||
|
||||
<div className="process-tabs mt-4" role="tablist" aria-label="Etats process">
|
||||
{STATES.map((state) => (
|
||||
<button
|
||||
key={state.key}
|
||||
type="button"
|
||||
className={`process-tab ${active === state.key ? 'is-active' : ''}`}
|
||||
onClick={() => setActive(state.key)}
|
||||
role="tab"
|
||||
aria-selected={active === state.key}
|
||||
aria-controls={`process-panel-${state.key}`}
|
||||
id={`process-tab-${state.key}`}
|
||||
>
|
||||
<span>{state.label}</span>
|
||||
<strong>{state.title}</strong>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="process-panel mt-4"
|
||||
id={`process-panel-${current.key}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`process-tab-${current.key}`}
|
||||
>
|
||||
<div className="process-col">
|
||||
<p className="process-kicker">Inputs</p>
|
||||
<ul>
|
||||
{current.inputs.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="process-col">
|
||||
<p className="process-kicker">Outputs</p>
|
||||
<ul>
|
||||
{current.outputs.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="process-col process-col--decision">
|
||||
<p className="process-kicker">Go / No-Go</p>
|
||||
<p>{current.goNoGo}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3,54 +3,46 @@ import { Card } from '@/components/ui/card';
|
||||
import { CtaDualRail } from '@/components/ui/cta-dual-rail';
|
||||
import type { CtaLink } from '@/lib/types';
|
||||
import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
|
||||
import { CASE_STUDIES } from '@/content/caseStudies';
|
||||
|
||||
type ProjectItem = {
|
||||
title: string;
|
||||
stage: string;
|
||||
description: string;
|
||||
trace: string;
|
||||
link?: CtaLink;
|
||||
};
|
||||
|
||||
const projects: ProjectItem[] = [
|
||||
{
|
||||
title: "L'électron rare",
|
||||
stage: 'Cas 01 - Studio core',
|
||||
description:
|
||||
'Contexte: besoin de plateforme R&D transverse. Intervention: architecture electronique et prototypage iteratif. Resultat: base systeme reutilisable pour nouvelles missions.',
|
||||
link: {
|
||||
label: 'Voir profil LinkedIn',
|
||||
href: 'https://fr.linkedin.com/in/electron-rare',
|
||||
event: TRACK_EVENTS.outboundLinkedinProject,
|
||||
destination: 'linkedin.com',
|
||||
external: true
|
||||
}
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
'studio-core': 'Studio core',
|
||||
'design-produit': 'Design produit',
|
||||
'rd-sonore': 'R&D sonore'
|
||||
};
|
||||
|
||||
const EXTERNAL_LINKS: Record<string, CtaLink> = {
|
||||
'studio-core': {
|
||||
label: 'Voir profil LinkedIn',
|
||||
href: 'https://fr.linkedin.com/in/electron-rare',
|
||||
event: TRACK_EVENTS.outboundLinkedinProject,
|
||||
destination: 'linkedin.com',
|
||||
external: true
|
||||
},
|
||||
{
|
||||
title: 'Bureau conception produit/systeme',
|
||||
stage: 'Cas 02 - Design produit',
|
||||
description:
|
||||
"Contexte: idee produit sous contrainte planning. Intervention: cadrage, maquette fonctionnelle et plan d'industrialisation. Resultat: decisions rapides cote CTO et business."
|
||||
},
|
||||
{
|
||||
title: 'Electron Fou (noise)',
|
||||
stage: 'Cas 03 - R&D sonore',
|
||||
description:
|
||||
'Contexte: experimentation artistique et diffusion publique. Intervention: pipeline de production son + outillage. Resultat: preuves publiees et processus reproduisible.',
|
||||
link: {
|
||||
label: 'Ecouter sur Bandcamp',
|
||||
href: 'https://lelectron-fou.bandcamp.com/',
|
||||
event: TRACK_EVENTS.outboundBandcampProject,
|
||||
destination: 'bandcamp.com',
|
||||
external: true
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Hardware/Firmware references',
|
||||
stage: 'Cas 04 - Open source',
|
||||
description:
|
||||
'Contexte: besoin de credibilite technique ouverte. Intervention: publication de stacks hardware/firmware terrain. Resultat: base de preuve consultable pour qualification mission.'
|
||||
'rd-sonore': {
|
||||
label: 'Ecouter sur Bandcamp',
|
||||
href: 'https://lelectron-fou.bandcamp.com/',
|
||||
event: TRACK_EVENTS.outboundBandcampProject,
|
||||
destination: 'bandcamp.com',
|
||||
external: true
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const projects: ProjectItem[] = CASE_STUDIES.map((study, index) => ({
|
||||
title: study.title,
|
||||
stage: `Cas 0${index + 1} - ${STAGE_LABELS[study.slug] ?? study.slug}`,
|
||||
trace: study.trace,
|
||||
description: `Contexte: ${study.context} Intervention: ${study.intervention} Resultat: ${study.result}`,
|
||||
link: EXTERNAL_LINKS[study.slug]
|
||||
}));
|
||||
|
||||
const topReferences = [
|
||||
'https://github.com/KomplexKapharnaum/KXKM_ESP32_Audio_Battery_hardware',
|
||||
@@ -91,6 +83,7 @@ export function Projects() {
|
||||
>
|
||||
<Card className="h-full">
|
||||
<p className="project-stage">{project.stage}</p>
|
||||
<p className="project-trace">{project.trace}</p>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="circuit-node" aria-hidden="true" />
|
||||
<h3 className="m-0 text-xl">{project.title}</h3>
|
||||
|
||||
@@ -20,16 +20,16 @@ const steps: PipelineStep[] = [
|
||||
{
|
||||
label: 'Architecture',
|
||||
detail: 'Flux, interfaces, points de mesure et hypotheses a valider',
|
||||
href: '#projets',
|
||||
href: '#lab-notes',
|
||||
event: TRACK_EVENTS.ctaHeroProjects,
|
||||
destination: '#projets'
|
||||
destination: '#lab-notes'
|
||||
},
|
||||
{
|
||||
label: 'Prototype sprint',
|
||||
detail: 'Execution en cycle court: build, test, calibration et feedback',
|
||||
href: '#projets',
|
||||
href: '#lab-notes',
|
||||
event: TRACK_EVENTS.ctaHeroProjects,
|
||||
destination: '#projets'
|
||||
destination: '#lab-notes'
|
||||
},
|
||||
{
|
||||
label: 'Delivery',
|
||||
|
||||
@@ -14,7 +14,7 @@ const proofItems = [
|
||||
},
|
||||
{
|
||||
label: 'References verifiables',
|
||||
value: 'LinkedIn, Malt, Bandcamp, GitHub',
|
||||
value: 'LinkedIn, Bandcamp, GitHub',
|
||||
detail: 'Cas et stacks consultables avant prise de contact.'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -8,7 +8,6 @@ type CtaDualRailProps = {
|
||||
};
|
||||
|
||||
const LINKEDIN_HREF = 'https://fr.linkedin.com/in/electron-rare';
|
||||
const MALT_HREF = 'https://www.malt.com/profile/clementsaillant';
|
||||
|
||||
export function CtaDualRail({ label = 'Canaux mission', className }: CtaDualRailProps) {
|
||||
return (
|
||||
@@ -30,19 +29,17 @@ export function CtaDualRail({ label = 'Canaux mission', className }: CtaDualRail
|
||||
</Button>
|
||||
<Button asChild variant="secondary" size="md" className="dual-cta-btn dual-cta-btn--secondary">
|
||||
<a
|
||||
href={MALT_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...trackAttrs(TRACK_EVENTS.outboundMaltContact, 'malt.com')}
|
||||
href="#contact"
|
||||
{...trackAttrs(TRACK_EVENTS.ctaHeroContact, '#contact')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="circuit-node circuit-node--green" aria-hidden="true" />
|
||||
Malt brief mission
|
||||
Form brief mission
|
||||
</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="dual-cta-helper">LinkedIn pour un echange direct. Malt pour un brief mission formel.</p>
|
||||
<p className="dual-cta-helper">LinkedIn pour un echange direct. Form brief pour cadrer la mission.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
export type CaseStudy = {
|
||||
slug: 'studio-core' | 'design-produit' | 'rd-sonore';
|
||||
title: string;
|
||||
trace: string;
|
||||
context: string;
|
||||
intervention: string;
|
||||
result: string;
|
||||
impactLabel: string;
|
||||
impactValue: string;
|
||||
proofTitle: string;
|
||||
proofBody: string;
|
||||
proofAsset: string;
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
};
|
||||
|
||||
export const CASE_STUDIES: CaseStudy[] = [
|
||||
{
|
||||
slug: 'studio-core',
|
||||
title: "L'electron rare",
|
||||
trace: 'POWER -> MCU -> TEST',
|
||||
context: 'Besoin: plateforme R&D transverse, reutilisable sur plusieurs missions.',
|
||||
intervention: 'Architecture electronique + prototypage iteratif + plan de test cadre.',
|
||||
result: 'Base systeme stable, exploitable pour accelerer les prochaines executions.',
|
||||
impactLabel: 'Impact delivery',
|
||||
impactValue: 'Reduction du temps de cadrage technique de 2 semaines a 4 jours.',
|
||||
proofTitle: 'Preuve: benchmark I/O + EMI + plan de validation',
|
||||
proofBody:
|
||||
'Livrable anonymise avec hypotheses, mesures de reference, risques prioritaires et decisions Go/NoGo.',
|
||||
proofAsset: '/assets/da/mesure-rig.svg',
|
||||
ctaLabel: 'Envoyer un brief',
|
||||
ctaHref: '#contact'
|
||||
},
|
||||
{
|
||||
slug: 'design-produit',
|
||||
title: 'Bureau conception produit/systeme',
|
||||
trace: 'CAPTEURS -> BUS -> DFM',
|
||||
context: "Besoin: idee produit avec contrainte planning et enjeux CTO/business.",
|
||||
intervention: "Cadrage, maquette fonctionnelle, puis trajectoire d'industrialisation.",
|
||||
result: 'Decisions plus rapides cote produit, tech et execution projet.',
|
||||
impactLabel: 'Impact risque',
|
||||
impactValue: 'Risque planning abaisse par validation precoce des interfaces critiques.',
|
||||
proofTitle: 'Preuve: matrice contraintes x arbitrages',
|
||||
proofBody:
|
||||
'Document de decision qui relie exigences metier, contraintes electronics et priorites de sprint.',
|
||||
proofAsset: '/assets/da/mesure-rig.svg',
|
||||
ctaLabel: 'Envoyer un brief',
|
||||
ctaHref: '#contact'
|
||||
},
|
||||
{
|
||||
slug: 'rd-sonore',
|
||||
title: 'Electron Fou (noise)',
|
||||
trace: 'AUDIO -> RF -> VALIDATION',
|
||||
context: 'Besoin: experimentation artistique avec diffusion publique et exigence de robustesse.',
|
||||
intervention: 'Pipeline de production sonore + outillage de validation reproductible.',
|
||||
result: 'Preuves publiees et processus reutilisable pour de nouvelles productions.',
|
||||
impactLabel: 'Impact fiabilite',
|
||||
impactValue: 'Stabilite de performance maintenue sur usage terrain et sessions longues.',
|
||||
proofTitle: 'Preuve: protocole audio + logs validation',
|
||||
proofBody:
|
||||
'Capture de protocole de test avec criteres de stabilite, mesures de bruit et decisions de correction.',
|
||||
proofAsset: '/assets/da/mesure-rig.svg',
|
||||
ctaLabel: 'Envoyer un brief',
|
||||
ctaHref: '#contact'
|
||||
}
|
||||
];
|
||||
|
||||
export function getCaseBySlug(slug: string) {
|
||||
return CASE_STUDIES.find((item) => item.slug === slug);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
export type CopyVariant = 'public' | 'cto';
|
||||
|
||||
type MissionLabels = {
|
||||
productType: string;
|
||||
constraint: string;
|
||||
deadline: string;
|
||||
};
|
||||
|
||||
type MissionPlanLabels = {
|
||||
recommended: string;
|
||||
copyBrief: string;
|
||||
copiedBrief: string;
|
||||
sendBrief: string;
|
||||
briefTitle: string;
|
||||
productLine: string;
|
||||
constraintLine: string;
|
||||
deadlineLine: string;
|
||||
planLine: string;
|
||||
};
|
||||
|
||||
type MissionRecommendations = {
|
||||
emiQuick: string;
|
||||
emiStandard: string;
|
||||
autonomie: string;
|
||||
cout: string;
|
||||
rf: string;
|
||||
defaultPlan: string;
|
||||
};
|
||||
|
||||
export type CopyContent = {
|
||||
hero: {
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
about: {
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
mission: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
labels: MissionLabels;
|
||||
planLabels: MissionPlanLabels;
|
||||
recommendations: MissionRecommendations;
|
||||
};
|
||||
contact: {
|
||||
title: string;
|
||||
lead: string;
|
||||
helpText: string;
|
||||
formKicker: string;
|
||||
copyBrief: string;
|
||||
sendByMail: string;
|
||||
dmRailLabel: string;
|
||||
mailSubject: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const COPY_VARIANTS: Record<CopyVariant, CopyContent> = {
|
||||
public: {
|
||||
hero: {
|
||||
title: 'From idea to measured reality.',
|
||||
body:
|
||||
"Une idee, c'est une etincelle. Un produit, c'est une orchestration. Je transforme votre intention en systeme vivant: architecture claire, prototype instrumente, mesures qui tranchent, puis un plan propre pour avancer vers la production."
|
||||
},
|
||||
about: {
|
||||
title: 'From idea to measured reality.',
|
||||
paragraphs: [
|
||||
"Entre une idee et un produit, il y a un moment ou tout doit tenir: ca marche aujourd'hui, demain, et dans des conditions reelles. C'est la que beaucoup de projets bloquent, non pas par manque d'inspiration, mais parce qu'il manque une methode pour transformer une intuition en realite solide.",
|
||||
"Mon role, c'est de vous guider de l'idee a un premier modele qui dit la verite: ce qui marche, ce qui ne marche pas, et pourquoi. On construit, on teste, on corrige, et on stabilise.",
|
||||
"Je ne fais pas juste fabriquer un truc: je compose l'ensemble pour que tout fonctionne harmonieusement, et je vous laisse avec un resultat exploitable et un plan clair pour aller plus loin."
|
||||
]
|
||||
},
|
||||
mission: {
|
||||
title: 'Mission configurator (30 secondes)',
|
||||
subtitle: 'Trois choix rapides pour cadrer un brief concret, comprenable et actionnable.',
|
||||
labels: {
|
||||
productType: 'Type produit',
|
||||
constraint: 'Contrainte #1',
|
||||
deadline: 'Deadline'
|
||||
},
|
||||
planLabels: {
|
||||
recommended: 'Plan recommande',
|
||||
copyBrief: 'Copier le brief',
|
||||
copiedBrief: 'Brief copie',
|
||||
sendBrief: 'Envoyer le brief',
|
||||
briefTitle: 'Brief mission produit',
|
||||
productLine: 'Produit',
|
||||
constraintLine: 'Contrainte prioritaire',
|
||||
deadlineLine: 'Deadline',
|
||||
planLine: 'Plan recommande'
|
||||
},
|
||||
recommendations: {
|
||||
emiQuick:
|
||||
'Sprint 01: clarifier les points sensibles et lancer des tests simples de perturbation. Sprint 02: corriger les causes majeures puis revalider.',
|
||||
emiStandard:
|
||||
'Construire une architecture stable, verifier les signaux critiques et valider la robustesse avec un plan de tests progressif.',
|
||||
autonomie:
|
||||
"Mesurer la consommation dans les usages reels, optimiser les postes lourds, puis valider l'autonomie cible avec marge.",
|
||||
cout: 'Identifier les composants les plus chers, proposer des alternatives, puis verifier que la qualite reste au niveau.',
|
||||
rf:
|
||||
'Verifier la fiabilite de la liaison, ajuster antenne/layout si besoin, puis confirmer les performances dans un environnement reel.',
|
||||
defaultPlan:
|
||||
"Cadrage en 2 sprints: objectif clair, prototype fonctionnel, tests concrets, puis decision de suite sur des preuves."
|
||||
}
|
||||
},
|
||||
contact: {
|
||||
title: 'Demarrer une mission',
|
||||
lead: 'Deux voies simples: DM LinkedIn direct ou form bref pre-rempli.',
|
||||
helpText:
|
||||
"Le configurator remplit deja la base du brief. Vous pouvez le copier, l'ajuster, puis l'envoyer pour demarrer vite.",
|
||||
formKicker: 'Voie 2 / Form bref',
|
||||
copyBrief: 'Copier le brief',
|
||||
sendByMail: 'Envoyer par mail',
|
||||
dmRailLabel: 'Voie 1 / DM LinkedIn',
|
||||
mailSubject: 'Brief mission produit'
|
||||
}
|
||||
},
|
||||
cto: {
|
||||
hero: {
|
||||
title: 'From idea to measured reality.',
|
||||
body:
|
||||
'Je transforme une intention produit en systeme maitrise: architecture, prototype instrumente, mesures, decisions. On remplace les suppositions par des preuves (tests, risques, arbitrages) et on securise la trajectoire. Objectif: un prototype credible et un handoff propre vers l industrialisation.'
|
||||
},
|
||||
about: {
|
||||
title: 'From idea to measured reality.',
|
||||
paragraphs: [
|
||||
"Un produit electronique ne se resume pas a une carte: c'est un systeme ou energie, signaux, timing, interfaces et validation doivent rester coherents. Les derives viennent rarement d'un composant unique, elles viennent des interactions: alimentations qui decrochent, integrite de signal fragile, EMI, thermique, RF, testabilite.",
|
||||
"Je conçois l'electronique comme une orchestration: architecture (interfaces, contraintes, risques, priorites), prototype (bring-up, instrumentation, plan de mesure), validation (Go/NoGo, logs, rapport, decisions tranchees), handoff (DFM/industrialisation, documentation, plan d'iteration).",
|
||||
"Mon role n'est pas de faire du design. C'est de mettre le systeme sous controle, pour accelerer les bonnes decisions et reduire le risque jusqu'au passage production."
|
||||
]
|
||||
},
|
||||
mission: {
|
||||
title: 'Mission configurator (30 secondes)',
|
||||
subtitle: 'Cadrez un brief exploitable: contraintes, risques dominants et trajectoire de validation.',
|
||||
labels: {
|
||||
productType: 'Type produit',
|
||||
constraint: 'Contrainte dominante',
|
||||
deadline: 'Horizon delivery'
|
||||
},
|
||||
planLabels: {
|
||||
recommended: 'Plan recommande',
|
||||
copyBrief: 'Copier le brief',
|
||||
copiedBrief: 'Brief copie',
|
||||
sendBrief: 'Envoyer le brief',
|
||||
briefTitle: 'Brief mission electronique',
|
||||
productLine: 'Produit',
|
||||
constraintLine: 'Contrainte prioritaire',
|
||||
deadlineLine: 'Deadline',
|
||||
planLine: 'Plan recommande'
|
||||
},
|
||||
recommendations: {
|
||||
emiQuick:
|
||||
'Sprint 01: architecture alim + plan de mesure EMI prioritaire. Sprint 02: proto cible + pre-scan.',
|
||||
emiStandard:
|
||||
'Architecture CEM complete, routage critique, puis validation pre-scan et plan de mitigation.',
|
||||
autonomie:
|
||||
'Profil de charge + budget energie, puis arbitrage MCU/RF et validation consommation en usage reel.',
|
||||
cout: 'Optimisation BOM par blocs critiques, equivalences composants et validation risque appro.',
|
||||
rf: 'Cadre RF: topologie antenne, isolation bruit, essais liaison et robustesse en environnement reel.',
|
||||
defaultPlan:
|
||||
'Cadrage mission en 2 sprints: risques systeme, prototype fonctionnel, puis go/no-go industriel.'
|
||||
}
|
||||
},
|
||||
contact: {
|
||||
title: 'Demarrer une mission',
|
||||
lead: 'Deux voies: DM LinkedIn direct ou form bref pre-rempli.',
|
||||
helpText:
|
||||
'Le configurator renseigne automatiquement le brief. Vous pouvez copier ce texte ou l envoyer via mail.',
|
||||
formKicker: 'Voie 2 / Form bref',
|
||||
copyBrief: 'Copier le brief',
|
||||
sendByMail: 'Envoyer par mail',
|
||||
dmRailLabel: 'Voie 1 / DM LinkedIn',
|
||||
mailSubject: 'Brief mission electronique'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -4,14 +4,26 @@ import { GTM_CONTAINER_ID_DEFAULT, SITE_META, SITE_OG_IMAGE_URL, SITE_URL } from
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
canonicalPath?: string;
|
||||
ogTitle?: string;
|
||||
ogDescription?: string;
|
||||
ogImage?: string;
|
||||
twitterDescription?: string;
|
||||
extraJsonLd?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title = SITE_META.title,
|
||||
description = SITE_META.description
|
||||
description = SITE_META.description,
|
||||
canonicalPath = '/',
|
||||
ogTitle = title,
|
||||
ogDescription = SITE_META.ogDescription,
|
||||
ogImage = SITE_OG_IMAGE_URL,
|
||||
twitterDescription = SITE_META.twitterDescription,
|
||||
extraJsonLd
|
||||
} = Astro.props;
|
||||
|
||||
const canonical = SITE_URL;
|
||||
const canonical = `${SITE_URL}${canonicalPath.replace(/^\//, '')}`;
|
||||
const gtmId = import.meta.env.PUBLIC_GTM_CONTAINER_ID || GTM_CONTAINER_ID_DEFAULT;
|
||||
const structuredDataJsonLd = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
@@ -32,7 +44,6 @@ const structuredDataJsonLd = JSON.stringify({
|
||||
logo: `${canonical}assets/brand/logo-mark.png`,
|
||||
sameAs: [
|
||||
'https://fr.linkedin.com/in/electron-rare',
|
||||
'https://www.malt.com/profile/clementsaillant',
|
||||
'https://lelectron-fou.bandcamp.com/'
|
||||
]
|
||||
},
|
||||
@@ -50,7 +61,6 @@ const structuredDataJsonLd = JSON.stringify({
|
||||
},
|
||||
sameAs: [
|
||||
'https://fr.linkedin.com/in/electron-rare',
|
||||
'https://www.malt.com/profile/clementsaillant',
|
||||
'https://lelectron-fou.bandcamp.com/'
|
||||
]
|
||||
}
|
||||
@@ -78,18 +88,18 @@ const structuredDataJsonLd = JSON.stringify({
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="fr_FR" />
|
||||
<meta property="og:site_name" content="L'électron rare" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:title" content={ogTitle} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:description" content={SITE_META.ogDescription} />
|
||||
<meta property="og:image" content={SITE_OG_IMAGE_URL} />
|
||||
<meta property="og:description" content={ogDescription} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="L'électron rare - performances et créations audiovisuelles" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={SITE_META.twitterDescription} />
|
||||
<meta name="twitter:image" content={SITE_OG_IMAGE_URL} />
|
||||
<meta name="twitter:title" content={ogTitle} />
|
||||
<meta name="twitter:description" content={twitterDescription} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
@@ -125,49 +135,41 @@ const structuredDataJsonLd = JSON.stringify({
|
||||
(function () {
|
||||
var root = document.documentElement;
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var raw = params.get('da') || params.get('variant') || params.get('da-variant') || '';
|
||||
var uiRaw = params.get('ui') || params.get('sprint') || '';
|
||||
var normalized = (raw || '').toLowerCase().trim();
|
||||
var copyRaw = params.get('copy') || '';
|
||||
var uiNormalized = (uiRaw || '').toLowerCase().trim();
|
||||
var allowed = {
|
||||
v1: 1,
|
||||
v2: 1,
|
||||
v3: 1,
|
||||
v4: 1,
|
||||
v5: 1,
|
||||
v6: 1,
|
||||
v7: 1,
|
||||
v8: 1,
|
||||
v9: 1,
|
||||
v10: 1,
|
||||
v11: 1,
|
||||
v12: 1,
|
||||
'1': 1,
|
||||
'2': 1,
|
||||
'3': 1,
|
||||
'4': 1,
|
||||
'5': 1,
|
||||
'6': 1,
|
||||
'7': 1,
|
||||
'8': 1,
|
||||
'9': 1,
|
||||
'10': 1,
|
||||
'11': 1,
|
||||
'12': 1
|
||||
};
|
||||
|
||||
var requested = normalized in allowed ? normalized : '';
|
||||
var fallbackVariant = 'v12';
|
||||
|
||||
if (requested && /^[1-9]([0-9])?$/.test(requested) && requested.length <= 2) {
|
||||
requested = 'v' + requested;
|
||||
}
|
||||
var copyNormalized = (copyRaw || '').toLowerCase().trim();
|
||||
var lockedVariant = 'v12';
|
||||
|
||||
var uiAllowed = { a: 1, b: 1 };
|
||||
var uiSprint = uiNormalized in uiAllowed ? uiNormalized : 'b';
|
||||
var copyAllowed = { public: 1, cto: 1 };
|
||||
var copyFromStorage = '';
|
||||
try {
|
||||
copyFromStorage = (window.localStorage.getItem('er_copy_variant') || '').toLowerCase().trim();
|
||||
} catch (_error) {
|
||||
copyFromStorage = '';
|
||||
}
|
||||
var copyVariant = 'public';
|
||||
var copyAssignmentSource = 'default';
|
||||
if (copyNormalized in copyAllowed) {
|
||||
copyVariant = copyNormalized;
|
||||
copyAssignmentSource = 'query';
|
||||
} else if (copyFromStorage in copyAllowed) {
|
||||
copyVariant = copyFromStorage;
|
||||
copyAssignmentSource = 'storage';
|
||||
}
|
||||
|
||||
root.setAttribute('data-da-variant', requested || fallbackVariant);
|
||||
root.setAttribute('data-da-variant', lockedVariant);
|
||||
root.setAttribute('data-ui-sprint', uiSprint);
|
||||
root.setAttribute('data-copy-variant', copyVariant);
|
||||
root.setAttribute('data-copy-source', copyAssignmentSource);
|
||||
root.setAttribute('data-audience-mode', 'discover');
|
||||
try {
|
||||
window.localStorage.setItem('er_copy_variant', copyVariant);
|
||||
} catch (_error) {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -186,14 +188,26 @@ const structuredDataJsonLd = JSON.stringify({
|
||||
<slot />
|
||||
|
||||
<script is:inline type="application/ld+json" set:html={structuredDataJsonLd} />
|
||||
{extraJsonLd ? <script is:inline type="application/ld+json" set:html={extraJsonLd} /> : null}
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
var daVariant = document.documentElement.getAttribute('data-da-variant') || 'v12';
|
||||
var pagePath = window.location.pathname || '/';
|
||||
var pageTitle = document.title || '';
|
||||
|
||||
var getPageContext = function () {
|
||||
var root = document.documentElement;
|
||||
return {
|
||||
da_variant: root.getAttribute('data-da-variant') || 'v12',
|
||||
ui_sprint: root.getAttribute('data-ui-sprint') || 'b',
|
||||
copy_variant: root.getAttribute('data-copy-variant') || 'public',
|
||||
copy_source: root.getAttribute('data-copy-source') || 'default',
|
||||
audience_mode: root.getAttribute('data-audience-mode') || 'discover'
|
||||
};
|
||||
};
|
||||
|
||||
var pushTrackingEvent = function (eventName, payload) {
|
||||
var context = getPageContext();
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.dataLayer.push(
|
||||
Object.assign(
|
||||
@@ -203,7 +217,11 @@ const structuredDataJsonLd = JSON.stringify({
|
||||
event_label: eventName,
|
||||
page_path: pagePath,
|
||||
page_title: pageTitle,
|
||||
da_variant: daVariant
|
||||
da_variant: context.da_variant,
|
||||
ui_sprint: context.ui_sprint,
|
||||
copy_variant: context.copy_variant,
|
||||
copy_source: context.copy_source,
|
||||
audience_mode: context.audience_mode
|
||||
},
|
||||
payload || {}
|
||||
)
|
||||
@@ -262,7 +280,7 @@ const structuredDataJsonLd = JSON.stringify({
|
||||
onScroll();
|
||||
|
||||
if ('IntersectionObserver' in window) {
|
||||
var sectionIds = ['a-propos', 'projets', 'lab-notes', 'contact'];
|
||||
var sectionIds = ['audience', 'offre-systeme', 'process-etats', 'cas', 'lab-notes', 'a-propos', 'mission-config', 'contact', 'sprints-graphiques'];
|
||||
var seenSections = {};
|
||||
var sectionObserver = new IntersectionObserver(
|
||||
function (entries, observer) {
|
||||
@@ -672,6 +690,36 @@ const structuredDataJsonLd = JSON.stringify({
|
||||
for (var c = 0; c < interactiveCards.length; c += 1) {
|
||||
bindTilt(interactiveCards[c]);
|
||||
}
|
||||
|
||||
var root = document.documentElement;
|
||||
var ticking = false;
|
||||
var updateScroll3D = function () {
|
||||
ticking = false;
|
||||
var scrollTop = window.scrollY || window.pageYOffset || 0;
|
||||
var maxScroll = Math.max(1, window.innerHeight * 1.6);
|
||||
var ratio = Math.max(0, Math.min(1, scrollTop / maxScroll));
|
||||
var rotateX = 7 - ratio * 8.5;
|
||||
var rotateY = -6 + ratio * 7.2;
|
||||
var zoom = 1 + ratio * 0.06;
|
||||
var shift = ratio * 14;
|
||||
|
||||
root.style.setProperty('--scroll-3d-rx', rotateX.toFixed(2) + 'deg');
|
||||
root.style.setProperty('--scroll-3d-ry', rotateY.toFixed(2) + 'deg');
|
||||
root.style.setProperty('--scroll-3d-zoom', zoom.toFixed(4));
|
||||
root.style.setProperty('--scroll-3d-shift', shift.toFixed(2) + 'px');
|
||||
};
|
||||
|
||||
var request3D = function () {
|
||||
if (ticking) {
|
||||
return;
|
||||
}
|
||||
ticking = true;
|
||||
window.requestAnimationFrame(updateScroll3D);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', request3D, { passive: true });
|
||||
window.addEventListener('resize', request3D);
|
||||
updateScroll3D();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import { COPY_VARIANTS, type CopyContent, type CopyVariant } from '@/content/copyVariants';
|
||||
|
||||
const COPY_VARIANT_STORAGE_KEY = 'er_copy_variant';
|
||||
const DEFAULT_COPY_VARIANT: CopyVariant = 'public';
|
||||
|
||||
function isCopyVariant(value: string): value is CopyVariant {
|
||||
return value === 'public' || value === 'cto';
|
||||
}
|
||||
|
||||
export function getCopyContent(variant: CopyVariant): CopyContent {
|
||||
return COPY_VARIANTS[variant];
|
||||
}
|
||||
|
||||
export function resolveCopyVariant(): CopyVariant {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_COPY_VARIANT;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const fromQuery = (params.get('copy') || '').toLowerCase().trim();
|
||||
if (isCopyVariant(fromQuery)) {
|
||||
return fromQuery;
|
||||
}
|
||||
|
||||
const fromDom = (document.documentElement.getAttribute('data-copy-variant') || '').toLowerCase().trim();
|
||||
if (isCopyVariant(fromDom)) {
|
||||
return fromDom;
|
||||
}
|
||||
|
||||
try {
|
||||
const fromStorage = (window.localStorage.getItem(COPY_VARIANT_STORAGE_KEY) || '').toLowerCase().trim();
|
||||
if (isCopyVariant(fromStorage)) {
|
||||
return fromStorage;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore localStorage access failures.
|
||||
}
|
||||
|
||||
return DEFAULT_COPY_VARIANT;
|
||||
}
|
||||
|
||||
export function setCopyVariant(variant: CopyVariant) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-copy-variant', variant);
|
||||
try {
|
||||
window.localStorage.setItem(COPY_VARIANT_STORAGE_KEY, variant);
|
||||
} catch (_error) {
|
||||
// Ignore localStorage access failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function useCopyContent() {
|
||||
const [variant, setVariantState] = React.useState<CopyVariant>(DEFAULT_COPY_VARIANT);
|
||||
|
||||
React.useEffect(() => {
|
||||
const resolved = resolveCopyVariant();
|
||||
setVariantState(resolved);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
variant,
|
||||
copy: getCopyContent(variant)
|
||||
} as const;
|
||||
}
|
||||
@@ -2,9 +2,13 @@ export const TRACK_EVENTS = {
|
||||
ctaHeroProjects: 'cta_hero_projets',
|
||||
ctaHeroContact: 'cta_hero_contact',
|
||||
ctaHeroProfile: 'cta_hero_profile',
|
||||
audienceModeSwitch: 'audience_mode_switch',
|
||||
engagementCaseOpened: 'engagement_case_opened',
|
||||
funnelBriefGenerated: 'funnel_brief_generated',
|
||||
funnelBriefCopied: 'funnel_brief_copied',
|
||||
funnelContactSubmitted: 'funnel_contact_submitted',
|
||||
outboundLinkedinProject: 'outbound_linkedin_project',
|
||||
outboundLinkedinContact: 'outbound_linkedin_contact',
|
||||
outboundMaltContact: 'outbound_malt_contact',
|
||||
outboundBandcampProject: 'outbound_bandcamp_project',
|
||||
outboundBandcampContact: 'outbound_bandcamp_contact',
|
||||
outboundGithubProject: 'outbound_github_project',
|
||||
@@ -26,3 +30,14 @@ export function trackAttrs(event: TrackEventName, destination: string) {
|
||||
'data-destination': destination
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function trackEvent(event: string, payload?: Record<string, unknown>) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const tracker = (window as typeof window & { __erTrackEvent?: (name: string, payload?: Record<string, unknown>) => void }).__erTrackEvent;
|
||||
if (typeof tracker !== 'function') {
|
||||
return;
|
||||
}
|
||||
tracker(event, payload);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||
import '@/styles/global.css';
|
||||
import { CASE_STUDIES, getCaseBySlug } from '@/content/caseStudies';
|
||||
import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return CASE_STUDIES.map((item) => ({
|
||||
params: { slug: item.slug }
|
||||
}));
|
||||
}
|
||||
|
||||
const slug = Astro.params.slug || '';
|
||||
const study = getCaseBySlug(slug);
|
||||
|
||||
if (!study) {
|
||||
return Astro.redirect('/');
|
||||
}
|
||||
|
||||
const pageTitle = `${study.title} — Cas d'execution | L'electron rare`;
|
||||
const pageDescription = `${study.context} ${study.intervention} ${study.result}`;
|
||||
const canonicalPath = `/cas/${study.slug}/`;
|
||||
const contactHref = study.ctaHref.startsWith('#') ? `/${study.ctaHref}` : study.ctaHref;
|
||||
const extraJsonLd = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CreativeWork',
|
||||
name: `${study.title} - cas d'execution`,
|
||||
description: `${study.context} ${study.result}`,
|
||||
url: `https://electron-rare.github.io${canonicalPath}`,
|
||||
inLanguage: 'fr-FR',
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: 'Clement Saillant'
|
||||
},
|
||||
about: ['electronique', 'prototype', 'validation']
|
||||
});
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
canonicalPath={canonicalPath}
|
||||
ogTitle={pageTitle}
|
||||
ogDescription={pageDescription}
|
||||
twitterDescription={pageDescription}
|
||||
extraJsonLd={extraJsonLd}
|
||||
>
|
||||
<main class="site-shell pb-12 pt-5">
|
||||
<section class="section-anchor case-page" aria-labelledby="case-page-title">
|
||||
<div class="circuit-board rounded-2xl p-5 md:p-6">
|
||||
<p class="case-page-kicker">CAS D'EXECUTION</p>
|
||||
<h1 id="case-page-title" class="m-0 text-3xl md:text-4xl">{study.title}</h1>
|
||||
<p class="case-trace mt-3">{study.trace}</p>
|
||||
|
||||
<div class="case-page-grid mt-4">
|
||||
<article class="case-page-card">
|
||||
<h2 class="case-page-heading">Contexte</h2>
|
||||
<p class="mb-0">{study.context}</p>
|
||||
</article>
|
||||
<article class="case-page-card">
|
||||
<h2 class="case-page-heading">Intervention technique</h2>
|
||||
<p class="mb-0">{study.intervention}</p>
|
||||
</article>
|
||||
<article class="case-page-card">
|
||||
<h2 class="case-page-heading">Impact</h2>
|
||||
<p class="mb-0">
|
||||
<strong>{study.impactLabel}:</strong> {study.impactValue}
|
||||
</p>
|
||||
<p class="mb-0 mt-2">{study.result}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="case-page-proof mt-4">
|
||||
<h2 class="case-page-heading m-0">Preuve</h2>
|
||||
<p class="mb-0 mt-2">
|
||||
<strong>{study.proofTitle}</strong>
|
||||
</p>
|
||||
<p class="mb-0 mt-2">{study.proofBody}</p>
|
||||
<figure class="case-proof-figure mt-3">
|
||||
<img
|
||||
src={study.proofAsset}
|
||||
alt={`Preuve visuelle - ${study.title}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={1200}
|
||||
height={780}
|
||||
/>
|
||||
</figure>
|
||||
</article>
|
||||
|
||||
<div class="case-page-actions mt-4">
|
||||
<a href={contactHref} class="figma-lab-pill figma-lab-pill--primary" {...trackAttrs(TRACK_EVENTS.ctaHeroContact, '/#contact')}>
|
||||
{study.ctaLabel}
|
||||
</a>
|
||||
<a href="/" class="figma-lab-pill figma-lab-pill--secondary" {...trackAttrs(TRACK_EVENTS.engagementCaseOpened, '/')}>
|
||||
Retour landing
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
@@ -2,15 +2,14 @@
|
||||
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||
import '@/styles/global.css';
|
||||
import { Hero } from '@/components/sections/Hero';
|
||||
import { AudienceModeSwitch } from '@/components/sections/AudienceModeSwitch';
|
||||
import { OfferDeliverables } from '@/components/sections/OfferDeliverables';
|
||||
import { CaseProofs } from '@/components/sections/CaseProofs';
|
||||
import { GraphicSprints } from '@/components/sections/GraphicSprints';
|
||||
import { IdentityLab } from '@/components/sections/IdentityLab';
|
||||
import { MissionConfigurator } from '@/components/sections/MissionConfigurator';
|
||||
import { About } from '@/components/sections/About';
|
||||
import { Projects } from '@/components/sections/Projects';
|
||||
import { FlowDiagram } from '@/components/sections/FlowDiagram';
|
||||
import { SystemPipeline } from '@/components/sections/SystemPipeline';
|
||||
import { ProjectsTimeline } from '@/components/sections/ProjectsTimeline';
|
||||
import { ProcessStateMachine } from '@/components/sections/ProcessStateMachine';
|
||||
import { LabNotes } from '@/components/sections/LabNotes';
|
||||
import { TrustStrip } from '@/components/sections/TrustStrip';
|
||||
import { Contact } from '@/components/sections/Contact';
|
||||
import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
|
||||
---
|
||||
@@ -22,7 +21,7 @@ import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
|
||||
<nav aria-label="Navigation principale">
|
||||
<ul class="figma-lab-nav-list">
|
||||
<li><a href="#a-propos" class="figma-lab-nav-link site-navlink">#a-propos</a></li>
|
||||
<li><a href="#projets" class="figma-lab-nav-link site-navlink">#projets</a></li>
|
||||
<li><a href="#lab-notes" class="figma-lab-nav-link site-navlink">#preuves</a></li>
|
||||
<li><a href="#contact" class="figma-lab-nav-link site-navlink">#contact</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -37,65 +36,61 @@ import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
|
||||
<div class="structure-cell structure-cell--hero">
|
||||
<Hero client:load />
|
||||
</div>
|
||||
<div class="structure-cell structure-cell--identity">
|
||||
<IdentityLab client:visible />
|
||||
</section>
|
||||
|
||||
<section class="structure-grid structure-grid--audience" data-reveal>
|
||||
<div class="structure-cell structure-cell--audience">
|
||||
<AudienceModeSwitch client:visible />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label="Cartographie sections Figma" class="figma-lab-bottom-map" data-reveal>
|
||||
<div class="figma-lab-bottom-grid">
|
||||
<a href="#a-propos" class="figma-lab-map-card figma-lab-map-card--cream">Process / cadrage</a>
|
||||
<a href="#identite" class="figma-lab-map-card figma-lab-map-card--blue">Architecture / cible</a>
|
||||
<a href="#projets" class="figma-lab-map-card figma-lab-map-card--green">Production / prototype</a>
|
||||
|
||||
<a href="#projets" class="figma-lab-map-card figma-lab-map-card--green">Impact / risque</a>
|
||||
<a href="#lab-notes" class="figma-lab-map-card figma-lab-map-card--blue">Conversion / preuve</a>
|
||||
<a href="#lab-notes" class="figma-lab-map-card figma-lab-map-card--cream">Handoff / delivery</a>
|
||||
|
||||
<a href="#contact" class="figma-lab-map-card figma-lab-map-card--cream">Process / sprint</a>
|
||||
<a href="#contact" class="figma-lab-map-card figma-lab-map-card--blue">Architecture / interfaces</a>
|
||||
<a href="#contact" class="figma-lab-map-card figma-lab-map-card--green">Production / sur mesure</a>
|
||||
<section class="structure-grid structure-grid--offer" data-reveal>
|
||||
<div class="structure-cell structure-cell--offer">
|
||||
<OfferDeliverables client:visible />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="structure-grid structure-grid--sprints" data-reveal>
|
||||
<div class="structure-cell structure-cell--sprints">
|
||||
<GraphicSprints client:visible />
|
||||
<section class="structure-grid structure-grid--method" data-reveal>
|
||||
<div class="structure-cell structure-cell--process">
|
||||
<ProcessStateMachine client:visible />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="structure-grid structure-grid--systems" data-reveal>
|
||||
<div class="structure-cell structure-cell--flow">
|
||||
<FlowDiagram client:visible />
|
||||
</div>
|
||||
<div class="structure-cell structure-cell--pipeline">
|
||||
<SystemPipeline client:visible />
|
||||
</div>
|
||||
<div class="structure-cell structure-cell--about">
|
||||
<About client:visible />
|
||||
<section class="structure-grid structure-grid--cases" data-reveal>
|
||||
<div class="structure-cell structure-cell--cases">
|
||||
<CaseProofs client:visible />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="structure-grid structure-grid--production" data-reveal>
|
||||
<div class="structure-cell structure-cell--projects">
|
||||
<Projects client:visible />
|
||||
</div>
|
||||
<div class="structure-cell structure-cell--timeline">
|
||||
<ProjectsTimeline client:visible />
|
||||
</div>
|
||||
<section class="structure-grid structure-grid--proofs" data-reveal>
|
||||
<div class="structure-cell structure-cell--notes">
|
||||
<LabNotes client:visible />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="structure-grid structure-grid--conversion" data-reveal>
|
||||
<div class="structure-cell structure-cell--trust">
|
||||
<TrustStrip client:visible />
|
||||
<section class="structure-grid structure-grid--about" data-reveal>
|
||||
<div class="structure-cell structure-cell--about">
|
||||
<About client:visible />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="structure-grid structure-grid--config" data-reveal>
|
||||
<div class="structure-cell structure-cell--config">
|
||||
<MissionConfigurator client:visible />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="structure-grid structure-grid--conversion" data-reveal>
|
||||
<div class="structure-cell structure-cell--contact">
|
||||
<Contact client:visible />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="structure-grid structure-grid--journal" data-reveal>
|
||||
<div class="structure-cell structure-cell--sprints">
|
||||
<GraphicSprints client:visible />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer border-t border-[var(--line)] py-6 text-sm text-[var(--text-muted)]">
|
||||
@@ -112,8 +107,9 @@ import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
|
||||
<div class="footer-col footer-col--protocols">
|
||||
<p class="m-0 footer-column-title">Protocoles actifs</p>
|
||||
<ul class="footer-protocol-list">
|
||||
<li><a href="#lab-notes" class="footer-link">Lab notes</a></li>
|
||||
<li><a href="#projets" class="footer-link">Cas d'usage</a></li>
|
||||
<li><a href="#lab-notes" class="footer-link">Preuves</a></li>
|
||||
<li><a href="#a-propos" class="footer-link">A propos</a></li>
|
||||
<li><a href="#sprints-graphiques" class="footer-link">Journal</a></li>
|
||||
<li><a href="#contact" class="footer-link">Mission</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -127,33 +123,6 @@ import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
<a
|
||||
href="https://www.malt.com/profile/clementsaillant"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...trackAttrs(TRACK_EVENTS.outboundMaltContact, 'malt.com')}
|
||||
class="footer-link"
|
||||
>
|
||||
Malt
|
||||
</a>
|
||||
<a
|
||||
href="https://lelectron-fou.bandcamp.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...trackAttrs(TRACK_EVENTS.outboundBandcampContact, 'bandcamp.com')}
|
||||
class="footer-link"
|
||||
>
|
||||
Bandcamp
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/electron-rare/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...trackAttrs(TRACK_EVENTS.outboundGithubContact, 'github.com')}
|
||||
class="footer-link"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||