thangvip's picture
docs(codex): publish Build Small submission package (#4)
b535bf5
Raw
History Blame Contribute Delete
21.2 kB
"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, "&quot;");
label.innerHTML = `
<input type="radio" name="intake-option" value="${sanitized}" />
<span>${option}</span>
`;
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);