From 7d2dc6c872c6e804740d196a71c01e475ff8306f Mon Sep 17 00:00:00 2001 From: electron-rare <108685187+electron-rare@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:59:13 +0200 Subject: [PATCH] docs: implementation plan reader + atom hub --- .../2026-06-10-formations-reader-hub3d.md | 1123 +++++++++++++++++ 1 file changed, 1123 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-formations-reader-hub3d.md diff --git a/docs/superpowers/plans/2026-06-10-formations-reader-hub3d.md b/docs/superpowers/plans/2026-06-10-formations-reader-hub3d.md new file mode 100644 index 0000000..85244eb --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-formations-reader-hub3d.md @@ -0,0 +1,1123 @@ +# Formations Reader + 3D Atom Hub — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Public reading experience for the 3 published formations: dark/copper catalogue, three.js "atom = table of contents" hub per course, server-rendered chapter reading — backed by Moodle web services. + +**Architecture:** Astro 6 SSR app (node adapter) in `electron-rare/formations-app`. Astro API/loaders act as the BFF to Moodle REST web services (read-only token, in-memory stale-while-revalidate cache). three.js (pinned exact) loaded lazily only on hub pages; every page has a complete SSR HTML fallback. Deployed as one Docker container on Tower (port 8096) next to Moodle, routed `formations.saillant.cc` via traefik on electron-server (Tailscale) + CF tunnel ingress. + +**Tech Stack:** Astro 6 + @astrojs/node + Tailwind 4 (`@tailwindcss/vite`), three (pinned exact, ONLY runtime 3D dep), vitest (dev-only, pinned) for lib unit tests. node:22-alpine Docker. + +**Repos/hosts:** Dev repo: `electron-server:/home/electron/lelectron-rare/formations-app` (git → Gitea `electron-rare/formations-app`). Build & run on **Tower** (`clems@192.168.0.120`, clone `~/formations-app`). Moodle: containers `moodle`/`moodle-db` on Tower, network `moodle-net`, public `https://moodle.saillant.cc`. + +**Build check command** (from the repo dir on electron-server; docker0 egress is blocked → `--network=host`): + +```bash +docker run --rm --network=host -v "$PWD":/app -w /app node:22-alpine \ + sh -c 'npm ci --silent && npm run build' +``` + +**Conventions:** commits subject ≤ 50 chars, body ≤ 72, no AI attribution. All file edits from this controller/agents: write locally, `scp` to the host, `mv` into place (no giant heredocs). + +--- + +### Task 1: Moodle web services provisioning + real fixtures + +**Files:** +- Create: `ops/provision-ws.php` (run inside the moodle container) +- Create: `fixtures/course-contents-3.json` (REAL response, committed) +- Create: `ops/README.md` + +- [ ] **Step 1.1:** Create `ops/provision-ws.php`: + +```php +dirroot.'/user/lib.php'); +require_once($CFG->dirroot.'/lib/enrollib.php'); + +// 1) Enable web services + REST + built-in mobile service +// (mobile service already includes core_course_get_contents & file serving) +set_config('enablewebservices', 1); +set_config('enablemobilewebservice', 1); +$protocols = (string)get_config('core', 'webserviceprotocols'); +if (strpos($protocols, 'rest') === false) { + set_config('webserviceprotocols', trim($protocols === '' ? 'rest' : $protocols . ',rest', ',')); +} + +// 2) Service user +$username = 'wsreader'; +$password = getenv('WSREADER_PASSWORD'); +if (!$password) { fwrite(STDERR, "WSREADER_PASSWORD required\n"); exit(1); } +$user = core_user::get_user_by_username($username); +if (!$user) { + $u = new stdClass(); + $u->username = $username; + $u->password = $password; + $u->firstname = 'WS'; + $u->lastname = 'Reader'; + $u->email = 'wsreader@saillant.cc'; + $u->confirmed = 1; + $u->mnethostid = $CFG->mnet_localhost_id; + $uid = user_create_user($u, true, false); + $user = core_user::get_user($uid); + echo "user created: {$user->id}\n"; +} else { + echo "user exists: {$user->id}\n"; +} + +// 3) Enrol as student in every real course (id > 1) so get_contents works +$student = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST); +$manual = enrol_get_plugin('manual'); +foreach ($DB->get_records_select('course', 'id > 1') as $course) { + $minstance = null; + foreach (enrol_get_instances($course->id, false) as $i) { + if ($i->enrol === 'manual') { $minstance = $i; break; } + } + if (!$minstance) { + $id = $manual->add_instance($course); + $minstance = $DB->get_record('enrol', ['id' => $id], '*', MUST_EXIST); + } + $manual->enrol_user($minstance, $user->id, $student->id); + echo "enrolled: {$course->shortname}\n"; +} +echo "OK\n"; +``` + +- [ ] **Step 1.2:** Run it on Tower (generate a strong password first, keep it in `clems@Tower:~/formations-app.env` — NOT in git): + +```bash +WSPASS=$(openssl rand -base64 18 | tr -d '/+=' | head -c 20)A1! +echo "WSREADER_PASSWORD=$WSPASS" >> ~/formations-app.env +docker cp ops/provision-ws.php moodle:/tmp/provision-ws.php +docker exec -e WSREADER_PASSWORD="$WSPASS" moodle php /tmp/provision-ws.php +``` +Expected: `user created: …`, 6× `enrolled: …`, `OK`. + +- [ ] **Step 1.3:** Obtain the token (public URL — Moodle enforces wwwroot) and PROVE the API works; capture the real fixture: + +```bash +TOKEN=$(curl -s "https://moodle.saillant.cc/login/token.php" \ + --data-urlencode "username=wsreader" \ + --data-urlencode "password=$WSPASS" \ + --data-urlencode "service=moodle_mobile_app" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])') +echo "MOODLE_WS_TOKEN=$TOKEN" >> ~/formations-app.env +curl -s "https://moodle.saillant.cc/webservice/rest/server.php" \ + --data-urlencode "wstoken=$TOKEN" \ + --data-urlencode "wsfunction=core_course_get_contents" \ + --data-urlencode "moodlewsrestformat=json" \ + --data-urlencode "courseid=3" > /tmp/course-contents-3.json +python3 -m json.tool /tmp/course-contents-3.json | head -40 +``` +Expected: JSON array of sections; `mod_book` modules carry `contents` entries with `fileurl` per chapter. **If the shape differs from the client in Task 3, Task 3's parser is adapted to THIS fixture — the fixture is the source of truth.** Also verify a chapter file fetch works: + +```bash +FILEURL=$(python3 -c "import json;d=json.load(open('/tmp/course-contents-3.json'));print(next(c['fileurl'] for s in d for m in s['modules'] if m['modname']=='book' for c in m.get('contents',[]) if c['filename']=='index.html'))") +curl -s "${FILEURL}&token=$TOKEN" | head -5 +``` +Expected: chapter HTML. + +- [ ] **Step 1.4:** Copy the fixture into the repo (electron-server) as `fixtures/course-contents-3.json`. Write `ops/README.md` documenting: what provision-ws.php does, where the env file lives on Tower (`~/formations-app.env`, holds `WSREADER_PASSWORD` + `MOODLE_WS_TOKEN`), and the token-renewal curl from Step 1.3. + +- [ ] **Step 1.5: Commit** — `git add ops fixtures && git commit -m 'feat: moodle ws provisioning + real fixture'` + +--- + +### Task 2: Scaffold the Astro app + +**Files:** +- Create: `package.json`, `astro.config.mjs`, `tsconfig.json`, `Dockerfile`, `.gitignore`, `.dockerignore` +- Create: `src/styles/global.css`, `src/layouts/Base.astro`, `src/components/Nav.astro`, `src/pages/index.astro` (placeholder) + +- [ ] **Step 2.1:** `package.json` (pin three EXACTLY — use the latest stable at implementation time via `npm view three version`, write it without `^`): + +```json +{ + "name": "formations-app", + "type": "module", + "version": "0.1.0", + "engines": { "node": ">=22.12.0" }, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "test": "vitest run", + "astro": "astro" + }, + "dependencies": { + "@astrojs/node": "^10.0.4", + "astro": "^6.1.3", + "@tailwindcss/vite": "^4.2.2", + "tailwindcss": "^4.2.2", + "three": "0.180.0" + }, + "devDependencies": { + "vitest": "^3.0.0", + "@types/three": "0.180.0" + } +} +``` +(If `three@0.180.0` does not exist, pin the latest exact version and matching `@types/three`; record the chosen version in the commit body.) + +- [ ] **Step 2.2:** `astro.config.mjs`: + +```js +// @ts-check +import { defineConfig } from 'astro/config'; +import tailwindcss from '@tailwindcss/vite'; +import node from '@astrojs/node'; + +export default defineConfig({ + site: 'https://formations.saillant.cc', + output: 'server', + adapter: node({ mode: 'standalone' }), + vite: { plugins: [tailwindcss()] }, +}); +``` + +- [ ] **Step 2.3:** `Dockerfile` (same pattern as lelectronrare.fr, no legacy-peer-deps needed here): + +```dockerfile +FROM node:22-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +ENV HOST=0.0.0.0 PORT=4321 +EXPOSE 4321 +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:4321/ || exit 1 +CMD ["node", "dist/server/entry.mjs"] +``` + +`.gitignore`: `node_modules`, `dist`, `.astro`, `*.env`. `.dockerignore`: `node_modules`, `dist`, `.git`, `docs`, `fixtures`, `ops`. + +- [ ] **Step 2.4:** `src/styles/global.css` — dark/copper identity (NOT the blue site palette; this app is the dark experience): + +```css +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); +@import "tailwindcss"; + +@theme { + --color-night: #0b0f17; + --color-night-soft: #141c2e; + --color-copper: #f97316; + --color-copper-soft: #fdba74; + --color-copper-dim: rgba(249, 115, 22, 0.12); + --color-ink: #1d1d1f; + --color-paper: #fafafa; + --font-family-sans: 'Inter', system-ui, sans-serif; + --font-family-mono: 'JetBrains Mono', monospace; + --max-width-content: 980px; + --max-width-prose: 720px; +} + +html { font-family: 'Inter', system-ui, sans-serif; background: var(--color-night); color: #e2e8f0; scroll-behavior: smooth; } +::selection { background: var(--color-copper); color: white; } +* { box-sizing: border-box; } +``` + +- [ ] **Step 2.5:** `src/layouts/Base.astro`: + +```astro +--- +interface Props { title: string; description: string; light?: boolean; } +const { title, description, light = false } = Astro.props; +const fullTitle = `${title} | Formations L'Electron Rare`; +--- + + + + + + {fullTitle} + + + + + + + + + + + +``` + +- [ ] **Step 2.6:** `src/components/Nav.astro` — fixed, transparent on dark / white on light pages: + +```astro +--- +interface Props { light?: boolean; } +const { light = false } = Astro.props; +--- + +``` + +- [ ] **Step 2.7:** Placeholder `src/pages/index.astro` (replaced in Task 5): + +```astro +--- +import Base from '../layouts/Base.astro'; +import Nav from '../components/Nav.astro'; +--- + +