From 4e6ce90f0b5898e2d538e4f8f7ca7f7bbef87ceb Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Sun, 8 Mar 2026 21:45:42 -0700 Subject: [PATCH] feat(skills): expand app-builder skill with comprehensive p5.js, Three.js, and dashboard guidance Rewrites the app-builder SKILL.md from ~250 lines to ~2300 lines covering the full Dench App system: p5.js for 2D games (state machines, sprites, particles, physics, tilemaps, sound), Three.js for 3D (scene setup, controls, GLTF loading, post-processing, first-person patterns), data dashboards (Chart.js, D3, CSS-only), interactive tools, multi-file organization, asset management, theme integration, error handling, and performance tuning. --- skills/app-builder/SKILL.md | 2335 +++++++++++++++++++++++++++++++++++ 1 file changed, 2335 insertions(+) create mode 100644 skills/app-builder/SKILL.md diff --git a/skills/app-builder/SKILL.md b/skills/app-builder/SKILL.md new file mode 100644 index 00000000000..bff78d6fe91 --- /dev/null +++ b/skills/app-builder/SKILL.md @@ -0,0 +1,2335 @@ +--- +name: app-builder +description: Build and manage DenchClaw apps — self-contained web applications that run inside the workspace with access to DuckDB data and the DenchClaw bridge API. Covers static HTML apps, 2D games with p5.js, 3D experiences with Three.js, data dashboards, interactive tools, and more. +metadata: { "openclaw": { "inject": true, "always": true, "emoji": "🔨" } } +--- + +# App Builder + +You can build **Dench Apps** — self-contained web applications that run inside DenchClaw's workspace. Apps appear in the sidebar with their own icon and name, and open as tabs in the main content area. They run in a sandboxed iframe with `allow-same-origin allow-scripts allow-popups allow-forms`. + +--- + +## Table of Contents + +1. [App Structure](#app-structure) +2. [Manifest Reference](#manifest-reference) +3. [Bridge API Reference](#bridge-api-reference) +4. [Theme & Styling System](#theme--styling-system) +5. [Loading External Libraries via CDN](#loading-external-libraries-via-cdn) +6. [2D Games with p5.js](#2d-games-with-p5js) +7. [3D Games & Experiences with Three.js](#3d-games--experiences-with-threejs) +8. [Data Dashboards & Visualization](#data-dashboards--visualization) +9. [Interactive Tools & Utilities](#interactive-tools--utilities) +10. [Multi-File App Organization](#multi-file-app-organization) +11. [Asset Management](#asset-management) +12. [Performance & Best Practices](#performance--best-practices) +13. [Error Handling Patterns](#error-handling-patterns) +14. [Full Example Apps](#full-example-apps) + +--- + +## App Structure + +Every app is a folder ending in `.dench.app/`. The default location is `{{WORKSPACE_PATH}}/apps/`, but apps can live anywhere in the workspace. + +``` +apps/ + my-app.dench.app/ + .dench.yaml # Required manifest + index.html # Entry point + style.css # Styles (optional, can inline) + app.js # Logic (optional, can inline) + assets/ # Images, sounds, models, etc. + sprite.png + bg-music.mp3 + lib/ # Vendored libraries (optional) + p5.min.js +``` + +### Critical Rules + +- The folder name MUST end with `.dench.app` +- The `.dench.yaml` manifest is REQUIRED inside every `.dench.app` folder +- The entry HTML file gets the bridge SDK (`window.dench`) auto-injected before `` +- All file paths within the app are relative to the `.dench.app` folder root +- The app is served at `/api/apps/serve//` — relative references (CSS, JS, images) resolve correctly +- Apps run in an iframe sandbox: `allow-same-origin allow-scripts allow-popups allow-forms` + +--- + +## Manifest Reference + +Every `.dench.app` folder MUST contain a `.dench.yaml` manifest. + +### Full Schema + +```yaml +name: "My App" # Required. Display name shown in sidebar and tab bar +description: "What this app does" # Optional. Shown in tooltips and app info +icon: "gamepad-2" # Optional. Lucide icon name OR relative path to image +version: "1.0.0" # Optional. Shown as badge in app header +author: "agent" # Optional. Creator attribution +entry: "index.html" # Optional. Main entry point (default: index.html) +runtime: "static" # Optional. static | esbuild | build (default: static) + +permissions: # Optional. List of bridge API permissions + - database # Can query workspace DuckDB via window.dench.db + - files # Can read workspace files via window.dench.files +``` + +### Runtime Modes + +| Mode | When to Use | How It Works | +|------|-------------|--------------| +| `static` | Vanilla HTML/CSS/JS apps, CDN-loaded libraries, games, dashboards | Serves files directly. **Use this by default for everything.** | +| `esbuild` | React/TSX apps without npm dependencies | Server-side esbuild transpiles JSX/TSX on load. Requires `esbuild.entry` and `esbuild.jsx` fields. | +| `build` | Complex apps with npm dependencies (rare) | Runs `build.install` then `build.command`. Serves from `build.output` directory. | + +**Always default to `static` runtime.** It handles p5.js, Three.js, D3.js, Chart.js, and any CDN-loaded library perfectly. Only use `esbuild` or `build` when the user explicitly asks for React/TSX or npm-based tooling. + +### Icon Support + +The `icon` field accepts: + +1. **A Lucide icon name** (string): `"gamepad-2"`, `"bar-chart-3"`, `"users"`, `"rocket"`, `"calculator"`, `"box"`, `"palette"` +2. **A relative path** to a square image file: `"icon.png"`, `"assets/logo.svg"` + +Supported image formats: PNG, SVG, JPG, JPEG, WebP. Use square aspect ratio (128x128px or larger). + +### Choosing Permissions + +| Permission | Grants | Use When | +|------------|--------|----------| +| `database` | `window.dench.db.query()` and `window.dench.db.execute()` | App reads/writes workspace DuckDB data | +| `files` | `window.dench.files.read()` and `window.dench.files.list()` | App reads workspace files or directory tree | + +Only request what you need. A game with no data access needs no permissions at all. + +--- + +## Bridge API Reference + +The bridge SDK is auto-injected into every app's HTML. It provides `window.dench` with the following methods. All methods return Promises with a 30-second timeout. + +### Database Access (`database` permission required) + +```javascript +// Run a SELECT query — returns { rows: [...], columns: [...] } +const result = await window.dench.db.query("SELECT * FROM objects"); +console.log(result.rows); // Array of row objects +console.log(result.columns); // Array of column name strings + +// Run a mutation (INSERT, UPDATE, DELETE, CREATE TABLE, etc.) +await window.dench.db.execute("INSERT INTO ..."); + +// Parameterized-style queries (use string interpolation carefully) +const objectName = "people"; +const entries = await window.dench.db.query( + `SELECT * FROM entries WHERE object_id = (SELECT id FROM objects WHERE name = '${objectName}')` +); +``` + +### File Access (`files` permission required) + +```javascript +// Read a workspace file by relative path +const fileContent = await window.dench.files.read("path/to/file.md"); + +// List the workspace directory tree +const tree = await window.dench.files.list(); +// Returns nested tree structure: { name, path, type, children? }[] +``` + +### App Utilities (no permission required) + +```javascript +// Get the app's own parsed manifest +const manifest = await window.dench.app.getManifest(); +// Returns: { name, description, icon, version, author, entry, runtime, permissions } + +// Get current DenchClaw UI theme +const theme = await window.dench.app.getTheme(); +// Returns: "dark" or "light" +``` + +### Agent Communication (no permission required) + +```javascript +// Send a message to the DenchClaw agent (triggers a chat message) +await window.dench.agent.send("Analyze the data in the people table"); +``` + +### Waiting for Bridge Readiness + +The bridge script is injected into ``, so it's available by the time your scripts run. However, if you use `defer` or `type="module"` scripts, you can safely access `window.dench` immediately since module scripts run after the document is parsed. + +```javascript +// Safe pattern for any script loading order +function whenDenchReady(fn) { + if (window.dench) return fn(); + const check = setInterval(() => { + if (window.dench) { clearInterval(check); fn(); } + }, 50); +} + +whenDenchReady(async () => { + const theme = await window.dench.app.getTheme(); + document.body.className = theme; +}); +``` + +--- + +## Theme & Styling System + +Apps should respect the DenchClaw theme. The bridge provides the current theme ("dark" or "light"). Build your CSS to support both. + +### Recommended Base Styles + +```css +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, sans-serif; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + transition: background-color 0.2s, color 0.2s; +} + +body.dark { + --app-bg: #0f0f1a; + --app-surface: #1a1a2e; + --app-surface-hover: #252540; + --app-border: #2a2a45; + --app-text: #e8e8f0; + --app-text-muted: #8888a8; + --app-accent: #6366f1; + --app-accent-hover: #818cf8; + --app-success: #22c55e; + --app-warning: #f59e0b; + --app-error: #ef4444; + background: var(--app-bg); + color: var(--app-text); +} + +body.light { + --app-bg: #ffffff; + --app-surface: #f8f9fa; + --app-surface-hover: #f0f1f3; + --app-border: #e2e4e8; + --app-text: #1a1a2e; + --app-text-muted: #6b7280; + --app-accent: #6366f1; + --app-accent-hover: #4f46e5; + --app-success: #16a34a; + --app-warning: #d97706; + --app-error: #dc2626; + background: var(--app-bg); + color: var(--app-text); +} +``` + +### Theme Initialization + +Always apply the theme as the first action in your app: + +```javascript +async function initTheme() { + try { + const theme = await window.dench.app.getTheme(); + document.body.className = theme; + } catch { + document.body.className = 'dark'; + } +} +initTheme(); +``` + +### Canvas-Based Apps (Games) + +For p5.js, Three.js, or any canvas-based app, set the canvas background based on theme and make sure the body has no scrollbars: + +```css +body { + margin: 0; + padding: 0; + overflow: hidden; + width: 100vw; + height: 100vh; +} + +canvas { + display: block; +} +``` + +--- + +## Loading External Libraries via CDN + +Since apps use `runtime: "static"`, load libraries via CDN ` + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### Import Maps for ES Modules + +For Three.js and other module-based libraries, use import maps: + +```html + + +``` + +--- + +## 2D Games with p5.js + +**Always use p5.js for 2D games, simulations, generative art, and interactive 2D experiences.** p5.js is the default choice for anything 2D unless the user specifically requests something else. + +### When to Use p5.js + +- 2D games (platformer, puzzle, arcade, card games, board games) +- Generative art and creative coding +- Physics simulations and particle systems +- Interactive data visualizations with animation +- Educational simulations and demonstrations +- Drawing and painting tools +- Any 2D canvas-based interactive experience + +### p5.js App Template + +``` +apps/my-game.dench.app/ + .dench.yaml + index.html + sketch.js + assets/ # sprites, sounds, fonts +``` + +**`.dench.yaml`:** +```yaml +name: "My Game" +description: "A fun 2D game built with p5.js" +icon: "gamepad-2" +version: "1.0.0" +entry: "index.html" +runtime: "static" +``` + +No permissions needed unless the game reads/writes workspace data. + +**`index.html`:** +```html + + + + + + My Game + + + + + + + +``` + +**`sketch.js` (game loop skeleton):** +```javascript +let isDark = true; + +function setup() { + createCanvas(windowWidth, windowHeight); + + // Detect theme from DenchClaw + if (window.dench) { + window.dench.app.getTheme().then(theme => { + isDark = theme === 'dark'; + }).catch(() => {}); + } +} + +function draw() { + background(isDark ? 15 : 245); + + // Game rendering goes here +} + +function windowResized() { + resizeCanvas(windowWidth, windowHeight); +} +``` + +### p5.js Instance Mode (Recommended for Complex Apps) + +Use instance mode to avoid global namespace pollution. This is especially important for multi-file apps: + +```javascript +const sketch = (p) => { + let isDark = true; + let player; + + p.setup = () => { + p.createCanvas(p.windowWidth, p.windowHeight); + player = { x: p.width / 2, y: p.height / 2, size: 30, speed: 4 }; + + if (window.dench) { + window.dench.app.getTheme().then(theme => { isDark = theme === 'dark'; }).catch(() => {}); + } + }; + + p.draw = () => { + p.background(isDark ? 15 : 245); + + // Input handling + if (p.keyIsDown(p.LEFT_ARROW) || p.keyIsDown(65)) player.x -= player.speed; + if (p.keyIsDown(p.RIGHT_ARROW) || p.keyIsDown(68)) player.x += player.speed; + if (p.keyIsDown(p.UP_ARROW) || p.keyIsDown(87)) player.y -= player.speed; + if (p.keyIsDown(p.DOWN_ARROW) || p.keyIsDown(83)) player.y += player.speed; + + // Keep in bounds + player.x = p.constrain(player.x, 0, p.width); + player.y = p.constrain(player.y, 0, p.height); + + // Draw player + p.fill(isDark ? '#6366f1' : '#4f46e5'); + p.noStroke(); + p.ellipse(player.x, player.y, player.size); + }; + + p.windowResized = () => { + p.resizeCanvas(p.windowWidth, p.windowHeight); + }; +}; + +new p5(sketch); +``` + +### p5.js Game Architecture Patterns + +#### Game State Machine + +```javascript +const GameState = { MENU: 'menu', PLAYING: 'playing', PAUSED: 'paused', GAME_OVER: 'gameover' }; +let state = GameState.MENU; +let score = 0; +let highScore = 0; + +function draw() { + switch (state) { + case GameState.MENU: drawMenu(); break; + case GameState.PLAYING: drawGame(); break; + case GameState.PAUSED: drawPause(); break; + case GameState.GAME_OVER: drawGameOver(); break; + } +} + +function keyPressed() { + if (state === GameState.MENU && (key === ' ' || key === 'Enter')) { + state = GameState.PLAYING; + resetGame(); + } else if (state === GameState.PLAYING && key === 'Escape') { + state = GameState.PAUSED; + } else if (state === GameState.PAUSED && key === 'Escape') { + state = GameState.PLAYING; + } else if (state === GameState.GAME_OVER && (key === ' ' || key === 'Enter')) { + state = GameState.PLAYING; + resetGame(); + } +} + +function drawMenu() { + background(15); + fill(255); + textAlign(CENTER, CENTER); + textSize(48); + text('MY GAME', width / 2, height / 2 - 60); + textSize(18); + fill(150); + text('Press SPACE or ENTER to start', width / 2, height / 2 + 20); + if (highScore > 0) { + textSize(14); + text('High Score: ' + highScore, width / 2, height / 2 + 60); + } +} + +function drawGameOver() { + background(15); + fill('#ef4444'); + textAlign(CENTER, CENTER); + textSize(48); + text('GAME OVER', width / 2, height / 2 - 60); + fill(255); + textSize(24); + text('Score: ' + score, width / 2, height / 2); + textSize(16); + fill(150); + text('Press SPACE to play again', width / 2, height / 2 + 50); +} +``` + +#### Sprite Management + +```javascript +class Sprite { + constructor(x, y, w, h) { + this.pos = createVector(x, y); + this.vel = createVector(0, 0); + this.w = w; + this.h = h; + this.alive = true; + } + + update() { + this.pos.add(this.vel); + } + + draw() { + rectMode(CENTER); + rect(this.pos.x, this.pos.y, this.w, this.h); + } + + collidesWith(other) { + return ( + this.pos.x - this.w / 2 < other.pos.x + other.w / 2 && + this.pos.x + this.w / 2 > other.pos.x - other.w / 2 && + this.pos.y - this.h / 2 < other.pos.y + other.h / 2 && + this.pos.y + this.h / 2 > other.pos.y - other.h / 2 + ); + } + + isOffscreen() { + return ( + this.pos.x < -this.w || this.pos.x > width + this.w || + this.pos.y < -this.h || this.pos.y > height + this.h + ); + } +} +``` + +#### Particle System + +```javascript +class Particle { + constructor(x, y, color) { + this.pos = createVector(x, y); + this.vel = p5.Vector.random2D().mult(random(1, 5)); + this.acc = createVector(0, 0.1); + this.color = color; + this.alpha = 255; + this.size = random(3, 8); + this.life = 1.0; + this.decay = random(0.01, 0.04); + } + + update() { + this.vel.add(this.acc); + this.pos.add(this.vel); + this.life -= this.decay; + this.alpha = this.life * 255; + } + + draw() { + noStroke(); + fill(red(this.color), green(this.color), blue(this.color), this.alpha); + ellipse(this.pos.x, this.pos.y, this.size); + } + + isDead() { + return this.life <= 0; + } +} + +let particles = []; + +function spawnExplosion(x, y, col, count = 30) { + for (let i = 0; i < count; i++) { + particles.push(new Particle(x, y, col)); + } +} + +function updateParticles() { + for (let i = particles.length - 1; i >= 0; i--) { + particles[i].update(); + particles[i].draw(); + if (particles[i].isDead()) particles.splice(i, 1); + } +} +``` + +#### Camera / Scrolling + +```javascript +let camera = { x: 0, y: 0 }; + +function draw() { + background(15); + + // Follow player + camera.x = lerp(camera.x, player.x - width / 2, 0.1); + camera.y = lerp(camera.y, player.y - height / 2, 0.1); + + push(); + translate(-camera.x, -camera.y); + + // Draw world (in world coordinates) + drawWorld(); + drawPlayer(); + drawEnemies(); + + pop(); + + // Draw HUD (in screen coordinates) + drawHUD(); +} +``` + +#### Tilemap Rendering + +```javascript +const TILE_SIZE = 32; +const tilemap = [ + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 0, 0, 0, 0, 0, 0, 1], + [1, 0, 0, 2, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 3, 0, 1], + [1, 1, 1, 1, 1, 1, 1, 1], +]; + +const TILE_COLORS = { + 0: null, // empty + 1: '#4a4a6a', // wall + 2: '#22c55e', // item + 3: '#ef4444', // enemy +}; + +function drawTilemap() { + for (let row = 0; row < tilemap.length; row++) { + for (let col = 0; col < tilemap[row].length; col++) { + const tile = tilemap[row][col]; + if (TILE_COLORS[tile]) { + fill(TILE_COLORS[tile]); + noStroke(); + rect(col * TILE_SIZE, row * TILE_SIZE, TILE_SIZE, TILE_SIZE); + } + } + } +} +``` + +#### Sound Effects (using p5.sound or Howler.js) + +For sound, prefer Howler.js since p5.sound adds significant bundle size: + +```html + +``` + +```javascript +const sounds = { + jump: new Howl({ src: ['assets/jump.wav'], volume: 0.5 }), + hit: new Howl({ src: ['assets/hit.wav'], volume: 0.7 }), + coin: new Howl({ src: ['assets/coin.wav'], volume: 0.4 }), + music: new Howl({ src: ['assets/music.mp3'], loop: true, volume: 0.3 }), +}; +``` + +If no sound assets are available, generate simple audio with Tone.js or the Web Audio API: + +```javascript +function playBeep(freq = 440, duration = 0.1) { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.frequency.value = freq; + gain.gain.setValueAtTime(0.3, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration); + osc.start(); + osc.stop(ctx.currentTime + duration); +} +``` + +### p5.js with Physics (Matter.js) + +For games needing realistic 2D physics (platformers, ragdoll, pinball): + +```html + + +``` + +```javascript +const { Engine, World, Bodies, Body, Events } = Matter; + +let engine, world; +let ground, player; + +function setup() { + createCanvas(windowWidth, windowHeight); + engine = Engine.create(); + world = engine.world; + + ground = Bodies.rectangle(width / 2, height - 20, width, 40, { isStatic: true }); + player = Bodies.circle(width / 2, height / 2, 20, { restitution: 0.5 }); + + World.add(world, [ground, player]); +} + +function draw() { + Engine.update(engine); + background(15); + + // Draw ground + fill('#4a4a6a'); + rectMode(CENTER); + rect(ground.position.x, ground.position.y, width, 40); + + // Draw player + fill('#6366f1'); + ellipse(player.position.x, player.position.y, 40); +} + +function keyPressed() { + if (key === ' ') { + Body.applyForce(player, player.position, { x: 0, y: -0.05 }); + } +} +``` + +### p5.js Responsive Canvas + +Always handle window resizing and use the full viewport: + +```javascript +function setup() { + createCanvas(windowWidth, windowHeight); +} + +function windowResized() { + resizeCanvas(windowWidth, windowHeight); +} +``` + +For fixed-aspect-ratio games (e.g., retro pixel games), scale the canvas: + +```javascript +const GAME_W = 320; +const GAME_H = 240; +let scaleFactor; + +function setup() { + createCanvas(windowWidth, windowHeight); + pixelDensity(1); + noSmooth(); + calcScale(); +} + +function calcScale() { + scaleFactor = min(windowWidth / GAME_W, windowHeight / GAME_H); +} + +function draw() { + background(0); + push(); + translate((width - GAME_W * scaleFactor) / 2, (height - GAME_H * scaleFactor) / 2); + scale(scaleFactor); + + // All game drawing at GAME_W x GAME_H logical resolution + drawGameWorld(); + + pop(); +} + +function windowResized() { + resizeCanvas(windowWidth, windowHeight); + calcScale(); +} +``` + +### Touch / Mobile Input for p5.js Games + +```javascript +let touchActive = false; +let touchX = 0, touchY = 0; + +function touchStarted() { + touchActive = true; + touchX = mouseX; + touchY = mouseY; + return false; // prevent default +} + +function touchMoved() { + touchX = mouseX; + touchY = mouseY; + return false; +} + +function touchEnded() { + touchActive = false; + return false; +} + +// Unified input: works for both mouse and touch +function getInputX() { return mouseX; } +function getInputY() { return mouseY; } +function isInputActive() { return mouseIsPressed || touchActive; } +``` + +### p5.js High Score Persistence with DuckDB + +If the game has a `database` permission, persist high scores: + +```javascript +async function loadHighScore() { + try { + await window.dench.db.execute(` + CREATE TABLE IF NOT EXISTS game_scores ( + game TEXT, score INTEGER, played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + const result = await window.dench.db.query( + `SELECT MAX(score) as high_score FROM game_scores WHERE game = 'my-game'` + ); + return result.rows?.[0]?.high_score || 0; + } catch { return 0; } +} + +async function saveScore(score) { + try { + await window.dench.db.execute( + `INSERT INTO game_scores (game, score) VALUES ('my-game', ${score})` + ); + } catch {} +} +``` + +--- + +## 3D Games & Experiences with Three.js + +**Always use Three.js for 3D games, visualizations, and interactive 3D experiences.** Three.js is the default choice for anything 3D. + +### When to Use Three.js + +- 3D games (first-person, third-person, flying, racing) +- 3D product viewers and configurators +- Terrain and world visualization +- 3D data visualization (3D scatter plots, network graphs) +- Architectural walkthroughs +- Generative 3D art +- Physics-based 3D simulations + +### Three.js App Template + +``` +apps/my-3d-app.dench.app/ + .dench.yaml + index.html + app.js # Main Three.js module + assets/ + model.glb # 3D models (optional) + texture.jpg # Textures (optional) +``` + +**`.dench.yaml`:** +```yaml +name: "3D World" +description: "An interactive 3D experience" +icon: "box" +version: "1.0.0" +entry: "index.html" +runtime: "static" +``` + +**`index.html`:** +```html + + + + + + 3D World + + + + +
Loading...
+ + + +``` + +**`app.js` (Three.js module skeleton):** +```javascript +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; + +// --- Scene setup --- +const scene = new THREE.Scene(); +scene.background = new THREE.Color(0x0f0f1a); +scene.fog = new THREE.Fog(0x0f0f1a, 50, 200); + +const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000); +camera.position.set(0, 5, 10); + +const renderer = new THREE.WebGLRenderer({ antialias: true }); +renderer.setSize(window.innerWidth, window.innerHeight); +renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); +renderer.shadowMap.enabled = true; +renderer.shadowMap.type = THREE.PCFSoftShadowMap; +renderer.toneMapping = THREE.ACESFilmicToneMapping; +renderer.toneMappingExposure = 1.0; +document.body.appendChild(renderer.domElement); + +// --- Controls --- +const controls = new OrbitControls(camera, renderer.domElement); +controls.enableDamping = true; +controls.dampingFactor = 0.05; +controls.maxPolarAngle = Math.PI / 2; + +// --- Lighting --- +const ambientLight = new THREE.AmbientLight(0x404060, 0.5); +scene.add(ambientLight); + +const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5); +directionalLight.position.set(10, 20, 10); +directionalLight.castShadow = true; +directionalLight.shadow.mapSize.set(2048, 2048); +directionalLight.shadow.camera.near = 0.5; +directionalLight.shadow.camera.far = 100; +directionalLight.shadow.camera.left = -30; +directionalLight.shadow.camera.right = 30; +directionalLight.shadow.camera.top = 30; +directionalLight.shadow.camera.bottom = -30; +scene.add(directionalLight); + +// --- Ground --- +const groundGeo = new THREE.PlaneGeometry(200, 200); +const groundMat = new THREE.MeshStandardMaterial({ color: 0x1a1a2e, roughness: 0.8 }); +const ground = new THREE.Mesh(groundGeo, groundMat); +ground.rotation.x = -Math.PI / 2; +ground.receiveShadow = true; +scene.add(ground); + +// --- Objects --- +const geometry = new THREE.BoxGeometry(2, 2, 2); +const material = new THREE.MeshStandardMaterial({ + color: 0x6366f1, + roughness: 0.3, + metalness: 0.5, +}); +const cube = new THREE.Mesh(geometry, material); +cube.position.y = 1; +cube.castShadow = true; +scene.add(cube); + +// --- Theme --- +if (window.dench) { + window.dench.app.getTheme().then(theme => { + if (theme === 'light') { + scene.background = new THREE.Color(0xf0f0f5); + scene.fog = new THREE.Fog(0xf0f0f5, 50, 200); + groundMat.color.set(0xe8e8f0); + } + }).catch(() => {}); +} + +// --- Hide loading screen --- +document.getElementById('loading')?.classList.add('hidden'); + +// --- Animation loop --- +const clock = new THREE.Clock(); + +function animate() { + requestAnimationFrame(animate); + const dt = clock.getDelta(); + const elapsed = clock.getElapsedTime(); + + cube.rotation.y = elapsed * 0.5; + cube.position.y = 1 + Math.sin(elapsed) * 0.5; + + controls.update(); + renderer.render(scene, camera); +} + +animate(); + +// --- Resize --- +window.addEventListener('resize', () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +}); +``` + +### Three.js Common Addons + +Load additional Three.js modules as needed via the import map: + +```javascript +// First-person controls +import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js'; + +// GLTF model loading +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; + +// Post-processing +import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; +import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; +import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; + +// Environment maps +import { RGBELoader } from 'three/addons/loaders/RGBELoader.js'; + +// Text +import { FontLoader } from 'three/addons/loaders/FontLoader.js'; +import { TextGeometry } from 'three/addons/geometries/TextGeometry.js'; + +// Sky +import { Sky } from 'three/addons/objects/Sky.js'; + +// Water +import { Water } from 'three/addons/objects/Water.js'; + +// Physics integration (use cannon-es via CDN) +// Add to importmap: "cannon-es": "https://unpkg.com/cannon-es@0.20/dist/cannon-es.js" +import * as CANNON from 'cannon-es'; +``` + +### Three.js First-Person Game Pattern + +```javascript +import * as THREE from 'three'; +import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js'; + +const scene = new THREE.Scene(); +const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); +const renderer = new THREE.WebGLRenderer({ antialias: true }); +renderer.setSize(window.innerWidth, window.innerHeight); +renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); +document.body.appendChild(renderer.domElement); + +const controls = new PointerLockControls(camera, document.body); + +// Click to enter pointer lock +document.addEventListener('click', () => { + if (!controls.isLocked) controls.lock(); +}); + +// Movement state +const velocity = new THREE.Vector3(); +const direction = new THREE.Vector3(); +const keys = { forward: false, backward: false, left: false, right: false, jump: false }; + +document.addEventListener('keydown', (e) => { + switch (e.code) { + case 'KeyW': case 'ArrowUp': keys.forward = true; break; + case 'KeyS': case 'ArrowDown': keys.backward = true; break; + case 'KeyA': case 'ArrowLeft': keys.left = true; break; + case 'KeyD': case 'ArrowRight': keys.right = true; break; + case 'Space': keys.jump = true; break; + } +}); + +document.addEventListener('keyup', (e) => { + switch (e.code) { + case 'KeyW': case 'ArrowUp': keys.forward = false; break; + case 'KeyS': case 'ArrowDown': keys.backward = false; break; + case 'KeyA': case 'ArrowLeft': keys.left = false; break; + case 'KeyD': case 'ArrowRight': keys.right = false; break; + case 'Space': keys.jump = false; break; + } +}); + +let onGround = true; +const MOVE_SPEED = 50; +const JUMP_FORCE = 12; +const GRAVITY = 30; + +const clock = new THREE.Clock(); + +function animate() { + requestAnimationFrame(animate); + const dt = Math.min(clock.getDelta(), 0.1); + + if (controls.isLocked) { + // Apply friction + velocity.x -= velocity.x * 10.0 * dt; + velocity.z -= velocity.z * 10.0 * dt; + velocity.y -= GRAVITY * dt; + + direction.z = Number(keys.forward) - Number(keys.backward); + direction.x = Number(keys.right) - Number(keys.left); + direction.normalize(); + + if (keys.forward || keys.backward) velocity.z -= direction.z * MOVE_SPEED * dt; + if (keys.left || keys.right) velocity.x -= direction.x * MOVE_SPEED * dt; + if (keys.jump && onGround) { velocity.y = JUMP_FORCE; onGround = false; } + + controls.moveRight(-velocity.x * dt); + controls.moveForward(-velocity.z * dt); + camera.position.y += velocity.y * dt; + + if (camera.position.y < 1.7) { + velocity.y = 0; + camera.position.y = 1.7; + onGround = true; + } + } + + renderer.render(scene, camera); +} + +animate(); +``` + +### Three.js GLTF Model Loading + +```javascript +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; + +const loader = new GLTFLoader(); +const dracoLoader = new DRACOLoader(); +dracoLoader.setDecoderPath('https://unpkg.com/three@0.170/examples/jsm/libs/draco/'); +loader.setDRACOLoader(dracoLoader); + +// Load a model from the app's assets folder +loader.load('assets/model.glb', (gltf) => { + const model = gltf.scene; + model.traverse((child) => { + if (child.isMesh) { + child.castShadow = true; + child.receiveShadow = true; + } + }); + model.scale.setScalar(1); + scene.add(model); + + // If the model has animations + if (gltf.animations.length > 0) { + const mixer = new THREE.AnimationMixer(model); + const action = mixer.clipAction(gltf.animations[0]); + action.play(); + // In animate loop: mixer.update(dt); + } +}, undefined, (error) => { + console.error('Model load error:', error); +}); +``` + +### Three.js Post-Processing (Bloom, etc.) + +```javascript +import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; +import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; +import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; + +const composer = new EffectComposer(renderer); +composer.addPass(new RenderPass(scene, camera)); + +const bloomPass = new UnrealBloomPass( + new THREE.Vector2(window.innerWidth, window.innerHeight), + 0.5, // strength + 0.4, // radius + 0.85 // threshold +); +composer.addPass(bloomPass); + +// In animate loop, replace renderer.render(scene, camera) with: +// composer.render(); + +// On resize, also update: +// composer.setSize(window.innerWidth, window.innerHeight); +``` + +### Three.js Procedural Terrain + +```javascript +function createTerrain(width, depth, resolution) { + const geometry = new THREE.PlaneGeometry(width, depth, resolution, resolution); + const vertices = geometry.attributes.position.array; + + for (let i = 0; i < vertices.length; i += 3) { + const x = vertices[i]; + const z = vertices[i + 1]; + vertices[i + 2] = noise(x * 0.02, z * 0.02) * 15; + } + + geometry.computeVertexNormals(); + + const material = new THREE.MeshStandardMaterial({ + color: 0x3a7d44, + roughness: 0.9, + flatShading: true, + }); + + const mesh = new THREE.Mesh(geometry, material); + mesh.rotation.x = -Math.PI / 2; + mesh.receiveShadow = true; + return mesh; +} + +// Simple noise function (for procedural generation without dependencies) +function noise(x, y) { + const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453; + return n - Math.floor(n); +} +``` + +### Three.js HUD / UI Overlay + +Since Three.js renders to a canvas, use HTML overlays for UI: + +```html +
+
+
+
+
+
+``` + +```javascript +function updateHUD(score, health) { + document.getElementById('score').textContent = `Score: ${score}`; + document.getElementById('health-fill').style.width = `${health}%`; + document.getElementById('health-fill').style.background = + health > 50 ? '#22c55e' : health > 25 ? '#f59e0b' : '#ef4444'; +} +``` + +--- + +## Data Dashboards & Visualization + +For data-heavy apps that query the workspace DuckDB, use Chart.js, D3.js, or plain HTML/CSS. + +### Chart.js Dashboard + +```html + +``` + +```javascript +async function renderChart() { + const result = await window.dench.db.query( + "SELECT name, entry_count FROM objects ORDER BY entry_count DESC" + ); + + const ctx = document.getElementById('myChart').getContext('2d'); + new Chart(ctx, { + type: 'bar', + data: { + labels: result.rows.map(r => r.name), + datasets: [{ + label: 'Entries', + data: result.rows.map(r => r.entry_count), + backgroundColor: '#6366f180', + borderColor: '#6366f1', + borderWidth: 1, + borderRadius: 6, + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + y: { beginAtZero: true, grid: { color: '#2a2a4530' } }, + x: { grid: { display: false } }, + } + } + }); +} +``` + +### D3.js Visualization + +```html + +``` + +```javascript +async function renderViz() { + const result = await window.dench.db.query("SELECT * FROM pivot_people LIMIT 100"); + const data = result.rows; + + const margin = { top: 20, right: 20, bottom: 40, left: 60 }; + const width = window.innerWidth - margin.left - margin.right; + const height = 400 - margin.top - margin.bottom; + + const svg = d3.select('#chart') + .append('svg') + .attr('width', width + margin.left + margin.right) + .attr('height', height + margin.top + margin.bottom) + .append('g') + .attr('transform', `translate(${margin.left},${margin.top})`); + + // Build scales, axes, bindings as needed +} +``` + +### CSS-Only Stat Cards (No Library Needed) + +For simple metric displays, plain HTML/CSS is often better than a charting library: + +```html +
+
+
Total Records
+
+
+12% this week
+
+
+``` + +```css +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + padding: 24px; +} + +.stat-card { + padding: 20px; + border-radius: 12px; + background: var(--app-surface); + border: 1px solid var(--app-border); +} + +.stat-label { + font-size: 13px; + color: var(--app-text-muted); + margin-bottom: 8px; +} + +.stat-value { + font-size: 36px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.stat-change { + font-size: 12px; + margin-top: 4px; +} + +.stat-change.positive { color: var(--app-success); } +.stat-change.negative { color: var(--app-error); } +``` + +--- + +## Interactive Tools & Utilities + +### Form-Based Tools + +For tools that collect input and process it: + +```html +
+
+ + +
+ +
+
+``` + +```css +.field { margin-bottom: 16px; } +.field label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--app-text-muted); + margin-bottom: 6px; +} +.field textarea, .field input, .field select { + width: 100%; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--app-border); + background: var(--app-surface); + color: var(--app-text); + font-size: 14px; + font-family: inherit; + resize: vertical; +} +.field textarea:focus, .field input:focus { + outline: none; + border-color: var(--app-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-accent) 20%, transparent); +} +button[type="submit"] { + padding: 10px 20px; + border: none; + border-radius: 8px; + background: var(--app-accent); + color: white; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; +} +button[type="submit"]:hover { background: var(--app-accent-hover); } +.output-box { + margin-top: 16px; + padding: 16px; + border-radius: 8px; + background: var(--app-surface); + border: 1px solid var(--app-border); + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 13px; + white-space: pre-wrap; + max-height: 400px; + overflow: auto; +} +``` + +### Kanban / Drag-and-Drop + +For sortable/draggable interfaces, use the native HTML Drag and Drop API or load SortableJS: + +```html + +``` + +```javascript +document.querySelectorAll('.kanban-column').forEach(col => { + Sortable.create(col, { + group: 'tasks', + animation: 150, + ghostClass: 'drag-ghost', + onEnd: (evt) => { + // Persist order change via DuckDB if needed + }, + }); +}); +``` + +--- + +## Multi-File App Organization + +For complex apps, split code across multiple files: + +``` +apps/complex-app.dench.app/ + .dench.yaml + index.html + css/ + main.css + components.css + js/ + app.js # Entry point + game.js # Game logic + renderer.js # Rendering + ui.js # UI overlays + utils.js # Helpers + assets/ + sprites/ + sounds/ + models/ +``` + +### Using ES Modules for Multi-File JS + +```html + +``` + +```javascript +// js/app.js +import { Game } from './game.js'; +import { Renderer } from './renderer.js'; +import { UI } from './ui.js'; + +const game = new Game(); +const renderer = new Renderer(game); +const ui = new UI(game); + +async function init() { + if (window.dench) { + const theme = await window.dench.app.getTheme(); + renderer.setTheme(theme); + } + game.start(); +} + +init(); +``` + +```javascript +// js/game.js +export class Game { + constructor() { + this.state = 'menu'; + this.score = 0; + this.entities = []; + } + + start() { this.state = 'playing'; this.loop(); } + loop() { + this.update(); + requestAnimationFrame(() => this.loop()); + } + update() { /* game logic */ } +} +``` + +Relative imports (`./game.js`) work because all files are served from the same `/api/apps/serve/` base path. + +--- + +## Asset Management + +### Referencing Assets + +All asset paths are relative to the `.dench.app` folder root: + +```javascript +// In p5.js +let img; +function preload() { + img = loadImage('assets/player.png'); +} + +// In Three.js (module) +const texture = new THREE.TextureLoader().load('assets/texture.jpg'); + +// In HTML +// +// +``` + +### Supported MIME Types + +The file server recognizes these extensions automatically: + +| Extension | MIME Type | +|-----------|-----------| +| `.html`, `.htm` | `text/html` | +| `.css` | `text/css` | +| `.js`, `.mjs` | `application/javascript` | +| `.json` | `application/json` | +| `.png` | `image/png` | +| `.jpg`, `.jpeg` | `image/jpeg` | +| `.gif` | `image/gif` | +| `.svg` | `image/svg+xml` | +| `.webp` | `image/webp` | +| `.woff`, `.woff2` | `font/woff`, `font/woff2` | +| `.ttf`, `.otf` | `font/ttf`, `font/otf` | +| `.wasm` | `application/wasm` | +| `.mp3`, `.wav`, `.ogg` | Served as `application/octet-stream` (works fine for `