From cf9ffc6a0625d18e96bc5fc3e586b65a4514b23a Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 01:04:36 -0400 Subject: [PATCH] feat: floating bookshelves with merged PR spine labels (#264) Adds two floating bookshelves to the Nexus 3D scene, each holding books whose spines display merged PR numbers and titles. Books are built from canvas textures via the Gitea API, with fallback data if unreachable. Both shelves gently bob in the scene. Refs #264 Co-Authored-By: Claude Sonnet 4.6 --- app.js | 220 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) diff --git a/app.js b/app.js index 9ac2064..5bd4e92 100644 --- a/app.js +++ b/app.js @@ -1002,6 +1002,12 @@ function animate() { loraPanelSprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.22; } + // Animate floating bookshelves — gentle slow bob + for (const shelf of bookshelfGroups) { + const ud = shelf.userData; + shelf.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.18; + } + // Animate Timmy speech bubble — fade in, hold, fade out if (timmySpeechState) { const age = elapsed - timmySpeechState.startTime; @@ -1552,6 +1558,9 @@ window.addEventListener('beforeunload', () => { // === COMMIT BANNERS === const commitBanners = []; +/** @type {THREE.Group[]} */ +const bookshelfGroups = []; + @@ -1761,6 +1770,217 @@ async function initCommitBanners() { initCommitBanners(); loadPortals(); +// === FLOATING BOOKSHELVES === +// Floating bookshelves display merged PR history as books with spine labels. +// Each book spine shows a PR number and truncated title rendered via canvas texture. + +/** + * Creates a canvas texture for a book spine. + * @param {number} prNum + * @param {string} title + * @param {string} bgColor CSS color string for book cover + * @returns {THREE.CanvasTexture} + */ +function createSpineTexture(prNum, title, bgColor) { + const canvas = document.createElement('canvas'); + canvas.width = 128; + canvas.height = 512; + const ctx = canvas.getContext('2d'); + + // Background — book cover color + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, 128, 512); + + // Accent border + ctx.strokeStyle = '#4488ff'; + ctx.lineWidth = 3; + ctx.strokeRect(3, 3, 122, 506); + + // PR number — accent blue, near top + ctx.font = 'bold 32px "Courier New", monospace'; + ctx.fillStyle = '#4488ff'; + ctx.textAlign = 'center'; + ctx.fillText(`#${prNum}`, 64, 58); + + // Divider line + ctx.strokeStyle = '#4488ff'; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.4; + ctx.beginPath(); + ctx.moveTo(12, 78); + ctx.lineTo(116, 78); + ctx.stroke(); + ctx.globalAlpha = 1.0; + + // Title — rotated 90° to read bottom-to-top (spine convention) + ctx.save(); + ctx.translate(64, 300); + ctx.rotate(-Math.PI / 2); + const displayTitle = title.length > 30 ? title.slice(0, 30) + '\u2026' : title; + ctx.font = '21px "Courier New", monospace'; + ctx.fillStyle = '#ccd6f6'; + ctx.textAlign = 'center'; + ctx.fillText(displayTitle, 0, 0); + ctx.restore(); + + return new THREE.CanvasTexture(canvas); +} + +/** + * Builds a single floating bookshelf group and adds it to the scene. + * @param {Array<{prNum: number, title: string}>} books + * @param {THREE.Vector3} position + * @param {number} rotationY + */ +function buildBookshelf(books, position, rotationY) { + const group = new THREE.Group(); + group.position.copy(position); + group.rotation.y = rotationY; + + const SHELF_W = books.length * 0.52 + 0.6; + const SHELF_THICKNESS = 0.12; + const SHELF_DEPTH = 0.72; + const ENDPANEL_H = 2.0; + + // Dark metallic shelf material + const shelfMat = new THREE.MeshStandardMaterial({ + color: 0x0d1520, + metalness: 0.6, + roughness: 0.5, + emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.02), + }); + + // Shelf plank (horizontal) + const plank = new THREE.Mesh(new THREE.BoxGeometry(SHELF_W, SHELF_THICKNESS, SHELF_DEPTH), shelfMat); + group.add(plank); + + // End panels + const endGeo = new THREE.BoxGeometry(0.1, ENDPANEL_H, SHELF_DEPTH); + const leftEnd = new THREE.Mesh(endGeo, shelfMat); + leftEnd.position.set(-SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0); + group.add(leftEnd); + + const rightEnd = new THREE.Mesh(endGeo.clone(), shelfMat); + rightEnd.position.set(SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0); + group.add(rightEnd); + + // Accent glow strip along front edge of shelf + const glowStrip = new THREE.Mesh( + new THREE.BoxGeometry(SHELF_W, 0.035, 0.035), + new THREE.MeshBasicMaterial({ color: NEXUS.colors.accent, transparent: true, opacity: 0.55 }) + ); + glowStrip.position.set(0, SHELF_THICKNESS / 2 + 0.017, SHELF_DEPTH / 2); + group.add(glowStrip); + + // Book cover colors — dark tones with slight variation + const BOOK_COLORS = [ + '#0f0818', '#080f18', '#0f1108', '#07120e', + '#130c06', '#060b12', '#120608', '#080812', + ]; + + // Spine thickness (X), book height (Y), cover depth (Z) + // +Z face (index 4) = spine visible to viewer + const bookStartX = -(SHELF_W / 2) + 0.36; + books.forEach((book, i) => { + const spineW = 0.34 + (i % 3) * 0.05; // slight width variation + const bookH = 1.35 + (i % 4) * 0.13; // slight height variation + const coverD = 0.58; + + const bgColor = BOOK_COLORS[i % BOOK_COLORS.length]; + const spineTexture = createSpineTexture(book.prNum, book.title, bgColor); + + const plainMat = new THREE.MeshStandardMaterial({ + color: new THREE.Color(bgColor), + roughness: 0.85, + metalness: 0.05, + }); + const spineMat = new THREE.MeshBasicMaterial({ map: spineTexture }); + + // Material array: +X, -X, +Y, -Y, +Z (spine), -Z + const bookMats = [plainMat, plainMat, plainMat, plainMat, spineMat, plainMat]; + + const bookGeo = new THREE.BoxGeometry(spineW, bookH, coverD); + const bookMesh = new THREE.Mesh(bookGeo, bookMats); + bookMesh.position.set( + bookStartX + i * 0.5, + SHELF_THICKNESS / 2 + bookH / 2, + 0 + ); + bookMesh.userData.zoomLabel = `PR #${book.prNum}: ${book.title.slice(0, 40)}`; + group.add(bookMesh); + }); + + // Soft point light beneath shelf for ambient glow + const shelfLight = new THREE.PointLight(NEXUS.colors.accent, 0.25, 5); + shelfLight.position.set(0, -0.4, 0); + group.add(shelfLight); + + group.userData.zoomLabel = 'PR Archive — Merged Contributions'; + group.userData.baseY = position.y; + group.userData.floatPhase = bookshelfGroups.length * Math.PI; + group.userData.floatSpeed = 0.17 + bookshelfGroups.length * 0.06; + + scene.add(group); + bookshelfGroups.push(group); +} + +/** + * Fetches merged PRs and spawns floating bookshelves in the scene. + */ +async function initBookshelves() { + let prs = []; + try { + const res = await fetch( + 'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/pulls?state=closed&limit=20', + { headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } } + ); + if (!res.ok) throw new Error('fetch failed'); + const data = await res.json(); + prs = data + .filter(/** @type {(p: any) => boolean} */ p => p.merged) + .map(/** @type {(p: any) => {prNum: number, title: string}} */ p => ({ + prNum: p.number, + // Strip "[claude]" prefix and trailing "(#N)" suffix for cleaner spine labels + title: p.title + .replace(/^\[[\w\s]+\]\s*/i, '') + .replace(/\s*\(#\d+\)\s*$/, ''), + })); + } catch { + // Fallback if API unreachable + prs = [ + { prNum: 324, title: 'Model training status — LoRA adapters' }, + { prNum: 323, title: 'The Oath — interactive SOUL.md reading' }, + { prNum: 320, title: 'Hermes session save/load' }, + { prNum: 304, title: 'Session export as markdown' }, + { prNum: 303, title: 'Procedural Web Audio ambient soundtrack' }, + { prNum: 301, title: 'Warp tunnel effect for portals' }, + { prNum: 296, title: 'Procedural terrain for floating island' }, + { prNum: 294, title: 'Northern lights flash on PR merge' }, + ]; + } + + if (prs.length === 0) return; + + // Split PRs across two shelves — left and right of the scene background + const mid = Math.ceil(prs.length / 2); + + buildBookshelf( + prs.slice(0, mid), + new THREE.Vector3(-8.5, 1.5, -4.5), + Math.PI * 0.1, // slight angle toward scene center + ); + + if (prs.slice(mid).length > 0) { + buildBookshelf( + prs.slice(mid), + new THREE.Vector3(8.5, 1.5, -4.5), + -Math.PI * 0.1, + ); + } +} + +initBookshelves(); + // === THE OATH === // Interactive reading of SOUL.md with dramatic lighting. // Trigger: press 'O' or double-click the tome object in the scene. -- 2.43.0