diff --git a/index.html b/index.html
index f013251..eaecf38 100644
--- a/index.html
+++ b/index.html
@@ -4,8 +4,33 @@
Clément Saillant — LE BUS
+
+
+
+
+
+
+
+
+
+
+ Version animée : ce site est une demo 3D (un bus sur un PCB).
+ Activez JavaScript / les animations pour monter dedans.
+
+
diff --git a/package-lock.json b/package-lock.json
index d082371..f028890 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
},
"devDependencies": {
"@playwright/test": "^1.53",
+ "@types/node": "^26.1.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.177.0",
@@ -1415,6 +1416,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "26.1.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
+ "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
@@ -2758,6 +2769,13 @@
"node": ">=14.17"
}
},
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
diff --git a/package.json b/package.json
index 2840ba0..dc55d60 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
},
"devDependencies": {
"@playwright/test": "^1.53",
+ "@types/node": "^26.1.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.177.0",
diff --git a/src/boot.ts b/src/boot.ts
new file mode 100644
index 0000000..84a783a
--- /dev/null
+++ b/src/boot.ts
@@ -0,0 +1,3 @@
+export function boot(): void {
+ console.log('demo boot placeholder — replaced by Task 5')
+}
diff --git a/src/fallback/inject.test.ts b/src/fallback/inject.test.ts
new file mode 100644
index 0000000..4135ad9
--- /dev/null
+++ b/src/fallback/inject.test.ts
@@ -0,0 +1,23 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import { renderFallbackHtml } from './inject'
+import { LINKS } from '../content/links'
+
+const template = readFileSync('index.html', 'utf8')
+
+describe('renderFallbackHtml', () => {
+ it('injects every identity link into the raw HTML', () => {
+ const out = renderFallbackHtml(template)
+ for (const l of LINKS) expect(out).toContain(`href="${l.href}"`)
+ })
+ it('injects the rel=me anchor for Mastodon', () => {
+ const out = renderFallbackHtml(template)
+ expect(out).toMatch(
+ /]*href="https:\/\/mastodon\.saillant\.cc\/@clement"[^>]*rel="me"/,
+ )
+ })
+ it('leaves no marker comments behind', () => {
+ const out = renderFallbackHtml(template)
+ expect(out).not.toMatch(//)
+ })
+})
diff --git a/src/fallback/inject.ts b/src/fallback/inject.ts
new file mode 100644
index 0000000..a8a2948
--- /dev/null
+++ b/src/fallback/inject.ts
@@ -0,0 +1,23 @@
+import { BIO_LINES, NAME, TAGLINE } from '../content/bio'
+import { GREETINGS } from '../content/greetings'
+import { LINKS } from '../content/links'
+import { MUSIC } from '../content/music'
+
+export function renderFallbackHtml(html: string): string {
+ const nav = LINKS.map(
+ (l) =>
+ `${l.label}`,
+ ).join('\n ')
+ const bio = BIO_LINES.map((line) => `${line}
`).join('\n ')
+ const music =
+ `${MUSIC.title}
${MUSIC.blurb}
` +
+ `${MUSIC.listenLabel}
`
+ const greetings = `Greetings to ${GREETINGS.join(', ')}.
`
+ return html
+ .replace('', NAME)
+ .replace('', TAGLINE)
+ .replace('', nav)
+ .replace('', bio)
+ .replace('', music)
+ .replace('', greetings)
+}
diff --git a/src/main.tsx b/src/main.tsx
index 3d4c112..1bca0e5 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,3 +1,15 @@
import './style.css'
-console.log('LE BUS — scaffold OK')
+function canRunDemo(): boolean {
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return false
+ const gl = document.createElement('canvas').getContext('webgl2')
+ return gl !== null
+}
+
+if (canRunDemo()) {
+ document.body.classList.add('demo-on')
+ // App is mounted in Task 5; for now the gate is in place.
+ import('./boot').then((m) => m.boot()).catch(() => {
+ document.body.classList.remove('demo-on')
+ })
+}
diff --git a/src/style.css b/src/style.css
index 0fcd296..4f83ce5 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1,2 +1,32 @@
:root { color-scheme: dark; }
body { margin: 0; background: #05010d; color: #ffd23f; font-family: "Courier New", monospace; }
+
+#fallback {
+ max-width: 42rem;
+ margin: 0 auto;
+ padding: 2rem 1rem 4rem;
+ line-height: 1.5;
+}
+#fallback h1 { color: #ff5c00; font-size: 2.2rem; margin-bottom: 0; }
+#fallback .tagline { margin-top: 0.2rem; color: #9be564; }
+#fallback nav a {
+ display: block;
+ padding: 0.6rem 0.8rem;
+ margin: 0.4rem 0;
+ border: 3px solid #ffd23f;
+ color: #ffd23f;
+ text-decoration: none;
+ font-weight: bold;
+}
+#fallback nav a:hover, #fallback nav a:focus { background: #ffd23f; color: #05010d; }
+#fallback a { color: #9be564; }
+#fallback .hint { color: #666; font-size: 0.85rem; }
+/* When the 3D demo is on, keep the fallback for screen readers only */
+body.demo-on #fallback {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
diff --git a/vite.config.ts b/vite.config.ts
index 9ffcc67..def65cb 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,6 +1,14 @@
-import { defineConfig } from 'vite'
+import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
+import { renderFallbackHtml } from './src/fallback/inject'
+
+function fallbackPlugin(): Plugin {
+ return {
+ name: 'inject-fallback',
+ transformIndexHtml: (html) => renderFallbackHtml(html),
+ }
+}
export default defineConfig({
- plugins: [react()],
+ plugins: [react(), fallbackPlugin()],
})