"use strict"; const entryView = document.querySelector("#entry-view"); const experienceView = document.querySelector("#experience-view"); const form = document.querySelector("#forest-form"); const formSteps = Array.from(document.querySelectorAll(".form-step")); const stepTracker = document.querySelector("#step-tracker"); const nameInput = document.querySelector("#name"); const situationInput = document.querySelector("#situation"); const styleInput = document.querySelector("#style"); const growButton = document.querySelector("#grow-button"); const statusRegion = document.querySelector("#status-region"); const intakeStep = document.querySelector("#intake-step"); const intakeQuestion = document.querySelector("#intake-question"); const intakeOptions = document.querySelector("#intake-options"); const intakeProgress = document.querySelector("#intake-progress"); const styleStep = document.querySelector("#style-step"); const forestTitle = document.querySelector("#forest-title"); const clearingProgress = document.querySelector("#clearing-progress"); const artStyleLabel = document.querySelector("#art-style-label"); const progressDots = document.querySelector("#progress-dots"); const soundscapePlayer = document.querySelector("#soundscape-player"); const soundscapeAudio = document.querySelector("#soundscape-audio"); const clearingImage = document.querySelector("#clearing-image"); const sceneTitle = document.querySelector("#scene-title"); const sceneIntro = document.querySelector("#scene-intro"); const narration = document.querySelector("#narration"); const reflection = document.querySelector("#reflection"); const spell = document.querySelector("#spell"); const copySpell = document.querySelector("#copy-spell"); const walkOn = document.querySelector("#walk-on"); const saveForest = document.querySelector("#save-forest"); const startOver = document.querySelector("#start-over"); const brandHome = document.querySelector("#brand-home"); const INTAKE_TURNS = 5; const STYLE_LABELS = { watercolor: "Watercolor Storybook", paper_cut: "Layered Paper Cut", moonlit_gouache: "Moonlit Gouache", botanical_ink: "Botanical Ink Wash", }; const state = { forestTitle: "", clearingCount: 0, clearings: [], currentIndex: 0, currentStep: "name", intake: [], pendingQuestion: null, complete: false, name: "", situation: "", style: "surprise", resolvedStyle: "", seed: 0, }; function randomSeed() { const values = new Uint32Array(1); crypto.getRandomValues(values); return values[0] % 2147483648; } function setStatus(message, isError = false) { statusRegion.textContent = message; statusRegion.classList.toggle("is-error", isError); } function updateStepTracker() { const items = Array.from(stepTracker.querySelectorAll("li")); let currentSlot; if (state.currentStep === "name") currentSlot = 0; else if (state.currentStep === "style") currentSlot = 6; else if (state.currentStep === "intake") currentSlot = 1 + state.intake.length; else currentSlot = 0; for (const [index, item] of items.entries()) { item.classList.toggle("is-current", index === currentSlot); item.classList.toggle("is-complete", index < currentSlot); } } function showStep(step, focus = true) { state.currentStep = step; const lookup = { name: '[data-step="0"]', intake: '[data-step="intake"]', style: '[data-step="style"]', }; for (const section of formSteps) { section.hidden = true; section.classList.remove("is-active"); } const active = document.querySelector(lookup[step]); if (active) { active.hidden = false; active.classList.add("is-active"); if (focus) { const target = active.querySelector("input, textarea, button, select"); if (target) target.focus({ preventScroll: true }); } } updateStepTracker(); } function resetState() { state.forestTitle = ""; state.clearingCount = 0; state.clearings = []; state.currentIndex = 0; state.intake = []; state.pendingQuestion = null; state.complete = false; state.resolvedStyle = ""; statusRegion.textContent = ""; statusRegion.classList.remove("is-error"); artStyleLabel.textContent = ""; soundscapeAudio.pause(); soundscapeAudio.removeAttribute("src"); soundscapeAudio.load(); soundscapePlayer.hidden = true; saveForest.hidden = true; walkOn.hidden = false; walkOn.disabled = false; walkOn.querySelector("span").textContent = "Walk on"; } function renderIntakeQuestion(question) { state.pendingQuestion = question; intakeProgress.textContent = `Question ${state.intake.length + 1} of ${INTAKE_TURNS}`; intakeQuestion.textContent = question.question; intakeOptions.replaceChildren(); for (const option of question.options) { const label = document.createElement("label"); label.className = "choice-card"; const sanitized = option.replace(/"/g, """); label.innerHTML = ` ${option} `; intakeOptions.append(label); } } async function fetchNextIntakeQuestion() { setStatus("The forest is listening… (first response can take ~60s while it wakes)"); let response; try { response = await fetch("/api/intake/next", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: state.name, situation: state.situation, history: state.intake, }), }); } catch (networkError) { throw new Error( `Could not reach the forest (network): ${networkError.message}`, ); } if (!response.ok) { let detail = ""; try { const body = await response.json(); detail = body && body.detail ? body.detail : JSON.stringify(body); } catch { detail = await response.text().catch(() => ""); } throw new Error( `The forest is too quiet (HTTP ${response.status}): ${detail.slice(0, 800)}`, ); } const question = await response.json(); setStatus("The forest has a question for you."); return question; } async function startIntake() { state.name = nameInput.value.trim(); state.situation = situationInput.value.trim(); state.intake = []; showStep("intake"); await advanceIntake(); } async function advanceIntake() { if (state.intake.length >= INTAKE_TURNS) { showStep("style"); setStatus(""); return; } try { const question = await fetchNextIntakeQuestion(); renderIntakeQuestion(question); } catch (error) { setStatus(error.message, true); } } function validateNameStep() { for (const input of [nameInput, situationInput]) { if (!input.checkValidity()) { input.reportValidity(); return false; } } return true; } function validateIntakeChoice() { const checked = intakeOptions.querySelector('input[name="intake-option"]:checked'); if (!checked) { setStatus("Choose one option before walking on.", true); return null; } setStatus(""); return checked.value; } for (const button of document.querySelectorAll(".step-next")) { button.addEventListener("click", async () => { if (state.currentStep === "name") { if (!validateNameStep()) return; await startIntake(); return; } if (state.currentStep === "intake") { const answer = validateIntakeChoice(); if (!answer || !state.pendingQuestion) return; state.intake.push({ question: state.pendingQuestion.question, answer }); state.pendingQuestion = null; updateStepTracker(); await advanceIntake(); return; } }); } for (const button of document.querySelectorAll(".step-back")) { button.addEventListener("click", async () => { if (state.currentStep === "intake") { if (state.intake.length === 0) { showStep("name"); return; } state.intake.pop(); state.pendingQuestion = null; updateStepTracker(); await advanceIntake(); return; } if (state.currentStep === "style") { // Drop the last answer so advanceIntake re-asks instead of bouncing back to style. if (state.intake.length > 0) state.intake.pop(); state.pendingQuestion = null; showStep("intake"); updateStepTracker(); await advanceIntake(); } }); } form.addEventListener("submit", async (submitEvent) => { submitEvent.preventDefault(); if (state.currentStep !== "style") return; state.style = styleInput.value; state.seed = randomSeed(); growButton.disabled = true; growButton.querySelector("span").textContent = "Growing your path"; setStatus("The forest is shaping the walk you talked about."); try { const response = await fetch("/api/forest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: state.name, situation: state.situation, style: state.style, seed: state.seed, intake: state.intake, }), }); await readForestStream(response); } catch (error) { setStatus( error.message || "The forest could not begin. Please try again.", true, ); growButton.disabled = false; growButton.querySelector("span").textContent = "Grow my forest"; } }); function buildDots() { progressDots.replaceChildren(); const count = state.clearingCount || state.clearings.length; for (let index = 0; index < count; index += 1) { const dot = document.createElement("i"); dot.className = "progress-dot"; if (index < state.currentIndex) dot.classList.add("is-complete"); if (index === state.currentIndex) dot.classList.add("is-current"); progressDots.append(dot); } } function updateActions() { const hasNext = state.currentIndex + 1 < state.clearings.length; const atKnownEnd = state.currentIndex === state.clearings.length - 1; const atFinalEnd = state.complete && atKnownEnd; walkOn.hidden = atFinalEnd; saveForest.hidden = !atFinalEnd; walkOn.disabled = !hasNext; walkOn.querySelector("span").textContent = hasNext ? "Walk on" : "Painting the next clearing"; } function renderNarration(text) { narration.replaceChildren(); const paragraphs = text.split(/\n{2,}/).map((piece) => piece.trim()).filter(Boolean); for (const paragraph of paragraphs) { const node = document.createElement("p"); node.textContent = paragraph; narration.append(node); } } function renderCurrent() { const item = state.clearings[state.currentIndex]; if (!item) return; entryView.hidden = true; experienceView.hidden = false; forestTitle.textContent = state.forestTitle; clearingProgress.textContent = `Clearing ${state.currentIndex + 1} of ${ state.clearingCount || state.clearings.length }`; if (item.image) { clearingImage.src = item.image; clearingImage.alt = item.clearing.scene_title; clearingImage.hidden = false; } else { clearingImage.removeAttribute("src"); clearingImage.alt = ""; clearingImage.hidden = true; } sceneTitle.textContent = item.clearing.scene_title; sceneIntro.textContent = item.clearing.scene_intro; renderNarration(item.clearing.narration); reflection.textContent = item.clearing.reflection; spell.textContent = item.clearing.spell; buildDots(); updateActions(); window.scrollTo({ top: 0, behavior: "smooth" }); } function handleEvent(event) { if (event.type === "status") { setStatus(event.message); return; } if (event.type === "support" || event.type === "error") { setStatus(event.message, true); growButton.disabled = false; growButton.querySelector("span").textContent = "Grow my forest"; return; } if (event.type === "forest") { state.forestTitle = event.data.forest_title; state.clearingCount = event.data.clearing_count || 0; state.resolvedStyle = event.data.style || ""; artStyleLabel.textContent = state.resolvedStyle ? `Painted in ${STYLE_LABELS[state.resolvedStyle] || state.resolvedStyle}` : ""; setStatus("The first clearing is almost in view."); return; } if (event.type === "clearing") { state.clearings.push(event.data); if (state.clearings.length === 1) { renderCurrent(); } else { updateActions(); } return; } if (event.type === "soundscape") { soundscapeAudio.src = event.data.audio; soundscapePlayer.hidden = false; return; } if (event.type === "complete") { state.complete = true; updateActions(); growButton.disabled = false; growButton.querySelector("span").textContent = "Grow my forest"; } } async function readForestStream(response) { if (!response.ok || !response.body) { throw new Error("The forest could not begin. Please try again."); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { value, done } = await reader.read(); buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (line.trim()) handleEvent(JSON.parse(line)); } if (done) break; } if (buffer.trim()) handleEvent(JSON.parse(buffer)); } walkOn.addEventListener("click", () => { if (state.currentIndex + 1 >= state.clearings.length) return; state.currentIndex += 1; renderCurrent(); }); copySpell.addEventListener("click", async () => { await navigator.clipboard.writeText(spell.textContent); const label = copySpell.querySelector("span"); label.textContent = "Spell copied"; window.setTimeout(() => { label.textContent = "Copy spell"; }, 1600); }); function splitOversizeWord(context, word, maxWidth) { if (context.measureText(word).width <= maxWidth) return [word]; const pieces = []; let piece = ""; for (const character of word) { const next = piece + character; if (piece && context.measureText(next).width > maxWidth) { pieces.push(piece); piece = character; } else { piece = next; } } if (piece) pieces.push(piece); return pieces; } function wrapTextLines(context, text, maxWidth) { const words = text .trim() .split(/\s+/) .flatMap((word) => splitOversizeWord(context, word, maxWidth)); const lines = []; let line = ""; for (const word of words) { const next = line ? `${line} ${word}` : word; if (context.measureText(next).width > maxWidth && line) { lines.push(line); line = word; } else { line = next; } } if (line) lines.push(line); return lines.length ? lines : [""]; } function drawWrappedText(context, lines, x, y, lineHeight) { for (const [index, line] of lines.entries()) { context.fillText(line, x, y + index * lineHeight); } return y + lines.length * lineHeight; } function measureTextBlock(context, text, font, maxWidth, lineHeight) { context.font = font; const lines = wrapTextLines(context, text, maxWidth); return { font, lines, lineHeight, height: lines.length * lineHeight }; } function measureForestCardLayout(context) { const textWidth = 800; const title = measureTextBlock( context, state.forestTitle, '58px "Iowan Old Style", Georgia, serif', 1180, 66, ); const headerHeight = 178 + Math.max(0, title.lines.length - 1) * title.lineHeight; let top = headerHeight + 28; const rows = state.clearings.map((item) => { const sceneBlock = measureTextBlock( context, item.clearing.scene_title.toUpperCase(), '600 17px "Avenir Next", Arial, sans-serif', textWidth, 24, ); const introBlock = measureTextBlock( context, item.clearing.scene_intro, 'italic 22px "Iowan Old Style", Georgia, serif', textWidth, 30, ); const narrationBlock = measureTextBlock( context, item.clearing.narration.replace(/\n+/g, " "), '28px "Iowan Old Style", Georgia, serif', textWidth, 38, ); const spellBlock = measureTextBlock( context, item.clearing.spell, '25px "Iowan Old Style", Georgia, serif', textWidth, 34, ); const textHeight = 24 + sceneBlock.height + 8 + introBlock.height + 18 + narrationBlock.height + 36 + spellBlock.height + 18; const height = Math.max(360, textHeight); const row = { item, top, height, sceneBlock, introBlock, narrationBlock, spellBlock, }; top += height + 38; return row; }); return { title, headerHeight, rows, height: top + 92, }; } function loadImage(source) { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = reject; image.src = source; }); } async function drawForestCard() { const width = 1400; const canvas = document.createElement("canvas"); canvas.width = width; let context = canvas.getContext("2d"); const layout = measureForestCardLayout(context); canvas.height = layout.height; context = canvas.getContext("2d"); const height = layout.height; context.fillStyle = "#f5f0df"; context.fillRect(0, 0, width, height); context.fillStyle = "rgba(83,104,61,0.06)"; for (let index = 0; index < 120; index += 1) { const x = (index * 197) % width; const y = (index * 311) % height; context.beginPath(); context.arc(x, y, 30 + (index % 7) * 9, 0, Math.PI * 2); context.fill(); } context.fillStyle = "#243c31"; context.textAlign = "center"; context.font = layout.title.font; drawWrappedText( context, layout.title.lines, width / 2, 92, layout.title.lineHeight, ); context.font = '22px "Avenir Next", Arial, sans-serif'; const subtitleY = 138 + Math.max(0, layout.title.lines.length - 1) * layout.title.lineHeight; context.fillText("A path from The Compliment Forest", width / 2, subtitleY); if (state.resolvedStyle) { context.font = '18px "Avenir Next", Arial, sans-serif'; context.fillStyle = "#3f5634"; context.fillText( `Painted in ${STYLE_LABELS[state.resolvedStyle] || state.resolvedStyle}`, width / 2, subtitleY + 32, ); } context.textAlign = "left"; for (const row of layout.rows) { const { item, top } = row; try { if (item.image) { const image = await loadImage(item.image); context.save(); context.beginPath(); context.roundRect(80, top, 350, 280, 20); context.clip(); context.drawImage(image, 80, top, 350, 280); context.restore(); } else { throw new Error("No generated image"); } } catch { context.fillStyle = "#d8ddc8"; context.fillRect(80, top, 350, 280); } context.fillStyle = "#3f5634"; context.font = row.sceneBlock.font; let textY = drawWrappedText( context, row.sceneBlock.lines, 490, top + 24, row.sceneBlock.lineHeight, ); textY += 8; context.fillStyle = "#4c574f"; context.font = row.introBlock.font; textY = drawWrappedText( context, row.introBlock.lines, 490, textY, row.introBlock.lineHeight, ); textY += 18; context.fillStyle = "#243c31"; context.font = row.narrationBlock.font; textY = drawWrappedText( context, row.narrationBlock.lines, 490, textY, row.narrationBlock.lineHeight, ); context.strokeStyle = "rgba(83,104,61,0.45)"; context.lineWidth = 2; context.beginPath(); context.moveTo(490, textY + 18); context.lineTo(1290, textY + 18); context.stroke(); context.font = row.spellBlock.font; context.fillStyle = "#3f5634"; drawWrappedText( context, row.spellBlock.lines, 490, textY + 62, row.spellBlock.lineHeight, ); } context.textAlign = "center"; context.fillStyle = "#4c574f"; context.font = '18px "Avenir Next", Arial, sans-serif'; context.fillText( "Whimsical encouragement, not a substitute for professional support.", width / 2, height - 60, ); return canvas; } saveForest.addEventListener("click", async () => { saveForest.disabled = true; saveForest.querySelector("span").textContent = "Pressing leaves into paper"; const canvas = await drawForestCard(); canvas.toBlob((blob) => { const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "my-compliment-forest.png"; link.click(); URL.revokeObjectURL(link.href); saveForest.disabled = false; saveForest.querySelector("span").textContent = "Save your forest"; }, "image/png"); }); function showEntry() { resetState(); form.reset(); showStep("name", false); experienceView.hidden = true; entryView.hidden = false; growButton.disabled = false; growButton.querySelector("span").textContent = "Grow my forest"; window.scrollTo({ top: 0, behavior: "smooth" }); nameInput.focus(); } startOver.addEventListener("click", showEntry); brandHome.addEventListener("click", showEntry); showStep("name", false);