72 Commits

Author SHA1 Message Date
electron-rare 7d9a6174c9 feat(site): add Mastodon rel=me verification link
Add rel=me links to https://mastodon.saillant.cc/@clement in both
page variants (preview + index): head link tag, visible header
social anchor, and JSON-LD sameAs entry. Enables green verified
badge on the Mastodon profile Site field.
2026-07-09 09:57:06 +02:00
L'électron rare 7787bed2e9 refactor: contact form delegates to mail-api
Replace nodemailer SMTP with HTTP fetch to mail-api
container (http://mail-api:3200/api/mail/send) which
already holds the Brevo SMTP creds. Single source of
truth for mail credentials, no duplication across
electron-rare-site and mail-api.

Drop nodemailer + @types/nodemailer from deps. Restore
npm ci in Dockerfile since the lock no longer drifts.

Override defaults via env: MAIL_API_URL, MAIL_FROM,
MAIL_TO. mail-api intra-Docker network reachable
because both services are on the traefik network.
2026-04-28 10:02:07 +02:00
L'électron rare 225bd1d457 feat: contact form via SMTP + 3D click links
Replace Frappe/Dolibarr API with direct SMTP send via
nodemailer. Form posts to local /api/submit-lead, no
external proxy on api.lelectronrare.fr (which was down
since Frappe was retired ~13 days ago).

Add onClick handler on BMU/I2C/MOSFET 3D meshes that
redirects to /contact/. Cursor pointer on hover for
affordance.

Switch Dockerfile from npm ci to npm install --no-audit
since package-lock will regen with nodemailer added.

Container env vars to configure:
  SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS
  MAIL_FROM (defaults to SMTP_USER)
  MAIL_TO (defaults to contact@lelectronrare.fr)
2026-04-28 09:39:40 +02:00
L'électron rare 97fea66947 feat(home): cards link to dedicated pages
Wrap Conception/Formation/Consulting cards in <a> tags
pointing to /conception/, /formation/, /consulting/.
Add hover affordance: translateY(-2px), background and
border-color shift. Applied to both index.astro and
preview.astro variants for visual consistency.
2026-04-28 09:21:03 +02:00
L'électron rare 913df2aee8 wip: rescue source from photon
14 files (6 modified + 8 new) recovered from
photon /home/electron/electron-rare.github.io
during decom forensics.

New:
- src/components/BmuViewer.tsx (3D viewer)
- public/assets/models3d/{bmu_v2,bmu-assembly,
  i2c_repeater,mosfet_switch}.glb
- src/pages/{conception,consulting,contact,
  index-classic}.astro
- infra/Dockerfile.prebuild
- CLAUDE.md

Modified:
- public/sitemap.xml
- src/layouts/BaseLayout.astro
- src/pages/{formation,index,preview}.astro
- src/pages/sitemap.xml.ts (+5/-)

Snapshot mtime 2026-04-02 (photon last edits).
Recovery 2026-04-28 02:30 CEST.
2026-04-28 08:37:59 +02:00
Clément SAILLANT e49fdea8f0 refactor: clean slate — remove all WebGL, keep static + loading screen
REMOVED:
- AtomGlitch.tsx (countdown 3D atom)
- WebGLBackground.tsx (PCB 3D scene)
- WebGLCards.tsx (scroll animations)
- webgl-cards.css
- All GLB models (models3d/)
- Orbitron + Space Mono fonts

KEPT:
- static.astro (accessible version, no JS)
- Loading screen with BMU photo on preview
- Dark site CSS tokens in global.css
- bmu-assembly.jpg photo

SIMPLIFIED:
- index.astro: countdown without WebGL (placeholder div ready)
- preview.astro: loading screen + sections, WebGL placeholder ready
- Both pages have empty #webgl-* divs for new WebGL implementation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:04:19 +00:00
Clément SAILLANT adb756272b feat: accessible static version without 3D — SOTA 2026 a11y
- /static route — full site without WebGL/Three.js
- Skip-to-content link
- Semantic HTML5: header, main, footer, nav, section with aria-labels
- WCAG 2.2: focus-visible outlines (3px #0071e3), high contrast
- forced-colors media query support
- prefers-reduced-motion: all animations disabled
- Print stylesheet
- Light theme (white bg, high contrast text)
- Sticky header with nav links
- Photo grid (responsive CSS grid, no carousel)
- Link to "Version 3D" for users who want it
- No JavaScript dependencies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:32:37 +00:00
Clément SAILLANT b01b8dc5cb feat: KiCad board + STEP components combined (248 meshes, 2.7MB, with PCB traces) 2026-03-30 17:27:50 +00:00
Clément SAILLANT 4d7a2580af feat: colored PCB assembly — STEP materials preserved (11 colors)
- Scene GLB export preserves per-geometry materials from STEP files
- 158 sub-meshes with individual colors:
  - Green PCB substrate (20,51,36)
  - Orange copper traces (226,138,7)
  - Silver pads/solder (165,163,146)
  - Black IC bodies (5,5,5)
  - Yellow silkscreen (198,196,160)
- 397K vertices, 334K faces, 2.5MB (was 8.4MB gray)
- Board thickness visible (12.1mm Z-depth)
- BMU board + 4x switch MOSFET properly assembled

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:00:55 +00:00
Clément SAILLANT 5959141642 fix: camera/lights/fog in meters (STEP units), BMU board included in assembly 2026-03-30 16:49:20 +00:00
Clément SAILLANT 34968c32e7 fix: camera targeting board center + cleanup orphans + label positions
Camera audit fixes:
- Board bounds: X[-74,81] Y[-43,63] Z[-17,150] mm
- After -π/2 rotation, scene center ≈ (4, 66, -10)
- All camera stops now look at board center
- Overview: Y=250 (sees full 155mm assembly height)
- Close-ups: Y=80-140 (proper zoom on zones)
- Fog: 150-500mm range

Cleanup:
- Removed SwitchBoard() dead code
- Deleted orphan GLBs: bmu-v2-full.glb (1.6MB), pcb-bmu-v2.glb (269KB), bmu-switch-mosfet.glb (370KB)
- Saved 2.2MB from production bundle

Label positions:
- All SectionLabels repositioned to match board zones in scene coords
- MCU/ANALOG at Y=90 (top), POWER/FORMATION at Y=30 (bottom)
- MISSIONS at Y=110, CONTACT at center (4, 66, -10)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:32:02 +00:00
Clément SAILLANT bd788182d3 fix: camera distances in mm units (200mm overview, 80mm close) 2026-03-30 16:11:47 +00:00
Clément SAILLANT 0e820aef48 fix: scale=1 with camera/lights/fog adjusted for mm units
- PCB scale: 1 (native mm from FreeCAD)
- Camera stops: all positions scaled to mm (0.15m overview, 0.06m close-ups)
- Fog: 0.1-0.4 range (was 12-40)
- Lights: positions scaled to mm, distance 0.15-0.2
- Mouse parallax: 0.015 (was 1.5)
- CameraLight follow offset: 0.01 (was 1)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:43:43 +00:00
Clément SAILLANT 78d5602ca6 fix: PCB scale 0.15→15 + remove Hero import + static version button on loading
- PCB assembly scale fixed (was 0.15, now 15 — model in mm units)
- Hero component import removed from preview.astro
- Loading screen: added "Version sans 3D →" button linking to /static/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:44:55 +00:00
Clément SAILLANT 9d526e9d88 feat: complete BMU assembly — board + 4x switch MOSFET + components
- BMU v2 board (from STEP, 225K verts)
- 4x switch MOSFET boards (from STEP, offset positions)
- 76 SMD components (Part::Feature from FCStd)
- Properly simplified with fast_simplification (560K faces, 8.4MB)
- No mesh corruption (proper topology-preserving decimation)
- Loading screen forced to 10s minimum

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:55:48 +00:00
Clément SAILLANT fdedaf77bb fix: countdown cartouche footer + mouse capture zone fix
Countdown page:
- Cartouche footer bottom-right: titre, copyright, contact LinkedIn/email
- Glassmorphism (blur 12px, dark 50%)
- Mobile: becomes full-width centered
- Links are pointer-events: auto (clickable through overlay)

Mouse capture fix:
- .webgl-bg now has pointer-events: auto (was missing)
- .overlay children are pointer-events: none (was auto — was blocking mouse events)
- Only links and tagline area are interactive
- Three.js canvas receives mouse events across entire viewport

PCB silkscreen labels still present on preview (SectionLabel with scrollTarget)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:21:59 +00:00
Clément SAILLANT a053bdbceb feat: loading screen with BMU photo while WebGL loads
- Full-screen loading overlay with BMU.jpeg from KXKM repo
- Title "L'electron rare" with cyan accent
- Animated progress bar (gradient cyan → amber)
- Rotating status messages (electronics-themed)
- Floating animation on BMU photo
- Auto-detects when WebGL canvas renders, then fades out (1s)
- Falls back gracefully if WebGL never loads

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:08:09 +00:00
Clément SAILLANT b5ed4ec309 fix: full assembly (380 objects, 4 MOSFET boards + BMU + I2C) + remove silkscreen labels
- Assembly now exports ALL 380 meshable objects (was 76)
- Includes: 4x switch MOSFET boards, BMU v2, I2C repeater, all SMD components
- Subsampled to 267K faces / 5.7MB GLB for web performance
- Removed BoardTitle silkscreen text overlay (was white labels on board)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:57:01 +00:00
Clément SAILLANT 05a9a220d8 feat: complete FreeCAD BMU assembly (76 real components, 4.6MB GLB)
- Exported from BMU.FCStd via amrit3701/freecad-cli Docker container
- 686 objects in FCStd, 76 Part::Feature exported (real geometry only)
- Full assembly: BMU v2 board + switch MOSFET + I2C repeater + all SMD components
- Components: D2PAK MOSFETs, SOT-23, R_0603, capacitors, connectors, inductors
- Replaces separate bmu-v2-full.glb + bmu-switch-mosfet.glb
- SwitchBoard component removed (included in assembly)
- Hero HTML removed — PCB 3D is the full-page hero

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:35:50 +00:00
Clément SAILLANT be20ae4400 feat: Hero removed — PCB silkscreen labels ARE the navigation
- Hero HTML deleted — the 3D PCB board IS the hero
- First viewport = 100vh spacer showing full board overview
- SectionLabel now clickable:
  MCU → #a-propos (Approche)
  ANALOG → #cas-concrets
  POWER → #photos
  FORMATION → /formation/
  MISSIONS → #graphic-sprints-title
  CONTACT → opens 3D popup
- Hover: label glows white, grows, ring appears
- Click: smooth scroll to target section
- Cursor changes to pointer on hover
- Labels visible directly on the PCB 3D as silkscreen text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:13:23 +00:00
Clément SAILLANT 00b94c3cfa feat: Hero becomes BMU overview with clickable component zones
- Hero replaced by PCB-style navigation with 5 zones:
  MCU (Approche), ANALOG (Cas concrets), MISSIONS (Sprints),
  FORMATION (link), CONTACT (popup)
- Each zone: glassmorphism card with icon, Orbitron label, description
- Color-coded: cyan MCU, amber ANALOG, green MISSIONS, orange FORMATION, red CONTACT
- Gradient glow border on hover
- Smooth scroll to target sections
- Contact opens 3D popup
- Mobile: zones stack vertically
- Title: "L'electron rare" with cyan accent
- Subtitle: "Systemes electroniques specifiques, du besoin au livrable fiable"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:59:02 +00:00
Clément SAILLANT 4b5de5df8f fix: brighter PCB lighting + camera-follow light
- Ambient light 0.1 → 0.25
- Directional lights doubled (0.4→0.7, 0.15→0.3)
- Point light 0.2 → 0.4
- CameraLight: PointLight that follows camera position (intensity 0.6)
- Fog pushed back (8,30 → 12,40) for better PCB visibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:47:16 +00:00
Clément SAILLANT 6b914f088e fix: add 60vh PCB spacers between sections for board visibility
- 6x pcb-spacer (60vh transparent) between each content section
- Allows the PCB 3D model to be visible during scroll transitions
- Camera zooms on components during spacer gaps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:25:15 +00:00
Clément SAILLANT 0f3990ca3d feat: ER logo PNG orbiting nucleus — 3 copies on elliptical paths
- LogoOrbit uses logo-mark.png as texture on 3D plane (not text)
- 3 copies at different tilts/speeds/colors (cyan, amber, green)
- Radii 1.0-1.3 (very close to nucleus)
- Billboard rendering (always faces camera)
- Glow sphere behind each logo
- Emissive tint matches orbit color

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:50:18 +00:00
Clément SAILLANT 1f960a1bb1 feat: real FreeCAD BMU v2 3D model + Orbitron font + ER nucleus logo
PCB 3D Models:
- bmu-v2-full.glb (1.6MB) — complete BMU v2 from FreeCAD with all components
- bmu-switch-mosfet.glb (369KB) — switch MOSFET daughter board
- Source: KXKM_Batterie_Parallelator branch object-orriented
- Converted via cascadio/trimesh from STEP

Fonts:
- Orbitron Bold (17KB) — geometric futuristic font for 3D text
- Space Mono Bold (80KB) — tech monospace
- Applied to all WebGL text (section labels, silkscreen, countdown)

Countdown (AtomGlitch):
- 3x "ER" logo at nucleus center, each on different rotation axis
- Cyan, amber, green glow variants
- Orbitron font for all orbital text

WebGL Background:
- RealPCB now uses bmu-v2-full.glb (real FreeCAD model)
- SwitchBoard added as secondary board
- Orbitron font for section labels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:31:52 +00:00
Clément SAILLANT 4f9f464dab feat: real PCB refonte + tighter countdown text orbits
WebGL Background v2:
- Real KXKM BMU v2 PCB (GLB) as central 3D object
- 6 component zones with real KiCad GLB models:
  MCU (QFP32+SOIC8), ANALOG (SOIC8s), POWER (QFP32+caps+inductor),
  COM (SOIC8s), MISSIONS (QFP32), CONTACT
- ~70 real components (resistors, caps, inductors, LEDs, ICs)
- 25 extra scattered passives for realism
- Camera orbits PCB, zooms into each zone on scroll (8 stops)
- Current flow: 80 cyan + 40 amber particles circling the board
- Section labels as silkscreen text
- Board title: "L'ELECTRON RARE REV 2026.1"

Countdown (AtomGlitch):
- Text orbits tightened (radius 3.8→2.0, 3.5→1.8, etc.)
- Countdown ring radius 4.2→2.5
- Decorative ring 5.8→3.5
- All text closer to nucleus for denser, more energetic feel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:07:24 +00:00
Clément SAILLANT a7fd9dbac2 feat: integrate real KiCad GLB models into WebGL PCB scene
- ICComponent: uses qfp32.glb (large) or soic8.glb (small) with fallback to procedural
- Capacitor: uses capacitor_0805.glb with scroll-reactive glow
- Resistor: uses resistor_0603.glb
- LED: uses led_0603.glb + dynamic PointLight for glow effect
- All models preloaded via useGLTF.preload()
- Fallback to procedural geometry if GLB fails to load
- Models from KiCad 10.0 official 3D library (STEP → GLB via cascadio)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 07:54:26 +00:00
Clément SAILLANT 0a0a6fdfa9 feat: real KiCad 3D component models (GLB) for WebGL PCB
7 GLB models converted from KiCad official STEP library via cascadio/trimesh:
- resistor_0603.glb (8KB) — SMD 0603 resistor
- capacitor_0805.glb (11KB) — SMD 0805 capacitor
- inductor_0805.glb (11KB) — SMD 0805 inductor
- led_0603.glb (11KB) — SMD 0603 LED
- soic8.glb (33KB) — SOIC-8 IC package
- qfp32.glb (118KB) — LQFP-32 IC package
- pcb-bmu-v2.glb (275KB) — KXKM BMU v2 PCB with copper traces

Source: KiCad 10.0 official 3D library (/usr/share/kicad/3dmodels/)
Conversion: cascadio (OpenCASCADE) + trimesh → GLB on KXKM-AI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 07:44:27 +00:00
Clément SAILLANT 177458fd07 feat: 50+ transition components — STM32, FPGA, ARM NPU, DSP, selfs
Transition components between each section:
- HERO→ABOUT: STM32F030 (Cortex-M0) + 8 passives + LEDs
- ABOUT→CASES: 74HC595 shift register + 2 selfs (inductors) + passives
- CASES→MEDIA: XC7A35T FPGA Artix-7 + DONE/INIT LEDs + passives
- MEDIA→SPRINTS: STM32N6 ARM Neural Unit + self + decoupling
- SPRINTS→CONTACT: TMS320F DSP C2000 + PWM/ADC LEDs + self

All with silkscreen ref designators and component values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 07:18:54 +00:00
Clément SAILLANT e3b202cde6 fix: dimmer hero background (10% opacity) + transition components between sections
- Hero panel image opacity 0.3 → 0.1 (much more subtle)
- Added ~20 transition components between each section:
  - HERO→ABOUT: caps, resistors, LED
  - ABOUT→CASES: 74HC595 shift register + passives
  - CASES→MEDIA: resistors, cap, LED
  - MEDIA→SPRINTS: AMS1117 regulator + decoupling caps
  - SPRINTS→CONTACT: caps, resistors, LED
- At least 2 components visible between each section stop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 07:08:18 +00:00
Clément SAILLANT c4cfd63eb6 feat: space out PCB journey — 2.4x wider component spacing
- Board expanded from 38 to 90 units depth
- All component positions, vias, branches, labels rescaled
- Zoom stops recalibrated for wider travel
- Substrate expanded (35x110)
- More breathing room between sections
- Camera Z range updated to match

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 06:52:44 +00:00
Clément SAILLANT 582fee2e44 feat: silkscreen labels + zoom camera stops on PCB components
- Silkscreen text on all components (ref designators, values, section labels)
- SectionLabel component: glows when camera approaches (scroll proximity)
- Camera zoom stops: 8 waypoints, smooth interpolation between components
- FOV transitions (50→40→42 etc.) for cinematic zoom effect
- smoothstep easing between stops
- Mouse parallax on camera position and lookAt
- Board markings: "L'ELECTRON RARE REV 2026.1", "Made in France"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:50:35 +00:00
Clément SAILLANT 16341a9889 feat: PCB journey — 3D components + trace current + contact popup
WebGL Background:
- Camera follows copper trace path (CatmullRom curve) on scroll
- Green PCB substrate with solder mask grid
- Main copper trace tube + 10 secondary branch traces
- 15 vias (metallic rings with holes)
- Current flow: 60 cyan + 30 amber particles along traces
- 200 ambient dust particles

3D Electronic Components:
- IC/MCU (ESP32, LM358, IRFZ44, LM7805) with pins + silkscreen
- Capacitors (cylinders, electrolytic + ceramic)
- Resistors (SMD rectangles with solder pads)
- LEDs (glowing domes, intensity = scroll proximity)
- USB-C connector

Section mapping on PCB:
- Hero = ESP32 MCU area
- About = Op-amp + passives
- Cases = Power MOSFET stage
- Media = USB connector
- Sprints = Voltage regulator
- Contact = Energy storage capacitors

Contact 3D popup:
- Hidden by default, opens on #contact click or scroll >95%
- 3D perspective entry (rotateY -15deg → 0)
- Glassmorphism backdrop blur
- Close via backdrop click, X button, or Escape

CSS:
- Orbital card entry animations (left/right alternating)
- Atomic carousel with electron dot hover
- Dark tech design tokens

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:36:13 +00:00
Clément SAILLANT 96f4ca0f4e feat: 3D scroll tilt cards + atomic carousel for photos/videos
- Cards: perspective 3D enter animation (rotateX/Y from blur)
- Scroll tilt: cards subtly rotate based on viewport position
- Atomic carousel: orbital scroll container with electron dot animation on hover
- Navigation arrows with glow + orbital track indicator
- Video hover autoplay in carousel items
- Lightbox: click any photo/video for full-view with energy rings
- Carousel items: 3D perspective rotateY(-3deg) on hover + glow border

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:55:44 +00:00
Clément SAILLANT dab02bdfb8 feat: full dark tech WebGL refonte — atomic scroll cards + media lightbox
- WebGLBackground: fixed subtle atom + particles (dimmed, camera z=14)
- WebGLCards: scroll-triggered tile materialize with particle burst
- MediaLightbox: click photos/videos for full-view with orbital energy rings
- Dark design tokens in global.css (.dark-site variant)
- Glassmorphism cards, glow borders, staggered reveals
- Photo/video hover glow effects
- Each section wrapped in webgl-section for atomic reveal
- Each content block in webgl-card with per-section accent color
- Respects prefers-reduced-motion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:36:08 +00:00
Clément SAILLANT 9b0cdd4dea feat: add WebGL atom background to preview Hero section
- AtomGlitch component integrated as Hero background (25% opacity)
- Respects prefers-reduced-motion
- Preview page now matches post-launch index.astro layout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:58:04 +00:00
Clément SAILLANT acab730922 feat(seo): SOTA 2026 AI-ready SEO overhaul
- Schema.org: FAQPage, OfferCatalog, Occupation, EducationalOccupationalCredential, SearchAction, ContactPoint
- E-E-A-T signals: author, credentials (BTS SN), memberOf (KXKM), hasOccupation, priceRange
- AI crawling: llms.txt (llmstxt.org standard), robots.txt with GPTBot/Claude-Web/PerplexityBot/Google-Extended
- Meta: ai-content-declaration=human-created, generator-assisted, speakable, keywords, geo.region
- Auto date-switch: countdown (before May 1) / full site (after May 1) with Hero WebGL background
- Theme color updated to brand cyan #5bd1d8

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:49:04 +00:00
Clément SAILLANT d5f1ce84b4 feat: WebGL cinematic countdown — 3D atom with circular orbital text
- Three.js + React Three Fiber + postprocessing (bloom, chromatic aberration, glitch, scanlines)
- Nucleus with pulsing energy core, wireframe icosahedron shell
- 3 electron orbits with trail particles + energy arcs
- 600 particle cloud with organic movement
- Circular orbital text: title, countdown timer, subtitle curved on 3D rings
- Mouse interactions: camera parallax, atom speed boost, nucleus glow on proximity
- Camera smooth lerp follow with gentle auto-drift
- Font fix: woff2 → ttf for troika-three-text compatibility
- Logo: static mark instead of missing sprite frames

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:32:38 +00:00
Clément SAILLANT 666f05bf59 ci: harden monthly report branch-protection check 2026-03-28 14:12:56 +01:00
Clément SAILLANT fd9af8c2ba ci: add monthly cross-stack report workflow 2026-03-28 14:10:06 +01:00
L'électron rare 41389a88b7 feat: add CI E2E workflow with ntfy alerts
- Self-hosted runner on Tower
- Playwright E2E 23 tests
- ntfy notifications on pass/fail
- Runs on push to main + PRs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:38:11 +01:00
L'électron rare d97db3d918 fix: deploy workflow Photon -> Tower
- SCP + restart on Tower instead of unreachable Photon
- Node SSR container with mascarade-frontend network
- Requires TOWER_HOST, TOWER_USER, TOWER_SSH_KEY secrets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:31:41 +01:00
L'électron rare dfee9a58d7 fix: enrich ops health display + API contracts + auth prep
- Health: parse agents count + providers from /health
- API contracts documented in script header
- Auth Bearer header prepared (token from data-attr/meta)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:34:30 +01:00
Clément SAILLANT 389b5ca6cd refactor: countdown + ops Apple SOTA 2026
- index.astro: white bg, Manrope font, Apple blue accent,
  clean timer (light weight, tabular-nums), gentle-float logo,
  fade-in stagger, no retro/glitch effects
- ops.astro: max-width 1200px (aligned with content grid),
  fluid padding clamp()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 06:40:17 +01:00
Clément SAILLANT 193f2cc67d refactor: Apple SOTA 2026 design system
- Rewrite global.css: CSS Grid named columns layout (full-bleed sections
  with centered content), Apple color tokens, glassmorphism nav,
  fluid typography clamp(), generous whitespace
- Remove home-workbench.css v12 variant overrides (all in global.css)
- Contact.astro: clean labels, no terminal/minitel aesthetic
- ops.astro: Apple-style segmented tabs, cards, KPIs, agent grid
- Uniform responsive: all panels full-bleed with 1200px centered content
- Override Tailwind p-5/p-6/rounded-2xl/mt-* on circuit-board panels
- Alternate section backgrounds (bg-secondary / surface)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 06:25:26 +01:00
Clément SAILLANT 6a1c2f7383 refactor: ops IA-native 3 vues (547 lignes vs 1381) 2026-03-21 15:38:52 +01:00
Clément SAILLANT 857f69b9db feat: ops tasks from Frappe DB + CRUD + AI execute 2026-03-21 09:24:05 +01:00
Clément SAILLANT 60bd17bd1b feat: catalogue 14 formations (initiation, créatif, pro, enfants) 2026-03-20 22:55:02 +01:00
Clément SAILLANT 9085533265 fix: location Beaujolais/Lyon (not Marseille) 2026-03-20 18:38:34 +01:00
Clément SAILLANT e14c59d708 feat: ops with full collab tools integration 2026-03-20 17:15:09 +01:00
Clément SAILLANT 5ad9be9582 feat: ops with business plan + marketing + KPIs 2026-03-20 16:43:41 +01:00
Clément SAILLANT 7d49213fbb perf: optimize images + robots noindex 2026-03-20 16:05:12 +01:00
Clément SAILLANT 874224fdbb perf: optimize images + robots noindex 2026-03-20 16:05:11 +01:00
Clément SAILLANT 418cc06c17 feat: ops page synced with plan execution avril 2026-03-20 14:59:36 +01:00
Clément SAILLANT 3fcf089e92 feat: update robots.txt.ts 2026-03-20 14:53:51 +01:00
Clément SAILLANT 285d8e71a0 feat: update ops.astro 2026-03-20 14:53:49 +01:00
Clément SAILLANT 9e7a54c716 feat: update intranet.astro 2026-03-20 14:53:48 +01:00
Clément SAILLANT 4420f18eb0 feat: update portfolio.astro 2026-03-20 14:53:47 +01:00
Clément SAILLANT eb7de7f44d feat: update index.astro 2026-03-20 14:53:45 +01:00
Clément SAILLANT 1a07d17230 feat: countdown with new logo + hero-bg + aria-live 2026-03-20 08:54:54 +01:00
Clément SAILLANT 5b505b04f1 feat: update Contact.astro 2026-03-20 00:12:05 +01:00
Clément SAILLANT 6ed434c44d feat: update intranet.astro 2026-03-20 00:12:04 +01:00
Clément SAILLANT bada31c775 feat: update portfolio.astro 2026-03-20 00:12:03 +01:00
Clément SAILLANT 8ca257f4dd feat: update countdown.astro 2026-03-20 00:12:02 +01:00
Clément SAILLANT a5d9d2abe8 feat: update 404.astro 2026-03-20 00:12:01 +01:00
Clément SAILLANT 4139dea802 feat: update preview.astro 2026-03-20 00:12:01 +01:00
Clément SAILLANT b121cad8f2 feat: update index.astro 2026-03-20 00:12:00 +01:00
Clément SAILLANT e793f38bd3 feat: standalone countdown page 2026-03-19 22:07:34 +01:00
Clément SAILLANT bf487437a0 feat: quantum 404 page 2026-03-19 22:07:33 +01:00
Clément SAILLANT 3276adb866 feat: countdown homepage for pre-launch 2026-03-19 22:07:32 +01:00
Clément SAILLANT 3b7ad132c8 feat: add preview page with full site 2026-03-19 22:07:18 +01:00
Clément SAILLANT b699ff14a3 feat: contact form CRM only, remove mailto/LinkedIn tabs 2026-03-19 22:06:40 +01:00
40 changed files with 7502 additions and 7946 deletions
+41
View File
@@ -0,0 +1,41 @@
name: CI + E2E
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
e2e:
runs-on: self-hosted
timeout-minutes: 10
steps:
- name: Run E2E tests
id: e2e
run: |
cd /home/clems/mascarade/web
BASE_URL=http://localhost:3100 npx playwright test --reporter=line 2>&1 | tee /tmp/e2e-output.txt
PASSED=$(grep -oP '\d+ passed' /tmp/e2e-output.txt | grep -oP '\d+' || echo 0)
FAILED=$(grep -oP '\d+ failed' /tmp/e2e-output.txt | grep -oP '\d+' || echo 0)
echo "passed=$PASSED" >> $GITHUB_OUTPUT
echo "failed=$FAILED" >> $GITHUB_OUTPUT
if [ "${FAILED:-0}" != "0" ] && [ "${FAILED:-0}" != "" ]; then exit 1; fi
- name: Notify ntfy on failure
if: failure()
run: |
curl -s -d "E2E FAILED: ${{ steps.e2e.outputs.failed }} tests failed on $(date '+%H:%M')" \
-H "Title: Ops ER E2E Failed" \
-H "Priority: high" \
-H "Tags: rotating_light" \
https://ntfy.saillant.cc/ops-alerts 2>/dev/null || true
- name: Notify ntfy on success
if: success()
run: |
curl -s -d "E2E OK: ${{ steps.e2e.outputs.passed }} passed" \
-H "Title: Ops ER E2E Pass" \
-H "Priority: low" \
-H "Tags: white_check_mark" \
https://ntfy.saillant.cc/ops-alerts 2>/dev/null || true
+23 -18
View File
@@ -1,4 +1,4 @@
name: Deploy to Cloudflare (via Photon)
name: Deploy to Tower
on:
push:
@@ -31,45 +31,50 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build static site
- name: Build site
run: npm run build
- name: Deploy to Photon
- name: Deploy to Tower
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.PHOTON_HOST }}
username: root
key: ${{ secrets.PHOTON_SSH_KEY }}
host: ${{ secrets.TOWER_HOST }}
username: ${{ secrets.TOWER_USER }}
key: ${{ secrets.TOWER_SSH_KEY }}
source: "dist/"
target: "/tmp/site-dist/"
target: "/home/clems/electron-rare-site/"
overwrite: true
rm: true
- name: Restart site container
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PHOTON_HOST }}
username: root
key: ${{ secrets.PHOTON_SSH_KEY }}
host: ${{ secrets.TOWER_HOST }}
username: ${{ secrets.TOWER_USER }}
key: ${{ secrets.TOWER_SSH_KEY }}
script: |
cd /home/clems/electron-rare-site
docker rm -f electron-rare-site 2>/dev/null || true
docker run -d \
--name electron-rare-site \
--network zacus-stack_default \
-v /tmp/site-dist/dist:/app/dist \
-p 127.0.0.1:4321:4321 \
--network mascarade-frontend \
-v /home/clems/electron-rare-site/dist:/app/dist \
-v /home/clems/electron-rare-site/node_modules:/app/node_modules \
-w /app \
-p 4321:4321 \
-e HOST=0.0.0.0 \
-e PORT=4321 \
--restart unless-stopped \
node:22-alpine \
sh -c 'npm install -g serve 2>/dev/null && serve /app/dist -l 4321 -s'
node dist/server/entry.mjs
- name: Verify deployment
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PHOTON_HOST }}
username: root
key: ${{ secrets.PHOTON_SSH_KEY }}
host: ${{ secrets.TOWER_HOST }}
username: ${{ secrets.TOWER_USER }}
key: ${{ secrets.TOWER_SSH_KEY }}
script: |
sleep 8
sleep 5
STATUS=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:4321/)
if [ "$STATUS" != "200" ]; then
echo "Site returned $STATUS instead of 200"
@@ -0,0 +1,87 @@
name: Monthly Cross-stack Report
on:
schedule:
- cron: '10 7 1 * *'
workflow_dispatch:
permissions:
contents: read
actions: read
jobs:
monthly-report:
name: Monthly report
runs-on: ubuntu-latest
steps:
- name: Generate report
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
NOW_UTC="$(date -u +'%Y-%m-%d %H:%M:%S UTC')"
OUT_DIR="artifacts"
OUT_FILE="$OUT_DIR/monthly-cross-stack-report-$(date -u +'%Y-%m-%d').md"
mkdir -p "$OUT_DIR"
WORKFLOWS_JSON="$(curl -sS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$GH_REPO/actions/workflows?per_page=100")"
RUNS_JSON="$(curl -sS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$GH_REPO/actions/runs?per_page=20")"
LABELS_JSON="$(curl -sS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$GH_REPO/labels?per_page=100")"
PROTECTION_STATUS_AND_BODY="$(curl -sS -w "\n%{http_code}" -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$GH_REPO/branches/main/protection")"
PROTECTION_HTTP_CODE="$(echo "$PROTECTION_STATUS_AND_BODY" | tail -n1)"
PROTECTION_JSON="$(echo "$PROTECTION_STATUS_AND_BODY" | sed '$d')"
EXPECTED_LABELS=(core api web e2e deploy infra docs)
MISSING_LABELS=()
for lbl in "${EXPECTED_LABELS[@]}"; do
if ! echo "$LABELS_JSON" | jq -e --arg L "$lbl" '.[] | select(.name == $L)' >/dev/null; then
MISSING_LABELS+=("$lbl")
fi
done
CHECK_STATE="unknown"
if [ "$PROTECTION_HTTP_CODE" = "200" ]; then
REQ_CHECK_CHECKS=$(echo "$PROTECTION_JSON" | jq -r '.required_status_checks.checks[]?.context // empty' | grep -Fx 'Cross-stack coherence' || true)
REQ_CHECK_CONTEXTS=$(echo "$PROTECTION_JSON" | jq -r '.required_status_checks.contexts[]? // empty' | grep -Fx 'Cross-stack coherence' || true)
if [ -n "$REQ_CHECK_CHECKS" ] || [ -n "$REQ_CHECK_CONTEXTS" ]; then
CHECK_STATE="required"
else
CHECK_STATE="not-required"
fi
fi
{
echo "# Monthly Cross-stack Report"
echo
echo "Generated: $NOW_UTC"
echo "Repository: $GH_REPO"
echo
echo "## Branch Protection (main)"
echo "- Cross-stack coherence: $CHECK_STATE"
if [ "$CHECK_STATE" = "unknown" ]; then
echo "- Note: branch protection endpoint not readable with current token (HTTP $PROTECTION_HTTP_CODE)."
fi
echo
echo "## Expected Labels"
if [ ${#MISSING_LABELS[@]} -eq 0 ]; then
echo "- All expected labels are present: core, api, web, e2e, deploy, infra, docs"
else
echo "- Missing labels: ${MISSING_LABELS[*]}"
fi
echo
echo "## Active Workflows"
echo "$WORKFLOWS_JSON" | jq -r '.workflows[] | "- \(.name) [\(.state)]"'
echo
echo "## Latest Runs (Top 10)"
echo "$RUNS_JSON" | jq -r '.workflow_runs[:10][] | "- \(.name) | \(.event) | \(.status) | \(.conclusion // "in_progress") | \(.html_url)"'
} > "$OUT_FILE"
cat "$OUT_FILE" >> "$GITHUB_STEP_SUMMARY"
- name: Upload report artifact
uses: actions/upload-artifact@v4
with:
name: monthly-cross-stack-report
path: artifacts/monthly-cross-stack-report-*.md
+1
View File
@@ -31,3 +31,4 @@ artifacts/previews/
artifacts/imagegen/
artifacts/qa-test/*/responsive-v12-pass2/
artifacts/qa-test/*/deploy-sftp-dryrun.txt
.env
+73
View File
@@ -0,0 +1,73 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Corporate site for **L'électron rare** (electronics consulting studio) — an Astro 5 SSR site with a React Three Fiber 3D product viewer as its centerpiece. Production URL: `https://www.lelectronrare.fr/`
## Commands
```bash
npm run dev # Astro dev server (default port 4321)
npm run build # Production build (SSR, outputs dist/)
npm run build:external # Build with PUBLIC_SITE_URL for external domain
npm run preview # Preview production build locally
npm run typecheck # astro check (TypeScript validation)
npm run storybook # Component dev on port 6006
npm run lab:dev # Sub-app (apps/lab-interactif) dev server
npm run tracking:check # Validate GA4 tracking events
npm run image:budget # Check image sizes against budget
```
**Docker build & deploy (from `/home/electron/electron-rare.github.io/infra/`):**
```bash
docker compose -f docker-compose.site.yml build electron-rare-site
docker compose -f docker-compose.site.yml up -d electron-rare-site
```
## Architecture
**Runtime:** Astro SSR with `@astrojs/node` adapter (standalone mode). The built site runs as `node dist/server/entry.mjs` on port 4321, behind Traefik.
**Styling:** Tailwind CSS 4 integrated via `@tailwindcss/vite` plugin — no separate tailwind.config. Uses `cva` + `clsx` + `tailwind-merge` for component variants.
**Path alias:** `@/*` maps to `src/*`.
### Site URL & Base Path
The site supports multiple deployment targets (GitHub Pages subpath, custom domain). `PUBLIC_SITE_URL` env var drives both `astro.config.mjs` and `src/lib/site.ts`. All internal links must use `withSiteBase()` from `src/lib/site.ts` to work across deployments.
### 3D Viewer (`src/components/BmuViewer.tsx`)
The main differentiating feature — a scroll-driven 3D PCB viewer on the `/preview/` page:
- Loads 5 GLTF models (BMU, I2C Repeater, MOSFET switches) from `public/assets/models3d/`
- 16+ named scroll sections, each defining camera position, rotation, focus card, and explode amount
- Scroll progress (01) interpolates between section states for smooth transitions
- Uses `@react-three/drei` Html component for interactive PCB label overlays
- Post-processing: Bloom + Vignette via `@react-three/postprocessing`
- VideoTexture meshes for PCB bottom views
- Brand color: `#5bd1d8` (cyan)
### Pages
- `index.astro` — Landing page with section components (Hero, About, CaseStudies, GraphicSprints, Faq, Contact)
- `preview.astro` — 3D scroll-driven viewer (1000vh scroll track, fixed overlays, dot navigation)
- `api/submit-lead.ts` — Server endpoint posting to Frappe CRM
- `formation.astro`, `portfolio.astro`, `ops.astro` — Content pages
### CRM Integration
Contact form submits to Frappe CRM via `src/pages/api/submit-lead.ts`. Requires `FRAPPE_URL`, `FRAPPE_API_KEY`, `FRAPPE_API_SECRET` env vars (set in `infra/.env`).
## CI/CD
Push to `main` triggers GitHub Actions (`.github/workflows/deploy-cloudflare.yml`):
1. `npm ci` + `npm run build` with `PUBLIC_SITE_URL=https://www.lelectronrare.fr/`
2. SCP `dist/` to Tower server
3. Docker container restart (node:22-alpine running `dist/server/entry.mjs`)
4. Health check on `http://127.0.0.1:4321/`
## GLB Pipeline
KiCad `.kicad_pcb` → STEP export → FreeCAD Docker tessellation → GLB with per-component materials and KiCad raytraced textures. Models stored in `public/assets/models3d/`.
+9
View File
@@ -0,0 +1,9 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY dist ./dist
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["node", "dist/server/entry.mjs"]
+665 -10
View File
@@ -11,13 +11,18 @@
"@astrojs/node": "^9.4.5",
"@astrojs/react": "^4.4.1",
"@radix-ui/react-slot": "^1.2.3",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@react-three/postprocessing": "^3.0.4",
"astro": "^5.13.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"motion": "^12.23.24",
"postprocessing": "^6.39.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.3.1",
"three": "^0.183.2"
},
"devDependencies": {
"@astrojs/check": "^0.9.6",
@@ -1013,9 +1018,7 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -1077,6 +1080,12 @@
"node": ">=18"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"license": "Apache-2.0"
},
"node_modules/@emmetio/abbreviation": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz",
@@ -2189,6 +2198,24 @@
"react": ">=16"
}
},
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.17",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
"integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
"license": "Apache-2.0"
},
"node_modules/@monogrid/gainmap-js": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
"integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==",
"license": "MIT",
"dependencies": {
"promise-worker-transferable": "^1.0.4"
},
"peerDependencies": {
"three": ">= 0.159.0"
}
},
"node_modules/@oslojs/encoding": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
@@ -2239,6 +2266,120 @@
}
}
},
"node_modules/@react-three/drei": {
"version": "10.7.7",
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
"integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mediapipe/tasks-vision": "0.10.17",
"@monogrid/gainmap-js": "^3.0.6",
"@use-gesture/react": "^10.3.1",
"camera-controls": "^3.1.0",
"cross-env": "^7.0.3",
"detect-gpu": "^5.0.56",
"glsl-noise": "^0.0.0",
"hls.js": "^1.5.17",
"maath": "^0.10.8",
"meshline": "^3.3.1",
"stats-gl": "^2.2.8",
"stats.js": "^0.17.0",
"suspend-react": "^0.1.3",
"three-mesh-bvh": "^0.8.3",
"three-stdlib": "^2.35.6",
"troika-three-text": "^0.52.4",
"tunnel-rat": "^0.1.2",
"use-sync-external-store": "^1.4.0",
"utility-types": "^3.11.0",
"zustand": "^5.0.1"
},
"peerDependencies": {
"@react-three/fiber": "^9.0.0",
"react": "^19",
"react-dom": "^19",
"three": ">=0.159"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/@react-three/fiber": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz",
"integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/webxr": "*",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
"its-fine": "^2.0.0",
"react-use-measure": "^2.1.7",
"scheduler": "^0.27.0",
"suspend-react": "^0.1.3",
"use-sync-external-store": "^1.4.0",
"zustand": "^5.0.3"
},
"peerDependencies": {
"expo": ">=43.0",
"expo-asset": ">=8.4",
"expo-file-system": ">=11.0",
"expo-gl": ">=11.0",
"react": ">=19 <19.3",
"react-dom": ">=19 <19.3",
"react-native": ">=0.78",
"three": ">=0.156"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
},
"expo-asset": {
"optional": true
},
"expo-file-system": {
"optional": true
},
"expo-gl": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/@react-three/postprocessing": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-3.0.4.tgz",
"integrity": "sha512-e4+F5xtudDYvhxx3y0NtWXpZbwvQ0x1zdOXWTbXMK6fFLVDd4qucN90YaaStanZGS4Bd5siQm0lGL/5ogf8iDQ==",
"license": "MIT",
"dependencies": {
"maath": "^0.6.0",
"n8ao": "^1.9.4",
"postprocessing": "^6.36.6"
},
"peerDependencies": {
"@react-three/fiber": "^9.0.0",
"react": "^19.0",
"three": ">= 0.156.0"
}
},
"node_modules/@react-three/postprocessing/node_modules/maath": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/maath/-/maath-0.6.0.tgz",
"integrity": "sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw==",
"license": "MIT",
"peerDependencies": {
"@types/three": ">=0.144.0",
"three": ">=0.144.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -3230,6 +3371,12 @@
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -3313,6 +3460,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/draco3d": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
"integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3359,6 +3512,12 @@
"@types/unist": "*"
}
},
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -3379,6 +3538,15 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-reconciler": {
"version": "0.28.9",
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
"integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/resolve": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
@@ -3386,18 +3554,63 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.183.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
"integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~1.0.1"
}
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
"node_modules/@use-gesture/core": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
"integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
"license": "MIT"
},
"node_modules/@use-gesture/react": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
"integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
"license": "MIT",
"dependencies": {
"@use-gesture/core": "10.3.1"
},
"peerDependencies": {
"react": ">= 16.8.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -3611,6 +3824,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@webgpu/types": {
"version": "0.1.69",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
"license": "BSD-3-Clause"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -4472,6 +4691,26 @@
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@@ -4497,6 +4736,15 @@
"node": ">=12.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -4568,6 +4816,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/camelcase": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz",
@@ -4580,6 +4852,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camera-controls": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz",
"integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==",
"license": "MIT",
"engines": {
"node": ">=22.0.0",
"npm": ">=10.5.1"
},
"peerDependencies": {
"three": ">=0.126.1"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001775",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz",
@@ -4896,11 +5181,28 @@
"integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==",
"license": "MIT"
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -5100,6 +5402,15 @@
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"license": "MIT"
},
"node_modules/detect-gpu": {
"version": "5.0.70",
"resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz",
"integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==",
"license": "MIT",
"dependencies": {
"webgl-constants": "^1.1.1"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -5244,6 +5555,12 @@
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/draco3d": {
"version": "1.5.7",
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
"integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==",
"license": "Apache-2.0"
},
"node_modules/dset": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz",
@@ -5509,6 +5826,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz",
@@ -5693,6 +6016,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glsl-noise": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz",
"integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==",
"license": "MIT"
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -5907,6 +6236,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hls.js": {
"version": "1.6.15",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
"license": "Apache-2.0"
},
"node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
@@ -5949,6 +6284,32 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-meta-resolve": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
@@ -6054,6 +6415,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
"license": "MIT"
},
"node_modules/is-wsl": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
@@ -6073,9 +6440,20 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/its-fine": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz",
"integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==",
"license": "MIT",
"dependencies": {
"@types/react-reconciler": "^0.28.9"
},
"peerDependencies": {
"react": "^19.0.0"
}
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -6167,6 +6545,15 @@
"node": ">=6"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -6470,6 +6857,16 @@
"lz-string": "bin/bin.js"
}
},
"node_modules/maath": {
"version": "0.10.8",
"resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
"integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==",
"license": "MIT",
"peerDependencies": {
"@types/three": ">=0.134.0",
"three": ">=0.134.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -6731,6 +7128,21 @@
"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
"license": "CC0-1.0"
},
"node_modules/meshline": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz",
"integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==",
"license": "MIT",
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/meshoptimizer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
"integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
"license": "MIT"
},
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -7428,6 +7840,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/n8ao": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/n8ao/-/n8ao-1.10.1.tgz",
"integrity": "sha512-hhI1pC+BfOZBV1KMwynBrVlIm8wqLxj/abAWhF2nZ0qQKyzTSQa1QtLVS2veRiuoBQXojxobcnp0oe+PUoxf/w==",
"license": "ISC",
"peerDependencies": {
"postprocessing": ">=6.30.0",
"three": ">=0.137"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -7739,7 +8161,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -7838,6 +8259,21 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postprocessing": {
"version": "6.39.0",
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.0.tgz",
"integrity": "sha512-/G6JY8hs426lcto/pBZlnFSkyEo1fHsh4gy7FPJtq1SaSUOzJgDW6f6f1K/+aMOYzK/eQEefyOb3++jPPIUeDA==",
"license": "Zlib",
"peerDependencies": {
"three": ">= 0.168.0 < 0.184.0"
}
},
"node_modules/potpack": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
"license": "ISC"
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
@@ -7879,6 +8315,16 @@
"node": ">=6"
}
},
"node_modules/promise-worker-transferable": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
"integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
"license": "Apache-2.0",
"dependencies": {
"is-promise": "^2.1.0",
"lie": "^3.0.2"
}
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -7987,6 +8433,21 @@
"node": ">=0.10.0"
}
},
"node_modules/react-use-measure": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
"integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.13",
"react-dom": ">=16.13"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -8231,7 +8692,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -8487,7 +8947,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -8500,7 +8959,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8582,6 +9040,32 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stats-gl": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz",
"integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==",
"license": "MIT",
"dependencies": {
"@types/three": "*",
"three": "^0.170.0"
},
"peerDependencies": {
"@types/three": "*",
"three": "*"
}
},
"node_modules/stats-gl/node_modules/three": {
"version": "0.170.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
"integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
"license": "MIT"
},
"node_modules/stats.js": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz",
"integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -9268,6 +9752,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/suspend-react": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
"integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=17.0"
}
},
"node_modules/svgo": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz",
@@ -9324,6 +9817,44 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/three": {
"version": "0.183.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
"integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
"license": "MIT"
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz",
"integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==",
"license": "MIT",
"peerDependencies": {
"three": ">= 0.159.0"
}
},
"node_modules/three-stdlib": {
"version": "2.36.1",
"resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz",
"integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==",
"license": "MIT",
"dependencies": {
"@types/draco3d": "^1.4.0",
"@types/offscreencanvas": "^2019.6.4",
"@types/webxr": "^0.5.2",
"draco3d": "^1.4.1",
"fflate": "^0.6.9",
"potpack": "^1.0.1"
},
"peerDependencies": {
"three": ">=0.128.0"
}
},
"node_modules/three-stdlib/node_modules/fflate": {
"version": "0.6.10",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz",
"integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==",
"license": "MIT"
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
@@ -9401,6 +9932,36 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/troika-three-text": {
"version": "0.52.4",
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
"integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
"license": "MIT",
"dependencies": {
"bidi-js": "^1.0.2",
"troika-three-utils": "^0.52.4",
"troika-worker-utils": "^0.52.0",
"webgl-sdf-generator": "1.1.1"
},
"peerDependencies": {
"three": ">=0.125.0"
}
},
"node_modules/troika-three-utils": {
"version": "0.52.4",
"resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
"integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
"license": "MIT",
"peerDependencies": {
"three": ">=0.125.0"
}
},
"node_modules/troika-worker-utils": {
"version": "0.52.0",
"resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
"integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
"license": "MIT"
},
"node_modules/trough": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
@@ -9462,6 +10023,43 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tunnel-rat": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
"integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==",
"license": "MIT",
"dependencies": {
"zustand": "^4.3.2"
}
},
"node_modules/tunnel-rat/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
@@ -9850,6 +10448,24 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utility-types": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
"integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@@ -10264,6 +10880,17 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/webgl-constants": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
"integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="
},
"node_modules/webgl-sdf-generator": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
"license": "MIT"
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
@@ -10275,7 +10902,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -10642,6 +11268,35 @@
"zod": "^3"
}
},
"node_modules/zustand": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+6 -1
View File
@@ -26,13 +26,18 @@
"@astrojs/node": "^9.4.5",
"@astrojs/react": "^4.4.1",
"@radix-ui/react-slot": "^1.2.3",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@react-three/postprocessing": "^3.0.4",
"astro": "^5.13.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"motion": "^12.23.24",
"postprocessing": "^6.39.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.3.1",
"three": "^0.183.2"
},
"devDependencies": {
"@astrojs/check": "^0.9.6",
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>Thats an error.</ins>
<p>The requested URL <code>/s/ajdhani/v17/LDI2apCSOBg7S-QT7pa8FsOs.ttf</code> was not found on this server. <ins>Thats all we know.</ins>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

+42
View File
@@ -0,0 +1,42 @@
# L'Electron Rare — llms.txt
# This file helps AI systems understand this website.
# Standard: https://llmstxt.org/
## Identity
Name: L'Electron Rare
Type: Professional service — custom electronic systems design
Owner: Clément Saillant
Location: France
Language: French (primary), English (technical docs)
Website: https://www.lelectronrare.fr
## What we do
L'Electron Rare designs, builds, and validates custom electronic systems.
Specialties: embedded electronics, industrial automation, energy management, IoT, PCB design (KiCad), instrumentation, live performance tech (DMX, Art-Net, DANTE).
We work with partners for multi-disciplinary projects (mechanical, software, scenography).
## Services
- Diagnostic: feasibility analysis, risk assessment, action plan (1-2 weeks)
- Prototype: tested prototype, documented measurements, go/no-go decision (4-6 weeks)
- Full mission: phased deliverables, production dossier, turnkey transfer (2-6 months)
- Training: hands-on electronics workshops for schools and companies
## Key expertise
- ESP32, STM32 firmware development
- KiCad PCB design (IPC-A-610 compliant)
- Industrial automation (Siemens S7, Schneider)
- Battery management, energy storage, LiFePO4
- EMC/CEM compliance
- AI-assisted electronics design
- Live performance technology (audio, LED, DMX)
## Contact
Email: contact@lelectronrare.fr
LinkedIn: https://fr.linkedin.com/in/electron-rare
GitHub: https://github.com/electron-rare
Blog: https://blog.saillant.cc
## AI content policy
Content on this site is human-authored by Clément Saillant.
Site structure and WebGL animations were developed with AI assistance (Claude Code by Anthropic).
Case studies and technical descriptions reflect real projects and measurable outcomes.
+20 -1
View File
@@ -1,4 +1,23 @@
User-agent: *
Allow: /
Sitemap: https://electron-rare.github.io/sitemap.xml
# AI crawlers — explicitly allowed (SOTA 2026)
User-agent: GPTBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: Claude-Web
Allow: /
User-agent: Applebot-Extended
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Google-Extended
Allow: /
Sitemap: https://www.lelectronrare.fr/sitemap.xml
+12 -6
View File
@@ -1,20 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://electron-rare.github.io/</loc>
<lastmod>2026-03-02</lastmod>
<loc>https://www.lelectronrare.fr/</loc>
<lastmod>2026-04-01</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://electron-rare.github.io/formation/</loc>
<lastmod>2026-03-14</lastmod>
<loc>https://www.lelectronrare.fr/formation/</loc>
<lastmod>2026-04-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://electron-rare.github.io/mentions-legales/</loc>
<lastmod>2026-03-14</lastmod>
<loc>https://www.lelectronrare.fr/preview/</loc>
<lastmod>2026-04-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://www.lelectronrare.fr/mentions-legales/</loc>
<lastmod>2026-04-01</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
+589
View File
@@ -0,0 +1,589 @@
import { useRef, Suspense, useMemo, useEffect, useState } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { useGLTF, Environment, ContactShadows, Html } from '@react-three/drei';
import { EffectComposer, Bloom, Vignette } from '@react-three/postprocessing';
import * as THREE from 'three';
const MODELS_BASE = '/assets/models3d';
const S = 0.1;
function fc2three(fx: number, fy: number, fz: number): [number, number, number] {
return [fx * S, fz * S, -fy * S];
}
// explodeDelay: 0-1 stagger (0=moves first, 1=moves last)
// explodeRotTarget: rotation when fully exploded (null = keep original)
const CARDS_DEF = [
{ file: `${MODELS_BASE}/bmu_v2.glb`, name: 'BMU v2',
position: fc2three(0, 0, 0), rotation: [-Math.PI / 2, 0, 0] as [number, number, number],
explodeDir: [0, 0.8, 0] as [number, number, number], explodeDist: 1.5,
explodeDelay: 0, explodeRotTarget: null as [number, number, number] | null },
{ file: `${MODELS_BASE}/i2c_repeater.glb`, name: 'I2C Repeater',
position: fc2three(-52, 33, 4), rotation: [-Math.PI / 2, 0, 0] as [number, number, number],
explodeDir: [-0.8, 0.2, -0.6] as [number, number, number], explodeDist: 2.0,
explodeDelay: 0.15, explodeRotTarget: null as [number, number, number] | null },
{ file: `${MODELS_BASE}/mosfet_switch.glb`, name: 'Mosfet #1',
position: fc2three(23.2, 20, -11.2), rotation: [Math.PI / 2, 0, Math.PI] as [number, number, number],
explodeDir: [0.5, -0.8, 0.4] as [number, number, number], explodeDist: 4.0,
explodeDelay: 0.25,
explodeRotTarget: [0, 0, Math.PI] as [number, number, number] },
{ file: `${MODELS_BASE}/mosfet_switch.glb`, name: 'Mosfet #2',
position: fc2three(-15.56, 20, -11.16), rotation: [Math.PI / 2, 0, Math.PI] as [number, number, number],
explodeDir: [-0.2, -0.8, 0.5] as [number, number, number], explodeDist: 4.0,
explodeDelay: 0.35,
explodeRotTarget: [0, 0, Math.PI] as [number, number, number] },
{ file: `${MODELS_BASE}/mosfet_switch.glb`, name: 'Mosfet #3',
position: fc2three(-54.72, 20, -11.12), rotation: [Math.PI / 2, 0, Math.PI] as [number, number, number],
explodeDir: [-0.6, -0.8, 0.3] as [number, number, number], explodeDist: 4.0,
explodeDelay: 0.45,
explodeRotTarget: [0, 0, Math.PI] as [number, number, number] },
{ file: `${MODELS_BASE}/mosfet_switch.glb`, name: 'Mosfet #4',
position: fc2three(62.12, 20, -11.16), rotation: [Math.PI / 2, 0, Math.PI] as [number, number, number],
explodeDir: [0.7, -0.8, 0.2] as [number, number, number], explodeDist: 4.0,
explodeDelay: 0.55,
explodeRotTarget: [0, 0, Math.PI] as [number, number, number] },
];
CARDS_DEF.forEach((c) => useGLTF.preload(c.file));
/* ── Sections ── */
// Camera positions are relative: offset from card center when focused
// 'focus' = which card index camera should look at (null = assembly center)
interface Section {
id: string; start: number; end: number;
camOffset: [number, number, number]; // camera position offset from target
camOffsetEnd?: [number, number, number];
targetShift?: [number, number, number]; // shift lookAt from card center (start)
targetShiftEnd?: [number, number, number]; // shift lookAt (end) — for dolly movement
focus: number | null; // card index to focus on, null = center
focusEnd?: number | null;
explode: number; explodeEnd?: number;
}
const SECTIONS: Section[] = [
// 1. HERO — vue large, focus sur l'assemblage (card 0 = BMU)
{ id: 'hero', start: 0.00, end: 0.10,
camOffset: [2.0, 1.5, 2.0], camOffsetEnd: [1.5, 1.2, 1.5], focus: 0, explode: 0 },
// 1→2 TRANSITION — zoom progressif vers la zone I2C (haut-gauche)
{ id: 'hero-to-exp', start: 0.10, end: 0.16,
camOffset: [1.5, 1.2, 1.5], camOffsetEnd: [0.6, 0.7, 0.7],
targetShift: [0, 0, 0], targetShiftEnd: [-0.7, 0.35, 0],
focus: 0, explode: 0 },
// 2a. Électronique — vise la zone I2C sur la BMU
{ id: 'exp-elec', start: 0.16, end: 0.20,
camOffset: [0.4, 0.6, 0.5], camOffsetEnd: [0.2, 0.5, 0.4],
targetShift: [-0.7, 0.35, 0],
focus: 0, explode: 0 },
// 2b. BAS — tuile card-local [0.85, 0, 0.45] → Rx(-π/2) → [0.85, 0.45, 0]
{ id: 'exp-auto', start: 0.20, end: 0.24,
camOffset: [-0.3, 0.35, -0.2], camOffsetEnd: [-0.15, 0.3, -0.15],
targetShift: [0.85, 0.45, 0],
focus: 0, explode: 0 },
// 2c. HAUT — tuile card-local [-0.5, 0, -0.45] → Rx(-π/2) → [-0.5, -0.45, 0]
{ id: 'exp-energie', start: 0.24, end: 0.28,
camOffset: [0.25, 0.35, 0.3], camOffsetEnd: [0.12, 0.3, 0.2],
targetShift: [-0.5, -0.45, 0],
focus: 0, explode: 0 },
// 2d. BAS — tuile card-local [-0.85, 0, 0.45] → Rx(-π/2) → [-0.85, 0.45, 0]
{ id: 'exp-dispositifs', start: 0.28, end: 0.31,
camOffset: [0.3, 0.35, -0.2], camOffsetEnd: [0.15, 0.3, -0.1],
targetShift: [-0.85, 0.45, 0],
focus: 0, explode: 0 },
// 2e. HAUT — tuile card-local [0.3, 0, -0.45] → Rx(-π/2) → [0.3, -0.45, 0]
{ id: 'exp-consulting', start: 0.31, end: 0.34,
camOffset: [-0.2, 0.35, 0.3], camOffsetEnd: [0.1, 0.3, 0.2],
targetShift: [0.3, -0.45, 0],
focus: 0, explode: 0 },
// 3a. RECUL — rapide
{ id: 'eclate-recul', start: 0.34, end: 0.36,
camOffset: [0.0, 1.0, 0.8], camOffsetEnd: [1.0, 3.0, 4.0], focus: 0, focusEnd: null,
explode: 0 },
// 3b. SÉPARATION — rapide
{ id: 'eclate-sep', start: 0.36, end: 0.39,
camOffset: [1.0, 3.0, 4.0], camOffsetEnd: [1.6, 3.6, 4.4], focus: null,
explode: 0, explodeEnd: 0.7 },
// 3c. MISE À 90° — rapide
{ id: 'eclate-plat', start: 0.39, end: 0.41,
camOffset: [1.6, 3.6, 4.4], camOffsetEnd: [2.0, 3.6, 5.0], focus: null,
explode: 0.7, explodeEnd: 1 },
// 4. CAS — recul caméra, vue des 4 mosfets, tuiles + médias visibles
{ id: 'cas', start: 0.41, end: 0.65,
camOffset: [0.5, -0.5, -3.0], camOffsetEnd: [-0.5, -0.3, -2.5], focus: null, explode: 1 },
// 7a. MEDIA — vidéo 1, caméra de l'autre côté (Z négatif)
{ id: 'media', start: 0.65, end: 0.69,
camOffset: [0.2, 0.0, -1.0], camOffsetEnd: [0.0, 0.0, -0.8], focus: 2, explode: 1 },
// 7b. MEDIA — vidéo 2
{ id: 'media', start: 0.69, end: 0.73,
camOffset: [0.2, 0.0, -1.0], camOffsetEnd: [-0.1, 0.0, -0.8], focus: 3, explode: 1 },
// 7c. MEDIA — vidéo 3
{ id: 'media', start: 0.73, end: 0.77,
camOffset: [-0.1, 0.0, -1.0], camOffsetEnd: [-0.3, 0.0, -0.8], focus: 4, explode: 1 },
// 7d. MEDIA — carrousel photos
{ id: 'media', start: 0.77, end: 0.81,
camOffset: [-0.2, 0.0, -1.0], camOffsetEnd: [0.1, 0.0, -0.8], focus: 5, explode: 1 },
// 8. FORMATS
{ id: 'formats', start: 0.81, end: 0.91,
camOffset: [2.4, 3.0, 4.0], camOffsetEnd: [1.6, 2.4, 3.0], focus: null, explode: 1, explodeEnd: 0 },
// 9. CONTACT — zoom serré sur la tuile contact au centre de la BMU
{ id: 'contact', start: 0.91, end: 1.00,
camOffset: [0.15, 0.3, 0.2], camOffsetEnd: [0.0, 0.25, 0.15], focus: 0, explode: 0 },
];
/* ── Tuiles plaquées sur les faces PCB ── */
interface PcbLabel {
section: string; cardIndex: number;
localOffset: [number, number, number];
title: string; sub?: string; color?: string;
href?: string;
}
const PCB_LABELS: PcbLabel[] = [
// Expertise — alternance haut/bas du PCB BMU
// 1. HAUT — côté I2C (card 1)
{ section: 'exp-elec', cardIndex: 1, localOffset: [0.0, 0.005, 0.0],
title: 'Électronique spécifique', sub: 'Cartes · Interfaces · Capteurs · Alimentation' },
// 2. BAS — bas-droit BMU
{ section: 'exp-auto', cardIndex: 0, localOffset: [0.85, 0.005, 0.45],
title: 'Instrumentation & Automatisme', sub: 'Bancs · Automates · Variateurs · Protocoles' },
// 3. HAUT — haut BMU, à droite de I2C
{ section: 'exp-energie', cardIndex: 0, localOffset: [-0.5, 0.005, -0.45],
title: 'Énergie & Stockage', sub: 'Batterie · Conversion · Supervision · Télémétrie' },
// 4. BAS — bas-gauche BMU
{ section: 'exp-dispositifs', cardIndex: 0, localOffset: [-0.85, 0.005, 0.45],
title: 'Dispositifs pour le réel', sub: 'Audio · LED · Scène · Robustesse terrain' },
// 5. HAUT — centre-haut BMU
{ section: 'exp-consulting', cardIndex: 0, localOffset: [0.3, 0.005, -0.45],
title: 'Consulting & Formation', sub: 'Audit · Transfert · Pédagogie · Projet' },
// 6-9. CAS — les 4 tuiles visibles en même temps, face bottom des mosfets
{ section: 'cas', cardIndex: 2, localOffset: [0.0, 0.0, -0.08],
title: 'Industries créatives', sub: 'Audio embarqué · Batterie LiFePO4 · KXKM' },
{ section: 'cas', cardIndex: 3, localOffset: [0.0, 0.0, -0.08],
title: 'Industrie', sub: 'Production V3.2 · KXKM ESP32 → STM32F030' },
{ section: 'cas', cardIndex: 4, localOffset: [0.0, 0.0, -0.08],
title: 'Formation', sub: 'PCB · KiCad · µC' },
{ section: 'cas', cardIndex: 5, localOffset: [0.0, 0.0, -0.08],
title: 'Service', sub: 'Mise en service · Maintenance · Support terrain' },
// 9. Conception — Mosfet #1 (card 2)
{ section: 'formats', cardIndex: 2, localOffset: [0.0, 0.005, 0.0],
title: 'Conception', sub: 'Schéma · PCB · Firmware · Validation',
href: '/conception/' },
// 10. Formation — Mosfet #2 (card 3)
{ section: 'formats', cardIndex: 3, localOffset: [0.0, 0.005, 0.0],
title: 'Formation', sub: 'PCB · KiCad · µC · Soudure',
href: '/formation/' },
// 11. Consulting — Mosfet #3 (card 4)
{ section: 'formats', cardIndex: 4, localOffset: [0.0, 0.005, 0.0],
title: 'Consulting', sub: 'Audit · Diagnostic · Transfert',
href: '/consulting/' },
// 12. Contact — sur BMU (card 0)
{ section: 'contact', cardIndex: 0, localOffset: [0.0, 0.005, 0.0],
title: 'Parlons de votre projet', sub: 'Premier échange sans engagement',
href: '/contact/' },
];
/* ── Tuile plaquée sur la face du PCB ── */
function PcbTile({ offset, title, sub, visible, section, href }: {
offset: [number, number, number];
title: string; sub?: string; color?: string; visible: boolean; section?: string; href?: string;
}) {
const isContact = section === 'contact';
const isClickable = !!href || isContact;
const linkHref = isContact ? undefined : href;
const isBig = isContact;
const content = (
<div style={{
minWidth: isBig ? '340px' : '260px',
padding: isBig ? '24px 36px' : '14px 24px',
textAlign: 'center',
background: isClickable ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.55)',
border: isClickable ? '2px solid #5bd1d8' : '1.5px solid rgba(255,255,255,0.4)',
borderRadius: isClickable ? '12px' : '8px',
backdropFilter: 'blur(4px)',
cursor: isClickable ? 'pointer' : 'default',
}}>
<div style={{
fontSize: isBig ? '24px' : '18px',
fontWeight: 800,
color: isClickable ? '#5bd1d8' : '#fff',
letterSpacing: '0.05em', textTransform: 'uppercase',
fontFamily: "'Courier New', monospace",
}}>{title}</div>
{sub && <div style={{
fontSize: isBig ? '14px' : '12px',
color: 'rgba(255,255,255,0.55)',
marginTop: isBig ? '10px' : '6px',
fontFamily: "'Courier New', monospace", letterSpacing: '0.03em',
}}>{sub}</div>}
</div>
);
return (
<Html position={offset} center distanceFactor={isBig ? 0.4 : 0.8}
style={{
opacity: visible ? 1 : 0,
transition: 'opacity 0.5s',
pointerEvents: isClickable && visible ? 'auto' : 'none',
whiteSpace: 'nowrap',
}}>
{isContact ? (
<div onClick={() => window.dispatchEvent(new CustomEvent('open-contact-drawer', { detail: { source: 'pcb_contact' } }))}
style={{ textDecoration: 'none', display: 'block' }}>{content}</div>
) : linkHref ? (
<a href={linkHref} style={{ textDecoration: 'none', display: 'block' }}>{content}</a>
) : content}
</Html>
);
}
/* ── Card ── */
function CardModel({ file }: { file: string }) {
const { scene } = useGLTF(file);
const cloned = useMemo(() => {
const c = scene.clone(true);
c.traverse((child) => { if (child instanceof THREE.Mesh && child.material) child.material.envMapIntensity = 1.5; });
return c;
}, [scene]);
return <primitive object={cloned} />;
}
/* ── Video plane on PCB bottom (real 3D mesh with VideoTexture) ── */
function PcbVideoMesh({ offset, src, visible }: {
offset: [number, number, number]; src: string; visible: boolean;
}) {
const meshRef = useRef<THREE.Mesh>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const texRef = useRef<THREE.VideoTexture | null>(null);
// Mosfet card local space: ~0.38 x 0.86 (XZ), Y = thickness
// Plane size: cover most of the bottom face
const planeW = 1.9, planeH = 4.275;
useEffect(() => {
const video = document.createElement('video');
video.src = src;
video.crossOrigin = 'anonymous';
video.loop = true;
video.muted = true;
video.playsInline = true;
video.preload = 'metadata';
videoRef.current = video;
const tex = new THREE.VideoTexture(video);
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
tex.colorSpace = THREE.SRGBColorSpace;
texRef.current = tex;
return () => { video.pause(); video.src = ''; tex.dispose(); };
}, [src]);
useEffect(() => {
if (!videoRef.current) return;
if (visible) { videoRef.current.play().catch(() => {}); }
else { videoRef.current.pause(); }
}, [visible]);
useFrame(() => {
if (meshRef.current && texRef.current) {
(meshRef.current.material as THREE.MeshStandardMaterial).opacity = visible ? 1 : 0;
texRef.current.needsUpdate = visible;
}
});
return (
<mesh ref={meshRef} position={offset} rotation={[0, Math.PI, 0]} scale={[-1, -1, 1]}>
<planeGeometry args={[planeW, planeH]} />
<meshStandardMaterial
map={texRef.current}
transparent
opacity={0}
emissive="#ffffff"
emissiveIntensity={0.3}
emissiveMap={texRef.current}
side={THREE.DoubleSide}
/>
</mesh>
);
}
/* ── Photo carousel plane on PCB bottom (real 3D mesh with swapping texture) ── */
const CAROUSEL_PHOTOS = [
'/assets/photos/pcb-teensy-led-kxkm.webp',
'/assets/photos/pcb-verso-soudures.webp',
'/assets/photos/soudure-pcb-composants.webp',
'/assets/photos/bench-bms-batteries-xt60.webp',
'/assets/photos/automate-siemens-s7.webp',
'/assets/photos/armoire-automate-schneider.webp',
'/assets/photos/oscilloscope-philips-vintage.webp',
'/assets/photos/workspace-dev-tektronix.webp',
'/assets/photos/platine-automate-cablage.webp',
'/assets/photos/bench-ampli-oscilloscope.webp',
'/assets/photos/pcb-produit-embarque.webp',
'/assets/photos/portrait-bench.webp',
];
function PcbCarouselMesh({ offset, visible }: {
offset: [number, number, number]; visible: boolean;
}) {
const meshRef = useRef<THREE.Mesh>(null);
const textures = useRef<THREE.Texture[]>([]);
const [idx, setIdx] = useState(0);
const autoTimer = useRef(0);
const planeW = 1.9, planeH = 4.275;
useEffect(() => {
const loader = new THREE.TextureLoader();
textures.current = CAROUSEL_PHOTOS.map(url => {
const t = loader.load(url);
t.colorSpace = THREE.SRGBColorSpace;
t.minFilter = THREE.LinearFilter;
return t;
});
return () => textures.current.forEach(t => t.dispose());
}, []);
// Auto-advance every 3s when visible
useFrame((_, delta) => {
if (!meshRef.current) return;
const mat = meshRef.current.material as THREE.MeshStandardMaterial;
mat.opacity = visible ? 1 : 0;
if (textures.current[idx]) {
mat.map = textures.current[idx];
mat.emissiveMap = textures.current[idx];
mat.needsUpdate = true;
}
if (visible) {
autoTimer.current += delta;
if (autoTimer.current > 3) {
autoTimer.current = 0;
setIdx(i => (i + 1) % CAROUSEL_PHOTOS.length);
}
}
});
return (
<mesh ref={meshRef} position={offset} rotation={[0, Math.PI, 0]} scale={[-1, -1, 1]}>
<planeGeometry args={[planeW, planeH]} />
<meshStandardMaterial
transparent
opacity={0}
emissive="#ffffff"
emissiveIntensity={0.3}
side={THREE.DoubleSide}
/>
</mesh>
);
}
/* ── Media definitions for mosfet bottoms ── */
const MOSFET_VIDEOS = [
{ cardIndex: 2, src: '/assets/videos/video-test-prototype.mp4' },
{ cardIndex: 3, src: '/assets/videos/video-bench-electronique.mp4' },
{ cardIndex: 4, src: '/assets/videos/video-atelier-terrain.mp4' },
];
const MOSFET_CAROUSEL_CARD = 5; // mosfet #4
/* ── Assembly ── */
function Assembly({ scrollRef, onSection }: {
scrollRef: React.MutableRefObject<number>; onSection: (id: string) => void;
}) {
const assemblyGroup = useRef<THREE.Group>(null);
const cardsRef = useRef<THREE.Group[]>([]);
const fitted = useRef(false);
const { camera } = useThree();
const smoothScroll = useRef(0);
const origPositions = useRef<THREE.Vector3[]>([]);
const assemblyScale = useRef(1);
const [currentSection, setCurrentSection] = useState('hero');
useFrame((_, delta) => {
if (!assemblyGroup.current) return;
// Auto-fit on first valid frame
if (!fitted.current && cardsRef.current.filter(Boolean).length === CARDS_DEF.length) {
fitted.current = true;
const box = new THREE.Box3().setFromObject(assemblyGroup.current);
const center = box.getCenter(new THREE.Vector3());
const maxDim = Math.max(...box.getSize(new THREE.Vector3()).toArray());
const scale = 2.2 / maxDim;
assemblyScale.current = scale;
assemblyGroup.current.scale.setScalar(scale);
assemblyGroup.current.position.copy(center.multiplyScalar(-scale));
assemblyGroup.current.updateMatrixWorld(true);
origPositions.current = cardsRef.current.map(g => g.position.clone());
}
if (!fitted.current) return;
// Smooth scroll
smoothScroll.current += (scrollRef.current - smoothScroll.current) * Math.min(1, delta * 2);
const t = Math.max(0, Math.min(1, smoothScroll.current));
// Find current section
let section = SECTIONS[SECTIONS.length - 1];
for (const s of SECTIONS) { if (t >= s.start && t <= s.end) { section = s; break; } }
const range = section.end - section.start || 1;
let f = Math.max(0, Math.min(1, (t - section.start) / range));
f = f * f * (3 - 2 * f); // smoothstep
// Explode
const explodeEnd = section.explodeEnd ?? section.explode;
const explode = section.explode + (explodeEnd - section.explode) * f;
// Apply explode to cards with stagger delay and rotation
for (let i = 0; i < cardsRef.current.length; i++) {
if (!cardsRef.current[i] || !origPositions.current[i]) continue;
const g = cardsRef.current[i];
const orig = origPositions.current[i];
const def = CARDS_DEF[i];
// Staggered explode: each card starts moving at its delay point
const delay = def.explodeDelay;
const cardExplode = Math.max(0, Math.min(1, (explode - delay) / (1 - delay)));
// Position
g.position.set(
orig.x + def.explodeDir[0] * def.explodeDist * cardExplode,
orig.y + def.explodeDir[1] * def.explodeDist * cardExplode,
orig.z + def.explodeDir[2] * def.explodeDist * cardExplode,
);
// Rotation: interpolate toward flat when explodeRotTarget is set
if (def.explodeRotTarget) {
const origRot = def.rotation;
// Mosfets flip to flat during the last phase (cardExplode > 0.5)
const rotT = Math.max(0, Math.min(1, (cardExplode - 0.5) * 2));
const smoothRot = rotT * rotT * (3 - 2 * rotT);
g.rotation.set(
origRot[0] + (def.explodeRotTarget[0] - origRot[0]) * smoothRot,
origRot[1] + (def.explodeRotTarget[1] - origRot[1]) * smoothRot,
origRot[2] + (def.explodeRotTarget[2] - origRot[2]) * smoothRot,
);
}
}
// Compute camera: focus on card or center
const focusStart = section.focus;
const focusEnd = section.focusEnd !== undefined ? section.focusEnd : focusStart;
// Get world position of focused card
function getCardWorldPos(cardIdx: number | null): THREE.Vector3 {
if (cardIdx !== null && cardsRef.current[cardIdx]) {
const wp = new THREE.Vector3();
cardsRef.current[cardIdx].getWorldPosition(wp);
return wp;
}
return new THREE.Vector3(0, 0, 0); // assembly center
}
const targetStart = getCardWorldPos(focusStart);
const targetEnd = getCardWorldPos(focusEnd);
const target = new THREE.Vector3().lerpVectors(targetStart, targetEnd, f);
// Apply targetShift — interpolate between start and end for dolly effect
if (section.targetShift) {
const shiftStart = new THREE.Vector3(...section.targetShift);
const shiftEnd = new THREE.Vector3(...(section.targetShiftEnd || section.targetShift));
const shift = new THREE.Vector3().lerpVectors(shiftStart, shiftEnd, f);
if (assemblyGroup.current) {
shift.applyMatrix4(assemblyGroup.current.matrixWorld).sub(
new THREE.Vector3().setFromMatrixPosition(assemblyGroup.current.matrixWorld)
);
}
target.add(shift);
}
const offStart = new THREE.Vector3(...section.camOffset);
const offEnd = new THREE.Vector3(...(section.camOffsetEnd || section.camOffset));
const offset = new THREE.Vector3().lerpVectors(offStart, offEnd, f);
const camPos = target.clone().add(offset);
camera.position.lerp(camPos, Math.min(1, delta * 2));
camera.lookAt(target);
// Update section
if (section.id !== currentSection) {
setCurrentSection(section.id);
onSection(section.id);
}
});
return (
<group ref={assemblyGroup} rotation={[-0.25, 0, 0]}>
{CARDS_DEF.map((card, i) => (
<group key={i}
ref={(el) => { if (el) cardsRef.current[i] = el; }}
position={card.position} rotation={card.rotation}
onClick={(e) => { e.stopPropagation(); window.location.href = '/contact/'; }}
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
onPointerOut={() => { document.body.style.cursor = ''; }}>
<CardModel file={card.file} />
{/* Tuiles 3D plaquées sur les faces */}
{PCB_LABELS.filter(l => l.cardIndex === i).map((label, li) => (
<PcbTile key={`t${li}`} offset={label.localOffset}
title={label.title} sub={label.sub} color={label.color}
visible={currentSection === label.section || (label.section === 'cas' && currentSection === 'media')} section={label.section}
href={label.href} />
))}
{/* Videos on mosfet bottoms — real 3D mesh planes */}
{MOSFET_VIDEOS.filter(v => v.cardIndex === i).map((v, vi) => (
<PcbVideoMesh key={`v${vi}`} offset={[0, 0, -0.1]} src={v.src}
visible={currentSection === 'media' || currentSection === 'cas'} />
))}
{/* Carousel on mosfet #4 bottom — real 3D mesh plane */}
{i === MOSFET_CAROUSEL_CARD && (
<PcbCarouselMesh offset={[0, 0, -0.1]} visible={currentSection === 'media' || currentSection === 'cas'} />
)}
</group>
))}
</group>
);
}
/* ── Pulsed Bloom — machine breathing ── */
function PulsedBloom() {
const bloomRef = useRef<any>(null);
useFrame(({ clock }) => {
if (!bloomRef.current) return;
const t = clock.getElapsedTime();
bloomRef.current.intensity = 0.25 + Math.sin(t * 0.8) * 0.1;
});
return <Bloom ref={bloomRef} intensity={0.25} luminanceThreshold={0.8} luminanceSmoothing={0.9} mipmapBlur />;
}
/* ── Export ── */
export function BmuViewer() {
const scrollRef = useRef(0);
const [, setSectionId] = useState('hero');
useEffect(() => {
function onScroll() {
const max = document.documentElement.scrollHeight - window.innerHeight;
scrollRef.current = max > 0 ? window.scrollY / max : 0;
}
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<div style={{ position: 'fixed', inset: 0, width: '100vw', height: '100vh', zIndex: 0, pointerEvents: 'none' }}>
<Canvas gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
dpr={[1, 1.5]} camera={{ fov: 35, near: 0.01, far: 50 }}
style={{ background: 'transparent', pointerEvents: 'none' }}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 8, 5]} intensity={2.2} color="#ffffff" />
<directionalLight position={[-3, 4, -2]} intensity={0.9} color="#5bd1d8" />
<directionalLight position={[0, -4, 2]} intensity={0.5} color="#f1c27a" />
<Suspense fallback={null}>
<Assembly scrollRef={scrollRef} onSection={setSectionId} />
<ContactShadows position={[0, -1.5, 0]} opacity={0.15} scale={10} blur={2.5} far={5} color="#000000" />
<Environment preset="city" />
</Suspense>
</Canvas>
</div>
);
}
+24 -120
View File
@@ -12,9 +12,7 @@ Contexte :
Ce que j'attends :
Contraintes (délai, budget) :`;
const defaultMailtoHref = `mailto:contact@lelectronrare.fr?subject=${encodeURIComponent("Contact L'Électron Rare")}&body=${encodeURIComponent(DEFAULT_MESSAGE)}`;
const apiBase = (import.meta.env.PUBLIC_API_URL || '').replace(/\/$/, '');
const CONTACT_FORM_ENDPOINT = apiBase ? `${apiBase}/api/submit-lead` : '/api/submit-lead';
// All contact goes through CRM form — no mailto
---
<section
@@ -28,12 +26,12 @@ const CONTACT_FORM_ENDPOINT = apiBase ? `${apiBase}/api/submit-lead` : '/api/sub
<span class="contact-minitel-dot" aria-hidden="true"></span>
<span class="contact-minitel-dot contact-minitel-dot--amber" aria-hidden="true"></span>
<span class="contact-minitel-dot contact-minitel-dot--green" aria-hidden="true"></span>
<span class="contact-minitel-title-bar">ÉLECTRON RARE — CONTACT</span>
<span class="contact-minitel-title-bar">Contact</span>
</div>
<div class="contact-minitel-screen">
<h2 id="contact-title" class="contact-minitel-h2">
&gt; Parlons de votre projet_
Parlons de votre projet
</h2>
<p class="contact-minitel-line">
@@ -43,67 +41,11 @@ const CONTACT_FORM_ENDPOINT = apiBase ? `${apiBase}/api/submit-lead` : '/api/sub
Réponse sous 48h avec un premier retour technique.
</p>
<div class="contact-minitel-tabs">
<button
type="button"
class="contact-minitel-tab contact-minitel-tab--active"
data-contact-tab="email"
>
[1] Via e-mail
</button>
<button
type="button"
class="contact-minitel-tab"
data-contact-tab="form"
>
[2] Formulaire direct
</button>
</div>
<div data-contact-panel="email">
<div class="contact-minitel-textarea-wrap">
<label for="contact-draft" class="contact-minitel-label">&gt; MESSAGE :</label>
<textarea
id="contact-draft"
class="contact-minitel-textarea"
spellcheck="true"
rows="8"
>{DEFAULT_MESSAGE}</textarea>
</div>
<div class="contact-minitel-actions">
<a
id="contact-mailto-link"
href={defaultMailtoHref}
{...trackAttrs(TRACK_EVENTS.outboundEmailContact, 'mailto:contact@lelectronrare.fr')}
class="contact-minitel-btn contact-minitel-btn--primary"
>
[ENVOI] Envoyer par mail
</a>
<button
type="button"
class="contact-minitel-btn"
id="contact-copy-btn"
>
[COPIE] Copier le message
</button>
<a
href="https://fr.linkedin.com/in/electron-rare"
target="_blank"
rel="noopener noreferrer"
{...trackAttrs(TRACK_EVENTS.outboundLinkedinContact, 'linkedin.com')}
class="contact-minitel-btn"
>
[LINKEDIN] Envoyer via LinkedIn
</a>
</div>
</div>
<div data-contact-panel="form" style="display:none;">
<div data-contact-panel="form">
<div id="contact-form-container">
<form id="contact-crm-form" class="contact-minitel-form" data-contact-submit-endpoint={CONTACT_FORM_ENDPOINT}>
<form id="contact-crm-form" class="contact-minitel-form">
<div class="contact-minitel-field">
<label for="contact-form-name" class="contact-minitel-label">&gt; NOM :</label>
<label for="contact-form-name" class="contact-minitel-label">Nom</label>
<input
id="contact-form-name"
type="text"
@@ -115,7 +57,7 @@ const CONTACT_FORM_ENDPOINT = apiBase ? `${apiBase}/api/submit-lead` : '/api/sub
</div>
<div class="contact-minitel-field">
<label for="contact-form-email" class="contact-minitel-label">&gt; EMAIL :</label>
<label for="contact-form-email" class="contact-minitel-label">Email</label>
<input
id="contact-form-email"
type="email"
@@ -127,7 +69,7 @@ const CONTACT_FORM_ENDPOINT = apiBase ? `${apiBase}/api/submit-lead` : '/api/sub
</div>
<div class="contact-minitel-field">
<label for="contact-form-phone" class="contact-minitel-label">&gt; TELEPHONE :</label>
<label for="contact-form-phone" class="contact-minitel-label">Telephone</label>
<input
id="contact-form-phone"
type="tel"
@@ -138,7 +80,7 @@ const CONTACT_FORM_ENDPOINT = apiBase ? `${apiBase}/api/submit-lead` : '/api/sub
</div>
<div class="contact-minitel-field">
<label for="contact-form-organization" class="contact-minitel-label">&gt; STRUCTURE :</label>
<label for="contact-form-organization" class="contact-minitel-label">Structure</label>
<input
id="contact-form-organization"
type="text"
@@ -149,7 +91,7 @@ const CONTACT_FORM_ENDPOINT = apiBase ? `${apiBase}/api/submit-lead` : '/api/sub
</div>
<div class="contact-minitel-field">
<label for="contact-form-need-type" class="contact-minitel-label">&gt; BESOIN :</label>
<label for="contact-form-need-type" class="contact-minitel-label">Type de besoin</label>
<select id="contact-form-need-type" class="contact-minitel-select" required>
<option value="" selected disabled>Choisir le type de besoin</option>
<option value="technique">Projet technique</option>
@@ -159,7 +101,7 @@ const CONTACT_FORM_ENDPOINT = apiBase ? `${apiBase}/api/submit-lead` : '/api/sub
</div>
<div class="contact-minitel-textarea-wrap">
<label for="contact-form-message" class="contact-minitel-label">&gt; MESSAGE :</label>
<label for="contact-form-message" class="contact-minitel-label">Message</label>
<textarea
id="contact-form-message"
class="contact-minitel-textarea"
@@ -182,7 +124,7 @@ const CONTACT_FORM_ENDPOINT = apiBase ? `${apiBase}/api/submit-lead` : '/api/sub
id="contact-form-submit"
class="contact-minitel-btn contact-minitel-btn--primary"
>
[ENVOI] Envoyer le message
Envoyer le message
</button>
</div>
<p
@@ -190,14 +132,14 @@ const CONTACT_FORM_ENDPOINT = apiBase ? `${apiBase}/api/submit-lead` : '/api/sub
class="contact-minitel-line"
style="color:var(--accent-warn, #f59e0b);margin-top:0.5rem;display:none"
>
&gt; Erreur d'envoi. Essayez via l'onglet [1] ou par mail direct.
Une erreur est survenue. Veuillez reessayer.
</p>
</form>
</div>
<div id="contact-form-success" style="display:none;">
<p class="contact-minitel-line" style="text-align:center;padding:2rem 0">
&gt; MESSAGE ENVOYE ✓
Message envoye
</p>
<p class="contact-minitel-line contact-minitel-line--dim" style="text-align:center">
Merci ! Je reviens vers vous sous 48h.
@@ -208,21 +150,18 @@ const CONTACT_FORM_ENDPOINT = apiBase ? `${apiBase}/api/submit-lead` : '/api/sub
id="contact-form-reset"
class="contact-minitel-btn"
>
[NOUVEAU] Envoyer un autre message
Envoyer un autre message
</button>
</div>
</div>
</div>
<div class="contact-minitel-footer">
<p class="contact-minitel-line contact-minitel-line--dim">
─────────────────────────────────────────
</p>
<p class="contact-minitel-line contact-minitel-line--dim">
Electronique · automatisme · energie · diagnostic · formation
</p>
<p class="contact-minitel-line">
Mail direct : <a href="mailto:contact@lelectronrare.fr" class="contact-minitel-link">contact@lelectronrare.fr</a>
<p class="contact-minitel-line contact-minitel-line--dim">
Vos donnees sont traitees de maniere securisee.
</p>
</div>
</div>
@@ -230,6 +169,7 @@ const CONTACT_FORM_ENDPOINT = apiBase ? `${apiBase}/api/submit-lead` : '/api/sub
</section>
<script>
const CRM_INTAKE_ENDPOINT = 'https://api.lelectronrare.fr/api/submit-lead';
const DEFAULT_MESSAGE = `Bonjour Clément,
J'ai un projet / besoin autour d'un système électronique spécifique.
@@ -253,42 +193,7 @@ Contraintes (délai, budget) :`;
});
};
const tabButtons = document.querySelectorAll<HTMLButtonElement>('[data-contact-tab]');
const panels = document.querySelectorAll<HTMLElement>('[data-contact-panel]');
tabButtons.forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.dataset.contactTab!;
tabButtons.forEach((button) => button.classList.toggle('contact-minitel-tab--active', button.dataset.contactTab === target));
panels.forEach((panel) => {
panel.style.display = panel.dataset.contactPanel === target ? '' : 'none';
});
});
});
const draftArea = document.getElementById('contact-draft') as HTMLTextAreaElement;
const mailtoLink = document.getElementById('contact-mailto-link') as HTMLAnchorElement;
function updateMailtoHref() {
const subject = encodeURIComponent("Contact L'Électron Rare");
const body = encodeURIComponent(draftArea.value);
mailtoLink.href = `mailto:contact@lelectronrare.fr?subject=${subject}&body=${body}`;
}
draftArea.addEventListener('input', updateMailtoHref);
const copyBtn = document.getElementById('contact-copy-btn') as HTMLButtonElement;
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(draftArea.value);
copyBtn.textContent = '[OK] Message copie ✓';
setTimeout(() => {
copyBtn.textContent = '[COPIE] Copier le message';
}, 1800);
} catch {
// Silently fail — clipboard API may not be available.
}
});
// All contact goes through CRM form — no tabs
const form = document.getElementById('contact-crm-form') as HTMLFormElement;
const formContainer = document.getElementById('contact-form-container') as HTMLElement;
@@ -302,7 +207,6 @@ Contraintes (délai, budget) :`;
const needTypeInput = document.getElementById('contact-form-need-type') as HTMLSelectElement;
const messageArea = document.getElementById('contact-form-message') as HTMLTextAreaElement;
const consentInput = document.getElementById('contact-form-consent') as HTMLInputElement;
const CRM_INTAKE_ENDPOINT = form.dataset.contactSubmitEndpoint || '/api/submit-lead';
const resetContactForm = () => {
nameInput.value = '';
@@ -317,9 +221,9 @@ Contraintes (délai, budget) :`;
form.addEventListener('submit', async (event) => {
event.preventDefault();
formError.style.display = 'none';
formError.textContent = "> Erreur d'envoi. Essayez via l'onglet [1] ou par mail direct.";
formError.textContent = "Une erreur est survenue. Veuillez reessayer.";
submitBtn.disabled = true;
submitBtn.textContent = '[...] Envoi en cours...';
submitBtn.textContent = 'Envoi en cours...';
const payload = {
name: nameInput.value.trim(),
@@ -354,8 +258,8 @@ Contraintes (délai, budget) :`;
} else {
const detail = result?.error?.message || result?.error?.code;
formError.textContent = detail
? `> ${detail}. Essayez via l'onglet [1] ou par mail direct.`
: "> Erreur d'envoi. Essayez via l'onglet [1] ou par mail direct.";
? detail
: "Une erreur est survenue. Veuillez reessayer.";
formError.style.display = '';
pushTrackingEvent('contact_form_error', {
destination: CRM_INTAKE_ENDPOINT,
@@ -373,7 +277,7 @@ Contraintes (délai, budget) :`;
}
submitBtn.disabled = false;
submitBtn.textContent = '[ENVOI] Envoyer le message';
submitBtn.textContent = 'Envoyer le message';
});
const resetBtn = document.getElementById('contact-form-reset') as HTMLButtonElement;
+149 -46
View File
@@ -38,36 +38,57 @@ const structuredDataJsonLd = JSON.stringify({
url: CANONICAL_URL,
name: "L'électron rare",
inLanguage: 'fr-FR',
description: SITE_META.description
description: SITE_META.description,
potentialAction: {
'@type': 'SearchAction',
target: `${CANONICAL_URL}?q={search_term_string}`,
'query-input': 'required name=search_term_string'
}
},
{
'@type': 'Organization',
'@id': `${CANONICAL_URL}#organization`,
name: "L'électron rare",
legalName: "Clément Saillant — L'Électron Rare",
url: CANONICAL_URL,
logo: new URL('assets/brand/logo-mark.png', CANONICAL_URL).href,
logo: {
'@type': 'ImageObject',
url: new URL('assets/brand/logo-mark.png', CANONICAL_URL).href,
width: 512,
height: 512
},
image: new URL('assets/og-cover.jpg', CANONICAL_URL).href,
foundingDate: '2024',
founder: { '@id': `${CANONICAL_URL}#person` },
address: {
'@type': 'PostalAddress',
addressCountry: 'FR',
addressLocality: 'France'
},
contactPoint: {
'@type': 'ContactPoint',
contactType: 'customer service',
email: 'contact@lelectronrare.fr',
url: `${CANONICAL_URL}#contact`,
availableLanguage: ['fr', 'en']
},
knowsAbout: [
'systèmes électroniques spécifiques',
'conception électronique sur mesure',
'conception de systèmes électroniques',
'intégration électronique multi-technique',
'systèmes électroniques sur mesure',
'consulting électronique',
'formation électronique appliquée',
'prototype hardware',
'automatisme industriel',
'gestion energie',
'stockage energie',
'optimisation systeme',
'développement IoT',
'microcontrôleur',
'instrumentation',
'télémétrie',
'contrôle LED',
'PCB KiCad',
'conception selon IPC-A-610',
'compatibilité électromagnétique CEM',
'DMX Art-Net DANTE'
'gestion énergie et stockage',
'développement IoT et embarqué',
'microcontrôleur ESP32 STM32',
'instrumentation et télémétrie',
'PCB KiCad conception IPC-A-610',
'compatibilité électromagnétique CEM EMC',
'DMX Art-Net DANTE spectacle vivant',
'intelligence artificielle embarquée',
'prototypage rapide et petites séries'
],
sameAs: [
'https://fr.linkedin.com/in/electron-rare',
@@ -81,34 +102,32 @@ const structuredDataJsonLd = JSON.stringify({
name: 'Clément Saillant',
alternateName: "L'électron rare",
url: CANONICAL_URL,
jobTitle: 'Concepteur de systemes electroniques specifiques',
description:
"Concepteur de systemes electroniques specifiques. Diagnostic, conception, mise au point et fiabilisation de projets electroniques, avec mobilisation de partenaires adaptes quand le projet devient multi-technique.",
jobTitle: 'Concepteur de systèmes électroniques spécifiques',
description: "Concepteur de systèmes électroniques spécifiques. Diagnostic, conception, mise au point et fiabilisation de projets électroniques, avec mobilisation de partenaires adaptés quand le projet devient multi-technique.",
hasCredential: {
'@type': 'EducationalOccupationalCredential',
credentialCategory: 'BTS',
name: 'BTS Systèmes Numériques'
},
hasOccupation: {
'@type': 'Occupation',
name: 'Concepteur électronique embarquée',
occupationLocation: { '@type': 'Country', name: 'France' },
skills: 'PCB design, embedded firmware, industrial automation, energy management, IoT, AI-assisted electronics'
},
knowsAbout: [
'specific electronic systems',
'electronic systems design',
'custom electronics designer',
'electronics consultant',
'electronics trainer',
'electronic systems developer',
'IoT developer',
'systemes electroniques specifiques',
'consulting electronique',
'formation electronique',
'prototype hardware',
'automatisme industriel',
'gestion energie',
'stockage energie',
'optimisation systeme',
'microcontroleur',
'instrumentation',
'PCB design KiCad',
'IPC-A-610',
'CEM EMC',
'DMX Art-Net DANTE'
'specific electronic systems', 'electronic systems design', 'custom electronics designer',
'electronics consultant', 'electronics trainer', 'IoT developer',
'embedded AI', 'ESP32', 'STM32', 'KiCad', 'FreeCAD',
'automatisme industriel', 'énergie et stockage',
'PCB design', 'IPC-A-610', 'CEM EMC', 'DMX Art-Net DANTE',
'spectacle vivant', 'industries créatives'
],
worksFor: {
'@id': `${CANONICAL_URL}#organization`
worksFor: { '@id': `${CANONICAL_URL}#organization` },
memberOf: {
'@type': 'Organization',
name: 'KompleX KapharnaüM',
url: 'https://www.kfrancetyphon.com'
},
sameAs: [
'https://fr.linkedin.com/in/electron-rare',
@@ -122,9 +141,79 @@ const structuredDataJsonLd = JSON.stringify({
name: "L'électron rare",
url: canonical,
provider: { '@id': `${CANONICAL_URL}#person` },
serviceType: ['Systèmes électroniques spécifiques', 'Prototype électronique', 'Instrumentation', 'Automatisme industriel', 'Consulting électronique', 'Formation technique'],
serviceType: [
'Systèmes électroniques spécifiques',
'Prototype électronique',
'Instrumentation et contrôle',
'Automatisme industriel',
'Consulting électronique',
'Formation technique',
'Énergie et stockage',
'Intégration IA embarquée'
],
areaServed: { '@type': 'Country', name: 'France' },
description: 'Conception, mise au point et fiabilisation de systèmes électroniques spécifiques, avec partenaires adaptés pour les projets multi-techniques.'
description: 'Conception, mise au point et fiabilisation de systèmes électroniques spécifiques, avec partenaires adaptés pour les projets multi-techniques.',
priceRange: '€€',
hasOfferCatalog: {
'@type': 'OfferCatalog',
name: 'Formats de mission',
itemListElement: [
{
'@type': 'Offer',
itemOffered: {
'@type': 'Service',
name: 'Diagnostic',
description: 'Analyse de faisabilité, identification des risques, plan d\'action — 1 à 2 semaines'
}
},
{
'@type': 'Offer',
itemOffered: {
'@type': 'Service',
name: 'Prototype',
description: 'Prototype testé, mesures documentées, go/no-go — 4 à 6 semaines'
}
},
{
'@type': 'Offer',
itemOffered: {
'@type': 'Service',
name: 'Mission complète',
description: 'Livrables phasés, dossier de production, transfert clé en main — 2 à 6 mois'
}
}
]
}
},
{
'@type': 'FAQPage',
'@id': `${CANONICAL_URL}#faq`,
mainEntity: [
{
'@type': 'Question',
name: 'Quels types de projets électroniques prenez-vous en charge ?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Cartes sur mesure, interfaces capteurs, alimentations, reprise d\'existant, instrumentation, automatisme, dispositifs pour le spectacle vivant, gestion énergie et stockage, et montages multi-techniques avec partenaires.'
}
},
{
'@type': 'Question',
name: 'Travaillez-vous avec des technologies IA embarquée ?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Oui, nous intégrons l\'intelligence artificielle dans les systèmes embarqués : inférence locale sur ESP32/STM32, optimisation de modèles pour edge computing, et assistance IA pour la conception PCB via KiCad et nos outils internes.'
}
},
{
'@type': 'Question',
name: 'Quelle est la durée typique d\'un projet ?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Un diagnostic prend 1 à 2 semaines. Un prototype 4 à 6 semaines. Une mission complète de 2 à 6 mois selon la complexité.'
}
}
]
}
]
});
@@ -140,6 +229,11 @@ const structuredDataJsonLd = JSON.stringify({
<title>{title}</title>
<meta name="theme-color" content={SITE_META.themeColor} />
<meta name="description" content={description} />
<meta name="keywords" content={SITE_META.keywords} />
<meta name="author" content={SITE_META.author} />
<meta name="language" content="fr" />
<meta name="geo.region" content="FR" />
<meta name="geo.placename" content="France" />
<link rel="icon" type="image/svg+xml" href={withSiteBase('/favicon.svg')} />
<link rel="icon" type="image/png" sizes="32x32" href={withSiteBase('/favicon-32x32.png')} />
<link rel="icon" type="image/png" sizes="16x16" href={withSiteBase('/favicon-16x16.png')} />
@@ -149,6 +243,15 @@ const structuredDataJsonLd = JSON.stringify({
<ClientRouter />
<link rel="canonical" href={canonical} />
<meta name="robots" content={robotsContent} />
<meta name="robots" content="max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
<!-- AI / LLM crawling directives (SOTA 2026) -->
<meta name="ai-content-declaration" content="human-created" />
<meta name="generator-assisted" content="Claude Code (Anthropic) — site structure and WebGL; content authored by Clément Saillant" />
<link rel="llms" href={withSiteBase('/llms.txt')} type="text/plain" />
<!-- Speakable (voice search optimization) -->
<meta name="speakable" content="true" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="fr_FR" />
@@ -218,7 +321,7 @@ const structuredDataJsonLd = JSON.stringify({
var uiAllowed = { a: 1, b: 1 };
var uiSprint = uiRaw.toLowerCase().trim() in uiAllowed ? uiRaw.toLowerCase().trim() : 'b';
root.setAttribute('data-da-variant', 'v12');
root.setAttribute('data-da-variant', 'classic');
root.setAttribute('data-ui-sprint', uiSprint);
})();
</script>
@@ -241,7 +344,7 @@ const structuredDataJsonLd = JSON.stringify({
<script is:inline>
(function () {
var daVariant = document.documentElement.getAttribute('data-da-variant') || 'v12';
var daVariant = document.documentElement.getAttribute('data-da-variant') || 'classic';
var pagePath = window.location.pathname || '/';
var pageTitle = document.title || '';
+4 -1
View File
@@ -61,7 +61,10 @@ export const SITE_META = {
twitterDescription:
"Électronique, automatisme, énergie, stockage : systèmes spécifiques, mise au point et coordination multi-technique avec les bons partenaires.",
ogImagePath: 'assets/og-cover.jpg',
themeColor: '#ffffff'
themeColor: '#5bd1d8',
keywords: 'systèmes électroniques spécifiques, conception électronique sur mesure, prototype électronique, PCB KiCad, automatisme industriel, ESP32, STM32, IoT embarqué, formation électronique, énergie stockage batterie, CEM EMC, spectacle vivant DMX, consultant électronique France, IA embarquée, intelligence artificielle électronique',
author: 'Clément Saillant',
locale: 'fr_FR'
} as const;
export const SITE_OG_IMAGE_URL = new URL(SITE_META.ogImagePath, PUBLIC_SITE_ROOT_URL).href;
+48 -214
View File
@@ -1,244 +1,78 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import '@/styles/global.css';
---
<BaseLayout title="404 — Électron introuvable" description="Cette page n'existe pas." canonicalPath="/404">
<BaseLayout title="404 — Électron introuvable" description="Cette page n'existe pas.">
<main class="page-404">
<svg class="atom" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
<!-- Orbital paths -->
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-cyan-soft, #5bd1d866)" stroke-width="0.8" transform="rotate(-30 150 150)"/>
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-amber-soft, #f1c27a66)" stroke-width="0.8" transform="rotate(30 150 150)"/>
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-green-soft, #b6d18f44)" stroke-width="0.8" transform="rotate(90 150 150)"/>
<!-- Nucleus glow -->
<circle cx="150" cy="150" r="18" fill="url(#nucleusGlow)"/>
<circle cx="150" cy="150" r="8" fill="var(--accent, #f1c27a)"/>
<!-- Electron 1 (cyan) -->
<circle r="5" fill="var(--electric, #5bd1d8)">
<animateMotion dur="3s" repeatCount="indefinite">
<mpath href="#orbit1"/>
</animateMotion>
</circle>
<circle r="10" fill="var(--electric, #5bd1d8)" opacity="0.3">
<animateMotion dur="3s" repeatCount="indefinite">
<mpath href="#orbit1"/>
</animateMotion>
</circle>
<!-- Electron 2 (amber) -->
<circle r="4" fill="var(--accent, #f1c27a)">
<animateMotion dur="4.2s" repeatCount="indefinite">
<mpath href="#orbit2"/>
</animateMotion>
</circle>
<circle r="9" fill="var(--accent, #f1c27a)" opacity="0.25">
<animateMotion dur="4.2s" repeatCount="indefinite">
<mpath href="#orbit2"/>
</animateMotion>
</circle>
<!-- Electron 3 (missing - dashed orbit, blinking) -->
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-green, #b6d18f)" stroke-width="0.5" stroke-dasharray="4 8" transform="rotate(90 150 150)" opacity="0.5">
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-cyan-soft,#5bd1d866)" stroke-width="0.8" transform="rotate(-30 150 150)"/>
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-amber-soft,#f1c27a66)" stroke-width="0.8" transform="rotate(30 150 150)"/>
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-green,#b6d18f)" stroke-width="0.5" stroke-dasharray="4 8" transform="rotate(90 150 150)" opacity="0.5">
<animate attributeName="stroke-dashoffset" from="0" to="48" dur="3s" repeatCount="indefinite"/>
</ellipse>
<circle r="3.5" fill="var(--trace-green, #b6d18f)" opacity="0.15">
<animateMotion dur="5.8s" repeatCount="indefinite">
<mpath href="#orbit3"/>
</animateMotion>
<circle cx="150" cy="150" r="18" fill="url(#ng)"/><circle cx="150" cy="150" r="8" fill="var(--accent,#f1c27a)"/>
<circle r="5" fill="var(--electric,#5bd1d8)"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#p1"/></animateMotion></circle>
<circle r="10" fill="var(--electric,#5bd1d8)" opacity="0.3"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#p1"/></animateMotion></circle>
<circle r="4" fill="var(--accent,#f1c27a)"><animateMotion dur="4.2s" repeatCount="indefinite"><mpath href="#p2"/></animateMotion></circle>
<circle r="9" fill="var(--accent,#f1c27a)" opacity="0.25"><animateMotion dur="4.2s" repeatCount="indefinite"><mpath href="#p2"/></animateMotion></circle>
<circle r="3.5" fill="var(--trace-green,#b6d18f)" opacity="0.15">
<animateMotion dur="5.8s" repeatCount="indefinite"><mpath href="#p3"/></animateMotion>
<animate attributeName="opacity" values="0.15;0.5;0.15;0" dur="5.8s" repeatCount="indefinite"/>
</circle>
<!-- Hidden motion paths -->
<defs>
<ellipse id="orbit1" cx="150" cy="150" rx="120" ry="45" transform="rotate(-30 150 150)" fill="none"/>
<ellipse id="orbit2" cx="150" cy="150" rx="120" ry="45" transform="rotate(30 150 150)" fill="none"/>
<ellipse id="orbit3" cx="150" cy="150" rx="120" ry="45" transform="rotate(90 150 150)" fill="none"/>
<radialGradient id="nucleusGlow">
<stop offset="0%" stop-color="var(--accent, #f1c27a)" stop-opacity="0.6"/>
<stop offset="100%" stop-color="var(--accent, #f1c27a)" stop-opacity="0"/>
</radialGradient>
<ellipse id="p1" cx="150" cy="150" rx="120" ry="45" transform="rotate(-30 150 150)" fill="none"/>
<ellipse id="p2" cx="150" cy="150" rx="120" ry="45" transform="rotate(30 150 150)" fill="none"/>
<ellipse id="p3" cx="150" cy="150" rx="120" ry="45" transform="rotate(90 150 150)" fill="none"/>
<radialGradient id="ng"><stop offset="0%" stop-color="var(--accent,#f1c27a)" stop-opacity="0.6"/><stop offset="100%" stop-color="var(--accent,#f1c27a)" stop-opacity="0"/></radialGradient>
</defs>
</svg>
<h1>4<span class="zero">0</span>4</h1>
<p class="tagline" id="tagline"></p>
<p class="explanation" id="explanation"></p>
<div class="actions">
<a href="/" class="btn-home">← Retour au noyau</a>
</div>
<div class="actions"><a href="/" class="btn-home">← Retour au noyau</a></div>
<p class="footnote" id="footnote"></p>
</main>
</BaseLayout>
<script>
const messages = [
{
tagline: "Électron introuvable.",
explanation: "Selon le principe d'incertitude de Heisenberg, plus on connaît précisément l'URL, moins on sait où est la page.",
footnote: "Erreur 404 · Couche électronique non peuplée · Probabilité de présence : 0%"
},
{
tagline: "Superposition quantique.",
explanation: "Cette page existe et n'existe pas en même temps. Malheureusement, votre navigateur vient de l'observer.",
footnote: "Erreur 404 · Fonction d'onde effondrée · Chat de Schrödinger : pas trouvé"
},
{
tagline: "Effet tunnel raté.",
explanation: "La page a tenté de traverser la barrière de potentiel du serveur. Elle n'avait pas assez d'énergie.",
footnote: "Erreur 404 · Barrière : 1.21 eV · Énergie requise : insuffisante"
},
{
tagline: "Signal non détecté.",
explanation: "On a branché l'oscilloscope, vérifié la masse, suivi les pistes du PCB. Rien. Le signal s'arrête ici.",
footnote: "Erreur 404 · Multimètre : OL · Continuité : FAIL"
},
{
tagline: "Composant manquant.",
explanation: "La BOM indique un composant à cet emplacement. Mais le pick-and-place a oublié de le poser.",
footnote: "Erreur 404 · Ref: U404 · Empreinte : NOT_POPULATED"
},
{
tagline: "Court-circuit temporel.",
explanation: "Cette page existera peut-être dans le futur. Ou elle a existé dans le passé. La relativité est floue là-dessus.",
footnote: "Erreur 404 · Référentiel : indéterminé · Temps propre : NaN"
},
{
tagline: "Masse flottante.",
explanation: "Sans référence de masse, impossible de mesurer quoi que ce soit. Cette URL flotte dans le vide intersidéral du serveur.",
footnote: "Erreur 404 · GND : déconnecté · Potentiel : indéfini"
},
{
tagline: "Intrication rompue.",
explanation: "Cette page était intriquée avec une autre. Quelqu'un a mesuré l'autre page et celle-ci s'est décohérée.",
footnote: "Erreur 404 · Bell inequality : violated · EPR : paradox"
},
{
tagline: "Fréquence hors bande.",
explanation: "Votre requête émet sur une fréquence que notre serveur ne peut pas capter. Essayez de réaccorder votre URL.",
footnote: "Erreur 404 · Bande passante : 0 Hz · Filtre : rejet total"
},
{
tagline: "Soudure froide.",
explanation: "La connexion entre votre navigateur et cette page présente une soudure froide. Contact intermittent. Page absente.",
footnote: "Erreur 404 · Joint : CMS · Diagnostic : reflow nécessaire"
}
{ tagline:"Électron introuvable.", explanation:"Selon le principe d'incertitude de Heisenberg, plus on connaît précisément l'URL, moins on sait où est la page.", footnote:"Erreur 404 · Couche électronique non peuplée · Probabilité de présence : 0%" },
{ tagline:"Superposition quantique.", explanation:"Cette page existe et n'existe pas en même temps. Malheureusement, votre navigateur vient de l'observer.", footnote:"Erreur 404 · Fonction d'onde effondrée · Chat de Schrödinger : pas trouvé" },
{ tagline:"Effet tunnel raté.", explanation:"La page a tenté de traverser la barrière de potentiel du serveur. Elle n'avait pas assez d'énergie.", footnote:"Erreur 404 · Barrière : 1.21 eV · Énergie requise : insuffisante" },
{ tagline:"Signal non détecté.", explanation:"On a branché l'oscilloscope, vérifié la masse, suivi les pistes du PCB. Rien. Le signal s'arrête ici.", footnote:"Erreur 404 · Multimètre : OL · Continuité : FAIL" },
{ tagline:"Composant manquant.", explanation:"La BOM indique un composant à cet emplacement. Mais le pick-and-place a oublié de le poser.", footnote:"Erreur 404 · Ref: U404 · Empreinte : NOT_POPULATED" },
{ tagline:"Court-circuit temporel.", explanation:"Cette page existera peut-être dans le futur. Ou elle a existé dans le passé. La relativité est floue là-dessus.", footnote:"Erreur 404 · Référentiel : indéterminé · Temps propre : NaN" },
{ tagline:"Masse flottante.", explanation:"Sans référence de masse, impossible de mesurer quoi que ce soit. Cette URL flotte dans le vide intersidéral du serveur.", footnote:"Erreur 404 · GND : déconnecté · Potentiel : indéfini" },
{ tagline:"Intrication rompue.", explanation:"Cette page était intriquée avec une autre. Quelqu'un a mesuré l'autre page et celle-ci s'est décohérée.", footnote:"Erreur 404 · Bell inequality : violated · EPR : paradox" },
{ tagline:"Fréquence hors bande.", explanation:"Votre requête émet sur une fréquence que notre serveur ne peut pas capter. Essayez de réaccorder votre URL.", footnote:"Erreur 404 · Bande passante : 0 Hz · Filtre : rejet total" },
{ tagline:"Soudure froide.", explanation:"La connexion entre votre navigateur et cette page présente une soudure froide. Contact intermittent. Page absente.", footnote:"Erreur 404 · Joint : CMS · Diagnostic : reflow nécessaire" }
];
let currentIndex = Math.floor(Math.random() * messages.length);
function updateMessage() {
const msg = messages[currentIndex];
const tagline = document.getElementById('tagline');
const explanation = document.getElementById('explanation');
const footnote = document.getElementById('footnote');
tagline.style.opacity = '0';
explanation.style.opacity = '0';
footnote.style.opacity = '0';
let i = Math.floor(Math.random() * messages.length);
function show() {
const m = messages[i];
['tagline','explanation','footnote'].forEach(id => { document.getElementById(id).style.opacity='0'; });
setTimeout(() => {
tagline.textContent = msg.tagline;
explanation.textContent = msg.explanation;
footnote.textContent = msg.footnote;
tagline.style.opacity = '1';
explanation.style.opacity = '1';
footnote.style.opacity = '1';
document.getElementById('tagline').textContent = m.tagline;
document.getElementById('explanation').textContent = m.explanation;
document.getElementById('footnote').textContent = m.footnote;
['tagline','explanation','footnote'].forEach(id => { document.getElementById(id).style.opacity='1'; });
}, 400);
currentIndex = (currentIndex + 1) % messages.length;
i = (i + 1) % messages.length;
}
updateMessage();
setInterval(updateMessage, 10000);
show(); setInterval(show, 10000);
</script>
<style>
.page-404 {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 48px 24px;
}
.atom {
width: clamp(200px, 40vw, 280px);
height: auto;
margin-bottom: 24px;
}
h1 {
font-family: 'IBM Plex Mono', monospace;
font-size: clamp(72px, 15vw, 140px);
font-weight: 700;
color: var(--text, #f4eee3);
margin: 0;
line-height: 1;
letter-spacing: -4px;
}
.zero {
display: inline-block;
animation: glitch 4s ease-in-out infinite;
color: var(--electric, #5bd1d8);
}
@keyframes glitch {
0%, 88%, 100% { opacity: 1; transform: none; }
90% { opacity: 0.5; transform: translateX(-3px) skewX(-3deg); color: var(--trace-magenta, #d89b63); }
92% { opacity: 0.8; transform: translateX(2px) skewX(1deg); }
94% { opacity: 0.3; transform: translateX(-1px); color: var(--accent, #f1c27a); }
96% { opacity: 1; transform: none; }
}
.tagline {
font-family: 'Fraunces', serif;
font-size: clamp(20px, 3.5vw, 28px);
color: var(--accent, #f1c27a);
margin: 12px 0 20px;
transition: opacity 0.4s ease;
}
.explanation {
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
color: var(--text-muted, #d6cbb9);
line-height: 1.8;
max-width: 480px;
margin: 0 0 32px;
transition: opacity 0.4s ease;
}
.actions { margin-bottom: 40px; }
.btn-home {
display: inline-block;
padding: 12px 28px;
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
font-weight: 500;
color: var(--bg, #0b0a09);
background: var(--accent, #f1c27a);
text-decoration: none;
border-radius: 4px;
transition: all 0.2s;
}
.btn-home:hover {
background: var(--electric, #5bd1d8);
box-shadow: 0 0 20px rgba(91, 209, 216, 0.3);
}
.footnote {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: var(--text-dim, #b6a894);
opacity: 0.5;
letter-spacing: 0.5px;
transition: opacity 0.4s ease;
}
.page-404 { min-height:100vh; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding:48px 24px; }
.atom { width:clamp(200px,40vw,280px); height:auto; margin-bottom:24px; }
h1 { font-family:'IBM Plex Mono',monospace; font-size:clamp(72px,15vw,140px); font-weight:700; color:var(--text,#f4eee3); margin:0; line-height:1; letter-spacing:-4px; }
.zero { display:inline-block; animation:glitch 4s ease-in-out infinite; color:var(--electric,#5bd1d8); }
@keyframes glitch { 0%,88%,100%{opacity:1;transform:none} 90%{opacity:.5;transform:translateX(-3px) skewX(-3deg);color:var(--trace-magenta,#d89b63)} 92%{opacity:.8;transform:translateX(2px) skewX(1deg)} 94%{opacity:.3;transform:translateX(-1px);color:var(--accent,#f1c27a)} 96%{opacity:1;transform:none} }
.tagline { font-family:'Fraunces',serif; font-size:clamp(20px,3.5vw,28px); color:var(--accent,#f1c27a); margin:12px 0 20px; transition:opacity .4s ease; }
.explanation { font-family:'IBM Plex Mono',monospace; font-size:14px; color:var(--text-muted,#d6cbb9); line-height:1.8; max-width:480px; margin:0 0 32px; transition:opacity .4s ease; }
.actions { margin-bottom:40px; }
.btn-home { display:inline-block; padding:12px 28px; font-family:'IBM Plex Mono',monospace; font-size:14px; font-weight:500; color:var(--bg,#0b0a09); background:var(--accent,#f1c27a); text-decoration:none; border-radius:4px; transition:all .2s; }
.btn-home:hover { background:var(--electric,#5bd1d8); box-shadow:0 0 20px rgba(91,209,216,.3); }
.footnote { font-family:'IBM Plex Mono',monospace; font-size:11px; color:var(--text-dim,#b6a894); opacity:.5; letter-spacing:.5px; transition:opacity .4s ease; }
</style>
+67 -109
View File
@@ -1,26 +1,15 @@
import type { APIRoute } from 'astro';
interface LeadSubmission {
interface ContactSubmission {
name: string;
email: string;
phone?: string;
organization?: string;
needType?: string;
message?: string;
sourceChannel?: string;
sourceDetail?: string;
pagePath?: string;
needType?: string;
}
interface FrappePayload {
first_name: string;
last_name?: string;
email: string;
mobile_no?: string;
organization?: string;
custom_source_channel: string;
custom_source_detail?: string;
custom_need_type: string;
custom_segment: string;
}
// Strict email validation (RFC 5322 simplified)
@@ -41,7 +30,6 @@ function isRateLimited(ip: string): boolean {
return false;
}
// Allowed origins for CORS
const ALLOWED_ORIGINS = new Set([
'https://www.lelectronrare.fr',
'https://lelectronrare.fr',
@@ -57,10 +45,13 @@ function corsHeaders(origin: string | null): Record<string, string> {
return headers;
}
function escapeHtml(s: string): string {
return String(s).replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' }[c] as string));
}
export const POST: APIRoute = async ({ request, clientAddress }) => {
const origin = request.headers.get('origin');
// CORS check — reject unknown origins (allow null for same-origin)
if (origin && !ALLOWED_ORIGINS.has(origin)) {
return new Response(JSON.stringify({ error: 'forbidden' }), {
status: 403,
@@ -68,7 +59,6 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
});
}
// Rate limiting
const ip = clientAddress || request.headers.get('x-forwarded-for') || 'unknown';
if (isRateLimited(ip)) {
return new Response(JSON.stringify({ error: 'too many requests' }), {
@@ -77,20 +67,11 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
});
}
const FRAPPE_URL = import.meta.env.FRAPPE_URL || process.env.FRAPPE_URL;
const FRAPPE_API_KEY = import.meta.env.FRAPPE_API_KEY || process.env.FRAPPE_API_KEY;
const FRAPPE_API_SECRET = import.meta.env.FRAPPE_API_SECRET || process.env.FRAPPE_API_SECRET;
const CRM_FALLBACK_URL = import.meta.env.CRM_FALLBACK_URL || process.env.CRM_FALLBACK_URL;
const MAIL_API_URL = process.env.MAIL_API_URL || 'http://mail-api:3200/api/mail/send';
const MAIL_FROM = process.env.MAIL_FROM || 'contact@lelectronrare.fr';
const MAIL_TO = process.env.MAIL_TO || 'contact@lelectronrare.fr';
if (!FRAPPE_URL || !FRAPPE_API_KEY || !FRAPPE_API_SECRET) {
console.error('[submit-lead] Missing Frappe env vars');
return new Response(JSON.stringify({ error: 'server misconfigured' }), {
status: 500,
headers: corsHeaders(origin),
});
}
let body: LeadSubmission;
let body: ContactSubmission;
try {
body = await request.json();
} catch {
@@ -100,9 +81,13 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
});
}
// Validate required fields
const name = typeof body.name === 'string' ? body.name.trim() : '';
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
const phone = typeof body.phone === 'string' ? body.phone.trim() : '';
const organization = typeof body.organization === 'string' ? body.organization.trim() : '';
const needType = typeof body.needType === 'string' ? body.needType.trim() : '';
const message = typeof body.message === 'string' ? body.message.trim() : '';
const pagePath = typeof body.pagePath === 'string' ? body.pagePath.trim() : '';
if (!name || name.length < 2 || name.length > 200) {
return new Response(JSON.stringify({ error: 'name must be 2-200 characters' }), {
@@ -110,108 +95,81 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
headers: corsHeaders(origin),
});
}
if (!email || !EMAIL_RE.test(email)) {
return new Response(JSON.stringify({ error: 'valid email required' }), {
status: 400,
headers: corsHeaders(origin),
});
}
// Split full name into first/last
const nameParts = name.split(/\s+/);
const firstName = nameParts[0];
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : undefined;
const sourceChannel = body.sourceChannel || 'site';
const needType = body.needType || 'technique';
const segment =
needType === 'formation'
? 'formation'
: sourceChannel === 'fat' || sourceChannel === 'hemisphere'
? 'culture'
: 'industrie';
const frappePayload: FrappePayload = {
first_name: firstName,
email,
custom_source_channel: sourceChannel,
custom_need_type: needType,
custom_segment: segment,
};
if (lastName) frappePayload.last_name = lastName;
if (body.phone) frappePayload.mobile_no = body.phone;
if (body.organization) frappePayload.organization = body.organization;
if (body.sourceDetail || body.pagePath) {
frappePayload.custom_source_detail = body.sourceDetail || body.pagePath;
if (message.length > 5000) {
return new Response(JSON.stringify({ error: 'message too long (max 5000)' }), {
status: 400,
headers: corsHeaders(origin),
});
}
console.info('[submit-lead] Submitting lead', { email, sourceChannel, needType, segment });
const subject = `[lelectronrare] ${needType || 'demande'}${name}`;
const FRAPPE_HOST = import.meta.env.FRAPPE_HOST_HEADER || process.env.FRAPPE_HOST_HEADER || '';
const text = `Nouveau message depuis lelectronrare.fr
Nom : ${name}
Email : ${email}
Téléphone : ${phone || '-'}
Organisation: ${organization || '-'}
Type : ${needType || '-'}
Page : ${pagePath || '-'}
IP : ${ip}
Message :
${message || '(vide)'}
`;
const html = `<!DOCTYPE html><html><body style="font-family:system-ui,-apple-system,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#222;">
<h2 style="color:#5bd1d8;border-bottom:1px solid #eee;padding-bottom:8px;">Nouveau message — lelectronrare.fr</h2>
<table style="border-collapse:collapse;width:100%;font-size:14px;">
<tr><td style="padding:6px;"><b>Nom</b></td><td style="padding:6px;">${escapeHtml(name)}</td></tr>
<tr><td style="padding:6px;"><b>Email</b></td><td style="padding:6px;"><a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a></td></tr>
<tr><td style="padding:6px;"><b>Téléphone</b></td><td style="padding:6px;">${escapeHtml(phone || '-')}</td></tr>
<tr><td style="padding:6px;"><b>Organisation</b></td><td style="padding:6px;">${escapeHtml(organization || '-')}</td></tr>
<tr><td style="padding:6px;"><b>Type</b></td><td style="padding:6px;">${escapeHtml(needType || '-')}</td></tr>
<tr><td style="padding:6px;color:#999;font-size:11px;"><b>IP</b></td><td style="padding:6px;color:#999;font-size:11px;">${escapeHtml(ip)}</td></tr>
</table>
<h3 style="margin-top:20px;font-size:14px;color:#666;">Message</h3>
<pre style="background:#f5f5f5;padding:12px;border-radius:6px;white-space:pre-wrap;font-family:inherit;font-size:13px;line-height:1.5;">${escapeHtml(message || '(vide)')}</pre>
<p style="margin-top:20px;font-size:11px;color:#aaa;">Reply-To pointe vers l'email du visiteur — répondre directement.</p>
</body></html>`;
try {
const frappeHeaders: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `token ${FRAPPE_API_KEY}:${FRAPPE_API_SECRET}`,
};
if (FRAPPE_HOST) frappeHeaders['Host'] = FRAPPE_HOST;
const resp = await fetch(`${FRAPPE_URL}/api/resource/CRM Lead`, {
const resp = await fetch(MAIL_API_URL, {
method: 'POST',
headers: frappeHeaders,
body: JSON.stringify(frappePayload),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
from: MAIL_FROM,
to: MAIL_TO,
subject,
text,
html,
replyTo: `${name.replace(/[<>"]/g, '')} <${email}>`,
}),
});
if (!resp.ok) {
// Log status only — never log response body (may contain secrets)
console.error('[submit-lead] Frappe returned', resp.status);
// Dual-write fallback
if (CRM_FALLBACK_URL) {
console.info('[submit-lead] Attempting CRM fallback');
try {
const fallbackResp = await fetch(`${CRM_FALLBACK_URL}/api/intake/lead`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
email,
phone: body.phone,
organization: body.organization,
sourceChannel,
needType,
}),
});
if (fallbackResp.ok) {
return new Response(JSON.stringify({ ok: true, via: 'fallback' }), {
status: 201,
headers: corsHeaders(origin),
});
}
console.error('[submit-lead] Fallback returned', fallbackResp.status);
} catch {
console.error('[submit-lead] Fallback network error');
}
}
return new Response(JSON.stringify({ error: 'submission failed' }), {
console.error('[submit-lead] mail-api returned', resp.status);
return new Response(JSON.stringify({ error: 'send failed' }), {
status: 502,
headers: corsHeaders(origin),
});
}
console.info('[submit-lead] Lead created');
console.info('[submit-lead] Mail relayed via mail-api', { needType, hasOrg: !!organization });
return new Response(JSON.stringify({ ok: true }), {
status: 201,
headers: corsHeaders(origin),
});
} catch {
console.error('[submit-lead] Network error');
return new Response(JSON.stringify({ error: 'internal error' }), {
status: 500,
} catch (err) {
console.error('[submit-lead] mail-api unreachable', err instanceof Error ? err.message : 'unknown');
return new Response(JSON.stringify({ error: 'mail service unreachable' }), {
status: 502,
headers: corsHeaders(origin),
});
}
+135
View File
@@ -0,0 +1,135 @@
---
---
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Conception électronique | L'électron rare</title>
<meta name="description" content="Conception de systèmes électroniques spécifiques : schéma, PCB, firmware, prototypage et validation. Du diagnostic à la mission complète." />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html { background: #06080b; color: #fff; }
body { background: #06080b; min-height: 100vh; font-family: 'Courier New', monospace; }
.site-hdr { position: fixed; top: 0; left: 0; right: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; padding: 16px clamp(20px, 3vw, 40px); background: rgba(6,8,11,0.85); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(255,255,255,0.05); }
.hdr-left { display: flex; align-items: center; gap: 10px; }
.hdr-left a { text-decoration: none; display: flex; align-items: center; gap: 10px; }
.hdr-logo { width: 36px; height: auto; filter: drop-shadow(0 0 12px rgba(91,209,216,0.3)); }
.hdr-name { font-size: 16px; font-weight: 800; color: rgba(255,255,255,0.8); letter-spacing: 0.04em; }
.hdr-nav { display: flex; align-items: center; gap: 8px; }
.hdr-link { padding: 8px 16px; color: rgba(255,255,255,0.6); font-size: 12px; font-weight: 600; text-decoration: none; letter-spacing: 0.04em; transition: color 0.2s; }
.hdr-link:hover { color: #5bd1d8; }
.hdr-link.active { color: #5bd1d8; }
.hdr-cta { padding: 8px 20px; border-radius: 6px; background: rgba(91,209,216,0.15); border: 1px solid rgba(91,209,216,0.3); color: #5bd1d8; font-size: 12px; font-weight: 700; text-decoration: none; letter-spacing: 0.05em; }
.burger { display: none; background: none; border: none; color: #fff; font-size: 24px; cursor: pointer; padding: 8px; }
.mobile-menu { display: none; position: fixed; inset: 0; z-index: 100; background: rgba(6,8,11,0.95); backdrop-filter: blur(16px); flex-direction: column; align-items: center; justify-content: center; gap: 24px; }
.mobile-menu.open { display: flex; }
.mobile-menu a { font-size: 20px; font-weight: 700; color: rgba(255,255,255,0.8); text-decoration: none; letter-spacing: 0.05em; padding: 12px 24px; }
.mobile-menu a:hover { color: #5bd1d8; }
.mobile-menu .close-btn { position: absolute; top: 20px; right: 20px; background: none; border: none; color: #fff; font-size: 28px; cursor: pointer; }
@media (max-width: 768px) {
.hdr-nav { display: none; }
.burger { display: block; }
}
.content { max-width: 800px; margin: 0 auto; padding: 120px 24px 80px; }
h1 { font-size: clamp(28px, 4vw, 48px); font-weight: 800; color: #5bd1d8; margin-bottom: 24px; letter-spacing: -0.02em; }
h2 { font-size: 18px; font-weight: 700; color: #fff; margin: 40px 0 16px; text-transform: uppercase; letter-spacing: 0.06em; }
p { font-size: 14px; color: rgba(255,255,255,0.6); line-height: 1.8; margin-bottom: 16px; }
.card { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 24px; margin-bottom: 16px; }
.card-t { font-size: 16px; font-weight: 700; color: #fff; margin-bottom: 8px; }
.card-pct { font-size: 28px; font-weight: 800; margin-bottom: 4px; }
.card-time { font-size: 11px; color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 12px; }
.card-desc { font-size: 13px; color: rgba(255,255,255,0.5); line-height: 1.6; margin-bottom: 12px; }
.card-del { font-size: 12px; color: rgba(255,255,255,0.4); }
.cta-box { margin-top: 48px; text-align: center; }
.cta-btn { display: inline-block; padding: 14px 32px; border-radius: 8px; background: #5bd1d8; color: #06080b; font-weight: 700; font-size: 14px; text-decoration: none; letter-spacing: 0.04em; }
.cta-btn:hover { background: #7de0e6; }
.footer { text-align: center; padding: 40px 24px; font-size: 10px; color: rgba(255,255,255,0.15); }
</style>
</head>
<body>
<header class="site-hdr">
<div class="hdr-left"><a href="/"><img src="/assets/brand/logo-mark.svg" alt="" class="hdr-logo" /><span class="hdr-name">L'électron rare</span></a></div>
<nav class="hdr-nav">
<a href="/conception/" class="hdr-link active">Conception</a>
<a href="/formation/" class="hdr-link">Formation</a>
<a href="/consulting/" class="hdr-link">Consulting</a>
<a href="/contact/" class="hdr-cta">Contact</a>
</nav>
<button class="burger" id="burger-btn" aria-label="Menu">☰</button>
</header>
<div class="mobile-menu" id="mobile-menu">
<button class="close-btn" id="close-menu">✕</button>
<a href="/">Accueil</a>
<a href="/conception/">Conception</a>
<a href="/formation/">Formation</a>
<a href="/consulting/">Consulting</a>
<a href="/contact/">Contact</a>
</div>
<script>
document.getElementById('burger-btn')?.addEventListener('click',()=>document.getElementById('mobile-menu')?.classList.add('open'));
document.getElementById('close-menu')?.addEventListener('click',()=>document.getElementById('mobile-menu')?.classList.remove('open'));
</script>
<main class="content">
<h1>Conception électronique</h1>
<p>L'Électron Rare conçoit, met au point et fiabilise des systèmes électroniques spécifiques, des automatismes et des ensembles liés à l'énergie, au stockage et à l'optimisation terrain.</p>
<h2>Formats de mission</h2>
<div class="card">
<p class="card-pct" style="color:#5bd1d8">28%</p>
<p class="card-t">Diagnostic</p>
<p class="card-time">1 à 2 semaines</p>
<p class="card-desc">Analyser votre besoin, identifier les sous-systèmes critiques et livrer une carte de faisabilité.</p>
<p class="card-del">Livrables : analyse de faisabilité · risques identifiés · plan d'action</p>
</div>
<div class="card">
<p class="card-pct" style="color:#a78bfa">55%</p>
<p class="card-t">Prototype</p>
<p class="card-time">4 à 6 semaines</p>
<p class="card-desc">Concevoir et tester un prototype fonctionnel : schéma, PCB, firmware, mesures et validation.</p>
<p class="card-del">Livrables : prototype testé · mesures documentées · go/no-go argumenté</p>
</div>
<div class="card">
<p class="card-pct" style="color:#f59e0b">100%</p>
<p class="card-t">Mission complète</p>
<p class="card-time">2 à 6 mois</p>
<p class="card-desc">Développement complet jusqu'au produit industrialisable : itérations, tests terrain, documentation et transfert.</p>
<p class="card-del">Livrables : livrables par lot · dossier de production · transfert clé en main</p>
</div>
<h2>Cas concrets</h2>
<div class="card">
<p class="card-t" style="color:#5bd1d8">Battery Parallelator — KXKM</p>
<p class="card-desc">Système de gestion parallèle de 4 batteries LiFePO4 pour le spectacle vivant. Conception complète KiCad : carte BMU, 4 cartes Mosfet Switch, carte I2C Repeater. Firmware ESP32, supervision I2C, protection thermique.</p>
<p class="card-del" style="color:#5bd1d8;">→ 8h autonomie · OTA · 0 panne sur 2 saisons</p>
</div>
<div class="card">
<p class="card-t" style="color:#a78bfa">HypnoLED — Contrôle LED embarqué</p>
<p class="card-desc">Carte de contrôle LED haute densité pour installations scéniques. Architecture multi-cartes, protocole DMX/Art-Net, topologie bus, alimentation distribuée. PCB 4 couches KiCad.</p>
<p class="card-del" style="color:#a78bfa;">→ 512 canaux DMX · Latence &lt;1ms · Déployé en tournée</p>
</div>
<div class="card">
<p class="card-t" style="color:#34d399">Télémétrie ESP32 — Monitoring énergie</p>
<p class="card-desc">Système de supervision terrain avec ESP32 : mesure courant/tension, transmission MQTT, dashboard temps réel. Capteurs INA226, connectivité WiFi, boîtier IP65.</p>
<p class="card-del" style="color:#34d399;">→ Autonomie 6 mois sur batterie · Dashboard Grafana</p>
</div>
<h2>Domaines d'intervention</h2>
<p>Électronique spécifique · Instrumentation et automatisme · Énergie et stockage · Dispositifs pour le réel · Montages multi-techniques avec partenaires.</p>
<div class="cta-box">
<a href="/contact/?from=conception" class="cta-btn">Discuter de votre projet</a>
</div>
</main>
<div class="footer">© 2026 Clément Saillant — L'électron rare · <a href="https://blog.saillant.cc" target="_blank" rel="noopener" style="color:rgba(255,255,255,0.3);">Blog</a> · <a href="https://fr.linkedin.com/in/electron-rare" target="_blank" rel="noopener" style="color:rgba(255,255,255,0.3);">LinkedIn</a> · <a href="https://github.com/electron-rare/" target="_blank" rel="noopener" style="color:rgba(255,255,255,0.3);">GitHub</a></div>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
---
---
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Consulting technique | L'électron rare</title>
<meta name="description" content="Consulting en systèmes électroniques : audit, diagnostic, transfert de compétences, accompagnement technique terrain." />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html { background: #06080b; color: #fff; }
body { background: #06080b; min-height: 100vh; font-family: 'Courier New', monospace; }
.site-hdr { position: fixed; top: 0; left: 0; right: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; padding: 16px clamp(20px, 3vw, 40px); background: rgba(6,8,11,0.85); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(255,255,255,0.05); }
.hdr-left { display: flex; align-items: center; gap: 10px; }
.hdr-left a { text-decoration: none; display: flex; align-items: center; gap: 10px; }
.hdr-logo { width: 36px; height: auto; filter: drop-shadow(0 0 12px rgba(91,209,216,0.3)); }
.hdr-name { font-size: 16px; font-weight: 800; color: rgba(255,255,255,0.8); letter-spacing: 0.04em; }
.hdr-nav { display: flex; align-items: center; gap: 8px; }
.hdr-link { padding: 8px 16px; color: rgba(255,255,255,0.6); font-size: 12px; font-weight: 600; text-decoration: none; letter-spacing: 0.04em; transition: color 0.2s; }
.hdr-link:hover { color: #5bd1d8; }
.hdr-link.active { color: #5bd1d8; }
.hdr-cta { padding: 8px 20px; border-radius: 6px; background: rgba(91,209,216,0.15); border: 1px solid rgba(91,209,216,0.3); color: #5bd1d8; font-size: 12px; font-weight: 700; text-decoration: none; letter-spacing: 0.05em; }
.burger { display: none; background: none; border: none; color: #fff; font-size: 24px; cursor: pointer; padding: 8px; }
.mobile-menu { display: none; position: fixed; inset: 0; z-index: 100; background: rgba(6,8,11,0.95); backdrop-filter: blur(16px); flex-direction: column; align-items: center; justify-content: center; gap: 24px; }
.mobile-menu.open { display: flex; }
.mobile-menu a { font-size: 20px; font-weight: 700; color: rgba(255,255,255,0.8); text-decoration: none; letter-spacing: 0.05em; padding: 12px 24px; }
.mobile-menu a:hover { color: #5bd1d8; }
.mobile-menu .close-btn { position: absolute; top: 20px; right: 20px; background: none; border: none; color: #fff; font-size: 28px; cursor: pointer; }
@media (max-width: 768px) { .hdr-nav { display: none; } .burger { display: block; } }
.content { max-width: 800px; margin: 0 auto; padding: 120px 24px 80px; }
h1 { font-size: clamp(28px, 4vw, 48px); font-weight: 800; color: #a78bfa; margin-bottom: 24px; letter-spacing: -0.02em; }
h2 { font-size: 18px; font-weight: 700; color: #fff; margin: 40px 0 16px; text-transform: uppercase; letter-spacing: 0.06em; }
p { font-size: 14px; color: rgba(255,255,255,0.6); line-height: 1.8; margin-bottom: 16px; }
.card { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 24px; margin-bottom: 16px; }
.card-t { font-size: 16px; font-weight: 700; color: #fff; margin-bottom: 8px; }
.card-desc { font-size: 13px; color: rgba(255,255,255,0.5); line-height: 1.6; }
.chip { display: inline-block; font-size: 11px; padding: 4px 12px; border-radius: 20px; border: 1px solid; margin: 4px 4px 4px 0; letter-spacing: 0.04em; }
.families { margin: 20px 0 32px; }
.cta-box { margin-top: 48px; text-align: center; }
.cta-btn { display: inline-block; padding: 14px 32px; border-radius: 8px; background: #a78bfa; color: #06080b; font-weight: 700; font-size: 14px; text-decoration: none; letter-spacing: 0.04em; }
.cta-btn:hover { background: #c4b5fd; }
.footer { text-align: center; padding: 40px 24px; font-size: 10px; color: rgba(255,255,255,0.15); }
</style>
</head>
<body>
<header class="site-hdr">
<div class="hdr-left"><a href="/"><img src="/assets/brand/logo-mark.svg" alt="" class="hdr-logo" /><span class="hdr-name">L'électron rare</span></a></div>
<nav class="hdr-nav">
<a href="/conception/" class="hdr-link">Conception</a>
<a href="/formation/" class="hdr-link">Formation</a>
<a href="/consulting/" class="hdr-link active">Consulting</a>
<a href="/contact/" class="hdr-cta">Contact</a>
</nav>
<button class="burger" id="burger-btn" aria-label="Menu">☰</button>
</header>
<div class="mobile-menu" id="mobile-menu">
<button class="close-btn" id="close-menu">✕</button>
<a href="/">Accueil</a><a href="/conception/">Conception</a><a href="/formation/">Formation</a><a href="/consulting/">Consulting</a><a href="/contact/">Contact</a>
</div>
<script>document.getElementById('burger-btn')?.addEventListener('click',()=>document.getElementById('mobile-menu')?.classList.add('open'));document.getElementById('close-menu')?.addEventListener('click',()=>document.getElementById('mobile-menu')?.classList.remove('open'));</script>
<main class="content">
<h1>Consulting technique</h1>
<p>Je suis Clément Saillant, fondateur de L'électron rare. Mon rôle n'est pas de vendre "un peu d'embarqué" en plus, mais de rendre possible le bon système pour le besoin réel : électronique, automatisme, énergie, stockage, optimisation, diagnostic, prototype, reprise d'existant ou fiabilisation.</p>
<h2>Familles d'intervention</h2>
<div class="families">
<span class="chip" style="color:#5bd1d8;border-color:#5bd1d833">Électronique spécifique</span>
<span class="chip" style="color:#a78bfa;border-color:#a78bfa33">Instrumentation & Automatisme</span>
<span class="chip" style="color:#f472b6;border-color:#f472b633">Dispositifs pour le réel</span>
<span class="chip" style="color:#34d399;border-color:#34d39933">Énergie & Stockage</span>
<span class="chip" style="color:#fbbf24;border-color:#fbbf2433">Multi-techniques & Partenaires</span>
</div>
<div class="card">
<p class="card-t">Audit & Diagnostic</p>
<p class="card-desc">Analyse de l'existant, identification des points critiques, recommandations chiffrées. Diagnostic terrain sur site industriel, automate, variateur, instrumentation.</p>
</div>
<div class="card">
<p class="card-t">Accompagnement technique</p>
<p class="card-desc">Pilotage de sous-traitance, revue de conception, validation de prototypes, suivi de production. Mobilisation de partenaires spécialisés quand le projet devient multi-technique.</p>
</div>
<div class="card">
<p class="card-t">Transfert & Documentation</p>
<p class="card-desc">Rédaction de dossiers techniques, procédures de maintenance, documentation de production. Transfert de compétences vers l'équipe interne.</p>
</div>
<h2>Cas concret</h2>
<div class="card">
<p class="card-t">Mise en production et correction V3.2 KXKM</p>
<p class="card-desc">Mise en production du hardware ESP32, correction des bugs V3.2, passage STM32F030. Tests terrain, validation embarquée, documentation technique.</p>
<p class="card-desc" style="color:#a78bfa;margin-top:8px;">→ Production V3.2 livrée · Migration STM32F030 validée</p>
</div>
<h2>À propos</h2>
<div class="card">
<p class="card-t">Clément Saillant — 10 ans d'expérience</p>
<p class="card-desc">Cyborg en systèmes électroniques, j'interviens depuis 10 ans sur des projets allant du spectacle vivant à l'industrie automatisée, en passant par l'énergie et la formation. Contributeur open-source (KiCad, KXKM), ambassadeur IA appliquée à l'électronique.</p>
<p class="card-desc">Secteurs : industries créatives, ferroviaire, automatisme industriel, éducation, énergie/stockage.</p>
</div>
<div class="cta-box">
<a href="/contact/?from=consulting" class="cta-btn">Discuter de votre besoin</a>
</div>
</main>
<div class="footer">
© 2026 Clément Saillant — L'électron rare ·
<a href="https://blog.saillant.cc" target="_blank" rel="noopener" style="color:rgba(255,255,255,0.3);">Blog</a> ·
<a href="https://fr.linkedin.com/in/electron-rare" target="_blank" rel="noopener" style="color:rgba(255,255,255,0.3);">LinkedIn</a> ·
<a href="https://github.com/electron-rare/" target="_blank" rel="noopener" style="color:rgba(255,255,255,0.3);">GitHub</a>
</div>
</body>
</html>
+170
View File
@@ -0,0 +1,170 @@
---
---
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Contact | L'électron rare</title>
<meta name="description" content="Parlons de votre projet électronique. Premier échange sans engagement." />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html { background: #06080b; color: #fff; }
body { background: #06080b; min-height: 100vh; font-family: 'Courier New', monospace; }
.site-hdr { position: fixed; top: 0; left: 0; right: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; padding: 16px clamp(20px, 3vw, 40px); background: rgba(6,8,11,0.85); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(255,255,255,0.05); }
.hdr-left { display: flex; align-items: center; gap: 10px; }
.hdr-left a { text-decoration: none; display: flex; align-items: center; gap: 10px; }
.hdr-logo { width: 36px; height: auto; filter: drop-shadow(0 0 12px rgba(91,209,216,0.3)); }
.hdr-name { font-size: 16px; font-weight: 800; color: rgba(255,255,255,0.8); letter-spacing: 0.04em; }
.hdr-nav { display: flex; align-items: center; gap: 8px; }
.hdr-link { padding: 8px 16px; color: rgba(255,255,255,0.6); font-size: 12px; font-weight: 600; text-decoration: none; letter-spacing: 0.04em; transition: color 0.2s; }
.hdr-link:hover { color: #5bd1d8; }
.hdr-cta { padding: 8px 20px; border-radius: 6px; background: rgba(91,209,216,0.15); border: 1px solid rgba(91,209,216,0.3); color: #5bd1d8; font-size: 12px; font-weight: 700; text-decoration: none; letter-spacing: 0.05em; }
.hdr-cta.active { background: rgba(91,209,216,0.25); }
.burger { display: none; background: none; border: none; color: #fff; font-size: 24px; cursor: pointer; padding: 8px; }
.mobile-menu { display: none; position: fixed; inset: 0; z-index: 100; background: rgba(6,8,11,0.95); backdrop-filter: blur(16px); flex-direction: column; align-items: center; justify-content: center; gap: 24px; }
.mobile-menu.open { display: flex; }
.mobile-menu a { font-size: 20px; font-weight: 700; color: rgba(255,255,255,0.8); text-decoration: none; letter-spacing: 0.05em; padding: 12px 24px; }
.mobile-menu .close-btn { position: absolute; top: 20px; right: 20px; background: none; border: none; color: #fff; font-size: 28px; cursor: pointer; }
@media (max-width: 768px) { .hdr-nav { display: none; } .burger { display: block; } }
.content { max-width: 600px; margin: 0 auto; padding: 120px 24px 80px; }
h1 { font-size: clamp(28px, 4vw, 48px); font-weight: 800; color: #5bd1d8; margin-bottom: 12px; letter-spacing: -0.02em; }
.sub { font-size: 14px; color: rgba(255,255,255,0.4); margin-bottom: 40px; }
label { display: block; font-size: 11px; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 6px; margin-top: 20px; }
input, select, textarea { width: 100%; padding: 12px 16px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; color: #fff; font-family: 'Courier New', monospace; font-size: 14px; outline: none; transition: border-color 0.2s; }
input:focus, select:focus, textarea:focus { border-color: #5bd1d8; }
select { appearance: none; cursor: pointer; }
select option { background: #06080b; color: #fff; }
textarea { resize: vertical; min-height: 160px; }
.consent { display: flex; align-items: flex-start; gap: 10px; margin-top: 20px; font-size: 11px; color: rgba(255,255,255,0.4); line-height: 1.5; }
.consent input { width: auto; }
.submit { display: block; width: 100%; margin-top: 28px; padding: 14px; border: none; border-radius: 8px; background: #5bd1d8; color: #06080b; font-family: 'Courier New', monospace; font-size: 14px; font-weight: 700; cursor: pointer; letter-spacing: 0.04em; transition: background 0.2s; }
.submit:hover { background: #7de0e6; }
.submit:disabled { opacity: 0.5; cursor: not-allowed; }
.success { display: none; text-align: center; padding: 40px 0; }
.success h2 { font-size: 24px; color: #5bd1d8; margin-bottom: 12px; }
.success p { font-size: 14px; color: rgba(255,255,255,0.5); }
.error { display: none; color: #f66; font-size: 12px; margin-top: 12px; }
.alt { margin-top: 40px; text-align: center; font-size: 12px; color: rgba(255,255,255,0.3); }
.alt a { color: #5bd1d8; text-decoration: none; }
.footer { text-align: center; padding: 40px 24px; font-size: 10px; color: rgba(255,255,255,0.15); }
</style>
</head>
<body>
<header class="site-hdr">
<div class="hdr-left"><a href="/"><img src="/assets/brand/logo-mark.svg" alt="" class="hdr-logo" /><span class="hdr-name">L'électron rare</span></a></div>
<nav class="hdr-nav">
<a href="/conception/" class="hdr-link">Conception</a>
<a href="/formation/" class="hdr-link">Formation</a>
<a href="/consulting/" class="hdr-link">Consulting</a>
<a href="/contact/" class="hdr-cta active">Contact</a>
</nav>
<button class="burger" id="burger-btn" aria-label="Menu">☰</button>
</header>
<div class="mobile-menu" id="mobile-menu">
<button class="close-btn" id="close-menu">✕</button>
<a href="/">Accueil</a><a href="/conception/">Conception</a><a href="/formation/">Formation</a><a href="/consulting/">Consulting</a><a href="/contact/">Contact</a>
</div>
<script>document.getElementById('burger-btn')?.addEventListener('click',()=>document.getElementById('mobile-menu')?.classList.add('open'));document.getElementById('close-menu')?.addEventListener('click',()=>document.getElementById('mobile-menu')?.classList.remove('open'));</script>
<main class="content">
<h1>Parlons de votre projet</h1>
<p class="sub">Décrivez votre besoin en quelques lignes — premier échange sans engagement.</p>
<form id="contact-form">
<label for="name">Nom *</label>
<input type="text" id="name" name="name" required placeholder="Votre nom" />
<label for="email">Email *</label>
<input type="email" id="email" name="email" required placeholder="votre@email.com" />
<label for="phone">Téléphone</label>
<input type="tel" id="phone" name="phone" placeholder="06 00 00 00 00" />
<label for="org">Organisation</label>
<input type="text" id="org" name="organization" placeholder="Entreprise / École / Association" />
<label for="need">Type de besoin *</label>
<select id="need" name="needType" required>
<option value="">Choisir...</option>
<option value="conception">Projet technique / Conception</option>
<option value="formation">Formation</option>
<option value="consulting">Consulting / Audit</option>
<option value="mixte">Projet mixte</option>
</select>
<script>
// Préremplir selon la page d'origine (?from=conception|formation|consulting)
const params = new URLSearchParams(window.location.search);
const from = params.get('from');
if (from) {
const sel = document.getElementById('need');
if (sel) sel.value = from;
}
</script>
<label for="message">Message *</label>
<textarea id="message" name="message" required placeholder="Bonjour Clément,
J'ai un projet / besoin autour d'un système électronique spécifique.
Contexte :
Ce que j'attends :
Contraintes (délai, budget) :"></textarea>
<div class="consent">
<input type="checkbox" id="consent" required />
<label for="consent" style="margin:0;text-transform:none;font-size:11px;">J'accepte que mes données soient utilisées pour répondre à ma demande. Aucune donnée n'est transmise à des tiers.</label>
</div>
<button type="submit" class="submit" id="submit-btn">Envoyer le message</button>
<p class="error" id="error-msg"></p>
</form>
<div class="success" id="success-msg">
<h2>Merci !</h2>
<p>Je reviens vers vous sous 48h.</p>
</div>
<div class="alt">
<p>Ou directement : <a href="mailto:contact@lelectronrare.fr">contact@lelectronrare.fr</a></p>
<p style="margin-top:8px;"><a href="https://fr.linkedin.com/in/electron-rare" target="_blank" rel="noopener">LinkedIn</a> · <a href="https://github.com/electron-rare/" target="_blank" rel="noopener">GitHub</a></p>
</div>
</main>
<div class="footer">© 2026 Clément Saillant — L'électron rare · <a href="https://blog.saillant.cc" target="_blank" rel="noopener" style="color:rgba(255,255,255,0.3);">Blog</a> · <a href="https://fr.linkedin.com/in/electron-rare" target="_blank" rel="noopener" style="color:rgba(255,255,255,0.3);">LinkedIn</a> · <a href="https://github.com/electron-rare/" target="_blank" rel="noopener" style="color:rgba(255,255,255,0.3);">GitHub</a></div>
<script>
const form = document.getElementById('contact-form');
const btn = document.getElementById('submit-btn');
const errEl = document.getElementById('error-msg');
const successEl = document.getElementById('success-msg');
form?.addEventListener('submit', async (e) => {
e.preventDefault();
btn.disabled = true;
btn.textContent = 'Envoi...';
errEl.style.display = 'none';
const data = Object.fromEntries(new FormData(form));
try {
const res = await fetch('/api/submit-lead', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Erreur serveur');
form.style.display = 'none';
successEl.style.display = 'block';
if (typeof gtag === 'function') gtag('event', 'contact_form_submit', { need_type: data.needType });
} catch (err) {
errEl.textContent = 'Erreur lors de l\'envoi. Essayez par email : contact@lelectronrare.fr';
errEl.style.display = 'block';
btn.disabled = false;
btn.textContent = 'Envoyer le message';
}
});
</script>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import '@/styles/global.css';
---
<BaseLayout title="L'Electron Rare — Bientôt" description="L'Electron Rare arrive. Systèmes électroniques spécifiques, formations, prototypage.">
<main class="countdown-page">
<svg class="atom" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-cyan-soft, #5bd1d866)" stroke-width="0.8" transform="rotate(-30 150 150)"/>
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-amber-soft, #f1c27a66)" stroke-width="0.8" transform="rotate(30 150 150)"/>
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-green-soft, #b6d18f44)" stroke-width="0.8" transform="rotate(90 150 150)"/>
<circle cx="150" cy="150" r="18" fill="url(#nucleusGlow)"/>
<circle cx="150" cy="150" r="8" fill="var(--accent, #f1c27a)"/>
<circle r="5" fill="var(--electric, #5bd1d8)"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#o1"/></animateMotion></circle>
<circle r="10" fill="var(--electric, #5bd1d8)" opacity="0.3"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#o1"/></animateMotion></circle>
<circle r="4" fill="var(--accent, #f1c27a)"><animateMotion dur="4.2s" repeatCount="indefinite"><mpath href="#o2"/></animateMotion></circle>
<circle r="9" fill="var(--accent, #f1c27a)" opacity="0.25"><animateMotion dur="4.2s" repeatCount="indefinite"><mpath href="#o2"/></animateMotion></circle>
<circle r="3" fill="var(--trace-green, #b6d18f)"><animateMotion dur="5.8s" repeatCount="indefinite"><mpath href="#o3"/></animateMotion></circle>
<circle r="8" fill="var(--trace-green, #b6d18f)" opacity="0.2"><animateMotion dur="5.8s" repeatCount="indefinite"><mpath href="#o3"/></animateMotion></circle>
<defs>
<ellipse id="o1" cx="150" cy="150" rx="120" ry="45" transform="rotate(-30 150 150)" fill="none"/>
<ellipse id="o2" cx="150" cy="150" rx="120" ry="45" transform="rotate(30 150 150)" fill="none"/>
<ellipse id="o3" cx="150" cy="150" rx="120" ry="45" transform="rotate(90 150 150)" fill="none"/>
<radialGradient id="nucleusGlow">
<stop offset="0%" stop-color="var(--accent, #f1c27a)" stop-opacity="0.6"/>
<stop offset="100%" stop-color="var(--accent, #f1c27a)" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>
<h1 class="title">L'<span class="electric">é</span>lectron rare</h1>
<div class="timer" id="timer">
<div class="unit"><span class="num" id="days">--</span><span class="label">jours</span></div>
<div class="sep">:</div>
<div class="unit"><span class="num" id="hours">--</span><span class="label">heures</span></div>
<div class="sep">:</div>
<div class="unit"><span class="num" id="minutes">--</span><span class="label">minutes</span></div>
<div class="sep">:</div>
<div class="unit"><span class="num" id="seconds">--</span><span class="label">secondes</span></div>
</div>
<p class="tagline" id="tagline"></p>
<p class="footnote">Systèmes électroniques spécifiques · Formations · Prototypage</p>
</main>
</BaseLayout>
<script>
const LAUNCH = new Date('2026-05-01T00:00:00+02:00').getTime();
const messages = [
"Charge des condensateurs en cours...",
"Calibration de l'oscilloscope...",
"Soudure des derniers composants...",
"Vérification de la BOM...",
"Flash du firmware en cours...",
"Mesure de continuité... bip.",
"Routage des dernières pistes...",
"Test de la chaîne d'alimentation...",
"Programmation du bootloader...",
"Revue de schéma en cours...",
"Placement des composants CMS...",
"Passage au four de refusion...",
"Debug JTAG en cours...",
"Vérification du plan de masse...",
"Analyse du spectre RF..."
];
let msgIndex = Math.floor(Math.random() * messages.length);
function pad(n) { return String(n).padStart(2, '0'); }
function update() {
const diff = Math.max(0, LAUNCH - Date.now());
document.getElementById('days').textContent = pad(Math.floor(diff / 86400000));
document.getElementById('hours').textContent = pad(Math.floor((diff % 86400000) / 3600000));
document.getElementById('minutes').textContent = pad(Math.floor((diff % 3600000) / 60000));
document.getElementById('seconds').textContent = pad(Math.floor((diff % 60000) / 1000));
}
function rotateMessage() {
const el = document.getElementById('tagline');
el.style.opacity = '0';
setTimeout(() => { el.textContent = messages[msgIndex]; el.style.opacity = '1'; msgIndex = (msgIndex + 1) % messages.length; }, 400);
}
update(); rotateMessage();
setInterval(update, 1000);
setInterval(rotateMessage, 8000);
</script>
<style>
.countdown-page { min-height:100vh; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding:48px 24px; background:var(--bg,#0b0a09); }
.atom { width:clamp(180px,35vw,260px); height:auto; margin-bottom:24px; }
.title { font-family:'Fraunces',serif; font-size:clamp(28px,5vw,48px); font-weight:700; color:var(--text,#f4eee3); margin:0 0 32px; letter-spacing:-1px; }
.electric { color:var(--electric,#5bd1d8); animation:pulse 3s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.6;text-shadow:0 0 12px var(--electric,#5bd1d8)} }
.timer { display:flex; align-items:center; gap:8px; margin-bottom:32px; }
.unit { display:flex; flex-direction:column; align-items:center; }
.num { font-family:'IBM Plex Mono','Share Tech Mono',monospace; font-size:clamp(36px,8vw,72px); font-weight:600; color:var(--text,#f4eee3); line-height:1; min-width:2ch; }
.label { font-family:'IBM Plex Mono',monospace; font-size:11px; color:var(--text-dim,#b6a894); text-transform:uppercase; letter-spacing:2px; margin-top:6px; }
.sep { font-family:'IBM Plex Mono',monospace; font-size:clamp(28px,6vw,56px); color:var(--accent,#f1c27a); opacity:.5; margin-bottom:20px; }
.tagline { font-family:'IBM Plex Mono',monospace; font-size:14px; color:var(--accent,#f1c27a); margin:0 0 40px; min-height:1.5em; transition:opacity .4s ease; }
.footnote { font-family:'IBM Plex Mono',monospace; font-size:12px; color:var(--text-dim,#b6a894); opacity:.4; letter-spacing:.5px; }
</style>
+151 -251
View File
@@ -1,271 +1,171 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import '@/styles/global.css';
import '@/styles/home-workbench.css';
import SiteHeader from '@/components/SiteHeader.astro';
import { withSiteBase } from '@/lib/site';
import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
const title = "Formation électronique appliquée | L'électron rare";
const description = "Formations sur mesure : électronique, automatisme, énergie, stockage, PCB, instrumentation, IoT et optimisation. 1 jour, 3 jours ou programme dédié. Écoles, entreprises.";
const homeHref = withSiteBase('/');
const homeAboutHref = withSiteBase('/#a-propos');
const homeMissionsHref = withSiteBase('/#graphic-sprints-title');
const formationHref = withSiteBase('/formation/');
const contactHref = withSiteBase('/#contact');
const mentionsHref = withSiteBase('/mentions-legales/');
const headerItems = [
{ href: homeAboutHref, label: 'Approche' },
{ href: homeMissionsHref, label: 'Missions' },
{ href: formationHref, label: 'Formation', current: true },
{ href: contactHref, label: 'Contact', desktopOnly: true }
];
---
<BaseLayout title={title} description={description} ogDescription="Formations sur mesure en électronique, automatisme, énergie, stockage, PCB, instrumentation et IoT. 1 jour, 3 jours ou programme dédié. Écoles, entreprises, centres de formation.">
<SiteHeader brandHref={homeHref} items={headerItems} quickHref={contactHref} />
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Formation électronique appliquée | L'électron rare</title>
<meta name="description" content="Formations sur mesure : électronique, automatisme, énergie, PCB, instrumentation, IoT. 1 jour, 3 jours ou programme dédié. Écoles, entreprises." />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html { background: #06080b; color: #fff; }
body { background: #06080b; min-height: 100vh; font-family: 'Courier New', monospace; }
.site-hdr { position: fixed; top: 0; left: 0; right: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; padding: 16px clamp(20px, 3vw, 40px); background: rgba(6,8,11,0.85); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(255,255,255,0.05); }
.hdr-left { display: flex; align-items: center; gap: 10px; }
.hdr-left a { text-decoration: none; display: flex; align-items: center; gap: 10px; }
.hdr-logo { width: 36px; height: auto; filter: drop-shadow(0 0 12px rgba(91,209,216,0.3)); }
.hdr-name { font-size: 16px; font-weight: 800; color: rgba(255,255,255,0.8); letter-spacing: 0.04em; }
.hdr-nav { display: flex; align-items: center; gap: 8px; }
.hdr-link { padding: 8px 16px; color: rgba(255,255,255,0.6); font-size: 12px; font-weight: 600; text-decoration: none; letter-spacing: 0.04em; transition: color 0.2s; }
.hdr-link:hover { color: #5bd1d8; }
.hdr-link.active { color: #f59e0b; }
.hdr-cta { padding: 8px 20px; border-radius: 6px; background: rgba(91,209,216,0.15); border: 1px solid rgba(91,209,216,0.3); color: #5bd1d8; font-size: 12px; font-weight: 700; text-decoration: none; letter-spacing: 0.05em; }
.burger { display: none; background: none; border: none; color: #fff; font-size: 24px; cursor: pointer; padding: 8px; }
.mobile-menu { display: none; position: fixed; inset: 0; z-index: 100; background: rgba(6,8,11,0.95); backdrop-filter: blur(16px); flex-direction: column; align-items: center; justify-content: center; gap: 24px; }
.mobile-menu.open { display: flex; }
.mobile-menu a { font-size: 20px; font-weight: 700; color: rgba(255,255,255,0.8); text-decoration: none; letter-spacing: 0.05em; padding: 12px 24px; }
.mobile-menu a:hover { color: #5bd1d8; }
.mobile-menu .close-btn { position: absolute; top: 20px; right: 20px; background: none; border: none; color: #fff; font-size: 28px; cursor: pointer; }
@media (max-width: 768px) { .hdr-nav { display: none; } .burger { display: block; } }
.content { max-width: 800px; margin: 0 auto; padding: 120px 24px 80px; }
h1 { font-size: clamp(28px, 4vw, 48px); font-weight: 800; color: #f59e0b; margin-bottom: 16px; letter-spacing: -0.02em; }
h2 { font-size: 18px; font-weight: 700; color: #fff; margin: 48px 0 16px; text-transform: uppercase; letter-spacing: 0.06em; border-bottom: 1px solid rgba(255,255,255,0.08); padding-bottom: 8px; }
h3 { font-size: 14px; font-weight: 700; color: #f59e0b; margin: 28px 0 8px; text-transform: uppercase; letter-spacing: 0.08em; }
p { font-size: 14px; color: rgba(255,255,255,0.6); line-height: 1.8; margin-bottom: 12px; }
.lead { font-size: 15px; color: rgba(255,255,255,0.5); margin-bottom: 32px; }
.card { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 20px; margin-bottom: 12px; }
.card-t { font-size: 15px; font-weight: 700; color: #fff; margin-bottom: 6px; }
.card-desc { font-size: 12px; color: rgba(255,255,255,0.45); line-height: 1.6; margin-bottom: 8px; }
.card-meta { font-size: 11px; color: rgba(255,255,255,0.3); letter-spacing: 0.04em; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }
.subjects { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 700px) { .subjects, .grid { grid-template-columns: 1fr; } }
.subject { background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; padding: 16px; }
.subject-t { font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 6px; }
.subject-d { font-size: 11px; color: rgba(255,255,255,0.4); line-height: 1.5; }
.audience { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0 32px; }
.audience-chip { font-size: 11px; padding: 6px 14px; border-radius: 20px; border: 1px solid rgba(255,255,255,0.12); color: rgba(255,255,255,0.55); }
.format-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
@media (max-width: 700px) { .format-grid { grid-template-columns: 1fr; } }
.format { text-align: center; padding: 24px 16px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; }
.format-dur { font-size: 24px; font-weight: 800; color: #f59e0b; margin-bottom: 4px; }
.format-name { font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.06em; }
.format-desc { font-size: 11px; color: rgba(255,255,255,0.4); line-height: 1.5; }
.cta-box { margin-top: 48px; text-align: center; }
.cta-btn { display: inline-block; padding: 14px 32px; border-radius: 8px; background: #f59e0b; color: #06080b; font-weight: 700; font-size: 14px; text-decoration: none; letter-spacing: 0.04em; }
.cta-btn:hover { background: #fbbf24; }
.info { margin-top: 40px; font-size: 11px; color: rgba(255,255,255,0.25); line-height: 1.8; }
.footer { text-align: center; padding: 40px 24px; font-size: 10px; color: rgba(255,255,255,0.15); }
</style>
</head>
<body>
<header class="site-hdr">
<div class="hdr-left"><a href="/"><img src="/assets/brand/logo-mark.svg" alt="" class="hdr-logo" /><span class="hdr-name">L'électron rare</span></a></div>
<nav class="hdr-nav">
<a href="/conception/" class="hdr-link">Conception</a>
<a href="/formation/" class="hdr-link active">Formation</a>
<a href="/consulting/" class="hdr-link">Consulting</a>
<a href="/contact/" class="hdr-cta">Contact</a>
</nav>
<button class="burger" id="burger-btn" aria-label="Menu">☰</button>
</header>
<div class="mobile-menu" id="mobile-menu">
<button class="close-btn" id="close-menu">✕</button>
<a href="/">Accueil</a><a href="/conception/">Conception</a><a href="/formation/">Formation</a><a href="/consulting/">Consulting</a><a href="/contact/">Contact</a>
</div>
<script>document.getElementById('burger-btn')?.addEventListener('click',()=>document.getElementById('mobile-menu')?.classList.add('open'));document.getElementById('close-menu')?.addEventListener('click',()=>document.getElementById('mobile-menu')?.classList.remove('open'));</script>
<main id="main-content" class="site-shell studio-structure pb-12 pt-3">
<section class="circuit-board rounded-2xl p-5 md:p-6 mt-4" data-reveal>
<div class="circuit-title-row">
<span class="circuit-node" aria-hidden="true"></span>
<h1 class="m-0 text-3xl md:text-4xl">Formation électronique appliquée</h1>
<span class="circuit-pinline" aria-hidden="true"></span>
<main class="content">
<h1>Formation électronique appliquée</h1>
<p class="lead">Des formations pratiques, ancrées dans le terrain. Chaque programme est construit sur mesure à partir de votre contexte réel : vos projets, vos équipes, vos contraintes.</p>
<div style="display:flex;align-items:center;gap:12px;padding:14px 18px;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.2);border-radius:10px;margin-bottom:32px;">
<span style="font-size:24px;">🏅</span>
<div>
<p style="font-size:13px;color:#f59e0b;font-weight:700;margin:0;">Formations finançables OPCO / Afdas</p>
<p style="font-size:11px;color:rgba(255,255,255,0.4);margin:4px 0 0;">Intervention possible via un organisme partenaire certifié Qualiopi.</p>
</div>
</div>
<p class="section-lead mb-0 mt-3">
Des formations pratiques, ancrées dans le terrain, pour monter en compétences sur l'électronique, l'automatisme, l'énergie, le stockage, la conception PCB, l'instrumentation, l'IoT et l'optimisation d'usage.
</p>
<p class="mb-0 mt-3 studio-muted">
Chaque formation est construite sur mesure à partir de votre contexte réel : vos projets, vos équipes, vos contraintes. Pas de slides génériques — du bench, du schéma et du livrable.
</p>
</section>
<div class="audience">
<span class="audience-chip">Entreprises industrielles</span>
<span class="audience-chip">Écoles et universités</span>
<span class="audience-chip">Centres de formation</span>
<span class="audience-chip">Studios créatifs</span>
</div>
<section class="circuit-board rounded-2xl p-5 md:p-6 mt-5" data-reveal>
<div class="circuit-title-row">
<span class="circuit-node circuit-node--magenta" aria-hidden="true"></span>
<h2 class="m-0 text-2xl md:text-3xl">Publics cibles</h2>
<span class="circuit-pinline" aria-hidden="true"></span>
<h2>Formats</h2>
<div class="format-grid">
<div class="format">
<p class="format-dur">1 jour</p>
<p class="format-name">Initiation</p>
<p class="format-desc">Fondamentaux, démos live, TP sur matériel réel, support PDF</p>
</div>
<div class="mt-4 rounded-xl border border-[color:var(--line)] p-3">
<ul class="m-0 grid grid-cols-1 gap-2 p-0">
<li class="list-none studio-chip studio-chip--cyan">
<strong>Entreprises industrielles</strong> — équipes R&D, bureaux d'études, techniciens de maintenance qui veulent concevoir, diagnostiquer ou fiabiliser des systèmes électroniques dans leurs produits ou process.
</li>
<li class="list-none studio-chip studio-chip--vio">
<strong>Écoles et universités</strong> — BTS, IUT, grandes écoles : TP encadrés, projets tutorés, modules spécialisés en systèmes électroniques, PCB et prototypage.
</li>
<li class="list-none studio-chip studio-chip--pink">
<strong>Centres de formation</strong> — organismes souhaitant intégrer des modules électronique, instrumentation, énergie, stockage, IoT ou automatisme à leur catalogue.
</li>
<li class="list-none studio-chip studio-chip--emerald">
<strong>Studios créatifs et compagnies</strong> — équipes techniques du spectacle vivant, de l'événementiel ou des arts numériques qui veulent gagner en autonomie.
</li>
</ul>
<div class="format">
<p class="format-dur">3 jours</p>
<p class="format-name">Approfondissement</p>
<p class="format-desc">Projet fil rouge, prototype fonctionnel, documentation technique</p>
</div>
</section>
<section class="mt-5" data-reveal>
<div class="graphic-sprints-panel graphic-sprints-panel--routing">
<div class="graphic-sprints-head">
<p class="graphic-sprints-kicker">Formats</p>
<h2 class="graphic-sprints-title">Trois formats, un principe : le concret d'abord</h2>
<p class="graphic-sprints-copy">
Chaque format inclut un support de cours, des exercices pratiques sur matériel réel et un livrable que les participants repartent avec.
</p>
</div>
<div class="graphic-sprints-grid">
<article class="graphic-sprint-card" style="--sprint-progress-target: 33%">
<header class="graphic-sprint-card-head">
<p class="graphic-sprint-status graphic-sprint-status--trace">Initiation</p>
<p class="graphic-sprint-window">1 jour (7h)</p>
</header>
<h3 class="graphic-sprint-title">Découverte</h3>
<p class="graphic-sprint-objective">
Comprendre les fondamentaux d'un domaine technique. Idéal pour une première prise en main ou une mise à niveau rapide.
</p>
<div class="graphic-sprint-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="33">
<span class="graphic-sprint-progress-fill"></span>
</div>
<ul class="graphic-sprint-deliverables">
<li>Théorie appliquée + démos live</li>
<li>TP sur matériel réel</li>
<li>Support de cours PDF</li>
</ul>
</article>
<article class="graphic-sprint-card" style="--sprint-progress-target: 66%">
<header class="graphic-sprint-card-head">
<p class="graphic-sprint-status graphic-sprint-status--bench">Approfondissement</p>
<p class="graphic-sprint-window">3 jours (21h)</p>
</header>
<h3 class="graphic-sprint-title">Pratique</h3>
<p class="graphic-sprint-objective">
Maîtriser un sujet de bout en bout avec un projet fil rouge. Les participants repartent avec un prototype fonctionnel.
</p>
<div class="graphic-sprint-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="66">
<span class="graphic-sprint-progress-fill"></span>
</div>
<ul class="graphic-sprint-deliverables">
<li>Projet fil rouge réalisé</li>
<li>Prototype fonctionnel</li>
<li>Documentation technique complète</li>
</ul>
</article>
<article class="graphic-sprint-card" style="--sprint-progress-target: 100%">
<header class="graphic-sprint-card-head">
<p class="graphic-sprint-status graphic-sprint-status--run">Sur mesure</p>
<p class="graphic-sprint-window">Durée adaptée</p>
</header>
<h3 class="graphic-sprint-title">Programme dédié</h3>
<p class="graphic-sprint-objective">
Formation construite intégralement autour de votre projet, vos équipements et vos objectifs. Accompagnement post-formation inclus.
</p>
<div class="graphic-sprint-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100">
<span class="graphic-sprint-progress-fill"></span>
</div>
<ul class="graphic-sprint-deliverables">
<li>Programme sur mesure</li>
<li>Matériel adapté à votre contexte</li>
<li>Suivi post-formation</li>
</ul>
</article>
</div>
<div class="format">
<p class="format-dur">Sur mesure</p>
<p class="format-name">Programme dédié</p>
<p class="format-desc">Autour de votre projet, vos équipements, suivi post-formation</p>
</div>
</section>
</div>
<section class="circuit-board rounded-2xl p-5 md:p-6 mt-5" data-reveal>
<div class="circuit-title-row">
<span class="circuit-node" aria-hidden="true"></span>
<h2 class="m-0 text-2xl md:text-3xl">Sujets de formation</h2>
<span class="circuit-pinline" aria-hidden="true"></span>
</div>
<h2>Sujets</h2>
<div class="subjects">
<div class="subject"><p class="subject-t">Systèmes électroniques et embarqués</p><p class="subject-d">Architecture matérielle, µC (STM32, ESP32, Teensy), alimentation, debug bench, IPC-A-610, CEM.</p></div>
<div class="subject"><p class="subject-t">Conception PCB</p><p class="subject-d">KiCad, routage, stackup, DRC/ERC, Gerber, BOM, pick & place.</p></div>
<div class="subject"><p class="subject-t">IoT et protocoles</p><p class="subject-d">Capteurs, MQTT, WiFi, BLE, LoRa, TinyML, du capteur au dashboard.</p></div>
<div class="subject"><p class="subject-t">Énergie et stockage</p><p class="subject-d">Batterie, BMS, télémétrie, supervision, optimisation d'exploitation.</p></div>
<div class="subject"><p class="subject-t">Automatisme industriel</p><p class="subject-d">Siemens S7, Schneider, câblage armoires, variateurs, IEC 60204-1.</p></div>
<div class="subject"><p class="subject-t">Contrôle LED et scénographie</p><p class="subject-d">DMX, Art-Net, DANTE, pilotage LED, intégration spectacle.</p></div>
<div class="subject"><p class="subject-t">Firmware et programmation</p><p class="subject-d">C/C++ embarqué, Python, debug série, interruptions, RTOS.</p></div>
<div class="subject"><p class="subject-t">Workflows agentiques et IA</p><p class="subject-d">Agents IA, génération de code, revue automatisée, documentation assistée.</p></div>
</div>
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
<article class="rounded-xl border border-[color:var(--line)] p-4">
<h3 class="m-0 text-lg font-semibold">Systèmes électroniques et embarqués</h3>
<p class="mt-2 mb-0 studio-muted">
Architecture matérielle, microcontrôleurs (STM32, ESP32, Teensy), alimentation, lecture de datasheet, debug sur bench. Conformité IPC-A-610 et CEM.
</p>
</article>
<h2>Catalogue</h2>
<article class="rounded-xl border border-[color:var(--line)] p-4">
<h3 class="m-0 text-lg font-semibold">Conception PCB</h3>
<p class="mt-2 mb-0 studio-muted">
De la schématique au routage avec KiCad. Règles de design, stackup, vérification DRC/ERC, préparation des fichiers de fabrication (Gerber, BOM, pick & place).
</p>
</article>
<h3>Initiations — Tous publics</h3>
<div class="grid">
<div class="card"><p class="card-t">Mes Premiers Circuits</p><p class="card-desc">Breadboard, soudure, badge lumineux. Zéro prérequis.</p><p class="card-meta">1 jour · 420€ · 6-8 pers.</p></div>
<div class="card"><p class="card-t">Arduino Pour Tous</p><p class="card-desc">Capteurs, LED, moteurs. Kit Arduino à emporter.</p><p class="card-meta">1 jour · 450€ · 6-8 pers.</p></div>
<div class="card"><p class="card-t">Lumière et Son</p><p class="card-desc">LED NeoPixel, DMX512, audio interactif. Kit inclus.</p><p class="card-meta">1 jour · 480€ · 6-8 pers.</p></div>
<div class="card"><p class="card-t">KiCad Découverte</p><p class="card-desc">Du breadboard au PCB. Commandez votre carte chez JLCPCB.</p><p class="card-meta">1 jour · 420€ · 6-8 pers.</p></div>
</div>
<article class="rounded-xl border border-[color:var(--line)] p-4">
<h3 class="m-0 text-lg font-semibold">IoT et protocoles</h3>
<p class="mt-2 mb-0 studio-muted">
Capteurs, télémétrie, protocoles terrain (MQTT, HTTP, VE.Direct), connectivité (WiFi, BLE, LoRa), TinyML et IA embarquée. Du capteur au dashboard.
</p>
</article>
<h3>Créatif & Artistes</h3>
<div class="grid">
<div class="card"><p class="card-t">Créer son Contrôleur MIDI</p><p class="card-desc">Teensy, potentiomètres, capteurs → instrument MIDI USB.</p><p class="card-meta">1 jour · 450€ · Variante 3h : 220€</p></div>
<div class="card"><p class="card-t">Programmation Embarquée pour Artistes</p><p class="card-desc">ESP32, capteurs, DMX, Art-Net, OSC → installations interactives.</p><p class="card-meta">1 jour · 480€ · Variante 3h : 240€</p></div>
<div class="card"><p class="card-t">IA pour la Création Artistique</p><p class="card-desc">Stable Diffusion, ComfyUI, Ollama. Image, son, vidéo, performance live.</p><p class="card-meta">1 jour · 500€ · Variante 3h : 250€</p></div>
</div>
<article class="rounded-xl border border-[color:var(--line)] p-4">
<h3 class="m-0 text-lg font-semibold">Énergie, stockage et optimisation</h3>
<p class="mt-2 mb-0 studio-muted">
Batterie, BMS, télémétrie, supervision, pilotage de charges, logique d'optimisation et amélioration d'exploitation. De la mesure au système mieux réglé.
</p>
</article>
<h3>Pro — Avancé</h3>
<div class="grid">
<div class="card"><p class="card-t">KiCad — Conception PCB</p><p class="card-desc">KiCad 9/10, routage avancé, impédance, DFM, fabrication JLCPCB.</p><p class="card-meta">2 jours · 1 550€ · 6 pers. max</p></div>
<div class="card"><p class="card-t">Dispositifs Culturels</p><p class="card-desc">LED, DMX512, Art-Net, autonomie batteries, intégration terrain spectacle.</p><p class="card-meta">1 jour · 775€ · Finançable Afdas/OPCO</p></div>
</div>
<article class="rounded-xl border border-[color:var(--line)] p-4">
<h3 class="m-0 text-lg font-semibold">Automatisme industriel</h3>
<p class="mt-2 mb-0 studio-muted">
Automates Siemens (S7) et Schneider, câblage d'armoires, variateurs, mise en service. Conformité IEC 60204-1, IEC 61439, NFC 13-200. Lecture de schémas électriques industriels.
</p>
</article>
<h3>Enfants & Jeunes</h3>
<div class="grid">
<div class="card"><p class="card-t">Premiers Circuits (8-10 ans)</p><p class="card-desc">micro:bit, programmation par blocs, LED, capteurs.</p><p class="card-meta">3 demi-journées · 50€/demi-journée</p></div>
<div class="card"><p class="card-t">Maker Junior (10-13 ans)</p><p class="card-desc">Arduino, première soudure, capteurs, projet personnel.</p><p class="card-meta">5 demi-journées · 50€/demi-journée</p></div>
<div class="card"><p class="card-t">Electro Lab (13-16 ans)</p><p class="card-desc">ESP32, KiCad, conception et fabrication de vrai PCB.</p><p class="card-meta">8 sessions · 299€ hors frais de fabrication</p></div>
<div class="card"><p class="card-t">Atelier Parent-Enfant</p><p class="card-desc">3h de découverte en duo. Montage, soudure, LED.</p><p class="card-meta">3h · 59€/binôme · À partir de 8 ans</p></div>
</div>
<article class="rounded-xl border border-[color:var(--line)] p-4">
<h3 class="m-0 text-lg font-semibold">Contrôle LED et scénographie</h3>
<p class="mt-2 mb-0 studio-muted">
Protocoles DMX, Art-Net, DANTE. Pilotage LED, topologies multi-cartes, intégration dans des dispositifs scéniques et événementiels.
</p>
</article>
<article class="rounded-xl border border-[color:var(--line)] p-4">
<h3 class="m-0 text-lg font-semibold">Firmware et programmation</h3>
<p class="mt-2 mb-0 studio-muted">
C/C++ embarqué, Python pour l'instrumentation, debug série, gestion mémoire, interruptions, RTOS. Du code qui tourne sur le terrain.
</p>
</article>
<article class="rounded-xl border border-[color:var(--line)] p-4">
<h3 class="m-0 text-lg font-semibold">Workflows agentiques et IA appliquée</h3>
<p class="mt-2 mb-0 studio-muted">
Intégrer l'IA et les agents autonomes dans vos flux de travail : génération de code embarqué, revue automatisée, documentation technique, analyse de datasheets et tests assistés par IA. Productivité et qualité augmentées.
</p>
</article>
</div>
</section>
<section class="circuit-board rounded-2xl p-5 md:p-6 mt-5" data-reveal>
<div class="circuit-title-row">
<span class="circuit-node circuit-node--magenta" aria-hidden="true"></span>
<h2 class="m-0 text-2xl md:text-3xl">Demander un programme</h2>
<span class="circuit-pinline" aria-hidden="true"></span>
</div>
<p class="section-lead mb-0 mt-3">
Décrivez votre besoin en quelques lignes. Je vous propose un programme adapté sous 48h.
</p>
<p class="mb-0 mt-3 studio-muted">
Précisez le public, le niveau, le sujet visé et le format souhaité. Même une idée vague suffit pour démarrer.
</p>
<div class="mt-4 flex flex-wrap gap-3">
<a
href="mailto:contact@lelectronrare.fr?subject=Demande%20de%20formation&body=Bonjour%20Cl%C3%A9ment%2C%0A%0AJe%20suis%20int%C3%A9ress%C3%A9%20par%20une%20formation.%0A%0AContexte%20(entreprise%2C%20%C3%A9cole%2C%20centre%20de%20formation)%20%3A%0ASujet%20vis%C3%A9%20%3A%0APublic%20concern%C3%A9%20%3A%0AFormat%20souhait%C3%A9%20(dur%C3%A9e%2C%20pr%C3%A9sentiel%2Fdistanciel)%20%3A"
class="figma-lab-pill figma-lab-pill--primary"
{...trackAttrs(TRACK_EVENTS.outboundEmailContact, 'mailto:contact@lelectronrare.fr')}
>
Envoyer par mail
</a>
<a
href="https://fr.linkedin.com/in/electron-rare"
target="_blank"
rel="noopener noreferrer"
class="figma-lab-pill figma-lab-pill--secondary"
{...trackAttrs(TRACK_EVENTS.outboundLinkedinContact, 'linkedin.com')}
>
Contacter sur LinkedIn
</a>
</div>
<ul aria-label="Repères" class="mb-0 mt-4 pl-5 studio-muted">
<li>Intervention en présentiel (France entière) ou en distanciel</li>
<li>Matériel de TP fourni ou adapté à votre parc existant</li>
<li>Convention de formation et programme détaillé sur demande</li>
<li>Intervention possible via un organisme certifié Qualiopi pour le financement OPCO</li>
<li>Assuré RC Pro · NDA sur demande</li>
</ul>
</section>
<div class="cta-box">
<a href="/contact/?from=formation" class="cta-btn">Demander un programme</a>
</div>
<p class="info">
Intervention en présentiel (France entière) ou en distanciel · Matériel de TP fourni · Convention de formation sur demande · Financement OPCO possible via organisme Qualiopi · Assuré RC Pro · NDA sur demande
</p>
</main>
<footer class="site-footer border-t border-[var(--line)] py-6 text-sm text-[var(--text-muted)]">
<div class="site-shell footer-grid footer-grid--enhanced">
<div class="footer-col footer-col--brand">
<p class="m-0 footer-title">L'électron rare</p>
<div class="footer-brand-meta">
<p class="mb-0 footer-copy">
© 2026 Clément Saillant - systèmes électroniques spécifiques, conseil, formation — industrie, culture et projets multi-techniques.
</p>
</div>
</div>
<nav aria-label="Ressources" class="footer-links footer-links--single-rail">
<a href={homeHref} class="footer-link">Accueil</a>
<a href={formationHref} class="footer-link">Formation</a>
<a href="https://fr.linkedin.com/in/electron-rare" target="_blank" rel="noopener noreferrer me" class="footer-link">LinkedIn</a>
<a href="mailto:contact@lelectronrare.fr" class="footer-link">contact@lelectronrare.fr</a>
<a href="https://github.com/electron-rare/" target="_blank" rel="noopener noreferrer me" class="footer-link">GitHub</a>
<a href={mentionsHref} class="footer-link">Mentions légales</a>
</nav>
</div>
</footer>
</BaseLayout>
<div class="footer">© 2026 Clément Saillant — L'électron rare · <a href="https://blog.saillant.cc" target="_blank" rel="noopener" style="color:rgba(255,255,255,0.3);">Blog</a> · <a href="https://fr.linkedin.com/in/electron-rare" target="_blank" rel="noopener" style="color:rgba(255,255,255,0.3);">LinkedIn</a> · <a href="https://github.com/electron-rare/" target="_blank" rel="noopener" style="color:rgba(255,255,255,0.3);">GitHub</a></div>
</body>
</html>
+508
View File
@@ -0,0 +1,508 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import '@/styles/global.css';
import '@/styles/home-workbench.css';
import SiteHeader from '@/components/SiteHeader.astro';
import { Hero } from '@/components/sections/Hero';
import { About } from '@/components/sections/About';
import { CaseStudies } from '@/components/sections/CaseStudies';
import { GraphicSprints } from '@/components/sections/GraphicSprints';
import Contact from '@/components/sections/Contact.astro';
import Faq from '@/components/sections/Faq.astro';
import { withSiteBase } from '@/lib/site';
import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
const LAUNCH_DATE = new Date('2026-05-01T00:00:00+02:00');
const isLaunched = Date.now() >= LAUNCH_DATE.getTime();
const formationHref = withSiteBase('/formation/');
const mentionsHref = withSiteBase('/mentions-legales/');
const headerItems = [
{ href: '#a-propos', label: 'Approche' },
{ href: '#graphic-sprints-title', label: 'Missions' },
{ href: formationHref, label: 'Formation' },
{ href: '#faq', label: 'FAQ' },
{ href: '#contact', label: 'Contact', desktopOnly: true }
];
const title = isLaunched
? "Systèmes électroniques spécifiques | L'électron rare"
: "L'Electron Rare — Bientôt";
const description = isLaunched
? "Conception, mise au point et fiabilisation de systèmes électroniques spécifiques."
: "L'Electron Rare arrive. Systèmes électroniques spécifiques, formations, prototypage.";
---
<BaseLayout title={title} description={description}>
{isLaunched ? (
<Fragment>
<SiteHeader brandHref="#top" items={headerItems} quickHref="#contact" />
<main id="main-content" class="site-shell studio-structure pb-12 pt-3">
<section class="structure-grid structure-grid--hero" data-reveal>
<div class="structure-cell structure-cell--hero">
<Hero />
</div>
</section>
<section class="structure-grid structure-grid--systems" data-reveal>
<div class="structure-cell structure-cell--about">
<About />
</div>
</section>
<section class="structure-grid structure-grid--cases" data-reveal>
<div class="structure-cell structure-cell--cases">
<CaseStudies />
</div>
</section>
<section class="structure-grid structure-grid--sprints" data-reveal>
<div class="structure-cell structure-cell--sprints">
<GraphicSprints />
</div>
</section>
<section class="structure-grid structure-grid--faq" data-reveal>
<div class="structure-cell structure-cell--faq">
<Faq />
</div>
</section>
<section class="structure-grid structure-grid--conversion" data-reveal>
<div class="structure-cell structure-cell--contact">
<Contact />
</div>
</section>
</main>
<footer class="site-footer border-t border-[var(--line)] py-6 text-sm text-[var(--text-muted)]">
<div class="site-shell footer-grid footer-grid--enhanced">
<div class="footer-col footer-col--brand">
<p class="m-0 footer-title">L'électron rare</p>
<p class="mb-0 footer-copy">© 2026 Clément Saillant</p>
</div>
<nav aria-label="Ressources" class="footer-links footer-links--single-rail">
<a href="#a-propos" class="footer-link">Approche</a>
<a href="#graphic-sprints-title" class="footer-link">Missions</a>
<a href={formationHref} class="footer-link">Formation</a>
<a href="#faq" class="footer-link">FAQ</a>
<a href="#contact" class="footer-link">Contact</a>
<a href={mentionsHref} class="footer-link">Mentions légales</a>
</nav>
</div>
</footer>
</Fragment>
) : (
<!-- COUNTDOWN — random WebGL or CSS -->
<main class="countdown-page">
<!-- BG A: WebGL shader (PCB traces) -->
<div class="countdown-bg" id="bg-webgl">
<div class="webgl-bg" id="webgl-countdown"></div>
</div>
<!-- BG B: Pure CSS circuit board -->
<div class="countdown-bg" id="bg-css">
<div class="css-circuit">
<div class="circuit-grid">
<!-- Horizontal traces -->
<div class="trace trace-h trace-h1"><span class="pulse"></span></div>
<div class="trace trace-h trace-h2"><span class="pulse"></span></div>
<div class="trace trace-h trace-h3"><span class="pulse"></span></div>
<div class="trace trace-h trace-h4"><span class="pulse"></span></div>
<div class="trace trace-h trace-h5"><span class="pulse"></span></div>
<div class="trace trace-h trace-h6"><span class="pulse"></span></div>
<!-- Vertical traces -->
<div class="trace trace-v trace-v1"><span class="pulse"></span></div>
<div class="trace trace-v trace-v2"><span class="pulse"></span></div>
<div class="trace trace-v trace-v3"><span class="pulse"></span></div>
<div class="trace trace-v trace-v4"><span class="pulse"></span></div>
<div class="trace trace-v trace-v5"><span class="pulse"></span></div>
<div class="trace trace-v trace-v6"><span class="pulse"></span></div>
<!-- Nodes (intersections) -->
<div class="node node-1"></div>
<div class="node node-2"></div>
<div class="node node-3"></div>
<div class="node node-4"></div>
<div class="node node-5"></div>
<div class="node node-6"></div>
<div class="node node-7"></div>
<div class="node node-8"></div>
<div class="node node-9"></div>
<div class="node node-10"></div>
<div class="node node-11"></div>
<div class="node node-12"></div>
<!-- IC chips -->
<div class="ic ic-1">
<span class="ic-label">MCU</span>
<div class="ic-pins ic-pins-l"></div>
<div class="ic-pins ic-pins-r"></div>
</div>
<div class="ic ic-2">
<span class="ic-label">FPGA</span>
<div class="ic-pins ic-pins-l"></div>
<div class="ic-pins ic-pins-r"></div>
</div>
<div class="ic ic-3">
<span class="ic-label">PWR</span>
<div class="ic-pins ic-pins-l"></div>
<div class="ic-pins ic-pins-r"></div>
</div>
<!-- SMD components -->
<div class="smd smd-1"></div>
<div class="smd smd-2"></div>
<div class="smd smd-3"></div>
<div class="smd smd-4"></div>
<div class="smd smd-5"></div>
<div class="smd smd-6"></div>
<div class="smd smd-7"></div>
<div class="smd smd-8"></div>
<!-- Vias -->
<div class="via via-1"></div>
<div class="via via-2"></div>
<div class="via via-3"></div>
<div class="via via-4"></div>
<div class="via via-5"></div>
<div class="via via-6"></div>
</div>
<!-- Scanline sweep -->
<div class="scanline"></div>
<!-- Vignette -->
<div class="css-vignette"></div>
</div>
</div>
<div class="overlay">
<div class="top-bar">
<div class="logo-sprite" id="logo-sprite" aria-label="L'Electron Rare"></div>
<span class="brand-mark">L'ELECTRON RARE</span>
</div>
<div class="center-spacer">
<div class="countdown-hero-text" id="hero-text">
<h1 class="hero-title">
<span class="hero-line hero-line-1">Systèmes</span>
<span class="hero-line hero-line-2">électroniques</span>
<span class="hero-line hero-line-3">spécifiques</span>
</h1>
<div class="hero-divider"></div>
<p class="hero-sub">Conception · Mise au point · Fiabilisation</p>
</div>
<div class="tagline-area" id="tagline-bubble" aria-live="polite">
<span class="tagline-cursor">></span>
<span class="tagline-text" id="tagline"></span>
</div>
</div>
<div class="bottom-bar">
<span class="bottom-left">electronique · automatisme · energie</span>
<span class="bottom-right">mai 2026</span>
</div>
<div class="cartouche">
<p class="cartouche-title">L'électron rare</p>
<p class="cartouche-copy">© 2026 Clément Saillant — systèmes électroniques spécifiques, conseil, formation — industrie, culture et projets multi-techniques.</p>
<p class="cartouche-contact">Contact : <a href="https://fr.linkedin.com/in/electron-rare" target="_blank" rel="noopener">LinkedIn</a> · <a href="mailto:contact@lelectronrare.fr">contact@lelectronrare.fr</a></p>
</div>
</div>
</main>
)}
</BaseLayout>
{!isLaunched && (
<script>
// ── Random mode pick ──
const useWebGL = Math.random() < 0.5;
const bgWebGL = document.getElementById('bg-webgl');
const bgCSS = document.getElementById('bg-css');
if (useWebGL) { bgCSS?.remove(); }
else { bgWebGL?.remove(); }
document.body.dataset.mode = useWebGL ? 'webgl' : 'css';
// ── Hero text reveal ──
const heroText = document.getElementById('hero-text');
if (heroText) {
setTimeout(() => heroText.classList.add('revealed'), 300);
}
// ── Tagline rotation ──
const messages = [
"Charge des condensateurs en cours...",
"Calibration de l'oscilloscope...",
"Soudure des derniers composants...",
"Vérification de la BOM...",
"Flash du firmware en cours...",
"Mesure de continuité... bip.",
"Routage des dernières pistes...",
"Test de la chaîne d'alimentation...",
"Programmation du bootloader...",
"Revue de schéma en cours...",
"Placement des composants CMS...",
"Passage au four de refusion...",
"Debug JTAG en cours...",
"Vérification du plan de masse...",
"Analyse du spectre RF..."
];
let msgIndex = Math.floor(Math.random() * messages.length);
function rotateMessage() {
const bubble = document.getElementById('tagline-bubble');
const el = document.getElementById('tagline');
if (!bubble || !el) return;
bubble.style.opacity = '0';
bubble.style.transform = 'translateY(4px)';
setTimeout(() => {
el.textContent = messages[msgIndex];
bubble.style.opacity = '1';
bubble.style.transform = 'translateY(0)';
msgIndex = (msgIndex + 1) % messages.length;
}, 400);
}
const sprite = document.getElementById('logo-sprite');
if (sprite) sprite.style.backgroundImage = 'url(/assets/brand/logo-mark.png)';
rotateMessage();
setInterval(rotateMessage, 8000);
// ── WebGL shader (only if picked) ──
if (useWebGL) {
(function () {
var c = document.getElementById('webgl-countdown');
if (!c) return;
var cvs = document.createElement('canvas');
cvs.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;';
c.appendChild(cvs);
var gl = cvs.getContext('webgl') || cvs.getContext('experimental-webgl');
if (!gl) { cvs.remove(); return; }
function mk(type, src) {
var s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.warn('WebGL shader error:', gl.getShaderInfoLog(s));
return null;
}
return s;
}
var vs = mk(gl.VERTEX_SHADER, 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}');
var fs = mk(gl.FRAGMENT_SHADER, [
'precision mediump float;',
'uniform float T;uniform vec2 R;',
'float h(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5);}',
'float h1(float n){return fract(sin(n)*43758.5);}',
'void main(){',
' vec2 uv=gl_FragCoord.xy/R;',
' float t=T*.22;',
' vec3 col=vec3(0.),cy=vec3(.357,.82,.847),wh=vec3(.7,.97,1.);',
' float S=18.;',
' vec2 g=fract(uv*S),cell=floor(uv*S);',
' float lw=.03;',
' float hH=h(cell),hV=h(cell.yx+7.3);',
' float tH=smoothstep(lw,0.,abs(g.y-.5))*step(.38,hH);',
' float tV=smoothstep(lw,0.,abs(g.x-.5))*step(.38,hV);',
' float tr=max(tH,tV);',
' col+=cy*tr*.2;',
' float nd=smoothstep(.11,.01,length(g-.5));',
' col+=cy*nd*step(.38,hH)*step(.38,hV)*.8;',
' for(int i=0;i<8;i++){',
' float fi=float(i),sp=.3+h1(fi*7.31)*.5,ph=h1(fi*3.17+9.);',
' float rw=floor(h1(fi*5.71+1.)*S);',
' float rm=step(0.,.499-abs(uv.y*S-rw-.5));',
' if(rm>.0){',
' float px=fract(t*sp+ph);',
' float pi=smoothstep(.06,.0,abs(uv.x-px));',
' float pl=smoothstep(lw*.5,.0,abs(g.y-.5));',
' col+=wh*pi*pl*step(.38,h(vec2(floor(uv.x*S),rw)))*rm*2.5;',
' }',
' float cl=floor(h1(fi*4.13+11.)*S);',
' float cm=step(0.,.499-abs(uv.x*S-cl-.5));',
' if(cm>.0){',
' float py=fract(t*sp*.75+ph+.5);',
' float pi2=smoothstep(.06,.0,abs(uv.y-py));',
' float pl2=smoothstep(lw*.5,.0,abs(g.x-.5));',
' col+=wh*pi2*pl2*step(.38,h(vec2(cl,floor(uv.y*S))+7.3))*cm*2.5;',
' }',
' }',
' col+=cy*tr*(sin(t*6.28)*.5+.5)*.05;',
' col=1.-exp(-col*1.8);',
' float v=length((uv-.5)*vec2(1.4,1.));',
' col*=1.-smoothstep(.35,1.1,v);',
' col*=.94+.06*sin(gl_FragCoord.y*3.14159);',
' gl_FragColor=vec4(col,1.);',
'}'
].join('\n'));
if (!vs || !fs) return;
var prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.warn('WebGL link error:', gl.getProgramInfoLog(prog));
return;
}
gl.useProgram(prog);
var buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
var ap = gl.getAttribLocation(prog, 'p');
gl.enableVertexAttribArray(ap);
gl.vertexAttribPointer(ap, 2, gl.FLOAT, false, 0, 0);
var uT = gl.getUniformLocation(prog, 'T');
var uR = gl.getUniformLocation(prog, 'R');
function resize() {
cvs.width = Math.round(cvs.offsetWidth * (window.devicePixelRatio || 1));
cvs.height = Math.round(cvs.offsetHeight * (window.devicePixelRatio || 1));
gl.viewport(0, 0, cvs.width, cvs.height);
}
resize();
window.addEventListener('resize', resize, { passive: true });
var t0 = performance.now();
function frame() {
gl.uniform1f(uT, (performance.now() - t0) * .001);
gl.uniform2f(uR, cvs.width, cvs.height);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(frame);
}
frame();
})();
}
// ── CSS mode: animate random node blinks ──
if (!useWebGL) {
const nodes = document.querySelectorAll('.node');
function blinkNodes() {
nodes.forEach(n => {
if (Math.random() < 0.3) {
n.classList.add('node-active');
setTimeout(() => n.classList.remove('node-active'), 600 + Math.random() * 1200);
}
});
}
blinkNodes();
setInterval(blinkNodes, 2000);
}
</script>
)}
<style>
/* ── Base countdown ── */
.countdown-page { position:relative; min-height:100vh; overflow:hidden; background:#000; color:#fff; font-family:'Manrope',-apple-system,sans-serif; }
.countdown-bg { position:fixed; inset:0; z-index:0; width:100vw; height:100vh; }
.webgl-bg { position:absolute; inset:0; width:100%; height:100%; }
.overlay { position:relative; z-index:10; min-height:100vh; display:flex; flex-direction:column; justify-content:space-between; padding:clamp(20px,3vw,40px) clamp(24px,5vw,60px); pointer-events:none; }
.overlay > * { pointer-events:none; }
.overlay a { pointer-events:auto; }
/* ── Top bar ── */
.top-bar { display:flex; align-items:center; gap:16px; }
.logo-sprite { width:48px; height:48px; background-size:contain; background-repeat:no-repeat; filter:brightness(1.2) drop-shadow(0 0 12px rgba(91,209,216,0.4)); }
.brand-mark { font-size:11px; font-weight:600; letter-spacing:0.25em; color:rgba(255,255,255,0.5); text-transform:uppercase; }
/* ── Hero text ── */
.center-spacer { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:24px; }
.countdown-hero-text { text-align:center; opacity:0; transform:translateY(20px); transition:opacity 1.2s ease, transform 1.2s ease; }
.countdown-hero-text.revealed { opacity:1; transform:translateY(0); }
.hero-title { margin:0; line-height:1.1; }
.hero-line { display:block; font-weight:800; letter-spacing:-0.03em; color:rgba(255,255,255,0.85); }
.hero-line-1 { font-size:clamp(32px,5vw,64px); opacity:0; animation:fadeUp 0.8s ease 0.4s forwards; }
.hero-line-2 { font-size:clamp(36px,6vw,72px); color:#5bd1d8; opacity:0; animation:fadeUp 0.8s ease 0.7s forwards; }
.hero-line-3 { font-size:clamp(32px,5vw,64px); opacity:0; animation:fadeUp 0.8s ease 1.0s forwards; }
.hero-divider { width:60px; height:2px; margin:16px auto; background:linear-gradient(90deg,transparent,#5bd1d8,transparent); opacity:0; animation:fadeUp 0.6s ease 1.3s forwards; }
.hero-sub { font-size:clamp(12px,1.4vw,16px); color:rgba(255,255,255,0.35); letter-spacing:0.15em; text-transform:uppercase; margin:0; opacity:0; animation:fadeUp 0.6s ease 1.5s forwards; }
@keyframes fadeUp { to { opacity:1; transform:translateY(0); } from { opacity:0; transform:translateY(12px); } }
/* ── Tagline ── */
.tagline-area { display:flex; align-items:center; gap:8px; padding:10px 20px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.06); border-radius:8px; transition:opacity 0.4s ease, transform 0.4s ease; pointer-events:auto; }
.tagline-cursor { font-family:monospace; font-size:14px; color:#5bd1d8; animation:blink 1s step-end infinite; }
@keyframes blink { 50% { opacity:0; } }
.tagline-text { font-family:monospace; font-size:clamp(11px,1.3vw,14px); color:rgba(255,255,255,0.4); }
/* ── Bottom bar ── */
.bottom-bar { display:flex; justify-content:space-between; align-items:flex-end; }
.bottom-left, .bottom-right { font-size:clamp(10px,1.1vw,12px); color:rgba(255,255,255,0.15); letter-spacing:0.08em; text-transform:uppercase; }
/* ── Cartouche ── */
.cartouche { position:fixed; bottom:clamp(16px,2vw,24px); right:clamp(16px,2vw,24px); max-width:320px; padding:14px 18px; background:rgba(0,0,0,0.5); backdrop-filter:blur(12px); border:1px solid rgba(255,255,255,0.06); border-radius:12px; pointer-events:auto; }
.cartouche-title { font-size:13px; font-weight:700; color:rgba(255,255,255,0.6); margin:0 0 6px; }
.cartouche-copy { font-size:9px; color:rgba(255,255,255,0.25); margin:0 0 4px; line-height:1.4; }
.cartouche-contact { font-size:9px; color:rgba(255,255,255,0.25); margin:0; }
.cartouche-contact a { color:#5bd1d8; text-decoration:none; }
/* ── CSS Circuit Board Background ── */
.css-circuit { position:absolute; inset:0; background:#020a0d; overflow:hidden; }
.circuit-grid { position:absolute; inset:5%; }
/* Traces */
.trace { position:absolute; background:rgba(91,209,216,0.08); }
.trace-h { height:1px; left:0; right:0; }
.trace-v { width:1px; top:0; bottom:0; }
.trace-h1 { top:8%; } .trace-h2 { top:22%; } .trace-h3 { top:38%; }
.trace-h4 { top:55%; } .trace-h5 { top:72%; } .trace-h6 { top:88%; }
.trace-v1 { left:10%; } .trace-v2 { left:25%; } .trace-v3 { left:42%; }
.trace-v4 { left:58%; } .trace-v5 { left:75%; } .trace-v6 { left:90%; }
/* Pulse on traces */
.trace .pulse { position:absolute; border-radius:50%; }
.trace-h .pulse { width:40px; height:3px; top:-1px; background:linear-gradient(90deg,transparent,#5bd1d8,transparent); animation:pulseH 4s linear infinite; }
.trace-v .pulse { width:3px; height:40px; left:-1px; background:linear-gradient(180deg,transparent,#5bd1d8,transparent); animation:pulseV 5s linear infinite; }
.trace-h1 .pulse { animation-delay:0s; } .trace-h2 .pulse { animation-delay:0.7s; } .trace-h3 .pulse { animation-delay:1.4s; }
.trace-h4 .pulse { animation-delay:2.1s; } .trace-h5 .pulse { animation-delay:0.3s; } .trace-h6 .pulse { animation-delay:1.8s; }
.trace-v1 .pulse { animation-delay:0.5s; } .trace-v2 .pulse { animation-delay:1.2s; } .trace-v3 .pulse { animation-delay:2.5s; }
.trace-v4 .pulse { animation-delay:0.9s; } .trace-v5 .pulse { animation-delay:1.7s; } .trace-v6 .pulse { animation-delay:3.1s; }
@keyframes pulseH { 0% { left:-40px; } 100% { left:100%; } }
@keyframes pulseV { 0% { top:-40px; } 100% { top:100%; } }
/* Nodes */
.node { position:absolute; width:6px; height:6px; border-radius:50%; background:rgba(91,209,216,0.15); border:1px solid rgba(91,209,216,0.2); transition:all 0.6s ease; }
.node-active { background:#5bd1d8; box-shadow:0 0 12px rgba(91,209,216,0.6), 0 0 24px rgba(91,209,216,0.3); }
.node-1 { top:8%; left:10%; } .node-2 { top:8%; left:42%; } .node-3 { top:8%; left:75%; }
.node-4 { top:22%; left:25%; } .node-5 { top:38%; left:58%; } .node-6 { top:38%; left:90%; }
.node-7 { top:55%; left:10%; } .node-8 { top:55%; left:42%; } .node-9 { top:72%; left:25%; }
.node-10 { top:72%; left:75%; } .node-11 { top:88%; left:42%; } .node-12 { top:88%; left:58%; }
/* IC chips */
.ic { position:absolute; background:rgba(91,209,216,0.04); border:1px solid rgba(91,209,216,0.12); border-radius:2px; display:flex; align-items:center; justify-content:center; }
.ic-label { font-family:monospace; font-size:8px; color:rgba(91,209,216,0.4); letter-spacing:0.1em; }
.ic-pins { position:absolute; }
.ic-pins-l { left:-4px; top:20%; bottom:20%; width:4px; border-right:1px solid rgba(91,209,216,0.15); background:repeating-linear-gradient(180deg,transparent,transparent 3px,rgba(91,209,216,0.1) 3px,rgba(91,209,216,0.1) 5px); }
.ic-pins-r { right:-4px; top:20%; bottom:20%; width:4px; border-left:1px solid rgba(91,209,216,0.15); background:repeating-linear-gradient(180deg,transparent,transparent 3px,rgba(91,209,216,0.1) 3px,rgba(91,209,216,0.1) 5px); }
.ic-1 { width:60px; height:36px; top:18%; left:55%; }
.ic-2 { width:50px; height:30px; top:50%; left:20%; }
.ic-3 { width:44px; height:24px; top:78%; left:68%; }
/* SMD resistors/caps */
.smd { position:absolute; width:12px; height:5px; background:rgba(241,194,122,0.08); border:1px solid rgba(241,194,122,0.15); border-radius:1px; }
.smd-1 { top:12%; left:32%; } .smd-2 { top:15%; left:65%; transform:rotate(90deg); }
.smd-3 { top:30%; left:15%; } .smd-4 { top:45%; left:80%; transform:rotate(90deg); }
.smd-5 { top:60%; left:48%; } .smd-6 { top:65%; left:35%; transform:rotate(90deg); }
.smd-7 { top:82%; left:52%; } .smd-8 { top:35%; left:72%; }
/* Vias */
.via { position:absolute; width:8px; height:8px; border-radius:50%; border:1px solid rgba(91,209,216,0.2); background:radial-gradient(circle,rgba(91,209,216,0.15) 30%,transparent 70%); }
.via::after { content:''; position:absolute; inset:2px; border-radius:50%; background:rgba(91,209,216,0.1); }
.via-1 { top:14%; left:48%; } .via-2 { top:28%; left:82%; } .via-3 { top:42%; left:18%; }
.via-4 { top:62%; left:62%; } .via-5 { top:76%; left:38%; } .via-6 { top:85%; left:85%; }
/* Scanline sweep */
.scanline { position:absolute; inset:0; background:linear-gradient(180deg,transparent 0%,transparent 48%,rgba(91,209,216,0.03) 50%,transparent 52%,transparent 100%); background-size:100% 200%; animation:scanline 8s linear infinite; pointer-events:none; }
@keyframes scanline { 0% { background-position:0 0; } 100% { background-position:0 100%; } }
/* CSS vignette */
.css-vignette { position:absolute; inset:0; background:radial-gradient(ellipse at center,transparent 30%,rgba(0,0,0,0.7) 100%); pointer-events:none; }
/* ── Mobile ── */
@media (max-width:640px) {
.bottom-bar { flex-direction:column; align-items:center; gap:4px; }
.cartouche { position:relative; bottom:auto; right:auto; max-width:100%; margin-top:12px; text-align:center; }
.hero-line-1, .hero-line-3 { font-size:28px; }
.hero-line-2 { font-size:32px; }
}
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
.trace .pulse, .scanline { animation:none !important; }
.hero-line-1, .hero-line-2, .hero-line-3, .hero-divider, .hero-sub { animation:none !important; opacity:1 !important; }
.countdown-hero-text { opacity:1 !important; transform:none !important; transition:none !important; }
}
</style>
+357 -180
View File
@@ -1,207 +1,384 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import '@/styles/global.css';
import { BmuViewer } from '@/components/BmuViewer';
---
<BaseLayout title="L'Electron Rare — Bientôt" description="L'Electron Rare arrive. Systèmes électroniques spécifiques, formations, prototypage.">
<main class="countdown-page">
<svg class="atom" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-cyan-soft, #5bd1d866)" stroke-width="0.8" transform="rotate(-30 150 150)"/>
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-amber-soft, #f1c27a66)" stroke-width="0.8" transform="rotate(30 150 150)"/>
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-green-soft, #b6d18f44)" stroke-width="0.8" transform="rotate(90 150 150)"/>
<circle cx="150" cy="150" r="18" fill="url(#nucleusGlow)"/>
<circle cx="150" cy="150" r="8" fill="var(--accent, #f1c27a)"/>
<circle r="5" fill="var(--electric, #5bd1d8)">
<animateMotion dur="3s" repeatCount="indefinite"><mpath href="#o1"/></animateMotion>
</circle>
<circle r="10" fill="var(--electric, #5bd1d8)" opacity="0.3">
<animateMotion dur="3s" repeatCount="indefinite"><mpath href="#o1"/></animateMotion>
</circle>
<circle r="4" fill="var(--accent, #f1c27a)">
<animateMotion dur="4.2s" repeatCount="indefinite"><mpath href="#o2"/></animateMotion>
</circle>
<circle r="9" fill="var(--accent, #f1c27a)" opacity="0.25">
<animateMotion dur="4.2s" repeatCount="indefinite"><mpath href="#o2"/></animateMotion>
</circle>
<circle r="3" fill="var(--trace-green, #b6d18f)">
<animateMotion dur="5.8s" repeatCount="indefinite"><mpath href="#o3"/></animateMotion>
</circle>
<circle r="8" fill="var(--trace-green, #b6d18f)" opacity="0.2">
<animateMotion dur="5.8s" repeatCount="indefinite"><mpath href="#o3"/></animateMotion>
</circle>
<defs>
<ellipse id="o1" cx="150" cy="150" rx="120" ry="45" transform="rotate(-30 150 150)" fill="none"/>
<ellipse id="o2" cx="150" cy="150" rx="120" ry="45" transform="rotate(30 150 150)" fill="none"/>
<ellipse id="o3" cx="150" cy="150" rx="120" ry="45" transform="rotate(90 150 150)" fill="none"/>
<radialGradient id="nucleusGlow">
<stop offset="0%" stop-color="var(--accent, #f1c27a)" stop-opacity="0.6"/>
<stop offset="100%" stop-color="var(--accent, #f1c27a)" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>L'électron rare — Systèmes électroniques spécifiques</title>
<meta name="description" content="Conception, mise au point et fiabilisation de systèmes électroniques spécifiques, automatismes et ensembles liés à l'énergie. Consulting et formation PCB, KiCad, µC." />
<meta property="og:title" content="L'électron rare — Systèmes électroniques spécifiques" />
<meta property="og:description" content="Conception, mise au point et fiabilisation de systèmes électroniques spécifiques. Consulting et formation." />
<meta property="og:image" content="https://www.lelectronrare.fr/assets/og-cover.jpg" />
<meta property="og:url" content="https://www.lelectronrare.fr/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="L'électron rare" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="L'électron rare — Systèmes électroniques spécifiques" />
<meta name="twitter:description" content="Conception, mise au point et fiabilisation de systèmes électroniques spécifiques." />
<meta name="twitter:image" content="https://www.lelectronrare.fr/assets/og-cover.jpg" />
<link rel="me" href="https://mastodon.saillant.cc/@clement" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preload" href="/assets/models3d/bmu_v2.glb" as="fetch" crossorigin />
<link rel="preload" href="/assets/models3d/i2c_repeater.glb" as="fetch" crossorigin />
<link rel="preload" href="/assets/models3d/mosfet_switch.glb" as="fetch" crossorigin />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProfessionalService",
"name": "L'électron rare",
"description": "Conception, mise au point et fiabilisation de systèmes électroniques spécifiques, automatismes et ensembles liés à l'énergie.",
"url": "https://www.lelectronrare.fr",
"email": "contact@lelectronrare.fr",
"founder": { "@type": "Person", "name": "Clément Saillant" },
"areaServed": "France",
"serviceType": ["Conception électronique", "Formation PCB et embarqué", "Consulting technique", "Automatisme industriel"],
"sameAs": ["https://fr.linkedin.com/in/electron-rare", "https://github.com/electron-rare/", "https://mastodon.saillant.cc/@clement"]
}
</script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html { background: #06080b; color: #fff; overflow-x: hidden; }
body { background: #06080b; min-height: 100vh; font-family: 'Courier New', monospace; }
.scroll-track { height: 1000vh; position: relative; z-index: 1; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
<h1 class="title">L'<span class="electric">é</span>lectron rare</h1>
/* Loader */
.loader { position: fixed; inset: 0; z-index: 9999; background: #06080b; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; transition: opacity 0.8s ease, visibility 0.8s ease; }
.loader.done { opacity: 0; visibility: hidden; pointer-events: none; }
.loader-logo { width: clamp(60px, 8vw, 100px); height: auto; filter: drop-shadow(0 0 20px rgba(91,209,216,0.3)); }
.loader-name { font-size: clamp(20px, 2.5vw, 32px); font-weight: 800; color: rgba(255,255,255,0.8); letter-spacing: 0.06em; }
.loader-bar { width: clamp(180px, 30vw, 280px); height: 2px; background: rgba(255,255,255,0.06); border-radius: 1px; overflow: hidden; }
.loader-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #5bd1d8, #a78bfa); transition: width 0.3s ease; }
.loader-status { font-size: 10px; color: rgba(255,255,255,0.25); letter-spacing: 0.12em; text-transform: uppercase; }
.loader-tagline { font-size: 12px; color: rgba(255,255,255,0.3); letter-spacing: 0.08em; margin-bottom: 20px; }
.loader-actions { margin-top: 24px; }
.loader-skip { display: inline-block; padding: 10px 24px; border: 1px solid rgba(91,209,216,0.3); border-radius: 8px; color: rgba(91,209,216,0.7); font-size: 12px; text-decoration: none; letter-spacing: 0.04em; transition: all 0.2s; }
.loader-skip:hover { border-color: #5bd1d8; color: #5bd1d8; background: rgba(91,209,216,0.08); }
.loader-a11y { margin-top: 20px; font-size: 10px; color: rgba(255,255,255,0.15); }
.loader-a11y a { color: rgba(255,255,255,0.25); text-decoration: none; }
.loader-a11y a:hover { color: #5bd1d8; }
<div class="timer" id="timer">
<div class="unit"><span class="num" id="days">--</span><span class="label">jours</span></div>
<div class="sep">:</div>
<div class="unit"><span class="num" id="hours">--</span><span class="label">heures</span></div>
<div class="sep">:</div>
<div class="unit"><span class="num" id="minutes">--</span><span class="label">minutes</span></div>
<div class="sep">:</div>
<div class="unit"><span class="num" id="seconds">--</span><span class="label">secondes</span></div>
.ov { position: fixed; z-index: 3; pointer-events: none; opacity: 0; transform: translateY(6px); transition: opacity 0.5s, transform 0.5s; }
.ov.on { opacity: 1; transform: translateY(0); }
/* Hero */
.ov-hero { inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; }
.hero-bottom { display: flex; flex-direction: column; align-items: center; padding-bottom: clamp(60px, 10vh, 120px); }
.hero-t { text-align: center; margin: 0; line-height: 1.1; }
.hl1,.hl3 { display: block; font-size: clamp(28px, 4.5vw, 56px); font-weight: 800; color: rgba(255,255,255,0.85); letter-spacing: -0.02em; }
.hl2 { display: block; font-size: clamp(32px, 5.5vw, 64px); font-weight: 800; color: #5bd1d8; letter-spacing: -0.02em; }
.hero-s { font-size: clamp(11px, 1.2vw, 15px); color: rgba(255,255,255,0.3); letter-spacing: 0.15em; text-transform: uppercase; margin: 16px 0 0; }
.hero-s2 { font-size: clamp(10px, 1vw, 13px); color: rgba(255,255,255,0.2); letter-spacing: 0.12em; text-transform: uppercase; margin: 8px 0 0; }
/* Header */
.site-hdr { position: fixed; top: 0; left: 0; right: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; padding: 16px clamp(20px, 3vw, 40px); pointer-events: none; }
.hdr-left { display: flex; align-items: center; gap: 10px; pointer-events: auto; }
.hdr-logo { width: 36px; height: auto; filter: drop-shadow(0 0 12px rgba(91,209,216,0.3)); }
.hdr-name { font-size: 16px; font-weight: 800; color: rgba(255,255,255,0.8); letter-spacing: 0.04em; }
.hdr-nav { display: flex; align-items: center; gap: 8px; pointer-events: auto; }
.hdr-link { padding: 8px 16px; color: rgba(255,255,255,0.6); font-size: 12px; font-weight: 600; text-decoration: none; letter-spacing: 0.04em; transition: color 0.2s; }
.hdr-link:hover { color: #5bd1d8; }
.hdr-social { font-weight: 800; font-size: 11px; text-transform: uppercase; padding: 8px 10px; opacity: 0.5; }
.hdr-social:hover { opacity: 1; }
.hdr-cta { padding: 8px 20px; border-radius: 6px; background: rgba(91,209,216,0.15); border: 1px solid rgba(91,209,216,0.3); color: #5bd1d8; font-size: 12px; font-weight: 700; text-decoration: none; letter-spacing: 0.05em; transition: background 0.2s; }
.hdr-cta:hover { background: rgba(91,209,216,0.25); }
.burger { display: none; background: none; border: none; color: #fff; font-size: 24px; cursor: pointer; padding: 8px; pointer-events: auto; }
.mobile-menu { display: none; position: fixed; inset: 0; z-index: 100; background: rgba(6,8,11,0.95); backdrop-filter: blur(16px); flex-direction: column; align-items: center; justify-content: center; gap: 24px; }
.mobile-menu.open { display: flex; }
.mobile-menu a { font-size: 20px; font-weight: 700; color: rgba(255,255,255,0.8); text-decoration: none; letter-spacing: 0.05em; padding: 12px 24px; }
.mobile-menu .close-btn { position: absolute; top: 20px; right: 20px; background: none; border: none; color: #fff; font-size: 28px; cursor: pointer; }
@media (max-width: 768px) { .hdr-nav { display: none; } .burger { display: block; } }
/* Formats */
.ov-formats { bottom: clamp(30px, 5vh, 60px); left: 50%; transform: translateX(-50%); display: flex; gap: 12px; max-width: 820px; width: 92vw; }
.ov-formats.on { transform: translateX(-50%); }
.fmt { flex: 1; padding: 14px 16px; border-radius: 8px; background: rgba(6,8,11,0.7); backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.05); text-align: center; text-decoration: none; color: inherit; cursor: pointer; transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease; }
.fmt:hover { background: rgba(6,8,11,0.85); border-color: rgba(255,255,255,0.18); transform: translateY(-2px); }
.fmt-t { font-size: 13px; font-weight: 700; color: #fff; letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 4px; }
.fmt-d { font-size: 10px; color: rgba(255,255,255,0.35); }
/* Progress */
.prog { position: fixed; top: 0; left: 0; height: 2px; z-index: 5; background: linear-gradient(90deg, #5bd1d8, #a78bfa, #f59e0b); width: 0%; }
.inf { position: fixed; bottom: 10px; right: 14px; z-index: 4; font-size: 8px; color: rgba(255,255,255,0.1); letter-spacing: 0.06em; }
/* Dots */
.dots { position: fixed; right: 20px; top: 50%; transform: translateY(-50%); z-index: 4; display: flex; flex-direction: column; gap: 14px; }
.dot { width: 12px; height: 12px; border-radius: 50%; background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.1); transition: background 0.3s, transform 0.3s, border-color 0.3s; cursor: pointer; pointer-events: auto; position: relative; }
.dot.on { background: #5bd1d8; border-color: #5bd1d8; transform: scale(1.3); box-shadow: 0 0 10px rgba(91,209,216,0.4); }
.dot:hover { background: rgba(91,209,216,0.5); border-color: rgba(91,209,216,0.5); }
.dot[data-l]:not([data-l=""])::after { content: attr(data-l); position: absolute; right: 20px; top: 50%; transform: translateY(-50%); font-size: 9px; color: rgba(255,255,255,0.5); letter-spacing: 0.05em; white-space: nowrap; opacity: 0; transition: opacity 0.2s; pointer-events: none; }
.dot[data-l]:not([data-l=""]):hover::after { opacity: 1; }
/* Trust */
.trust { position: fixed; bottom: 12px; left: clamp(16px, 3vw, 40px); z-index: 3; pointer-events: none; }
.trust-k { font-size: 9px; color: rgba(255,255,255,0.2); letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 4px; }
.trust-names { font-size: 11px; color: rgba(255,255,255,0.3); letter-spacing: 0.04em; }
@media (max-width: 768px) {
.ov-formats { flex-direction: column; gap: 6px; }
.trust { display: none; }
}
</style>
</head>
<body data-da-variant="preview">
<!-- Loader -->
<div class="loader" id="loader">
<img src="/assets/brand/logo-mark.svg" alt="L'électron rare" class="loader-logo" />
<p class="loader-name">L'électron rare</p>
<p class="loader-tagline">Systèmes électroniques spécifiques</p>
<div class="loader-bar"><div class="loader-fill" id="loader-fill"></div></div>
<p class="loader-status" id="loader-status">Chargement des modèles 3D...</p>
<div class="loader-actions">
<a href="/conception/" class="loader-skip">Accéder au site sans 3D →</a>
</div>
<p class="loader-a11y">Version accessible : <a href="/conception/">Conception</a> · <a href="/formation/">Formation</a> · <a href="/consulting/">Consulting</a> · <a href="/contact/">Contact</a></p>
</div>
<p class="tagline" id="tagline"></p>
<BmuViewer client:load />
<div class="prog" id="prog"></div>
<p class="footnote">Systèmes électroniques spécifiques · Formations · Prototypage</p>
</main>
</BaseLayout>
<div class="dots" id="dots">
<div class="dot on" data-s="hero" data-p="0.03" data-l="Hero"></div>
<div class="dot" data-s="hero-to-exp" data-p="0.09" data-l=""></div>
<div class="dot" data-s="exp-elec" data-p="0.14" data-l="Élec"></div>
<div class="dot" data-s="exp-auto" data-p="0.18" data-l="Auto"></div>
<div class="dot" data-s="exp-energie" data-p="0.22" data-l="Énergie"></div>
<div class="dot" data-s="exp-dispositifs" data-p="0.255" data-l="Dispositifs"></div>
<div class="dot" data-s="exp-consulting" data-p="0.285" data-l="Consulting"></div>
<div class="dot" data-s="eclate-recul" data-p="0.31" data-l=""></div>
<div class="dot" data-s="eclate-sep" data-p="0.34" data-l=""></div>
<div class="dot" data-s="eclate-plat" data-p="0.36" data-l=""></div>
<div class="dot" data-s="cas" data-p="0.53" data-l="Cas concrets"></div>
<div class="dot" data-s="media" data-p="0.70" data-l="Médias"></div>
<div class="dot" data-s="formats" data-p="0.85" data-l="Services"></div>
<div class="dot" data-s="contact" data-p="0.95" data-l="Contact"></div>
</div>
<script>
const LAUNCH = new Date('2026-05-01T00:00:00+02:00').getTime();
<!-- HEADER -->
<header class="site-hdr">
<div class="hdr-left">
<img src="/assets/brand/logo-mark.svg" alt="" class="hdr-logo" />
<span class="hdr-name">L'électron rare</span>
</div>
<nav class="hdr-nav">
<a href="/conception/" class="hdr-link">Conception</a>
<a href="/formation/" class="hdr-link">Formation</a>
<a href="/consulting/" class="hdr-link">Consulting</a>
<a href="https://fr.linkedin.com/in/electron-rare" target="_blank" rel="noopener" class="hdr-link hdr-social" title="LinkedIn">in</a>
<a href="https://github.com/electron-rare/" target="_blank" rel="noopener" class="hdr-link hdr-social" title="GitHub">gh</a>
<a href="https://mastodon.saillant.cc/@clement" target="_blank" rel="me noopener" class="hdr-link hdr-social" title="Mastodon">mt</a>
<a href="/contact/" class="hdr-cta">Contact</a>
</nav>
<button class="burger" id="burger-btn" aria-label="Menu">☰</button>
</header>
<div class="mobile-menu" id="mobile-menu">
<button class="close-btn" id="close-menu">✕</button>
<a href="/">Accueil</a><a href="/conception/">Conception</a><a href="/formation/">Formation</a><a href="/consulting/">Consulting</a><a href="/contact/">Contact</a>
</div>
const messages = [
"Charge des condensateurs en cours...",
"Calibration de l'oscilloscope...",
"Soudure des derniers composants...",
"Vérification de la BOM...",
"Flash du firmware en cours...",
"Mesure de continuité... bip.",
"Routage des dernières pistes...",
"Test de la chaîne d'alimentation...",
"Programmation du bootloader...",
"Revue de schéma en cours...",
"Placement des composants CMS...",
"Passage au four de refusion...",
"Debug JTAG en cours...",
"Vérification du plan de masse...",
"Analyse du spectre RF..."
];
<!-- HERO -->
<div class="ov ov-hero on" data-sec="hero">
<div class="hero-bottom">
<h1 class="hero-t">
<span class="hl1">Systèmes</span>
<span class="hl2">électroniques</span>
<span class="hl3">spécifiques</span>
</h1>
<p class="hero-s">électronique · automatisme · énergie</p>
<p class="hero-s2">Conception · Mise au point · Fiabilisation · Consulting · Formation</p>
</div>
</div>
let msgIndex = Math.floor(Math.random() * messages.length);
<!-- FORMATS -->
<div class="ov ov-formats" data-sec="formats">
<a class="fmt" href="/conception/">
<p class="fmt-t" style="color:#5bd1d8">Conception</p>
<p class="fmt-d">Schéma · PCB · Firmware · Validation</p>
</a>
<a class="fmt" href="/formation/">
<p class="fmt-t" style="color:#a78bfa">Formation</p>
<p class="fmt-d">PCB · KiCad · µC · Soudure</p>
</a>
<a class="fmt" href="/consulting/">
<p class="fmt-t" style="color:#f59e0b">Consulting</p>
<p class="fmt-d">Audit · Diagnostic · Transfert</p>
</a>
</div>
function pad(n) { return String(n).padStart(2, '0'); }
<!-- CONFIANCE -->
<div class="trust" id="trust">
<p class="trust-k">Ils m'ont fait confiance</p>
<p class="trust-names">KXKM · Spectacle vivant · Secteur ferroviaire · Industrie automatisée · Écoles d'art numérique</p>
</div>
function update() {
const now = Date.now();
const diff = Math.max(0, LAUNCH - now);
const d = Math.floor(diff / 86400000);
const h = Math.floor((diff % 86400000) / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
document.getElementById('days').textContent = pad(d);
document.getElementById('hours').textContent = pad(h);
document.getElementById('minutes').textContent = pad(m);
document.getElementById('seconds').textContent = pad(s);
}
<div class="scroll-track"></div>
function rotateMessage() {
const el = document.getElementById('tagline');
el.style.opacity = '0';
setTimeout(() => {
el.textContent = messages[msgIndex];
el.style.opacity = '1';
msgIndex = (msgIndex + 1) % messages.length;
}, 400);
}
<!-- SEO: texte indexable caché -->
<div class="sr-only" aria-hidden="false">
<h1>L'électron rare — Systèmes électroniques spécifiques</h1>
<p>Conception, mise au point et fiabilisation de systèmes électroniques spécifiques, automatismes et ensembles liés à l'énergie, au stockage et à l'optimisation terrain.</p>
<h2>Domaines d'expertise</h2>
<ul>
<li>Électronique spécifique : cartes, interfaces, capteurs, alimentation</li>
<li>Instrumentation et automatisme : bancs, automates, variateurs, protocoles terrain</li>
<li>Énergie et stockage : batterie, conversion, supervision, télémétrie</li>
<li>Dispositifs pour le réel : audio, LED, scène, robustesse terrain</li>
<li>Consulting et formation : audit, transfert, pédagogie, projet</li>
</ul>
<h2>Cas concrets</h2>
<ul>
<li>Industries créatives : système audio embarqué sur batterie pour le spectacle (KXKM)</li>
<li>Industrie : diagnostic et remise en service d'une ligne automatisée</li>
<li>Formation : formation PCB et embarqué pour une école d'art numérique</li>
</ul>
<h2>Services</h2>
<ul>
<li>Conception : schéma, PCB, firmware, validation</li>
<li>Formation : PCB, KiCad, microcontrôleurs, soudure</li>
<li>Consulting : audit, diagnostic, transfert</li>
</ul>
<h2>Ils m'ont fait confiance</h2>
<p>KXKM (spectacle vivant), secteur ferroviaire, industrie automatisée, écoles d'art numérique.</p>
<h2>À propos</h2>
<p>Clément Saillant — 10 ans d'expérience en systèmes électroniques. Contributeur open-source KiCad et KXKM. Ambassadeur IA appliquée à l'électronique.</p>
<p>Contact : <a href="/contact/">Parlons de votre projet</a> — contact@lelectronrare.fr · <a href="https://blog.saillant.cc">Blog technique</a></p>
</div>
update();
rotateMessage();
setInterval(update, 1000);
setInterval(rotateMessage, 8000);
</script>
<noscript>
<div style="position:fixed;inset:0;z-index:9999;background:#06080b;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#fff;font-family:monospace;text-align:center;padding:40px;">
<img src="/assets/photos/bmu-assembly.jpg" alt="BMU v2 Assembly" style="width:clamp(200px,50vw,500px);border-radius:12px;margin-bottom:20px;" />
<h1 style="font-size:24px;">L'électron rare</h1>
<p style="color:rgba(255,255,255,0.5);margin:12px 0;">Systèmes électroniques spécifiques</p>
<a href="https://www.lelectronrare.fr/" style="color:#5bd1d8;margin-top:16px;">Accéder au site →</a>
</div>
</noscript>
<style>
.countdown-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 48px 24px;
background: var(--bg, #0b0a09);
}
<div class="inf">L'électron rare · Clément Saillant</div>
.atom {
width: clamp(180px, 35vw, 260px);
height: auto;
margin-bottom: 24px;
}
<!-- GTM for A/B tracking -->
<script>
window.dataLayer = window.dataLayer || [];
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!=='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','GTM-5SLM67QF');
</script>
.title {
font-family: 'Fraunces', serif;
font-size: clamp(28px, 5vw, 48px);
font-weight: 700;
color: var(--text, #f4eee3);
margin: 0 0 32px;
letter-spacing: -1px;
}
<script>
const prog = document.getElementById('prog');
const dots = [...document.querySelectorAll('.dot')];
const overlays = [...document.querySelectorAll('.ov')];
const RANGES = [
['hero',0,0.10],['hero-to-exp',0.10,0.16],
['exp-elec',0.16,0.20],['exp-auto',0.20,0.24],['exp-energie',0.24,0.28],
['exp-dispositifs',0.28,0.31],['exp-consulting',0.31,0.34],
['eclate-recul',0.34,0.36],['eclate-sep',0.36,0.39],['eclate-plat',0.39,0.41],
['cas',0.41,0.65],
['media',0.65,0.81],['formats',0.81,0.91],['contact',0.91,1.0],
];
function update() {
const max = document.documentElement.scrollHeight - window.innerHeight;
const t = max > 0 ? window.scrollY / max : 0;
if (prog) prog.style.width = (t*100)+'%';
let active = 'hero';
for (const [id,s,e] of RANGES) { if (t>=s&&t<=e){active=id;break;} }
overlays.forEach(el => el.classList.toggle('on', el.getAttribute('data-sec')===active));
dots.forEach(d => d.classList.toggle('on', d.getAttribute('data-s')===active));
}
window.addEventListener('scroll', update, {passive:true});
update();
.electric {
color: var(--electric, #5bd1d8);
animation: pulse 3s ease-in-out infinite;
}
// Dots cliquables
dots.forEach(d => {
d.addEventListener('click', () => {
const p = parseFloat(d.getAttribute('data-p') || '0');
const max = document.documentElement.scrollHeight - window.innerHeight;
window.scrollTo({ top: Math.round(max * p), behavior: 'smooth' });
});
});
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; text-shadow: 0 0 12px var(--electric, #5bd1d8); }
}
// GA4 tracking
document.querySelectorAll('a[href]').forEach(a => {
a.addEventListener('click', () => {
const label = a.textContent?.trim() || a.getAttribute('href') || '';
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ event: 'click', event_category: 'navigation', event_label: label, da_variant: 'preview', page_path: '/preview/' });
});
});
.timer {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 32px;
}
// Burger menu
document.getElementById('burger-btn')?.addEventListener('click',()=>document.getElementById('mobile-menu')?.classList.add('open'));
document.getElementById('close-menu')?.addEventListener('click',()=>document.getElementById('mobile-menu')?.classList.remove('open'));
.unit {
display: flex;
flex-direction: column;
align-items: center;
}
// Preloader — télécharge les assets en arrière-plan avec progression réelle
const loader = document.getElementById('loader');
const loaderFill = document.getElementById('loader-fill');
const loaderStatus = document.getElementById('loader-status');
.num {
font-family: 'IBM Plex Mono', 'Share Tech Mono', monospace;
font-size: clamp(36px, 8vw, 72px);
font-weight: 600;
color: var(--text, #f4eee3);
line-height: 1;
min-width: 2ch;
}
const ASSETS = [
{ url: '/assets/models3d/bmu_v2.glb', label: 'Carte BMU principale', size: 5500000 },
{ url: '/assets/models3d/i2c_repeater.glb', label: 'Carte I2C Repeater', size: 1800000 },
{ url: '/assets/models3d/mosfet_switch.glb', label: 'Cartes Mosfet Switch', size: 1500000 },
];
const TOTAL_SIZE = ASSETS.reduce((s, a) => s + a.size, 0);
.label {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: var(--text-dim, #b6a894);
text-transform: uppercase;
letter-spacing: 2px;
margin-top: 6px;
}
let loadedBytes = 0;
let allDone = false;
.sep {
font-family: 'IBM Plex Mono', monospace;
font-size: clamp(28px, 6vw, 56px);
color: var(--accent, #f1c27a);
opacity: 0.5;
margin-bottom: 20px;
}
function updateProgress(bytes, label) {
loadedBytes += bytes;
const pct = Math.min(85, Math.round(loadedBytes / TOTAL_SIZE * 85));
if (loaderFill) loaderFill.style.width = pct + '%';
if (loaderStatus) loaderStatus.textContent = label;
}
.tagline {
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
color: var(--accent, #f1c27a);
margin: 0 0 40px;
min-height: 1.5em;
transition: opacity 0.4s ease;
}
async function preloadAsset(asset) {
try {
const res = await fetch(asset.url);
const reader = res.body?.getReader();
if (!reader) { updateProgress(asset.size, asset.label); return; }
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.length;
updateProgress(value.length, asset.label);
}
} catch (e) {
updateProgress(asset.size, asset.label + ' (cache)');
}
}
.footnote {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: var(--text-dim, #b6a894);
opacity: 0.4;
letter-spacing: 0.5px;
}
</style>
function hideLoader() {
if (allDone) return;
allDone = true;
if (loaderFill) loaderFill.style.width = '100%';
if (loaderStatus) loaderStatus.textContent = 'Prêt';
setTimeout(() => { if (loader) loader.classList.add('done'); }, 400);
}
// Phase 1 : preload les GLB en streaming
(async () => {
if (loaderStatus) loaderStatus.textContent = 'Téléchargement des modèles 3D...';
for (const asset of ASSETS) {
await preloadAsset(asset);
}
if (loaderStatus) loaderStatus.textContent = 'Initialisation WebGL...';
if (loaderFill) loaderFill.style.width = '90%';
// Phase 2 : attendre le canvas Three.js
const checkCanvas = setInterval(() => {
const cvs = document.querySelector('canvas');
if (cvs && cvs.width > 0) {
clearInterval(checkCanvas);
if (loaderFill) loaderFill.style.width = '95%';
if (loaderStatus) loaderStatus.textContent = 'Compilation shaders...';
setTimeout(hideLoader, 600);
}
}, 150);
})();
// Fallback 15s max
setTimeout(hideLoader, 15000);
</script>
</body>
</html>
+208
View File
@@ -0,0 +1,208 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import '@/styles/global.css';
---
<BaseLayout title="Intranet — L'Electron Rare" description="Dashboard interne des services.">
<main class="intranet">
<h1 class="intranet-title">Intranet <span class="electric">L'Electron Rare</span></h1>
<p class="intranet-sub">Accès rapide · Services · Monitoring</p>
<div class="grid">
<a href="https://tower.saillant.cc/app/crm" class="card" target="_blank" rel="noopener">
<div class="card-icon">📊</div>
<h2>Frappe CRM</h2>
<p>Leads, deals, pipeline commercial, AI scoring</p>
<span class="card-url">tower.saillant.cc/app/crm</span>
</a>
<a href="https://tower.saillant.cc/lms/courses" class="card" target="_blank" rel="noopener">
<div class="card-icon">🎓</div>
<h2>Frappe LMS</h2>
<p>3 formations : KiCad, Debug & Mesure, Dispositifs Culturels</p>
<span class="card-url">tower.saillant.cc/lms/courses</span>
</a>
<a href="https://mail.saillant.cc" class="card" target="_blank" rel="noopener">
<div class="card-icon">📧</div>
<h2>Listmonk</h2>
<p>Newsletter, welcome sequence 3 emails, subscribers</p>
<span class="card-url">mail.saillant.cc</span>
</a>
<a href="https://n8n.saillant.cc" class="card" target="_blank" rel="noopener">
<div class="card-icon">⚡</div>
<h2>n8n</h2>
<p>Workflows : lead scoring, newsletter sync, follow-up</p>
<span class="card-url">n8n.saillant.cc</span>
</a>
<a href="https://kpi.saillant.cc" class="card" target="_blank" rel="noopener">
<div class="card-icon">📈</div>
<h2>Metabase</h2>
<p>Dashboard pipeline, leads par segment/source, score IA</p>
<span class="card-url">kpi.saillant.cc</span>
</a>
<a href="https://rdv.saillant.cc" class="card" target="_blank" rel="noopener">
<div class="card-icon">📅</div>
<h2>Cal.com</h2>
<p>RDV : découverte 20min, projet 30min, formation, audit 1h</p>
<span class="card-url">rdv.saillant.cc</span>
</a>
<a href="https://mascarade.saillant.cc/health" class="card" target="_blank" rel="noopener">
<div class="card-icon">🤖</div>
<h2>mascarade IA</h2>
<p>16 agents IA : writer, analyst, KiCad, SPICE, planner...</p>
<span class="card-url">mascarade.saillant.cc</span>
</a>
<a href="https://auth.saillant.cc" class="card" target="_blank" rel="noopener">
<div class="card-icon">🔐</div>
<h2>Authentik SSO</h2>
<p>Utilisateurs, permissions, providers</p>
<span class="card-url">auth.saillant.cc</span>
</a>
<a href="https://www.lelectronrare.fr/ops" class="card card--highlight" target="_blank" rel="noopener">
<div class="card-icon">🎯</div>
<h2>Ops Center</h2>
<p>Workflow, copilote IA, CRM live, monitoring</p>
<span class="card-url">lelectronrare.fr/ops</span>
</a>
<a href="https://api.lelectronrare.fr/health" class="card" target="_blank" rel="noopener">
<div class="card-icon">🔌</div>
<h2>API Proxy</h2>
<p>Submit lead → Frappe + AI scoring Ollama</p>
<span class="card-url">api.lelectronrare.fr</span>
</a>
</div>
<div class="section">
<h2 class="section-title">Status live</h2>
<div id="status-grid" class="status-grid"></div>
</div>
<div class="section">
<h2 class="section-title">Site public</h2>
<div class="links">
<a href="https://www.lelectronrare.fr/">Countdown</a>
<a href="https://www.lelectronrare.fr/preview">Preview complet</a>
<a href="https://www.lelectronrare.fr/formation">Formations</a>
<a href="https://www.lelectronrare.fr/portfolio">Portfolio KiCanvas</a>
<a href="https://www.lelectronrare.fr/mentions-legales">Mentions légales</a>
</div>
</div>
<div class="section">
<h2 class="section-title">RDV publics</h2>
<div class="links">
<a href="https://rdv.saillant.cc/clement/decouverte">Appel découverte 20min</a>
<a href="https://rdv.saillant.cc/clement/point-projet">Point projet 30min</a>
<a href="https://rdv.saillant.cc/clement/formation">Échange formation 30min</a>
<a href="https://rdv.saillant.cc/clement/audit">Audit technique 1h</a>
</div>
</div>
<div class="section">
<h2 class="section-title">Ressources</h2>
<div class="links">
<a href="https://github.com/electron-rare">GitHub</a>
<a href="https://github.com/KomplexKapharnaum">GitHub KXKM</a>
<a href="https://fr.linkedin.com/in/electron-rare">LinkedIn</a>
<a href="https://tower.saillant.cc/app/lms-course">Admin LMS</a>
<a href="https://tower.saillant.cc/app/email-account">Email accounts</a>
</div>
</div>
<div class="section">
<h2 class="section-title">Credentials</h2>
<div class="cred-grid">
<div class="cred"><span class="cred-label">Frappe CRM</span><span class="cred-val">clement@lelectronrare.fr</span></div>
<div class="cred"><span class="cred-label">Frappe Admin</span><span class="cred-val">Administrator</span></div>
<div class="cred"><span class="cred-label">Authentik</span><span class="cred-val">clement@saillant.cc</span></div>
<div class="cred"><span class="cred-label">Listmonk</span><span class="cred-val">admin</span></div>
<div class="cred"><span class="cred-label">Cal.com</span><span class="cred-val">clement@lelectronrare.fr</span></div>
<div class="cred"><span class="cred-label">Metabase</span><span class="cred-val">clement@lelectronrare.fr</span></div>
<div class="cred"><span class="cred-label">n8n</span><span class="cred-val">clement@lelectronrare.fr</span></div>
</div>
</div>
</main>
</BaseLayout>
<script>
const services = [
{ name: 'Site', url: 'https://www.lelectronrare.fr/' },
{ name: 'API CRM', url: 'https://api.lelectronrare.fr/health' },
{ name: 'Frappe CRM', url: 'https://tower.saillant.cc/' },
{ name: 'Listmonk', url: 'https://mail.saillant.cc/' },
{ name: 'n8n', url: 'https://n8n.saillant.cc/' },
{ name: 'Cal.com', url: 'https://rdv.saillant.cc/' },
{ name: 'Metabase', url: 'https://kpi.saillant.cc/' },
{ name: 'mascarade', url: 'https://mascarade.saillant.cc/health' },
{ name: 'Authentik', url: 'https://auth.saillant.cc/' },
];
const grid = document.getElementById('status-grid');
if (grid) {
services.forEach(s => {
const el = document.createElement('div');
el.className = 'status-item';
el.innerHTML = `<span class="status-dot status-checking"></span><span class="status-name">${s.name}</span><span class="status-ms"></span>`;
grid.appendChild(el);
const t0 = Date.now();
fetch(s.url, { mode: 'no-cors', cache: 'no-store', signal: AbortSignal.timeout(5000) })
.then(() => {
const ms = Date.now() - t0;
el.querySelector('.status-dot')?.classList.replace('status-checking', 'status-up');
const msEl = el.querySelector('.status-ms');
if (msEl) msEl.textContent = ms + 'ms';
})
.catch(() => {
el.querySelector('.status-dot')?.classList.replace('status-checking', 'status-down');
const msEl = el.querySelector('.status-ms');
if (msEl) msEl.textContent = 'timeout';
});
});
}
</script>
<style>
.intranet { max-width:1000px; margin:0 auto; padding:48px 24px; min-height:100vh; }
.intranet-title { font-family:'Fraunces',serif; font-size:clamp(24px,4vw,36px); color:var(--text,#f4eee3); margin:0 0 8px; }
.electric { color:var(--electric,#5bd1d8); }
.intranet-sub { font-family:'IBM Plex Mono',monospace; font-size:13px; color:var(--text-dim,#b6a894); margin:0 0 32px; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:14px; margin-bottom:40px; }
.card { background:var(--surface,#1a1510); border:1px solid var(--line,#3a2f24); border-radius:8px; padding:18px; text-decoration:none; transition:all .2s; display:block; }
.card:hover { border-color:var(--electric,#5bd1d8); box-shadow:0 0 16px rgba(91,209,216,.15); transform:translateY(-2px); }
.card--highlight { border-color:var(--accent,#f1c27a); background:rgba(241,194,122,0.05); }
.card-icon { font-size:24px; margin-bottom:8px; }
.card h2 { font-family:'Fraunces',serif; font-size:15px; color:var(--text,#f4eee3); margin:0 0 4px; }
.card p { font-size:12px; color:var(--text-muted,#d6cbb9); margin:0 0 8px; line-height:1.4; }
.card-url { font-family:'IBM Plex Mono',monospace; font-size:10px; color:var(--text-dim,#b6a894); opacity:.5; }
.section { margin-bottom:28px; }
.section-title { font-family:'Fraunces',serif; font-size:17px; color:var(--accent,#f1c27a); margin:0 0 14px; padding-bottom:6px; border-bottom:1px solid var(--line,#3a2f24); }
.status-grid { display:flex; flex-wrap:wrap; gap:10px; }
.status-item { display:flex; align-items:center; gap:8px; font-family:'IBM Plex Mono',monospace; font-size:12px; color:var(--text-muted,#d6cbb9); background:var(--surface,#1a1510); padding:6px 12px; border-radius:6px; border:1px solid var(--line,#3a2f24); }
.status-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
.status-checking { background:#f1c27a; animation:blink 1s infinite; }
.status-up { background:#4ade80; box-shadow:0 0 4px #4ade80; }
.status-down { background:#ef4444; box-shadow:0 0 4px #ef4444; }
.status-ms { font-size:10px; color:var(--text-dim,#b6a894); opacity:.6; }
@keyframes blink { 50% { opacity:.3; } }
.links { display:flex; flex-wrap:wrap; gap:8px; }
.links a { font-family:'IBM Plex Mono',monospace; font-size:12px; color:var(--electric,#5bd1d8); text-decoration:none; padding:6px 12px; border:1px solid var(--line,#3a2f24); border-radius:6px; transition:all .2s; }
.links a:hover { border-color:var(--electric,#5bd1d8); background:rgba(91,209,216,.08); }
.cred-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:8px; }
.cred { display:flex; justify-content:space-between; align-items:center; font-family:'IBM Plex Mono',monospace; font-size:12px; padding:8px 12px; background:var(--surface,#1a1510); border:1px solid var(--line,#3a2f24); border-radius:6px; }
.cred-label { color:var(--text-dim,#b6a894); }
.cred-val { color:var(--electric,#5bd1d8); }
</style>
+884
View File
@@ -0,0 +1,884 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import '@/styles/global.css';
---
<BaseLayout title="Ops — L'Electron Rare" description="Centre opérationnel IA-native.">
<main class="ops" id="ops-main">
<div class="ops-header">
<div class="ops-header-left">
<img src="/assets/brand/logo-mark.jpg" alt="" width="32" height="32" class="ops-logo" />
<div>
<h1 class="ops-title">Ops</h1>
<p class="ops-sub">L'Electron Rare · <span id="task-count">—</span></p>
</div>
</div>
<div class="ops-header-right">
<div class="status-bar" id="status-bar">
<span class="status-dot" id="status-dot"></span>
<span class="status-text" id="status-text">...</span>
</div>
</div>
</div>
<nav class="tabs" id="tabs">
<button class="tab tab--active" data-tab="tasks">Tâches</button>
<button class="tab" data-tab="dashboard">Dashboard</button>
<button class="tab" data-tab="services">Services</button>
</nav>
<!-- VUE 1: TÂCHES -->
<section class="panel panel--active" id="panel-tasks">
<div class="tasks-toolbar">
<button class="btn btn--primary btn--sm" id="add-task-btn">+ Nouvelle tâche</button>
<div class="tasks-filters">
<select id="filter-cat" class="filter-select"><option value="">Toutes catégories</option></select>
<select id="filter-status" class="filter-select">
<option value="">Tous statuts</option>
<option value="À faire" selected>À faire</option>
<option value="En cours">En cours</option>
<option value="Terminé">Terminé</option>
</select>
<input type="text" id="filter-search" class="filter-input" placeholder="Rechercher..." />
</div>
<button class="btn btn--outline btn--sm" id="refresh-tasks">↻</button>
</div>
<div id="add-task-form" style="display:none" class="card-form">
<input type="text" id="new-task-title" class="form-input" placeholder="Titre de la tâche..." />
<div class="form-row">
<select id="new-task-category" class="form-select">
<option value="urgent">Urgent</option>
<option value="prospection">Prospection</option>
<option value="linkedin">LinkedIn</option>
<option value="technique">Technique</option>
<option value="lancement">Lancement</option>
<option value="formation">Formation</option>
<option value="gie">GIE</option>
<option value="marketing">Marketing</option>
<option value="business">Business</option>
<option value="crm">CRM</option>
</select>
<select id="new-task-priority" class="form-select">
<option value="P0">P0</option>
<option value="P1">P1</option>
<option value="P2" selected>P2</option>
<option value="P3">P3</option>
</select>
<input type="date" id="new-task-deadline" class="form-input" style="width:auto" />
<select id="new-task-agent" class="form-select">
<option value="">Sans IA</option>
<option value="writer">writer</option>
<option value="analyst">analyst</option>
<option value="brainstorm">brainstorm</option>
<option value="planner">planner</option>
<option value="prospection">prospection</option>
<option value="formation-designer">formation-designer</option>
</select>
</div>
<textarea id="new-task-desc" class="form-textarea" rows="2" placeholder="Description (optionnel)..."></textarea>
<div class="form-row">
<button class="btn btn--primary" id="save-task-btn">Enregistrer</button>
<button class="btn btn--outline" id="cancel-task-btn">Annuler</button>
</div>
</div>
<div id="tasks-container"><div class="loading">Chargement...</div></div>
</section>
<!-- VUE 2: DASHBOARD -->
<section class="panel" id="panel-dashboard">
<div class="kpi-grid" id="kpi-grid">
<div class="kpi-card"><div class="kpi-num" id="kpi-leads">—</div><div class="kpi-label">Leads</div></div>
<div class="kpi-card"><div class="kpi-num" id="kpi-hot">—</div><div class="kpi-label">Chauds</div></div>
<div class="kpi-card"><div class="kpi-num" id="kpi-score">—</div><div class="kpi-label">Score IA</div></div>
<div class="kpi-card"><div class="kpi-num" id="kpi-tasks">—</div><div class="kpi-label">Tâches</div></div>
<div class="kpi-card"><div class="kpi-num" id="kpi-done">—</div><div class="kpi-label">Terminées</div></div>
<div class="kpi-card"><div class="kpi-num" id="kpi-agents">—</div><div class="kpi-label">Agents IA</div></div>
</div>
<h3 class="sub-title">Agents mascarade <button class="btn btn--outline btn--sm" id="add-agent-btn" style="margin-left:8px">+ Créer</button></h3>
<div id="add-agent-form" style="display:none" class="card-form">
<input type="text" id="new-agent-name" class="form-input" placeholder="Nom (ex: email-drafter)" />
<input type="text" id="new-agent-desc" class="form-input" placeholder="Description" />
<textarea id="new-agent-prompt" class="form-textarea" rows="3" placeholder="System prompt..."></textarea>
<div class="form-row">
<select id="new-agent-strategy" class="form-select"><option value="best">best</option><option value="cheapest">cheapest</option><option value="fastest">fastest</option></select>
<select id="new-agent-provider" class="form-select"><option value="">Auto</option><option value="ollama">ollama</option><option value="claude">claude</option><option value="mistral">mistral</option></select>
<button class="btn btn--primary" id="save-agent-btn">Créer</button>
<button class="btn btn--outline" id="cancel-agent-btn">Annuler</button>
</div>
</div>
<div class="agents-grid" id="agents-grid"></div>
<h3 class="sub-title">Copilote rapide</h3>
<div class="copilot-bar">
<select id="chat-agent" class="form-select"></select>
<input type="text" id="chat-input" class="form-input" placeholder="Demande à l'IA..." style="flex:1" />
<button class="btn btn--primary" id="chat-send">→</button>
</div>
<div id="chat-output" class="chat-output"></div>
<div class="quick-links">
<a href="https://tower.saillant.cc/app/crm" target="_blank" class="btn btn--outline btn--sm">CRM</a>
<a href="https://kpi.saillant.cc/dashboard/2" target="_blank" class="btn btn--outline btn--sm">Metabase</a>
<a href="https://mail.saillant.cc" target="_blank" class="btn btn--outline btn--sm">Listmonk</a>
<a href="https://rdv.saillant.cc" target="_blank" class="btn btn--outline btn--sm">Cal.com</a>
<a href="https://n8n.saillant.cc" target="_blank" class="btn btn--outline btn--sm">n8n</a>
<a href="https://tower.saillant.cc/lms/courses" target="_blank" class="btn btn--outline btn--sm">LMS</a>
</div>
</section>
<!-- VUE 3: SERVICES -->
<section class="panel" id="panel-services">
<h3 class="sub-title">Status <span class="status-refresh" id="refresh-services">↻</span></h3>
<div class="monitor-grid" id="monitor-grid"></div>
<h3 class="sub-title">Business</h3>
<div class="services-grid">
<a href="https://tower.saillant.cc/app/crm" class="svc-card" target="_blank"><img src="/assets/icons/crm.png" width="36" height="36" /><span>CRM</span></a>
<a href="https://tower.saillant.cc/lms/courses" class="svc-card" target="_blank"><img src="/assets/icons/lms.png" width="36" height="36" /><span>LMS</span></a>
<a href="https://mail.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/newsletter.png" width="36" height="36" /><span>Listmonk</span></a>
<a href="https://n8n.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/automation.png" width="36" height="36" /><span>n8n</span></a>
<a href="https://kpi.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/analytics.png" width="36" height="36" /><span>Metabase</span></a>
<a href="https://rdv.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/calendar.png" width="36" height="36" /><span>Cal.com</span></a>
</div>
<h3 class="sub-title">IA & Collaboratif</h3>
<div class="services-grid">
<a href="https://mascarade.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/ai-brain.png" width="36" height="36" /><span>mascarade</span></a>
<a href="https://dify.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/workflow-ai.png" width="36" height="36" /><span>Dify</span></a>
<a href="https://langfuse.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/analytics.png" width="36" height="36" /><span>Langfuse</span></a>
<a href="https://memos.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/notes.png" width="36" height="36" /><span>Memos</span></a>
<a href="https://docs.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/documents.png" width="36" height="36" /><span>Paperless</span></a>
<a href="https://karakeep.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/bookmarks.png" width="36" height="36" /><span>Karakeep</span></a>
<a href="https://draw.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/eye.png" width="36" height="36" /><span>Excalidraw</span></a>
<a href="https://paste.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/logs.png" width="36" height="36" /><span>PrivateBin</span></a>
</div>
<h3 class="sub-title">Monitoring</h3>
<div class="services-grid">
<a href="https://grafana.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/chart.png" width="36" height="36" /><span>Grafana</span></a>
<a href="https://logs.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/logs.png" width="36" height="36" /><span>Dozzle</span></a>
<a href="https://ntfy.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/bell.png" width="36" height="36" /><span>ntfy</span></a>
<a href="https://checks.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/health.png" width="36" height="36" /><span>Healthchecks</span></a>
<a href="https://watch.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/eye.png" width="36" height="36" /><span>Changedetection</span></a>
<a href="https://feeds.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/rss.png" width="36" height="36" /><span>Miniflux</span></a>
<a href="https://auth.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/auth.png" width="36" height="36" /><span>Authentik</span></a>
<a href="https://browser.saillant.cc" class="svc-card" target="_blank"><img src="/assets/icons/eye.png" width="36" height="36" /><span>browser-use</span></a>
</div>
</section>
</main>
</BaseLayout>
<script>
// API Contracts:
// GET /health → { ok, agents, providers }
// GET /api/ai → { agents: [...] }
// POST /api/ai → create agent
// POST /api/ai/:name/run → { messages: [{role, content}] }
// GET /api/ops/tasks → { data: [...] }
// POST /api/ops/tasks → create task
// PUT /api/ops/tasks/:id → update task
// POST /api/ops/execute/:id → AI execute
const API = 'https://api.lelectronrare.fr';
const OPS = API + '/api/ops';
const AI = API + '/api/ai';
// Auth token for API calls — populate via data-token attribute on #ops-main
// or a <meta name="api-token"> tag. Empty string keeps unauthenticated flow working.
const AUTH_TOKEN = document.getElementById('ops-main')?.dataset?.token
|| document.querySelector('meta[name="api-token"]')?.content
|| '';
const catLabels = {urgent:'Urgent',prospection:'Prospection',linkedin:'LinkedIn',technique:'Technique',lancement:'Lancement',formation:'Formation',gie:'GIE',marketing:'Marketing',business:'Business',crm:'CRM'};
const catColors = {urgent:'red',prospection:'amber',linkedin:'blue',technique:'cyan',lancement:'green',formation:'amber',gie:'amber',crm:'cyan',marketing:'blue',business:'green'};
const prioColors = {P0:'red',P1:'amber',P2:'cyan',P3:'green'};
// --- Tabs ---
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('tab--active'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('panel--active'));
tab.classList.add('tab--active');
document.getElementById('panel-' + tab.dataset.tab)?.classList.add('panel--active');
});
});
// --- TASKS ---
let allTasks = [];
async function loadTasks() {
try {
const res = await fetch(OPS + '/tasks', {headers:{'Content-Type':'application/json'}});
allTasks = (await res.json()).data || [];
renderTasks();
} catch(e) { document.getElementById('tasks-container').innerHTML = '<p class="err">Erreur: '+e.message+'</p>'; }
}
function renderTasks() {
const cat = document.getElementById('filter-cat')?.value || '';
const status = document.getElementById('filter-status')?.value || '';
const search = (document.getElementById('filter-search')?.value || '').toLowerCase();
let filtered = allTasks;
if (cat) filtered = filtered.filter(t => t.category === cat);
if (status) filtered = filtered.filter(t => t.status === status);
if (search) filtered = filtered.filter(t => (t.task_title||'').toLowerCase().includes(search) || (t.description||'').toLowerCase().includes(search));
const groups = {};
const done = [];
filtered.forEach(t => {
if (t.status === 'Terminé') { done.push(t); return; }
const c = t.category || 'technique';
if (!groups[c]) groups[c] = [];
groups[c].push(t);
});
let html = '';
for (const [c, tasks] of Object.entries(groups)) {
html += `<div class="task-group"><div class="group-label group-label--${catColors[c]||'cyan'}">${catLabels[c]||c} (${tasks.length})</div><div class="task-list">${tasks.map(t => taskCard(t)).join('')}</div></div>`;
}
if (done.length) {
html += `<details class="task-group"><summary class="group-label group-done">Terminé (${done.length})</summary><div class="task-list">${done.map(t => taskCard(t,true)).join('')}</div></details>`;
}
const total = allTasks.length;
const doneCount = allTasks.filter(t => t.status === 'Terminé').length;
document.getElementById('task-count').textContent = `${doneCount}/${total} tâches`;
document.getElementById('tasks-container').innerHTML = html || '<p class="hint">Aucune tâche</p>';
bindTasks();
// Populate category filter
const catSelect = document.getElementById('filter-cat');
if (catSelect && catSelect.options.length <= 1) {
const cats = [...new Set(allTasks.map(t => t.category).filter(Boolean))].sort();
cats.forEach(c => { const o = document.createElement('option'); o.value = c; o.textContent = catLabels[c]||c; catSelect.appendChild(o); });
}
}
function taskCard(t, isDone) {
const overdue = t.deadline && new Date(t.deadline) < new Date() && !isDone;
const isLinkedin = t.category === 'linkedin';
const lines = isLinkedin && t.description ? t.description.split('\\n').map(l => l ? '<p class="li-line">'+l+'</p>' : '<div class="li-spacer"></div>').join('') : '';
return `<div class="task ${overdue?'task--overdue':''} ${isDone?'task--done':''}" data-id="${t.name}">
<div class="task-header">
<span class="task-status">${t.status==='Terminé'?'●':t.status==='En cours'?'◐':'○'}</span>
<span class="task-title">${t.task_title||''}</span>
${t.priority?`<span class="badge badge--${prioColors[t.priority]||'cyan'}">${t.priority}</span>`:''}
${t.deadline?`<span class="badge badge--${overdue?'red':'blue'}">${t.deadline}</span>`:''}
${t.ai_agent?`<span class="badge badge--cyan">IA</span>`:''}
</div>
<div class="task-body">
${t.description?`<div class="task-desc">${isLinkedin?'<div class="li-preview"><div class="li-header"><div class="li-avatar">CS</div><div><b>Clément Saillant</b><br><small>Ingénieur électronique · L\'Electron Rare</small></div></div>'+lines+'</div>':'<p>'+t.description+'</p>'}</div>`:''}
${t.ai_result?`<details class="ai-saved"><summary>Résultat IA</summary><pre>${(t.ai_result||'').substring(0,500)}</pre></details>`:''}
<div class="task-actions">
<select class="action-status" data-id="${t.name}">${['À faire','En cours','Terminé','Reporté'].map(s=>`<option${s===t.status?' selected':''}>${s}</option>`).join('')}</select>
<input type="text" class="action-note" data-id="${t.name}" placeholder="Note..." value="${(t.notes||'').replace(/"/g,'&quot;')}" />
<button class="btn btn--primary btn--sm save-btn" data-id="${t.name}">Save</button>
${t.ai_agent?`<button class="btn btn--outline btn--sm exec-btn" data-id="${t.name}">IA: ${t.ai_agent}</button>`:''}
${isLinkedin?`<button class="btn btn--outline btn--sm copy-btn" data-text="${encodeURIComponent(t.description||'')}">Copier</button><a href="https://linkedin.com/feed/" target="_blank" class="btn btn--outline btn--sm">LinkedIn</a>`:''}
</div>
<div class="ai-out" id="ai-${t.name}"></div>
</div>
</div>`;
}
function bindTasks() {
document.querySelectorAll('#tasks-container .task-header').forEach(h => h.addEventListener('click', () => h.closest('.task')?.classList.toggle('task--open')));
document.querySelectorAll('.save-btn').forEach(b => b.addEventListener('click', async e => {
e.stopPropagation(); const id=b.dataset.id, task=b.closest('.task');
b.textContent='...';
await fetch(OPS+'/tasks/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:task.querySelector('.action-status')?.value,notes:task.querySelector('.action-note')?.value})});
b.textContent='✓'; setTimeout(loadTasks,500);
}));
document.querySelectorAll('.exec-btn').forEach(b => b.addEventListener('click', async e => {
e.stopPropagation(); const id=b.dataset.id, out=document.getElementById('ai-'+id);
if(out){out.innerHTML='<div class="loading">IA...</div>';out.style.display='block';}
b.textContent='...';
try{const r=await fetch(OPS+'/execute/'+id,{method:'POST',headers:{'Content-Type':'application/json'}});const d=await r.json();
if(out)out.innerHTML='<pre>'+(d.result||d.error||'').substring(0,500).replace(/</g,'&lt;')+'</pre>';
}catch(err){if(out)out.innerHTML='<p class="err">'+err.message+'</p>';}
b.textContent='IA'; loadTasks();
}));
document.querySelectorAll('.copy-btn').forEach(b => b.addEventListener('click', e => {
e.stopPropagation(); navigator.clipboard.writeText(decodeURIComponent(b.dataset.text)); b.textContent='✓'; setTimeout(()=>b.textContent='Copier',2000);
}));
}
// Filters
['filter-cat','filter-status'].forEach(id => document.getElementById(id)?.addEventListener('change', renderTasks));
document.getElementById('filter-search')?.addEventListener('input', renderTasks);
document.getElementById('refresh-tasks')?.addEventListener('click', loadTasks);
// Add task
document.getElementById('add-task-btn')?.addEventListener('click', () => { const f=document.getElementById('add-task-form'); f.style.display=f.style.display==='none'?'block':'none'; });
document.getElementById('cancel-task-btn')?.addEventListener('click', () => document.getElementById('add-task-form').style.display='none');
document.getElementById('save-task-btn')?.addEventListener('click', async () => {
const title=document.getElementById('new-task-title')?.value?.trim(); if(!title)return;
await fetch(OPS+'/tasks',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
task_title:title, category:document.getElementById('new-task-category')?.value||'technique',
priority:document.getElementById('new-task-priority')?.value||'P2',
deadline:document.getElementById('new-task-deadline')?.value||null,
ai_agent:document.getElementById('new-task-agent')?.value||null,
description:document.getElementById('new-task-desc')?.value||'',
status:'À faire', assigned_to:'clement@lelectronrare.fr'
})});
document.getElementById('new-task-title').value=''; document.getElementById('add-task-form').style.display='none'; loadTasks();
});
loadTasks();
// --- DASHBOARD ---
let allAgents = [];
async function loadDashboard() {
// KPIs
try {
document.getElementById('kpi-tasks').textContent = allTasks.length;
document.getElementById('kpi-done').textContent = allTasks.filter(t=>t.status==='Terminé').length;
} catch {}
// Agents
try {
const agentHeaders = {'Content-Type':'application/json'};
if (AUTH_TOKEN) agentHeaders['Authorization'] = 'Bearer ' + AUTH_TOKEN;
const r = await fetch(AI, {headers: agentHeaders});
const d = await r.json();
allAgents = d.agents || (Array.isArray(d) ? d : []);
// kpi-agents is set from /health below; fallback to allAgents.length
if (!document.getElementById('kpi-agents').dataset.fromHealth) {
document.getElementById('kpi-agents').textContent = allAgents.length;
}
const grid = document.getElementById('agents-grid');
grid.innerHTML = allAgents.map(a => `<div class="agent-card" data-name="${a.name}"><b>${a.name}</b><span class="badge badge--${a.strategy==='best'?'cyan':a.strategy==='cheapest'?'green':'blue'}">${a.strategy}</span><p>${(a.description||'').substring(0,60)}</p></div>`).join('');
grid.querySelectorAll('.agent-card').forEach(c => c.addEventListener('click', () => {
const sel = document.getElementById('chat-agent'); if(sel) sel.value = c.dataset.name;
document.getElementById('chat-input')?.focus();
}));
// Populate chat selector
const sel = document.getElementById('chat-agent');
if (sel) sel.innerHTML = allAgents.map(a=>`<option value="${a.name}">${a.name}</option>`).join('');
} catch {}
// API health — parse { ok, agents, providers }
try {
const h = await fetch(API+'/health'); const hd = await h.json();
document.getElementById('status-dot').className = 'status-dot status-dot--ok';
const parts = ['API OK'];
if (typeof hd.agents === 'number') parts.push(hd.agents + ' agents');
if (Array.isArray(hd.providers) && hd.providers.length) parts.push(hd.providers.join(', '));
document.getElementById('status-text').textContent = parts.join(' \u00b7 ');
// Update kpi-agents from health (authoritative count)
if (typeof hd.agents === 'number') {
document.getElementById('kpi-agents').textContent = hd.agents;
document.getElementById('kpi-agents').dataset.fromHealth = '1';
}
} catch {
document.getElementById('status-dot').className = 'status-dot status-dot--err';
document.getElementById('status-text').textContent = 'API down';
}
}
// Agent CRUD
document.getElementById('add-agent-btn')?.addEventListener('click', () => { const f=document.getElementById('add-agent-form'); f.style.display=f.style.display==='none'?'block':'none'; });
document.getElementById('cancel-agent-btn')?.addEventListener('click', () => document.getElementById('add-agent-form').style.display='none');
document.getElementById('save-agent-btn')?.addEventListener('click', async () => {
const name=document.getElementById('new-agent-name')?.value?.trim(); if(!name)return;
await fetch(AI,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
name, description:document.getElementById('new-agent-desc')?.value||'',
system_prompt:document.getElementById('new-agent-prompt')?.value||'',
strategy:document.getElementById('new-agent-strategy')?.value||'best',
preferred_provider:document.getElementById('new-agent-provider')?.value||null,
temperature:0.5, max_tokens:4096
})});
document.getElementById('add-agent-form').style.display='none'; loadDashboard();
});
// Copilot quick
document.getElementById('chat-send')?.addEventListener('click', sendChat);
document.getElementById('chat-input')?.addEventListener('keydown', e => { if(e.key==='Enter') sendChat(); });
async function sendChat() {
const input = document.getElementById('chat-input');
const msg = input?.value?.trim(); if(!msg) return; input.value = '';
const agent = document.getElementById('chat-agent')?.value || 'agent-zero';
const out = document.getElementById('chat-output');
out.innerHTML = '<div class="loading">'+agent+' réfléchit...</div>';
try {
const r = await fetch(AI+'/'+agent+'/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:[{role:'user',content:msg}]})});
const d = await r.json();
out.innerHTML = '<pre>'+(d.content||d.response||JSON.stringify(d)).replace(/</g,'&lt;')+'</pre><button class="btn btn--outline btn--sm copy-chat">Copier</button>';
out.querySelector('.copy-chat')?.addEventListener('click', function(){navigator.clipboard.writeText(d.content||d.response||'');this.textContent='✓';});
} catch(e) { out.innerHTML = '<p class="err">Erreur: '+e.message+'</p>'; }
}
loadDashboard();
// --- SERVICES MONITORING ---
async function loadServices() {
const services = [
{name:'Site',url:'https://www.lelectronrare.fr/'},{name:'API',url:API+'/health'},
{name:'CRM',url:'https://tower.saillant.cc/'},{name:'Listmonk',url:'https://mail.saillant.cc/'},
{name:'n8n',url:'https://n8n.saillant.cc/'},{name:'Cal.com',url:'https://rdv.saillant.cc/'},
{name:'Metabase',url:'https://kpi.saillant.cc/'},{name:'Authentik',url:'https://auth.saillant.cc/'},
{name:'Memos',url:'https://memos.saillant.cc/'},{name:'Excalidraw',url:'https://draw.saillant.cc/'},
{name:'Paperless',url:'https://docs.saillant.cc/'},{name:'ntfy',url:'https://ntfy.saillant.cc/'},
];
const grid = document.getElementById('monitor-grid');
grid.innerHTML = services.map(s => `<div class="mon-card" id="mon-${s.name}"><span class="mon-dot"></span><span class="mon-name">${s.name}</span><span class="mon-ms"></span></div>`).join('');
for (const s of services) {
const card = document.getElementById('mon-'+s.name); if(!card) continue;
const t0 = Date.now();
try { await fetch(s.url,{mode:'no-cors',signal:AbortSignal.timeout(5000)}); card.querySelector('.mon-dot').classList.add('mon-ok'); card.querySelector('.mon-ms').textContent=(Date.now()-t0)+'ms'; }
catch { card.querySelector('.mon-dot').classList.add('mon-err'); card.querySelector('.mon-ms').textContent='timeout'; }
}
}
document.getElementById('refresh-services')?.addEventListener('click', loadServices);
loadServices();
</script>
<style>
/* ═══ Apple SOTA 2026 — Ops Dashboard ═══ */
.ops {
max-width: 1200px;
margin: 0 auto;
padding: 2rem clamp(1rem, 4vw, 2rem);
min-height: 100vh;
background: #ffffff;
color: #1d1d1f;
font-family: Manrope, -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Helvetica Neue, sans-serif;
font-size: 0.875rem;
letter-spacing: -0.01em;
-webkit-font-smoothing: antialiased;
}
/* Header */
.ops-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
}
.ops-header-left { display: flex; align-items: center; gap: 0.75rem; }
.ops-header-right { display: flex; align-items: center; gap: 0.5rem; }
.ops-logo { border-radius: 0.5rem; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.ops-title {
font-family: Manrope, -apple-system, "SF Pro Display", sans-serif;
font-size: 1.5rem;
font-weight: 700;
margin: 0;
line-height: 1.15;
letter-spacing: -0.025em;
}
.ops-sub { font-size: 0.75rem; color: #86868b; margin: 0.15rem 0 0; font-weight: 500; }
/* Status bar */
.status-bar {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.75rem;
background: #f5f5f7;
border-radius: 999px;
border: 0.5px solid rgba(0, 0, 0, 0.06);
}
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #d2d2d7; transition: background 0.3s; }
.status-dot--ok { background: #30d158; }
.status-dot--err { background: #ff3b30; }
.status-text { font-size: 0.7rem; color: #86868b; font-weight: 500; }
/* Tabs — segmented control Apple-style */
.tabs {
display: flex;
gap: 0;
margin-bottom: 1.5rem;
background: #f5f5f7;
border-radius: 0.75rem;
padding: 0.2rem;
}
.tab {
flex: 1;
font-size: 0.85rem;
padding: 0.55rem 1rem;
background: none;
border: none;
color: #86868b;
cursor: pointer;
border-radius: 0.6rem;
transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
font-weight: 600;
text-align: center;
}
.tab:hover { color: #1d1d1f; }
.tab--active {
color: #1d1d1f;
background: #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08), 0 0.5px 1px rgba(0, 0, 0, 0.04);
}
/* Panels */
.panel { display: none; animation: fadeIn 0.25s cubic-bezier(0.25, 0.1, 0.25, 1); }
.panel--active { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
/* Tasks toolbar */
.tasks-toolbar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
.tasks-filters { display: flex; gap: 0.35rem; flex: 1; flex-wrap: wrap; }
/* Form controls */
.filter-select, .form-select {
font-size: 0.8rem;
padding: 0.4rem 0.65rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
background: #ffffff;
color: #1d1d1f;
font-family: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
}
.filter-input, .form-input {
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.625rem;
background: #ffffff;
color: #1d1d1f;
font-family: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
}
.filter-input:focus, .form-input:focus, .form-textarea:focus, .filter-select:focus, .form-select:focus {
outline: none;
border-color: #0071e3;
box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.12);
}
.form-textarea {
width: 100%;
font-size: 0.85rem;
padding: 0.6rem 0.75rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.625rem;
background: #ffffff;
resize: vertical;
box-sizing: border-box;
margin-bottom: 0.5rem;
font-family: inherit;
line-height: 1.5;
}
.form-row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.card-form {
background: #ffffff;
border: 0.5px solid rgba(0, 0, 0, 0.08);
border-radius: 1rem;
padding: 1rem;
margin-bottom: 1rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
}
/* Task groups */
.task-group { margin-bottom: 1.25rem; }
.group-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.3rem 0.7rem;
border-radius: 999px;
display: inline-block;
font-weight: 700;
margin-bottom: 0.5rem;
}
.group-label--red { color: #ff3b30; background: rgba(255, 59, 48, 0.08); }
.group-label--amber { color: #ff9f0a; background: rgba(255, 159, 10, 0.08); }
.group-label--blue { color: #0071e3; background: rgba(0, 113, 227, 0.06); }
.group-label--green { color: #30d158; background: rgba(48, 209, 88, 0.08); }
.group-label--cyan { color: #0071e3; background: rgba(0, 113, 227, 0.06); }
.group-done { color: #86868b; background: #f5f5f7; cursor: pointer; list-style: none; border-radius: 999px; }
.group-done::-webkit-details-marker { display: none; }
.group-done::before { content: '+ '; font-weight: 700; }
details[open] > .group-done::before { content: '- '; }
/* Task cards */
.task-list { display: flex; flex-direction: column; gap: 0.5rem; }
.task {
background: #ffffff;
border: 0.5px solid rgba(0, 0, 0, 0.06);
border-radius: 0.875rem;
overflow: hidden;
transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
.task:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); transform: translateY(-1px); }
.task--overdue { border-left: 3px solid #ff3b30; }
.task--done { opacity: 0.35; }
.task--done .task-title { text-decoration: line-through; }
.task--open .task-body { display: block; }
.task-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1rem; cursor: pointer; }
.task-status { font-size: 0.9rem; color: #86868b; width: 1.25rem; text-align: center; }
.task-title { flex: 1; font-size: 0.875rem; color: #1d1d1f; font-weight: 500; }
/* Badges */
.badge {
font-size: 0.65rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-weight: 700;
border: none;
}
.badge--red { background: rgba(255, 59, 48, 0.08); color: #ff3b30; }
.badge--amber { background: rgba(255, 159, 10, 0.08); color: #c77d00; }
.badge--cyan { background: rgba(0, 113, 227, 0.06); color: #0071e3; }
.badge--blue { background: rgba(0, 113, 227, 0.06); color: #0071e3; }
.badge--green { background: rgba(48, 209, 88, 0.08); color: #1a9d4a; }
/* Task body */
.task-body { display: none; padding: 0 1rem 1rem; font-size: 0.85rem; color: #6e6e73; line-height: 1.55; }
.task-desc p { margin: 0.25rem 0; }
.task-actions { display: flex; gap: 0.35rem; margin-top: 0.5rem; flex-wrap: wrap; align-items: center; }
.action-status {
font-size: 0.8rem;
padding: 0.35rem 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
font-family: inherit;
background: #ffffff;
}
.action-note {
flex: 1;
min-width: 100px;
font-size: 0.8rem;
padding: 0.35rem 0.6rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
font-family: inherit;
}
.ai-out { margin-top: 0.5rem; }
.ai-out pre {
font-size: 0.8rem;
white-space: pre-wrap;
word-break: break-word;
background: #f5f5f7;
padding: 0.75rem;
border-radius: 0.75rem;
max-height: 200px;
overflow-y: auto;
line-height: 1.5;
}
.ai-saved summary { font-size: 0.75rem; color: #0071e3; cursor: pointer; font-weight: 600; }
.ai-saved pre {
font-size: 0.75rem;
white-space: pre-wrap;
background: rgba(0, 113, 227, 0.04);
padding: 0.6rem;
border-radius: 0.625rem;
margin-top: 0.25rem;
max-height: 150px;
overflow-y: auto;
}
/* LinkedIn preview */
.li-preview { background: #f5f5f7; border: 0.5px solid rgba(0, 0, 0, 0.06); border-radius: 0.75rem; padding: 0.85rem; margin-bottom: 0.5rem; }
.li-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.6rem; }
.li-avatar {
width: 2rem; height: 2rem; border-radius: 50%;
background: #0071e3; color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; font-weight: 700;
}
.li-line { margin: 0 0 0.15rem; font-size: 0.8rem; color: #1d1d1f; }
.li-spacer { height: 0.5rem; }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.45rem 0.85rem;
font-size: 0.8rem;
font-weight: 600;
border-radius: 999px;
text-decoration: none;
cursor: pointer;
border: none;
transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
font-family: inherit;
}
.btn--primary {
background: #0071e3;
color: #ffffff;
box-shadow: 0 2px 8px rgba(0, 113, 227, 0.2);
}
.btn--primary:hover {
filter: brightness(1.08);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 113, 227, 0.25);
}
.btn--outline {
background: #ffffff;
border: 0.5px solid rgba(0, 0, 0, 0.1);
color: #6e6e73;
}
.btn--outline:hover { color: #0071e3; border-color: rgba(0, 113, 227, 0.2); background: rgba(0, 113, 227, 0.04); }
.btn--sm { padding: 0.3rem 0.6rem; font-size: 0.7rem; }
/* Dashboard KPIs */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.kpi-card {
background: #ffffff;
border: 0.5px solid rgba(0, 0, 0, 0.06);
border-radius: 1rem;
padding: 1.25rem 1rem;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition: transform 0.2s, box-shadow 0.2s;
}
.kpi-card:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.06); }
.kpi-num {
font-size: 2rem;
font-weight: 700;
color: #1d1d1f;
letter-spacing: -0.02em;
}
.kpi-label {
font-size: 0.7rem;
color: #86868b;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-top: 0.15rem;
font-weight: 600;
}
.sub-title {
font-family: Manrope, -apple-system, "SF Pro Display", sans-serif;
font-size: 1.1rem;
font-weight: 700;
color: #1d1d1f;
margin: 1.5rem 0 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
letter-spacing: -0.015em;
}
/* Agents grid */
.agents-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.65rem;
margin-bottom: 1.25rem;
}
.agent-card {
background: #ffffff;
border: 0.5px solid rgba(0, 0, 0, 0.06);
border-radius: 0.875rem;
padding: 0.85rem;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
.agent-card:hover {
border-color: rgba(0, 113, 227, 0.15);
box-shadow: 0 4px 16px rgba(0, 113, 227, 0.08);
transform: translateY(-2px);
}
.agent-card b { font-size: 0.85rem; color: #0071e3; display: block; margin-bottom: 0.25rem; }
.agent-card p { font-size: 0.75rem; color: #86868b; margin: 0.25rem 0 0; line-height: 1.4; }
/* Copilot */
.copilot-bar { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
.chat-output { min-height: 1.25rem; }
.chat-output pre {
font-size: 0.85rem;
white-space: pre-wrap;
word-break: break-word;
background: #f5f5f7;
border: 0.5px solid rgba(0, 0, 0, 0.04);
padding: 1rem;
border-radius: 0.875rem;
max-height: 300px;
overflow-y: auto;
line-height: 1.55;
}
.quick-links { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1.25rem; }
/* Services monitoring */
.monitor-grid { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.25rem; }
.mon-card {
display: flex;
align-items: center;
gap: 0.4rem;
background: #ffffff;
border: 0.5px solid rgba(0, 0, 0, 0.06);
border-radius: 999px;
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
transition: box-shadow 0.2s;
}
.mon-card:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); }
.mon-dot { width: 8px; height: 8px; border-radius: 50%; background: #d2d2d7; transition: background 0.3s; }
.mon-ok { background: #30d158; }
.mon-err { background: #ff3b30; }
.mon-name { color: #1d1d1f; font-weight: 500; }
.mon-ms { color: #86868b; font-size: 0.65rem; }
/* Services cards */
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.65rem;
margin-bottom: 1.25rem;
}
.svc-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.6rem;
background: #ffffff;
border: 0.5px solid rgba(0, 0, 0, 0.06);
border-radius: 1rem;
text-decoration: none;
transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
.svc-card:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
transform: translateY(-3px);
}
.svc-card img { border-radius: 0.625rem; }
.svc-card span { font-size: 0.75rem; color: #6e6e73; text-align: center; font-weight: 500; }
.status-refresh { font-size: 1rem; cursor: pointer; color: #0071e3; font-weight: 500; }
.status-refresh:hover { opacity: 0.7; }
/* Utilities */
.loading { text-align: center; padding: 2rem; color: #0071e3; animation: pulse 1.5s infinite; font-weight: 500; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.err { color: #ff3b30; font-size: 0.85rem; }
.hint { color: #86868b; font-size: 0.85rem; }
/* Responsive */
@media (max-width: 640px) {
.ops { padding: 1rem 0.75rem; }
.ops-title { font-size: 1.25rem; }
.tasks-toolbar { flex-direction: column; align-items: stretch; }
.tasks-filters { flex-direction: column; }
.task-actions { flex-direction: column; }
.btn { width: 100%; justify-content: center; }
.kpi-grid { grid-template-columns: repeat(3, 1fr); gap: 0.5rem; }
.kpi-card { padding: 0.85rem 0.5rem; }
.kpi-num { font-size: 1.5rem; }
.services-grid { grid-template-columns: repeat(3, 1fr); }
.agents-grid { grid-template-columns: repeat(2, 1fr); }
.copilot-bar { flex-direction: column; }
.tab { padding: 0.45rem 0.6rem; font-size: 0.8rem; }
}
</style>
+196
View File
@@ -0,0 +1,196 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import '@/styles/global.css';
---
<BaseLayout title="Portfolio — L'Electron Rare" description="Projets électronique embarquée, prototypage et systèmes spécifiques.">
<main class="portfolio-page">
<h1 class="portfolio-title">Portfolio</h1>
<p class="portfolio-intro">
Explorez les schémas et PCB de projets réalisés. Les viewers interactifs
permettent de zoomer, naviguer et inspecter chaque composant.
</p>
<!-- KXKM ESP32 Base Board -->
<section class="portfolio-section">
<h2 class="portfolio-h2">KXKM — ESP32 Battery Management Board</h2>
<p class="portfolio-desc">
Carte de gestion batterie et ESP32 pour systèmes audio autonomes.
Monitoring batterie, alimentation régulée, connectique terrain.
<span class="portfolio-tag">ESP32</span>
<span class="portfolio-tag">BMS</span>
<span class="portfolio-tag">Audio</span>
<span class="portfolio-tag">KXKM</span>
</p>
<div class="viewer-tabs">
<button class="viewer-tab viewer-tab--active" data-target="kxkm-esp32-sch">Schéma</button>
<button class="viewer-tab" data-target="kxkm-esp32-pcb">PCB</button>
<button class="viewer-tab" data-target="kxkm-batt-sch">Monitoring batterie</button>
<button class="viewer-tab" data-target="kxkm-power-sch">Alimentation</button>
</div>
<div class="viewer-panels">
<div class="viewer-panel viewer-panel--active" id="kxkm-esp32-sch">
<kicanvas-embed src="/assets/kicad/kxkm-esp32-base.kicad_sch" controls="full" theme="kicad" style="width:100%;height:600px;"></kicanvas-embed>
</div>
<div class="viewer-panel" id="kxkm-esp32-pcb">
<kicanvas-embed src="/assets/kicad/kxkm-esp32-base.kicad_pcb" controls="full" theme="kicad" style="width:100%;height:600px;"></kicanvas-embed>
</div>
<div class="viewer-panel" id="kxkm-batt-sch">
<kicanvas-embed src="/assets/kicad/kxkm-battery-monitoring.kicad_sch" controls="full" theme="kicad" style="width:100%;height:600px;"></kicanvas-embed>
</div>
<div class="viewer-panel" id="kxkm-power-sch">
<kicanvas-embed src="/assets/kicad/kxkm-power.kicad_sch" controls="full" theme="kicad" style="width:100%;height:600px;"></kicanvas-embed>
</div>
</div>
</section>
<!-- KXKM Batterie Parallelator -->
<section class="portfolio-section">
<h2 class="portfolio-h2">KXKM — Batterie Parallelator (BMU v2)</h2>
<p class="portfolio-desc">
Système de parallélisation de batteries lithium avec monitoring individuel,
commutation MOSFET et bus I2C. Gestion intelligente multi-pack pour autonomie terrain.
<span class="portfolio-tag">BMS</span>
<span class="portfolio-tag">MOSFET</span>
<span class="portfolio-tag">I2C</span>
<span class="portfolio-tag">Lithium</span>
</p>
<div class="viewer-tabs">
<button class="viewer-tab viewer-tab--active" data-target="para-bmu-sch">Schéma BMU</button>
<button class="viewer-tab" data-target="para-bmu-pcb">PCB BMU</button>
<button class="viewer-tab" data-target="para-mosfet-sch">Switch MOSFET</button>
<button class="viewer-tab" data-target="para-mosfet-pcb">PCB MOSFET</button>
<button class="viewer-tab" data-target="para-batt-sch">Batteries</button>
<button class="viewer-tab" data-target="para-power-sch">Alimentation</button>
</div>
<div class="viewer-panels">
<div class="viewer-panel viewer-panel--active" id="para-bmu-sch">
<kicanvas-embed src="/assets/kicad/kxkm-parallelator-bmu-v2.kicad_sch" controls="full" theme="kicad" style="width:100%;height:600px;"></kicanvas-embed>
</div>
<div class="viewer-panel" id="para-bmu-pcb">
<kicanvas-embed src="/assets/kicad/kxkm-parallelator-bmu-v2.kicad_pcb" controls="full" theme="kicad" style="width:100%;height:600px;"></kicanvas-embed>
</div>
<div class="viewer-panel" id="para-mosfet-sch">
<kicanvas-embed src="/assets/kicad/kxkm-parallelator-mosfet.kicad_sch" controls="full" theme="kicad" style="width:100%;height:600px;"></kicanvas-embed>
</div>
<div class="viewer-panel" id="para-mosfet-pcb">
<kicanvas-embed src="/assets/kicad/kxkm-parallelator-mosfet.kicad_pcb" controls="full" theme="kicad" style="width:100%;height:600px;"></kicanvas-embed>
</div>
<div class="viewer-panel" id="para-batt-sch">
<kicanvas-embed src="/assets/kicad/kxkm-parallelator-batteries.kicad_sch" controls="full" theme="kicad" style="width:100%;height:600px;"></kicanvas-embed>
</div>
<div class="viewer-panel" id="para-power-sch">
<kicanvas-embed src="/assets/kicad/kxkm-parallelator-power.kicad_sch" controls="full" theme="kicad" style="width:100%;height:600px;"></kicanvas-embed>
</div>
</div>
</section>
<!-- Upload custom -->
<section class="portfolio-section">
<h2 class="portfolio-h2">Viewer libre</h2>
<p class="portfolio-desc">
Chargez votre propre fichier <code>.kicad_sch</code> ou <code>.kicad_pcb</code> pour l'explorer.
</p>
<div class="portfolio-upload">
<label for="kicad-file" class="portfolio-upload-label">
Glissez un fichier KiCad ou cliquez pour ouvrir
</label>
<input type="file" id="kicad-file" accept=".kicad_sch,.kicad_pcb,.kicad_prj" class="portfolio-upload-input" />
</div>
<div id="viewer-container" class="viewer-wrap" style="display:none;">
<kicanvas-embed id="kicad-viewer" controls="full" theme="kicad" style="width:100%;height:600px;"></kicanvas-embed>
</div>
</section>
<!-- Projets -->
<section class="portfolio-section">
<h2 class="portfolio-h2">Autres projets</h2>
<div class="portfolio-grid">
<div class="portfolio-card">
<h3>Contrôleur LED DMX</h3>
<p>Carte custom ESP32 pour pilotage LED haute puissance via DMX512. Spectacle vivant, KXKM.</p>
<span class="portfolio-tag">ESP32</span><span class="portfolio-tag">DMX</span><span class="portfolio-tag">LED</span>
</div>
<div class="portfolio-card">
<h3>Banc de test BMS</h3>
<p>Système de test automatisé pour batteries lithium. Mesure, charge/décharge, logging.</p>
<span class="portfolio-tag">BMS</span><span class="portfolio-tag">Mesure</span><span class="portfolio-tag">XT60</span>
</div>
<div class="portfolio-card">
<h3>Automate Siemens S7</h3>
<p>Diagnostic et reprogrammation d'automate industriel. Migration firmware, câblage armoire.</p>
<span class="portfolio-tag">Siemens</span><span class="portfolio-tag">PLC</span><span class="portfolio-tag">Industrie</span>
</div>
</div>
</section>
<p class="portfolio-note">
Viewers propulsés par <a href="https://kicanvas.org" target="_blank" rel="noopener" class="portfolio-link">KiCanvas</a> — open source.
Fichiers source : <a href="https://github.com/KomplexKapharnaum/KXKM_ESP32_Audio_Battery_hardware" target="_blank" rel="noopener" class="portfolio-link">GitHub KXKM</a>.
</p>
<div class="portfolio-cta">
<a href="/preview#contact" class="portfolio-btn">Discuter de votre projet →</a>
</div>
</main>
</BaseLayout>
<script type="module" src="/assets/js/kicanvas.js"></script>
<script>
// Tab switching for viewer panels
document.querySelectorAll('.viewer-tab').forEach(tab => {
tab.addEventListener('click', () => {
const section = tab.closest('.portfolio-section');
section.querySelectorAll('.viewer-tab').forEach(t => t.classList.remove('viewer-tab--active'));
section.querySelectorAll('.viewer-panel').forEach(p => p.classList.remove('viewer-panel--active'));
tab.classList.add('viewer-tab--active');
document.getElementById(tab.dataset.target)?.classList.add('viewer-panel--active');
});
});
// File upload
const fileInput = document.getElementById('kicad-file');
const viewerContainer = document.getElementById('viewer-container');
const viewer = document.getElementById('kicad-viewer');
fileInput?.addEventListener('change', (e) => {
const file = e.target.files?.[0];
if (!file) return;
viewer?.setAttribute('src', URL.createObjectURL(file));
if (viewerContainer) viewerContainer.style.display = 'block';
});
</script>
<style>
.portfolio-page { max-width:960px; margin:0 auto; padding:48px 24px; min-height:100vh; }
.portfolio-title { font-family:'Fraunces',serif; font-size:clamp(28px,5vw,42px); color:var(--text,#f4eee3); margin:0 0 16px; }
.portfolio-intro { font-family:'IBM Plex Mono',monospace; font-size:14px; color:var(--text-muted,#d6cbb9); line-height:1.7; margin:0 0 40px; }
.portfolio-section { margin-bottom:48px; }
.portfolio-h2 { font-family:'Fraunces',serif; font-size:22px; color:var(--accent,#f1c27a); margin:0 0 12px; }
.portfolio-desc { font-size:14px; color:var(--text-dim,#b6a894); margin:0 0 16px; line-height:1.6; }
.portfolio-desc code { color:var(--electric,#5bd1d8); background:var(--surface,#1a1510); padding:2px 6px; border-radius:3px; font-size:13px; }
.viewer-tabs { display:flex; gap:4px; margin-bottom:12px; border-bottom:1px solid var(--line,#3a2f24); }
.viewer-tab { font-family:'IBM Plex Mono',monospace; font-size:12px; padding:8px 14px; background:none; border:none; color:var(--text-dim,#b6a894); cursor:pointer; border-bottom:2px solid transparent; transition:all .2s; }
.viewer-tab:hover { color:var(--text,#f4eee3); }
.viewer-tab--active { color:var(--electric,#5bd1d8); border-bottom-color:var(--electric,#5bd1d8); }
.viewer-panel { display:none; }
.viewer-panel--active { display:block; }
.viewer-wrap { border-radius:8px; overflow:hidden; border:1px solid var(--line,#3a2f24); }
.portfolio-upload { border:2px dashed var(--line,#3a2f24); border-radius:8px; padding:32px; text-align:center; margin-bottom:20px; transition:border-color .2s; }
.portfolio-upload:hover { border-color:var(--electric,#5bd1d8); }
.portfolio-upload-label { font-family:'IBM Plex Mono',monospace; font-size:14px; color:var(--text-muted,#d6cbb9); cursor:pointer; display:block; }
.portfolio-upload-input { position:absolute; opacity:0; width:0; height:0; }
.portfolio-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:16px; }
.portfolio-card { background:var(--surface,#1a1510); border:1px solid var(--line,#3a2f24); border-radius:8px; padding:20px; }
.portfolio-card h3 { font-family:'Fraunces',serif; font-size:18px; color:var(--text,#f4eee3); margin:0 0 8px; }
.portfolio-card p { font-size:13px; color:var(--text-muted,#d6cbb9); line-height:1.6; margin:0 0 12px; }
.portfolio-tag { display:inline-block; font-family:'IBM Plex Mono',monospace; font-size:11px; color:var(--electric,#5bd1d8); background:rgba(91,209,216,.1); padding:2px 8px; border-radius:3px; margin-right:4px; margin-bottom:4px; }
.portfolio-note { font-size:12px; color:var(--text-dim,#b6a894); opacity:.6; margin:0 0 24px; }
.portfolio-link { color:var(--electric,#5bd1d8); }
.portfolio-cta { text-align:center; margin-top:32px; }
.portfolio-btn { display:inline-block; padding:12px 28px; font-family:'IBM Plex Mono',monospace; font-size:14px; color:var(--bg,#0b0a09); background:var(--accent,#f1c27a); text-decoration:none; border-radius:4px; transition:all .2s; }
.portfolio-btn:hover { background:var(--electric,#5bd1d8); }
</style>
+307
View File
@@ -0,0 +1,307 @@
---
import { BmuViewer } from '@/components/BmuViewer';
---
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>L'électron rare — Systèmes électroniques spécifiques</title>
<meta name="description" content="Conception, mise au point et fiabilisation de systèmes électroniques spécifiques, automatismes et ensembles liés à l'énergie. Consulting et formation PCB, KiCad, µC." />
<meta property="og:title" content="L'électron rare — Systèmes électroniques spécifiques" />
<meta property="og:description" content="Conception, mise au point et fiabilisation de systèmes électroniques spécifiques. Consulting et formation." />
<meta property="og:image" content="https://www.lelectronrare.fr/assets/og-cover.jpg" />
<meta property="og:url" content="https://www.lelectronrare.fr/preview/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="L'électron rare" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="L'électron rare — Systèmes électroniques spécifiques" />
<meta name="twitter:description" content="Conception, mise au point et fiabilisation de systèmes électroniques spécifiques." />
<meta name="twitter:image" content="https://www.lelectronrare.fr/assets/og-cover.jpg" />
<link rel="me" href="https://mastodon.saillant.cc/@clement" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProfessionalService",
"name": "L'électron rare",
"description": "Conception, mise au point et fiabilisation de systèmes électroniques spécifiques, automatismes et ensembles liés à l'énergie.",
"url": "https://www.lelectronrare.fr",
"email": "contact@lelectronrare.fr",
"founder": { "@type": "Person", "name": "Clément Saillant" },
"areaServed": "France",
"serviceType": ["Conception électronique", "Formation PCB et embarqué", "Consulting technique", "Automatisme industriel"],
"sameAs": ["https://fr.linkedin.com/in/electron-rare", "https://github.com/electron-rare/", "https://mastodon.saillant.cc/@clement"]
}
</script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html { background: #06080b; color: #fff; overflow-x: hidden; }
body { background: #06080b; min-height: 100vh; font-family: 'Courier New', monospace; }
.scroll-track { height: 1000vh; position: relative; z-index: 1; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
/* Loader */
.loader { position: fixed; inset: 0; z-index: 9999; background: #06080b; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; transition: opacity 0.8s ease, visibility 0.8s ease; }
.loader.done { opacity: 0; visibility: hidden; pointer-events: none; }
.loader-logo { width: clamp(60px, 8vw, 100px); height: auto; filter: drop-shadow(0 0 20px rgba(91,209,216,0.3)); }
.loader-name { font-size: clamp(20px, 2.5vw, 32px); font-weight: 800; color: rgba(255,255,255,0.8); letter-spacing: 0.06em; }
.loader-bar { width: clamp(180px, 30vw, 280px); height: 2px; background: rgba(255,255,255,0.06); border-radius: 1px; overflow: hidden; }
.loader-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #5bd1d8, #a78bfa); transition: width 0.3s ease; }
.loader-status { font-size: 10px; color: rgba(255,255,255,0.25); letter-spacing: 0.12em; text-transform: uppercase; }
.loader-tagline { font-size: 12px; color: rgba(255,255,255,0.3); letter-spacing: 0.08em; margin-bottom: 20px; }
.loader-actions { margin-top: 24px; }
.loader-skip { display: inline-block; padding: 10px 24px; border: 1px solid rgba(91,209,216,0.3); border-radius: 8px; color: rgba(91,209,216,0.7); font-size: 12px; text-decoration: none; letter-spacing: 0.04em; transition: all 0.2s; }
.loader-skip:hover { border-color: #5bd1d8; color: #5bd1d8; background: rgba(91,209,216,0.08); }
.loader-a11y { margin-top: 20px; font-size: 10px; color: rgba(255,255,255,0.15); }
.loader-a11y a { color: rgba(255,255,255,0.25); text-decoration: none; }
.loader-a11y a:hover { color: #5bd1d8; }
.ov { position: fixed; z-index: 3; pointer-events: none; opacity: 0; transform: translateY(6px); transition: opacity 0.5s, transform 0.5s; }
.ov.on { opacity: 1; transform: translateY(0); }
/* Hero */
.ov-hero { inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; }
.hero-bottom { display: flex; flex-direction: column; align-items: center; padding-bottom: clamp(60px, 10vh, 120px); }
.hero-t { text-align: center; margin: 0; line-height: 1.1; }
.hl1,.hl3 { display: block; font-size: clamp(28px, 4.5vw, 56px); font-weight: 800; color: rgba(255,255,255,0.85); letter-spacing: -0.02em; }
.hl2 { display: block; font-size: clamp(32px, 5.5vw, 64px); font-weight: 800; color: #5bd1d8; letter-spacing: -0.02em; }
.hero-s { font-size: clamp(11px, 1.2vw, 15px); color: rgba(255,255,255,0.3); letter-spacing: 0.15em; text-transform: uppercase; margin: 16px 0 0; }
.hero-s2 { font-size: clamp(10px, 1vw, 13px); color: rgba(255,255,255,0.2); letter-spacing: 0.12em; text-transform: uppercase; margin: 8px 0 0; }
/* Header */
.site-hdr { position: fixed; top: 0; left: 0; right: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; padding: 16px clamp(20px, 3vw, 40px); pointer-events: none; }
.hdr-left { display: flex; align-items: center; gap: 10px; pointer-events: auto; }
.hdr-logo { width: 36px; height: auto; filter: drop-shadow(0 0 12px rgba(91,209,216,0.3)); }
.hdr-name { font-size: 16px; font-weight: 800; color: rgba(255,255,255,0.8); letter-spacing: 0.04em; }
.hdr-nav { display: flex; align-items: center; gap: 8px; pointer-events: auto; }
.hdr-link { padding: 8px 16px; color: rgba(255,255,255,0.6); font-size: 12px; font-weight: 600; text-decoration: none; letter-spacing: 0.04em; transition: color 0.2s; }
.hdr-link:hover { color: #5bd1d8; }
.hdr-social { font-weight: 800; font-size: 11px; text-transform: uppercase; padding: 8px 10px; opacity: 0.5; }
.hdr-social:hover { opacity: 1; }
.hdr-cta { padding: 8px 20px; border-radius: 6px; background: rgba(91,209,216,0.15); border: 1px solid rgba(91,209,216,0.3); color: #5bd1d8; font-size: 12px; font-weight: 700; text-decoration: none; letter-spacing: 0.05em; transition: background 0.2s; }
.hdr-cta:hover { background: rgba(91,209,216,0.25); }
/* Formats */
.ov-formats { bottom: clamp(30px, 5vh, 60px); left: 50%; transform: translateX(-50%); display: flex; gap: 12px; max-width: 820px; width: 92vw; }
.ov-formats.on { transform: translateX(-50%); }
.fmt { flex: 1; padding: 14px 16px; border-radius: 8px; background: rgba(6,8,11,0.7); backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.05); text-align: center; text-decoration: none; color: inherit; cursor: pointer; transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease; }
.fmt:hover { background: rgba(6,8,11,0.85); border-color: rgba(255,255,255,0.18); transform: translateY(-2px); }
.fmt-t { font-size: 13px; font-weight: 700; color: #fff; letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 4px; }
.fmt-d { font-size: 10px; color: rgba(255,255,255,0.35); }
/* Progress */
.prog { position: fixed; top: 0; left: 0; height: 2px; z-index: 5; background: linear-gradient(90deg, #5bd1d8, #a78bfa, #f59e0b); width: 0%; }
.inf { position: fixed; bottom: 10px; right: 14px; z-index: 4; font-size: 8px; color: rgba(255,255,255,0.1); letter-spacing: 0.06em; }
/* Dots */
.dots { position: fixed; right: 20px; top: 50%; transform: translateY(-50%); z-index: 4; display: flex; flex-direction: column; gap: 14px; }
.dot { width: 12px; height: 12px; border-radius: 50%; background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.1); transition: background 0.3s, transform 0.3s, border-color 0.3s; cursor: pointer; pointer-events: auto; position: relative; }
.dot.on { background: #5bd1d8; border-color: #5bd1d8; transform: scale(1.3); box-shadow: 0 0 10px rgba(91,209,216,0.4); }
.dot:hover { background: rgba(91,209,216,0.5); border-color: rgba(91,209,216,0.5); }
.dot[data-l]:not([data-l=""])::after { content: attr(data-l); position: absolute; right: 20px; top: 50%; transform: translateY(-50%); font-size: 9px; color: rgba(255,255,255,0.5); letter-spacing: 0.05em; white-space: nowrap; opacity: 0; transition: opacity 0.2s; pointer-events: none; }
.dot[data-l]:not([data-l=""]):hover::after { opacity: 1; }
@media (max-width: 768px) {
.ov-formats { flex-direction: column; gap: 6px; }
}
</style>
</head>
<body data-da-variant="preview">
<!-- Loader -->
<div class="loader" id="loader">
<img src="/assets/brand/logo-mark.svg" alt="L'électron rare" class="loader-logo" />
<p class="loader-name">L'électron rare</p>
<p class="loader-tagline">Systèmes électroniques spécifiques</p>
<div class="loader-bar"><div class="loader-fill" id="loader-fill"></div></div>
<p class="loader-status" id="loader-status">Chargement des modèles 3D...</p>
<div class="loader-actions">
<a href="/conception/" class="loader-skip">Accéder au site sans 3D →</a>
</div>
<p class="loader-a11y">Version accessible : <a href="/conception/">Conception</a> · <a href="/formation/">Formation</a> · <a href="/consulting/">Consulting</a> · <a href="/contact/">Contact</a></p>
</div>
<BmuViewer client:load />
<div class="prog" id="prog"></div>
<div class="dots" id="dots">
<div class="dot on" data-s="hero" data-p="0.03" data-l="Hero"></div>
<div class="dot" data-s="hero-to-exp" data-p="0.09" data-l=""></div>
<div class="dot" data-s="exp-elec" data-p="0.14" data-l="Élec"></div>
<div class="dot" data-s="exp-auto" data-p="0.18" data-l="Auto"></div>
<div class="dot" data-s="exp-energie" data-p="0.22" data-l="Énergie"></div>
<div class="dot" data-s="exp-dispositifs" data-p="0.255" data-l="Dispositifs"></div>
<div class="dot" data-s="exp-consulting" data-p="0.285" data-l="Consulting"></div>
<div class="dot" data-s="eclate-recul" data-p="0.31" data-l=""></div>
<div class="dot" data-s="eclate-sep" data-p="0.34" data-l=""></div>
<div class="dot" data-s="eclate-plat" data-p="0.36" data-l=""></div>
<div class="dot" data-s="cas-kxkm" data-p="0.42" data-l="KXKM"></div>
<div class="dot" data-s="cas-industrie" data-p="0.52" data-l="Industrie"></div>
<div class="dot" data-s="cas-formation" data-p="0.60" data-l="Formation"></div>
<div class="dot" data-s="media" data-p="0.70" data-l="Médias"></div>
<div class="dot" data-s="formats" data-p="0.85" data-l="Services"></div>
<div class="dot" data-s="contact" data-p="0.95" data-l="Contact"></div>
</div>
<!-- HEADER -->
<header class="site-hdr">
<div class="hdr-left">
<img src="/assets/brand/logo-mark.svg" alt="" class="hdr-logo" />
<span class="hdr-name">L'électron rare</span>
</div>
<nav class="hdr-nav">
<a href="https://www.lelectronrare.fr/#graphic-sprints-title" class="hdr-link">Conception</a>
<a href="https://www.lelectronrare.fr/formation/" class="hdr-link">Formation</a>
<a href="https://www.lelectronrare.fr/#a-propos" class="hdr-link">Consulting</a>
<a href="https://fr.linkedin.com/in/electron-rare" target="_blank" rel="noopener" class="hdr-link hdr-social" title="LinkedIn">in</a>
<a href="https://github.com/electron-rare/" target="_blank" rel="noopener" class="hdr-link hdr-social" title="GitHub">gh</a>
<a href="https://mastodon.saillant.cc/@clement" target="_blank" rel="me noopener" class="hdr-link hdr-social" title="Mastodon">mt</a>
<a href="https://www.lelectronrare.fr/" class="hdr-link" style="opacity:0.4">Site →</a>
<a href="https://www.lelectronrare.fr/#contact" class="hdr-cta">Contact</a>
</nav>
</header>
<!-- HERO -->
<div class="ov ov-hero on" data-sec="hero">
<div class="hero-bottom">
<h1 class="hero-t">
<span class="hl1">Systèmes</span>
<span class="hl2">électroniques</span>
<span class="hl3">spécifiques</span>
</h1>
<p class="hero-s">électronique · automatisme · énergie</p>
<p class="hero-s2">Conception · Mise au point · Fiabilisation · Consulting · Formation</p>
</div>
</div>
<!-- FORMATS -->
<div class="ov ov-formats" data-sec="formats">
<a class="fmt" href="/conception/">
<p class="fmt-t" style="color:#5bd1d8">Conception</p>
<p class="fmt-d">Schéma · PCB · Firmware · Validation</p>
</a>
<a class="fmt" href="/formation/">
<p class="fmt-t" style="color:#a78bfa">Formation</p>
<p class="fmt-d">PCB · KiCad · µC · Soudure</p>
</a>
<a class="fmt" href="/consulting/">
<p class="fmt-t" style="color:#f59e0b">Consulting</p>
<p class="fmt-d">Audit · Diagnostic · Transfert</p>
</a>
</div>
<div class="scroll-track"></div>
<!-- SEO: texte indexable caché -->
<div class="sr-only" aria-hidden="false">
<h1>L'électron rare — Systèmes électroniques spécifiques</h1>
<p>Conception, mise au point et fiabilisation de systèmes électroniques spécifiques, automatismes et ensembles liés à l'énergie, au stockage et à l'optimisation terrain.</p>
<h2>Domaines d'expertise</h2>
<ul>
<li>Électronique spécifique : cartes, interfaces, capteurs, alimentation</li>
<li>Instrumentation et automatisme : bancs, automates, variateurs, protocoles terrain</li>
<li>Énergie et stockage : batterie, conversion, supervision, télémétrie</li>
<li>Dispositifs pour le réel : audio, LED, scène, robustesse terrain</li>
<li>Consulting et formation : audit, transfert, pédagogie, projet</li>
</ul>
<h2>Cas concrets</h2>
<ul>
<li>Industries créatives : système audio embarqué sur batterie pour le spectacle (KXKM)</li>
<li>Industrie : diagnostic et remise en service d'une ligne automatisée</li>
<li>Formation : formation PCB et embarqué pour une école d'art numérique</li>
</ul>
<h2>Services</h2>
<ul>
<li>Conception : schéma, PCB, firmware, validation</li>
<li>Formation : PCB, KiCad, microcontrôleurs, soudure</li>
<li>Consulting : audit, diagnostic, transfert</li>
</ul>
<p>Contact : <a href="https://www.lelectronrare.fr/#contact">Parlons de votre projet</a> — contact@lelectronrare.fr</p>
</div>
<noscript>
<div style="position:fixed;inset:0;z-index:9999;background:#06080b;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#fff;font-family:monospace;text-align:center;padding:40px;">
<img src="/assets/photos/bmu-assembly.jpg" alt="BMU v2 Assembly" style="width:clamp(200px,50vw,500px);border-radius:12px;margin-bottom:20px;" />
<h1 style="font-size:24px;">L'électron rare</h1>
<p style="color:rgba(255,255,255,0.5);margin:12px 0;">Systèmes électroniques spécifiques</p>
<a href="https://www.lelectronrare.fr/" style="color:#5bd1d8;margin-top:16px;">Accéder au site →</a>
</div>
</noscript>
<div class="inf">L'électron rare · Clément Saillant</div>
<!-- GTM for A/B tracking -->
<script>
window.dataLayer = window.dataLayer || [];
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!=='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','GTM-5SLM67QF');
</script>
<script>
const prog = document.getElementById('prog');
const dots = [...document.querySelectorAll('.dot')];
const overlays = [...document.querySelectorAll('.ov')];
const RANGES = [
['hero',0,0.10],['hero-to-exp',0.10,0.16],
['exp-elec',0.16,0.20],['exp-auto',0.20,0.24],['exp-energie',0.24,0.28],
['exp-dispositifs',0.28,0.31],['exp-consulting',0.31,0.34],
['eclate-recul',0.34,0.36],['eclate-sep',0.36,0.39],['eclate-plat',0.39,0.41],
['cas',0.41,0.65],
['media',0.65,0.81],['formats',0.81,0.91],['contact',0.91,1.0],
];
function update() {
const max = document.documentElement.scrollHeight - window.innerHeight;
const t = max > 0 ? window.scrollY / max : 0;
if (prog) prog.style.width = (t*100)+'%';
let active = 'hero';
for (const [id,s,e] of RANGES) { if (t>=s&&t<=e){active=id;break;} }
overlays.forEach(el => el.classList.toggle('on', el.getAttribute('data-sec')===active));
dots.forEach(d => d.classList.toggle('on', d.getAttribute('data-s')===active));
}
window.addEventListener('scroll', update, {passive:true});
update();
// Dots cliquables
dots.forEach(d => {
d.addEventListener('click', () => {
const p = parseFloat(d.getAttribute('data-p') || '0');
const max = document.documentElement.scrollHeight - window.innerHeight;
window.scrollTo({ top: Math.round(max * p), behavior: 'smooth' });
});
});
// GA4 tracking
document.querySelectorAll('a[href]').forEach(a => {
a.addEventListener('click', () => {
const label = a.textContent?.trim() || a.getAttribute('href') || '';
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ event: 'click', event_category: 'navigation', event_label: label, da_variant: 'preview', page_path: '/preview/' });
});
});
// Loader
const loader = document.getElementById('loader');
const loaderFill = document.getElementById('loader-fill');
const loaderStatus = document.getElementById('loader-status');
const msgs = ['Chargement des modèles 3D...', 'Compilation shaders...', 'Préparation du rendu...'];
let msgIdx = 0;
const msgInterval = setInterval(() => {
msgIdx = (msgIdx + 1) % msgs.length;
if (loaderStatus) loaderStatus.textContent = msgs[msgIdx];
if (loaderFill) loaderFill.style.width = Math.min(90, parseFloat(loaderFill.style.width || '0') + 20) + '%';
}, 1200);
function hideLoader() {
if (loaderFill) loaderFill.style.width = '100%';
if (loaderStatus) loaderStatus.textContent = 'Prêt';
clearInterval(msgInterval);
setTimeout(() => { if (loader) loader.classList.add('done'); }, 400);
}
const checkReady = setInterval(() => {
const cvs = document.querySelector('canvas');
if (cvs && cvs.width > 0) { clearInterval(checkReady); setTimeout(hideLoader, 800); }
}, 200);
setTimeout(hideLoader, 10000);
</script>
</body>
</html>
+5
View File
@@ -8,6 +8,11 @@ Disallow: /
`
: `User-agent: *
Allow: /
Disallow: /preview
Disallow: /ops
Disallow: /intranet
Disallow: /portfolio
Disallow: /countdown
Sitemap: ${CANONICAL_URL}sitemap.xml
`;
+4 -1
View File
@@ -1,11 +1,14 @@
import type { APIRoute } from 'astro';
import { CANONICAL_URL, PUBLIC_SITE_ROOT_URL, SITE_IS_SUBPATH_BUILD } from '../lib/site';
export const prerender = false;
export const GET: APIRoute = () => {
const sitemapRootUrl = SITE_IS_SUBPATH_BUILD ? PUBLIC_SITE_ROOT_URL : CANONICAL_URL;
const sitemapRootUrl = process.env.PUBLIC_SITE_URL || CANONICAL_URL;
const pages = [
{ path: '', priority: '1.0', changefreq: 'weekly' },
{ path: 'formation/', priority: '0.9', changefreq: 'monthly' },
{ path: 'preview/', priority: '0.8', changefreq: 'weekly' },
{ path: 'mentions-legales/', priority: '0.3', changefreq: 'yearly' }
];
+349
View File
@@ -0,0 +1,349 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import '@/styles/global.css';
import { About } from '@/components/sections/About';
import { CaseStudies } from '@/components/sections/CaseStudies';
import { GraphicSprints } from '@/components/sections/GraphicSprints';
import Contact from '@/components/sections/Contact.astro';
import Faq from '@/components/sections/Faq.astro';
import { withSiteBase } from '@/lib/site';
import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
const formationHref = withSiteBase('/formation/');
const mentionsHref = withSiteBase('/mentions-legales/');
---
<BaseLayout title="L'électron rare — Version accessible" description="Systèmes électroniques spécifiques. Version sans animations 3D, optimisée pour l'accessibilité.">
<a class="skip-link" href="#main-content">Aller au contenu principal</a>
<header class="static-header" role="banner">
<nav aria-label="Navigation principale" class="static-nav">
<a href="#top" class="static-brand">L'électron rare</a>
<ul class="static-nav-list" role="list">
<li><a href="#a-propos">Approche</a></li>
<li><a href="#cas-concrets">Cas concrets</a></li>
<li><a href="#missions">Missions</a></li>
<li><a href={formationHref}>Formation</a></li>
<li><a href="#faq">FAQ</a></li>
<li><a href="#contact" class="static-cta">Contact</a></li>
</ul>
</nav>
</header>
<main id="main-content">
<!-- HERO -->
<section class="static-hero" id="top" aria-labelledby="hero-title">
<h1 id="hero-title" class="static-hero-title">
Systèmes électroniques spécifiques, du besoin au livrable fiable
</h1>
<p class="static-hero-sub">
L'Électron Rare conçoit, met au point et fiabilise des systèmes électroniques spécifiques
en électronique, automatisme, énergie et stockage, avec les partenaires adaptés
quand le projet devient multi-technique.
</p>
<div class="static-hero-actions">
<a href="#contact" class="static-btn static-btn--primary">Discuter de votre projet</a>
<a href="/preview" class="static-btn static-btn--secondary">Version 3D</a>
</div>
<p class="static-hero-kicker" aria-hidden="true">électronique · automatisme · énergie · stockage</p>
</section>
<!-- ABOUT -->
<section id="a-propos" class="static-section" aria-labelledby="about-title">
<About />
</section>
<!-- CASE STUDIES -->
<section id="cas-concrets" class="static-section" aria-labelledby="cases-title">
<CaseStudies />
</section>
<!-- PHOTOS -->
<section class="static-section static-photos" aria-label="Photos de terrain">
<h2 class="static-section-title">Terrain</h2>
<div class="static-photo-grid">
<img src={withSiteBase('/assets/photos/pcb-teensy-led-kxkm.webp')} alt="PCB custom Teensy LED — KXKM" loading="lazy" width="280" height="190" />
<img src={withSiteBase('/assets/photos/automate-siemens-s7.webp')} alt="Automate Siemens S7" loading="lazy" width="280" height="190" />
<img src={withSiteBase('/assets/photos/bench-bms-batteries-xt60.webp')} alt="Banc de test BMS batteries" loading="lazy" width="280" height="190" />
<img src={withSiteBase('/assets/photos/soudure-pcb-composants.webp')} alt="Soudure PCB composants" loading="lazy" width="280" height="190" />
<img src={withSiteBase('/assets/photos/oscilloscope-philips-vintage.webp')} alt="Oscilloscope — instrumentation" loading="lazy" width="280" height="190" />
<img src={withSiteBase('/assets/photos/armoire-automate-schneider.webp')} alt="Armoire Schneider — automatisme" loading="lazy" width="280" height="190" />
</div>
</section>
<!-- SPRINTS -->
<section id="missions" class="static-section" aria-labelledby="sprints-title">
<GraphicSprints />
</section>
<!-- FAQ -->
<section id="faq" class="static-section" aria-labelledby="faq-title">
<Faq />
</section>
<!-- CONTACT -->
<section id="contact" class="static-section" aria-labelledby="contact-title">
<Contact />
</section>
</main>
<footer class="static-footer" role="contentinfo">
<div class="static-footer-inner">
<p class="static-footer-brand">L'électron rare</p>
<p class="static-footer-copy">© 2026 Clément Saillant — systèmes électroniques spécifiques, conseil, formation — industrie, culture et projets multi-techniques.</p>
<p class="static-footer-contact">Contact : <a href="https://fr.linkedin.com/in/electron-rare">LinkedIn</a> · <a href="mailto:contact@lelectronrare.fr">contact@lelectronrare.fr</a></p>
<nav aria-label="Liens de pied de page" class="static-footer-links">
<a href="#a-propos">Approche</a>
<a href="#missions">Missions</a>
<a href={formationHref}>Formation</a>
<a href="#faq">FAQ</a>
<a href="https://github.com/electron-rare/">GitHub</a>
<a href="https://blog.saillant.cc">Blog</a>
<a href={mentionsHref}>Mentions légales</a>
</nav>
</div>
</footer>
</BaseLayout>
<style>
/* ============ ACCESSIBLE STATIC VERSION ============ */
/* High contrast, no animations, semantic structure */
.skip-link {
position: absolute;
top: -100%;
left: 50%;
transform: translateX(-50%);
background: #0071e3;
color: #fff;
padding: 8px 16px;
border-radius: 0 0 8px 8px;
z-index: 1000;
font-weight: 600;
text-decoration: none;
}
.skip-link:focus {
top: 0;
}
/* Header */
.static-header {
background: #fff;
border-bottom: 2px solid #1d1d1f;
padding: 12px clamp(16px, 4vw, 48px);
position: sticky;
top: 0;
z-index: 100;
}
.static-nav {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.static-brand {
font-size: 18px;
font-weight: 800;
color: #1d1d1f;
text-decoration: none;
letter-spacing: -0.02em;
}
.static-nav-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.static-nav-list a {
display: block;
padding: 6px 12px;
color: #1d1d1f;
text-decoration: none;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
}
.static-nav-list a:hover,
.static-nav-list a:focus {
background: #f0f0f0;
outline: 2px solid #0071e3;
outline-offset: 2px;
}
.static-cta {
background: #0071e3 !important;
color: #fff !important;
font-weight: 600 !important;
}
/* Hero */
.static-hero {
max-width: 800px;
margin: 0 auto;
padding: clamp(40px, 8vw, 80px) clamp(16px, 4vw, 48px);
text-align: center;
}
.static-hero-title {
font-size: clamp(28px, 5vw, 48px);
font-weight: 800;
color: #1d1d1f;
line-height: 1.15;
margin: 0 0 16px;
}
.static-hero-sub {
font-size: clamp(16px, 2vw, 20px);
color: #6e6e73;
line-height: 1.6;
margin: 0 0 24px;
}
.static-hero-actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 24px;
}
.static-btn {
display: inline-block;
padding: 12px 24px;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
text-decoration: none;
transition: none;
}
.static-btn--primary {
background: #0071e3;
color: #fff;
}
.static-btn--primary:hover,
.static-btn--primary:focus {
background: #005bb5;
outline: 3px solid #0071e3;
outline-offset: 2px;
}
.static-btn--secondary {
background: #f5f5f7;
color: #1d1d1f;
border: 1px solid #d2d2d7;
}
.static-btn--secondary:hover,
.static-btn--secondary:focus {
background: #e8e8e8;
outline: 3px solid #0071e3;
outline-offset: 2px;
}
.static-hero-kicker {
font-size: 12px;
color: #aeaeb2;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* Sections */
.static-section {
max-width: 1200px;
margin: 0 auto;
padding: clamp(32px, 6vw, 64px) clamp(16px, 4vw, 48px);
border-top: 1px solid #e5e5e5;
}
.static-section-title {
font-size: 12px;
color: #86868b;
text-transform: uppercase;
letter-spacing: 0.1em;
margin: 0 0 16px;
text-align: center;
}
/* Photo grid */
.static-photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 12px;
}
.static-photo-grid img {
width: 100%;
height: auto;
border-radius: 8px;
border: 1px solid #e5e5e5;
}
/* Footer */
.static-footer {
background: #f5f5f7;
border-top: 2px solid #1d1d1f;
padding: 24px clamp(16px, 4vw, 48px);
}
.static-footer-inner {
max-width: 1200px;
margin: 0 auto;
}
.static-footer-brand {
font-size: 16px;
font-weight: 700;
color: #1d1d1f;
margin: 0 0 4px;
}
.static-footer-copy {
font-size: 12px;
color: #86868b;
margin: 0 0 4px;
}
.static-footer-contact {
font-size: 13px;
color: #6e6e73;
margin: 0 0 12px;
}
.static-footer-contact a {
color: #0071e3;
}
.static-footer-links {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.static-footer-links a {
font-size: 13px;
color: #6e6e73;
text-decoration: none;
padding: 4px 8px;
border-radius: 4px;
}
.static-footer-links a:hover,
.static-footer-links a:focus {
background: #e5e5e5;
outline: 2px solid #0071e3;
}
/* Focus styles — WCAG 2.2 compliant */
:focus-visible {
outline: 3px solid #0071e3;
outline-offset: 2px;
}
/* High contrast mode support */
@media (forced-colors: active) {
.static-btn--primary {
border: 2px solid ButtonText;
}
.static-header {
border-bottom-color: ButtonText;
}
}
/* Reduced motion — no animations at all */
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
/* Print styles */
@media print {
.static-header, .static-footer, .static-hero-actions { display: none; }
.static-section { border: none; page-break-inside: avoid; }
}
</style>
+2141 -5106
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff