JacobLinCool Codex commited on
Commit
dd8c015
·
verified ·
1 Parent(s): 8baaa6e

feat: adopt almanac frontend

Browse files

Co-authored-by: Codex <noreply@openai.com>

README.md CHANGED
@@ -22,9 +22,8 @@ tags:
22
  # Hackathon Advisor
23
 
24
  **Hackathon Advisor** is a text-first project advisor for the Build Small Hackathon. The user-facing experience is
25
- **The Unwritten Almanac**: Mothback, an archivist of unwritten project pages, compares your idea against real Spaces in
26
- the `build-small-hackathon` organization, finds under-explored territory, scores the idea, and drafts a practical build
27
- plan.
28
 
29
  The current milestone is a deployable, deterministic vertical slice:
30
 
 
22
  # Hackathon Advisor
23
 
24
  **Hackathon Advisor** is a text-first project advisor for the Build Small Hackathon. The user-facing experience is
25
+ **The Unwritten Almanac**: a journal-style workspace that compares your idea against real Spaces in the
26
+ `build-small-hackathon` organization, finds under-explored territory, scores the idea, and drafts a practical build plan.
 
27
 
28
  The current milestone is a deployable, deterministic vertical slice:
29
 
hackathon_advisor/agent.py CHANGED
@@ -344,7 +344,7 @@ class AdvisorEngine:
344
  profile[field] = str(call.arguments["value"])
345
  state["profile"] = profile
346
  tool_events.append(ToolEvent("update_profile", f"Remembered {field}."))
347
- response = f"Mothback adds a margin note: {field} = {profile[field]}."
348
  return self._result(normalized, corrections, response, state, tool_events, [], [], None, [], {})
349
 
350
  def _target_turn(
@@ -463,9 +463,9 @@ class AdvisorEngine:
463
  def _opening_response(self, projects: list[Project]) -> str:
464
  names = ", ".join(project.title for project in projects[:4])
465
  return (
466
- "Mothback opens the Almanac. The Wood is already inked with "
467
  f"{len(self.index.projects)} project pages; the brightest current echoes include {names}. "
468
- "Give me one project instinct and I will test whether it bleeds red or blooms gold."
469
  )
470
 
471
  def _overlap_response(self, idea: Idea, projects: list[Project], score: ScoreCard) -> str:
@@ -506,7 +506,7 @@ class AdvisorEngine:
506
  def _plan_response(self, idea: Idea, score: ScoreCard, plan: list[str]) -> str:
507
  steps = " ".join(f"{idx + 1}. {step}" for idx, step in enumerate(plan))
508
  return (
509
- f"Mothback presses the wax for {idea.title}: {score.overall}/10, {score.verdict}. "
510
  f"The build path is: {steps}"
511
  )
512
 
@@ -530,7 +530,7 @@ class AdvisorEngine:
530
  "title": idea.title,
531
  "verdict": score.verdict,
532
  "overall": score.overall,
533
- "caption": f"Mothback inked my Build Small fate page: {idea.title} - {score.verdict}.",
534
  "seal": score.to_dict(),
535
  "wood_map": build_wood_map(self.index, idea, score),
536
  }
 
344
  profile[field] = str(call.arguments["value"])
345
  state["profile"] = profile
346
  tool_events.append(ToolEvent("update_profile", f"Remembered {field}."))
347
+ response = f"Profile updated: {field} = {profile[field]}."
348
  return self._result(normalized, corrections, response, state, tool_events, [], [], None, [], {})
349
 
350
  def _target_turn(
 
463
  def _opening_response(self, projects: list[Project]) -> str:
464
  names = ", ".join(project.title for project in projects[:4])
465
  return (
466
+ "The current map is open with "
467
  f"{len(self.index.projects)} project pages; the brightest current echoes include {names}. "
468
+ "Describe one project idea and I will test where it overlaps, where it is quiet, and what to build next."
469
  )
470
 
471
  def _overlap_response(self, idea: Idea, projects: list[Project], score: ScoreCard) -> str:
 
506
  def _plan_response(self, idea: Idea, score: ScoreCard, plan: list[str]) -> str:
507
  steps = " ".join(f"{idx + 1}. {step}" for idx, step in enumerate(plan))
508
  return (
509
+ f"The wax seal for {idea.title} reads {score.overall}/10, {score.verdict}. "
510
  f"The build path is: {steps}"
511
  )
512
 
 
530
  "title": idea.title,
531
  "verdict": score.verdict,
532
  "overall": score.overall,
533
+ "caption": f"Idea page: {idea.title} - {score.verdict}.",
534
  "seal": score.to_dict(),
535
  "wood_map": build_wood_map(self.index, idea, score),
536
  }
hackathon_advisor/model_runtime.py CHANGED
@@ -4,6 +4,7 @@ from dataclasses import dataclass
4
  import os
5
  from typing import Any, Protocol
6
 
 
7
  from hackathon_advisor.tool_contracts import ToolResolution, resolve_tool_call, tool_schemas
8
 
9
 
@@ -53,7 +54,12 @@ class RuleBasedPlanner:
53
  elif any(term in lower for term in ("search", "similar", "already", "existing", "overlap", "echo")):
54
  output = f'<function name="search_projects">{{"query":{_json_string(text)}}}</function>'
55
  else:
56
- output = f'<function name="save_idea">{{"title":{_json_string(_title(text))},"pitch":{_json_string(text)}}}</function>'
 
 
 
 
 
57
  return resolve_tool_call(output, fallback_query=text)
58
 
59
 
 
4
  import os
5
  from typing import Any, Protocol
6
 
7
+ from hackathon_advisor.tools import idea_from_text
8
  from hackathon_advisor.tool_contracts import ToolResolution, resolve_tool_call, tool_schemas
9
 
10
 
 
54
  elif any(term in lower for term in ("search", "similar", "already", "existing", "overlap", "echo")):
55
  output = f'<function name="search_projects">{{"query":{_json_string(text)}}}</function>'
56
  else:
57
+ title, pitch = idea_from_text(text)
58
+ output = (
59
+ f'<function name="save_idea">'
60
+ f'{{"title":{_json_string(title)},"pitch":{_json_string(pitch)}}}'
61
+ f"</function>"
62
+ )
63
  return resolve_tool_call(output, fallback_query=text)
64
 
65
 
hackathon_advisor/tools.py CHANGED
@@ -175,10 +175,16 @@ def idea_from_text(text: str) -> tuple[str, str]:
175
  if cleaned.lower().startswith(prefix):
176
  title = cleaned[len(prefix) :].strip(" :-")
177
  break
178
- title = title[:64].strip(" .") or "Unwritten Page"
179
- if len(title) < len(cleaned):
 
 
 
 
 
 
180
  title = f"{title[:58].strip()}..."
181
- return _display_title(title), cleaned
182
 
183
 
184
  def _is_new_idea(current: Idea, title: str, pitch: str) -> bool:
 
175
  if cleaned.lower().startswith(prefix):
176
  title = cleaned[len(prefix) :].strip(" :-")
177
  break
178
+ pitch = cleaned
179
+ explicit_pitch = False
180
+ if " -- " in title:
181
+ title, pitch = (part.strip() for part in title.split(" -- ", 1))
182
+ explicit_pitch = True
183
+ raw_title = title
184
+ title = raw_title[:64].strip(" .") or "Unwritten Page"
185
+ if len(raw_title) > 64 or (not explicit_pitch and len(title) < len(cleaned)):
186
  title = f"{title[:58].strip()}..."
187
+ return _display_title(title), pitch
188
 
189
 
190
  def _is_new_idea(current: Idea, title: str, pitch: str) -> bool:
static/app.js CHANGED
@@ -16,6 +16,13 @@ const planEl = document.querySelector("#plan");
16
  const provenanceEl = document.querySelector("#provenance");
17
  const verdictEl = document.querySelector("#verdict");
18
  const overallEl = document.querySelector("#overall");
 
 
 
 
 
 
 
19
  const demoButton = document.querySelector("#load-demo");
20
  const exportButton = document.querySelector("#export-artifact");
21
  const exportNotesButton = document.querySelector("#export-notes");
@@ -49,6 +56,16 @@ form.addEventListener("submit", async (event) => {
49
  await runTurn(message);
50
  });
51
 
 
 
 
 
 
 
 
 
 
 
52
  document.querySelectorAll("[data-command]").forEach((button) => {
53
  button.addEventListener("click", async () => {
54
  await runTurn(button.dataset.command);
@@ -81,6 +98,7 @@ targetsEl.addEventListener("change", (event) => {
81
  session.targets = targetOptions.filter((option) => checked.has(option));
82
  syncCurrentIdeaTargets();
83
  saveSession();
 
84
  renderIdeas(session.ideas || []);
85
  });
86
 
@@ -104,8 +122,16 @@ ideasEl.addEventListener("click", (event) => {
104
  selectIdea(card.dataset.ideaId || "");
105
  });
106
 
 
 
 
 
 
 
 
107
  async function runTurn(message) {
108
  bumpSessionRevision();
 
109
  input.value = "";
110
  submit.disabled = true;
111
  setCommandDisabled(true);
@@ -177,8 +203,7 @@ function handleBootstrapError(error) {
177
  corrections.textContent = "Reload the page to try again.";
178
  provenanceEl.textContent = "index unavailable";
179
  renderScore(null);
180
- verdictEl.textContent = "UNWRITTEN";
181
- overallEl.textContent = "0.0";
182
  renderWoodMap(null);
183
  renderTargets([]);
184
  renderProfile({});
@@ -195,6 +220,44 @@ function defaultSession(data = bootstrapData) {
195
  };
196
  }
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  function bumpSessionRevision() {
199
  sessionRevision += 1;
200
  return sessionRevision;
@@ -205,9 +268,26 @@ function isCurrentSessionRevision(revision) {
205
  }
206
 
207
  function restoreExportButtonLabels() {
208
- exportNotesButton.textContent = "Notes";
209
- exportChapterButton.textContent = "Chapter";
210
- exportButton.textContent = PNG_EXPORT_LABEL;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  }
212
 
213
  function setSessionControlsDisabled(disabled) {
@@ -221,6 +301,9 @@ function setSessionControlsDisabled(disabled) {
221
  ideasEl.querySelectorAll("button[data-idea-id]").forEach((idea) => {
222
  idea.disabled = disabled;
223
  });
 
 
 
224
  }
225
 
226
  function resetSession() {
@@ -240,8 +323,7 @@ function resetSession() {
240
  renderTargets(session.targets);
241
  renderProfile(session.profile);
242
  renderScore(null);
243
- verdictEl.textContent = "UNWRITTEN";
244
- overallEl.textContent = "0.0";
245
  renderWoodMap(null);
246
  renderIdeas([]);
247
  renderPlan([]);
@@ -257,6 +339,7 @@ function resetSession() {
257
 
258
  async function loadDemoSession() {
259
  bumpSessionRevision();
 
260
  submit.disabled = true;
261
  setCommandDisabled(true);
262
  setSessionControlsDisabled(true);
@@ -290,8 +373,7 @@ function applyDemoSession(data) {
290
  ink.textContent = data.response || "Demo rehearsal loaded.";
291
  ink.classList.remove("thinking");
292
  if (data.score) {
293
- verdictEl.textContent = data.score.verdict;
294
- overallEl.textContent = Number(data.score.overall).toFixed(1);
295
  renderScore(data.score);
296
  ink.classList.toggle("bleed", data.score.verdict.startsWith("ECHO"));
297
  ink.classList.toggle("gold", data.score.verdict.startsWith("UNWRITTEN"));
@@ -329,8 +411,7 @@ function renderRestoredSession(data) {
329
  if (score) {
330
  renderScore(score);
331
  const verdict = currentArtifact?.verdict || score.verdict || "UNWRITTEN";
332
- verdictEl.textContent = verdict;
333
- overallEl.textContent = Number(currentArtifact?.overall || score.overall || 0).toFixed(1);
334
  ink.classList.toggle("bleed", verdict.startsWith("ECHO"));
335
  ink.classList.toggle("gold", verdict.startsWith("UNWRITTEN"));
336
  renderWoodMap(currentArtifact?.wood_map || null);
@@ -342,6 +423,7 @@ function renderRestoredSession(data) {
342
  exportButton.disabled = !currentArtifact;
343
  } else {
344
  renderScore(null);
 
345
  renderWoodMap(null);
346
  renderProjects(data.top_projects || []);
347
  exportButton.disabled = true;
@@ -424,6 +506,7 @@ function clearSavedSession() {
424
 
425
  function renderTargets(selectedTargets) {
426
  const selected = new Set(selectedTargets || []);
 
427
  targetsEl.innerHTML = "";
428
  if (!targetOptions.length) {
429
  targetsEl.innerHTML = `<div class="empty">No goals loaded.</div>`;
@@ -432,7 +515,7 @@ function renderTargets(selectedTargets) {
432
  for (const option of targetOptions) {
433
  const profile = targetProfileById.get(option) || { label: option, description: "" };
434
  const label = document.createElement("label");
435
- label.className = "target-toggle";
436
  label.innerHTML = `
437
  <input
438
  type="checkbox"
@@ -441,6 +524,9 @@ function renderTargets(selectedTargets) {
441
  ${sessionControlsLocked ? "disabled" : ""}
442
  ${selected.has(option) ? "checked" : ""}
443
  />
 
 
 
444
  <span class="target-copy">
445
  <strong>${escapeHtml(profile.label)}</strong>
446
  ${profile.description ? `<small>${escapeHtml(profile.description)}</small>` : ""}
@@ -464,6 +550,7 @@ function renderProfile(profile) {
464
  <input
465
  data-profile-field="${escapeAttribute(field)}"
466
  value="${escapeAttribute(profile?.[field] || "")}"
 
467
  autocomplete="off"
468
  ${sessionControlsLocked ? "disabled" : ""}
469
  />
@@ -510,8 +597,7 @@ function handleEvent(event) {
510
  renderIdeas(session.ideas || []);
511
  renderPlan(event.plan || []);
512
  if (event.score) {
513
- verdictEl.textContent = event.score.verdict;
514
- overallEl.textContent = Number(event.score.overall).toFixed(1);
515
  renderScore(event.score);
516
  ink.classList.toggle("bleed", event.score.verdict.startsWith("ECHO"));
517
  ink.classList.toggle("gold", event.score.verdict.startsWith("UNWRITTEN"));
@@ -528,25 +614,31 @@ function handleEvent(event) {
528
  }
529
 
530
  function renderIdeas(ideas) {
 
531
  ideasEl.innerHTML = "";
532
  if (!ideas.length) {
533
- ideasEl.innerHTML = `<div class="empty">No pages written.</div>`;
534
  return;
535
  }
536
  for (const idea of visibleIdeas(ideas)) {
537
  const score = idea.score?.overall ? Number(idea.score.overall).toFixed(1) : "0.0";
538
  const targets = (idea.targets || []).slice(0, 3).map(targetDisplayName).join(" · ");
539
  const selected = idea.id === session.current_idea_id;
 
 
540
  const item = document.createElement("button");
541
  item.type = "button";
542
- item.className = `idea ${selected ? "current" : ""}`;
543
  item.disabled = sessionControlsLocked;
544
  item.dataset.ideaId = idea.id || "";
545
  item.setAttribute("aria-pressed", selected ? "true" : "false");
546
  item.innerHTML = `
547
- <strong>${escapeHtml(idea.title)}</strong>
 
 
 
548
  <p>${escapeHtml((idea.pitch || "").slice(0, 120))}</p>
549
- <span>${escapeHtml(idea.score?.verdict || "DRAFT")} · ${score}</span>
550
  ${targets ? `<small>${escapeHtml(targets)}</small>` : ""}
551
  `;
552
  ideasEl.append(item);
@@ -576,8 +668,7 @@ function selectIdea(ideaId) {
576
  }
577
  const score = idea.score || null;
578
  if (score) {
579
- verdictEl.textContent = score.verdict || "DRAFT";
580
- overallEl.textContent = Number(score.overall || 0).toFixed(1);
581
  renderScore(score);
582
  ink.classList.toggle("bleed", String(score.verdict || "").startsWith("ECHO"));
583
  ink.classList.toggle("gold", String(score.verdict || "").startsWith("UNWRITTEN"));
@@ -605,19 +696,19 @@ function targetDisplayName(target) {
605
 
606
  function renderScore(score) {
607
  const rows = [
608
- ["Originality", score?.originality || 0],
609
  ["Delight", score?.delight || 0],
610
  ["AI Need", score?.ai_necessity || 0],
611
  ["Feasible", score?.feasibility || 0],
612
- ["Prize Fit", score?.prize_fit || 0],
613
  ];
614
  scoreEl.innerHTML = rows
615
  .map(
616
  ([label, value]) => `
617
- <div class="score-row">
618
- <span>${label}</span>
619
- <meter min="0" max="10" value="${value}"></meter>
620
- <strong>${value}</strong>
621
  </div>
622
  `,
623
  )
@@ -627,14 +718,14 @@ function renderScore(score) {
627
  function renderWoodMap(map) {
628
  woodMapEl.innerHTML = "";
629
  if (!map?.dots?.length) {
630
- woodMapEl.innerHTML = `<div class="empty">No page has been placed yet.</div>`;
631
  return;
632
  }
633
  const field = document.createElement("div");
634
- field.className = "wood-map-field";
635
  for (const dot of map.dots) {
636
  const marker = document.createElement(dot.url ? "a" : "span");
637
- const verdictClass = dot.kind === "idea" && String(dot.verdict || "").startsWith("ECHO") ? "echo-idea" : "";
638
  marker.className = `wood-dot ${dot.kind || "inked"} ${verdictClass}`.trim();
639
  marker.style.left = `${boundedPercent(dot.x)}%`;
640
  marker.style.top = `${boundedPercent(dot.y)}%`;
@@ -649,10 +740,17 @@ function renderWoodMap(map) {
649
  }
650
  field.append(marker);
651
  }
 
 
 
 
 
 
 
652
  const caption = document.createElement("p");
653
- caption.className = "wood-map-caption";
654
  caption.textContent = map.caption || "Your page is plotted against the current Wood.";
655
- woodMapEl.append(field, caption);
656
  }
657
 
658
  function renderProjects(projects) {
@@ -663,7 +761,7 @@ function renderProjects(projects) {
663
  }
664
  for (const project of projects.slice(0, 5)) {
665
  const item = document.createElement("a");
666
- item.className = "project";
667
  item.href = project.url;
668
  item.target = "_blank";
669
  item.rel = "noreferrer";
@@ -684,7 +782,7 @@ function renderCitations(echoes) {
684
  for (const echo of echoes.slice(0, 5)) {
685
  const project = echo.project || {};
686
  const item = document.createElement("a");
687
- item.className = "project citation";
688
  item.href = project.url || project.host || "#";
689
  item.target = "_blank";
690
  item.rel = "noreferrer";
@@ -693,7 +791,7 @@ function renderCitations(echoes) {
693
  item.innerHTML = `
694
  <strong>Page ${escapeHtml(echo.page_number || "?")} · ${escapeHtml(project.title || project.id || "Untitled")}</strong>
695
  <p>${escapeHtml(project.summary || project.id || "")}</p>
696
- <span>${Number(echo.score || 0).toFixed(3)} · ${escapeHtml(matched)}</span>
697
  `;
698
  projectsEl.append(item);
699
  }
@@ -706,11 +804,15 @@ function renderWhitespace(items) {
706
  return;
707
  }
708
  for (const item of items.slice(0, 4)) {
709
- const gap = document.createElement("div");
710
- gap.className = "gap";
 
 
 
711
  gap.innerHTML = `
712
  <strong>${escapeHtml(item.label)}</strong>
713
  <p>${escapeHtml(item.pitch)}</p>
 
714
  `;
715
  whitespaceEl.append(gap);
716
  }
@@ -804,9 +906,9 @@ async function exportChapter() {
804
  async function exportMarkdown({ endpoint, filename, button, busyLabel, pendingLabel, successLabel }) {
805
  if (!button || button.disabled) return;
806
  const revision = sessionRevision;
807
- const idleLabel = button.textContent;
808
  button.disabled = true;
809
- button.textContent = busyLabel;
810
  session.ui_status = pendingLabel;
811
  corrections.textContent = session.ui_status;
812
  saveSession();
@@ -827,7 +929,7 @@ async function exportMarkdown({ endpoint, filename, button, busyLabel, pendingLa
827
  session.ui_status = `Export failed: ${error.message}`;
828
  corrections.textContent = session.ui_status;
829
  } finally {
830
- button.textContent = idleLabel;
831
  if (!isCurrentSessionRevision(revision)) return;
832
  saveSession();
833
  setCommandDisabled(false);
@@ -835,9 +937,9 @@ async function exportMarkdown({ endpoint, filename, button, busyLabel, pendingLa
835
  }
836
 
837
  function exportArtifact(artifact) {
838
- const idleLabel = exportButton.textContent;
839
  exportButton.disabled = true;
840
- exportButton.textContent = "PNG...";
841
  session.ui_status = "Drawing PNG.";
842
  corrections.textContent = session.ui_status;
843
  saveSession();
@@ -857,7 +959,7 @@ function exportArtifact(artifact) {
857
  corrections.textContent = session.ui_status;
858
  } finally {
859
  saveSession();
860
- exportButton.textContent = idleLabel || PNG_EXPORT_LABEL;
861
  setCommandDisabled(false);
862
  }
863
  }
@@ -898,7 +1000,7 @@ function renderArtifactCanvas(artifact) {
898
  ["Delight", seal.delight || 0],
899
  ["AI Need", seal.ai_necessity || 0],
900
  ["Feasible", seal.feasibility || 0],
901
- ["Prize Fit", seal.prize_fit || 0],
902
  ];
903
  rows.forEach(([label, value], index) => {
904
  const y = 418 + index * 34;
@@ -1048,6 +1150,16 @@ function fieldLabel(value) {
1048
  .replace(/^\w/, (char) => char.toUpperCase());
1049
  }
1050
 
 
 
 
 
 
 
 
 
 
 
1051
  function boundedPercent(value) {
1052
  return Math.max(4, Math.min(96, Number(value || 50)));
1053
  }
 
16
  const provenanceEl = document.querySelector("#provenance");
17
  const verdictEl = document.querySelector("#verdict");
18
  const overallEl = document.querySelector("#overall");
19
+ const sealEl = document.querySelector("#seal");
20
+ const sealVerdictEl = document.querySelector("#seal-verdict");
21
+ const sealCopyEl = document.querySelector("#seal-copy");
22
+ const verdictStampEl = document.querySelector("#verdict-stamp");
23
+ const spreadEl = document.querySelector("#spread");
24
+ const ideaCountEl = document.querySelector("#idea-count");
25
+ const targetCountEl = document.querySelector("#target-count");
26
  const demoButton = document.querySelector("#load-demo");
27
  const exportButton = document.querySelector("#export-artifact");
28
  const exportNotesButton = document.querySelector("#export-notes");
 
56
  await runTurn(message);
57
  });
58
 
59
+ input.addEventListener("keydown", (event) => {
60
+ if (event.key !== "Enter" || event.shiftKey) return;
61
+ event.preventDefault();
62
+ form.requestSubmit();
63
+ });
64
+
65
+ document.querySelectorAll(".mobile-nav [data-tab]").forEach((button) => {
66
+ button.addEventListener("click", () => setActiveTab(button.dataset.tab || "page"));
67
+ });
68
+
69
  document.querySelectorAll("[data-command]").forEach((button) => {
70
  button.addEventListener("click", async () => {
71
  await runTurn(button.dataset.command);
 
98
  session.targets = targetOptions.filter((option) => checked.has(option));
99
  syncCurrentIdeaTargets();
100
  saveSession();
101
+ renderTargets(session.targets);
102
  renderIdeas(session.ideas || []);
103
  });
104
 
 
122
  selectIdea(card.dataset.ideaId || "");
123
  });
124
 
125
+ whitespaceEl.addEventListener("click", async (event) => {
126
+ const card = event.target.closest("[data-gap-prompt]");
127
+ if (!(card instanceof HTMLButtonElement) || !whitespaceEl.contains(card)) return;
128
+ if (card.disabled) return;
129
+ await runTurn(card.dataset.gapPrompt || "");
130
+ });
131
+
132
  async function runTurn(message) {
133
  bumpSessionRevision();
134
+ setActiveTab("page");
135
  input.value = "";
136
  submit.disabled = true;
137
  setCommandDisabled(true);
 
203
  corrections.textContent = "Reload the page to try again.";
204
  provenanceEl.textContent = "index unavailable";
205
  renderScore(null);
206
+ setVerdictDisplay("INDEX CLOSED", 0, null);
 
207
  renderWoodMap(null);
208
  renderTargets([]);
209
  renderProfile({});
 
220
  };
221
  }
222
 
223
+ function setActiveTab(tab) {
224
+ if (!spreadEl) return;
225
+ const next = ["page", "proof", "almanac"].includes(tab) ? tab : "page";
226
+ spreadEl.dataset.tab = next;
227
+ document.querySelectorAll(".mobile-nav [data-tab]").forEach((button) => {
228
+ button.classList.toggle("active", button.dataset.tab === next);
229
+ });
230
+ }
231
+
232
+ function setVerdictDisplay(verdict = "READY", overall = 0, score = null) {
233
+ const text = String(verdict || "READY");
234
+ const isEcho = text.startsWith("ECHO");
235
+ const isUnwritten = text.startsWith("UNWRITTEN");
236
+ const numericOverall = Number(overall || score?.overall || 0);
237
+
238
+ verdictEl.textContent = text;
239
+ overallEl.textContent = numericOverall.toFixed(1);
240
+ sealEl.classList.toggle("echo", isEcho);
241
+ sealEl.classList.toggle("unwritten", isUnwritten);
242
+
243
+ sealVerdictEl.textContent = text;
244
+ sealVerdictEl.classList.toggle("echo", isEcho);
245
+ sealVerdictEl.classList.toggle("unwritten", isUnwritten);
246
+ sealVerdictEl.classList.toggle("ready", !isEcho && !isUnwritten);
247
+
248
+ verdictStampEl.classList.toggle("verdict-echo", isEcho);
249
+ verdictStampEl.classList.toggle("verdict-unwritten", isUnwritten);
250
+ verdictStampEl.classList.toggle("verdict-ready", !isEcho && !isUnwritten);
251
+
252
+ if (!score) {
253
+ sealCopyEl.textContent = text === "INDEX CLOSED" ? "The project map did not load." : "No idea has been scored yet.";
254
+ } else if (isEcho) {
255
+ sealCopyEl.textContent = "Nearby projects already cover parts of this idea.";
256
+ } else {
257
+ sealCopyEl.textContent = "This idea sits in a quieter part of the current map.";
258
+ }
259
+ }
260
+
261
  function bumpSessionRevision() {
262
  sessionRevision += 1;
263
  return sessionRevision;
 
268
  }
269
 
270
  function restoreExportButtonLabels() {
271
+ setActionButtonLabel(exportNotesButton, "Notes");
272
+ setActionButtonLabel(exportChapterButton, "Chapter");
273
+ setActionButtonLabel(exportButton, PNG_EXPORT_LABEL);
274
+ }
275
+
276
+ function actionButtonLabel(button) {
277
+ return button?.dataset.actionLabel || button?.textContent.trim() || "";
278
+ }
279
+
280
+ function setActionButtonLabel(button, label) {
281
+ if (!button) return;
282
+ button.dataset.actionLabel = label;
283
+ const textNode = Array.from(button.childNodes).find(
284
+ (node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim(),
285
+ );
286
+ if (textNode) {
287
+ textNode.textContent = ` ${label}`;
288
+ } else {
289
+ button.append(document.createTextNode(` ${label}`));
290
+ }
291
  }
292
 
293
  function setSessionControlsDisabled(disabled) {
 
301
  ideasEl.querySelectorAll("button[data-idea-id]").forEach((idea) => {
302
  idea.disabled = disabled;
303
  });
304
+ whitespaceEl.querySelectorAll("button[data-gap-prompt]").forEach((gap) => {
305
+ gap.disabled = disabled;
306
+ });
307
  }
308
 
309
  function resetSession() {
 
323
  renderTargets(session.targets);
324
  renderProfile(session.profile);
325
  renderScore(null);
326
+ setVerdictDisplay("READY", 0, null);
 
327
  renderWoodMap(null);
328
  renderIdeas([]);
329
  renderPlan([]);
 
339
 
340
  async function loadDemoSession() {
341
  bumpSessionRevision();
342
+ setActiveTab("page");
343
  submit.disabled = true;
344
  setCommandDisabled(true);
345
  setSessionControlsDisabled(true);
 
373
  ink.textContent = data.response || "Demo rehearsal loaded.";
374
  ink.classList.remove("thinking");
375
  if (data.score) {
376
+ setVerdictDisplay(data.score.verdict, data.score.overall, data.score);
 
377
  renderScore(data.score);
378
  ink.classList.toggle("bleed", data.score.verdict.startsWith("ECHO"));
379
  ink.classList.toggle("gold", data.score.verdict.startsWith("UNWRITTEN"));
 
411
  if (score) {
412
  renderScore(score);
413
  const verdict = currentArtifact?.verdict || score.verdict || "UNWRITTEN";
414
+ setVerdictDisplay(verdict, currentArtifact?.overall || score.overall || 0, score);
 
415
  ink.classList.toggle("bleed", verdict.startsWith("ECHO"));
416
  ink.classList.toggle("gold", verdict.startsWith("UNWRITTEN"));
417
  renderWoodMap(currentArtifact?.wood_map || null);
 
423
  exportButton.disabled = !currentArtifact;
424
  } else {
425
  renderScore(null);
426
+ setVerdictDisplay("READY", 0, null);
427
  renderWoodMap(null);
428
  renderProjects(data.top_projects || []);
429
  exportButton.disabled = true;
 
506
 
507
  function renderTargets(selectedTargets) {
508
  const selected = new Set(selectedTargets || []);
509
+ if (targetCountEl) targetCountEl.textContent = selected.size;
510
  targetsEl.innerHTML = "";
511
  if (!targetOptions.length) {
512
  targetsEl.innerHTML = `<div class="empty">No goals loaded.</div>`;
 
515
  for (const option of targetOptions) {
516
  const profile = targetProfileById.get(option) || { label: option, description: "" };
517
  const label = document.createElement("label");
518
+ label.className = `target-toggle goal ${selected.has(option) ? "on" : ""}`;
519
  label.innerHTML = `
520
  <input
521
  type="checkbox"
 
524
  ${sessionControlsLocked ? "disabled" : ""}
525
  ${selected.has(option) ? "checked" : ""}
526
  />
527
+ <span class="check" aria-hidden="true">
528
+ <svg class="icon"><use href="#icon-check"></use></svg>
529
+ </span>
530
  <span class="target-copy">
531
  <strong>${escapeHtml(profile.label)}</strong>
532
  ${profile.description ? `<small>${escapeHtml(profile.description)}</small>` : ""}
 
550
  <input
551
  data-profile-field="${escapeAttribute(field)}"
552
  value="${escapeAttribute(profile?.[field] || "")}"
553
+ placeholder="${escapeAttribute(fieldPlaceholder(field))}"
554
  autocomplete="off"
555
  ${sessionControlsLocked ? "disabled" : ""}
556
  />
 
597
  renderIdeas(session.ideas || []);
598
  renderPlan(event.plan || []);
599
  if (event.score) {
600
+ setVerdictDisplay(event.score.verdict, event.score.overall, event.score);
 
601
  renderScore(event.score);
602
  ink.classList.toggle("bleed", event.score.verdict.startsWith("ECHO"));
603
  ink.classList.toggle("gold", event.score.verdict.startsWith("UNWRITTEN"));
 
614
  }
615
 
616
  function renderIdeas(ideas) {
617
+ if (ideaCountEl) ideaCountEl.textContent = ideas.length;
618
  ideasEl.innerHTML = "";
619
  if (!ideas.length) {
620
+ ideasEl.innerHTML = `<div class="empty">Your idea board is empty. Write an idea or open a gap.</div>`;
621
  return;
622
  }
623
  for (const idea of visibleIdeas(ideas)) {
624
  const score = idea.score?.overall ? Number(idea.score.overall).toFixed(1) : "0.0";
625
  const targets = (idea.targets || []).slice(0, 3).map(targetDisplayName).join(" · ");
626
  const selected = idea.id === session.current_idea_id;
627
+ const verdict = idea.score?.verdict || "DRAFT";
628
+ const isEcho = String(verdict).startsWith("ECHO");
629
  const item = document.createElement("button");
630
  item.type = "button";
631
+ item.className = `idea idea-card ${selected ? "current" : ""} ${isEcho ? "bleed" : ""}`;
632
  item.disabled = sessionControlsLocked;
633
  item.dataset.ideaId = idea.id || "";
634
  item.setAttribute("aria-pressed", selected ? "true" : "false");
635
  item.innerHTML = `
636
+ <div class="ihead">
637
+ <strong>${escapeHtml(idea.title)}</strong>
638
+ <span class="iscore">${score}</span>
639
+ </div>
640
  <p>${escapeHtml((idea.pitch || "").slice(0, 120))}</p>
641
+ <span class="iverdict ${isEcho ? "echo" : "unwritten"}">${escapeHtml(verdict)}</span>
642
  ${targets ? `<small>${escapeHtml(targets)}</small>` : ""}
643
  `;
644
  ideasEl.append(item);
 
668
  }
669
  const score = idea.score || null;
670
  if (score) {
671
+ setVerdictDisplay(score.verdict || "DRAFT", score.overall || 0, score);
 
672
  renderScore(score);
673
  ink.classList.toggle("bleed", String(score.verdict || "").startsWith("ECHO"));
674
  ink.classList.toggle("gold", String(score.verdict || "").startsWith("UNWRITTEN"));
 
696
 
697
  function renderScore(score) {
698
  const rows = [
699
+ ["Original", score?.originality || 0],
700
  ["Delight", score?.delight || 0],
701
  ["AI Need", score?.ai_necessity || 0],
702
  ["Feasible", score?.feasibility || 0],
703
+ ["Goal Fit", score?.prize_fit || 0],
704
  ];
705
  scoreEl.innerHTML = rows
706
  .map(
707
  ([label, value]) => `
708
+ <div class="quad">
709
+ <span class="ql">${label}</span>
710
+ <span class="qbar"><span class="qfill" style="width: ${Number(value) * 10}%"></span></span>
711
+ <span class="qv">${value}</span>
712
  </div>
713
  `,
714
  )
 
718
  function renderWoodMap(map) {
719
  woodMapEl.innerHTML = "";
720
  if (!map?.dots?.length) {
721
+ woodMapEl.innerHTML = `<div class="wood"><div class="empty wood-empty">No idea has been placed yet.</div></div>`;
722
  return;
723
  }
724
  const field = document.createElement("div");
725
+ field.className = "wood";
726
  for (const dot of map.dots) {
727
  const marker = document.createElement(dot.url ? "a" : "span");
728
+ const verdictClass = dot.kind === "idea" && String(dot.verdict || "").startsWith("ECHO") ? "bleed" : "";
729
  marker.className = `wood-dot ${dot.kind || "inked"} ${verdictClass}`.trim();
730
  marker.style.left = `${boundedPercent(dot.x)}%`;
731
  marker.style.top = `${boundedPercent(dot.y)}%`;
 
740
  }
741
  field.append(marker);
742
  }
743
+ const legend = document.createElement("div");
744
+ legend.className = "wood-legend";
745
+ legend.innerHTML = `
746
+ <span><i style="background: var(--leaf)"></i> You</span>
747
+ <span><i style="background: var(--oxblood)"></i> Echo</span>
748
+ <span><i style="background: rgba(73, 49, 22, 0.34)"></i> Indexed</span>
749
+ `;
750
  const caption = document.createElement("p");
751
+ caption.className = "wood-cap";
752
  caption.textContent = map.caption || "Your page is plotted against the current Wood.";
753
+ woodMapEl.append(field, legend, caption);
754
  }
755
 
756
  function renderProjects(projects) {
 
761
  }
762
  for (const project of projects.slice(0, 5)) {
763
  const item = document.createElement("a");
764
+ item.className = "project echo-item";
765
  item.href = project.url;
766
  item.target = "_blank";
767
  item.rel = "noreferrer";
 
782
  for (const echo of echoes.slice(0, 5)) {
783
  const project = echo.project || {};
784
  const item = document.createElement("a");
785
+ item.className = "project echo-item citation";
786
  item.href = project.url || project.host || "#";
787
  item.target = "_blank";
788
  item.rel = "noreferrer";
 
791
  item.innerHTML = `
792
  <strong>Page ${escapeHtml(echo.page_number || "?")} · ${escapeHtml(project.title || project.id || "Untitled")}</strong>
793
  <p>${escapeHtml(project.summary || project.id || "")}</p>
794
+ <span class="matched">${Number(echo.score || 0).toFixed(3)} · ${escapeHtml(matched)}</span>
795
  `;
796
  projectsEl.append(item);
797
  }
 
804
  return;
805
  }
806
  for (const item of items.slice(0, 4)) {
807
+ const gap = document.createElement("button");
808
+ gap.type = "button";
809
+ gap.className = "gap gap-item";
810
+ gap.disabled = sessionControlsLocked;
811
+ gap.dataset.gapPrompt = `idea: ${item.label} -- ${item.pitch}`;
812
  gap.innerHTML = `
813
  <strong>${escapeHtml(item.label)}</strong>
814
  <p>${escapeHtml(item.pitch)}</p>
815
+ <span class="use">Use this direction</span>
816
  `;
817
  whitespaceEl.append(gap);
818
  }
 
906
  async function exportMarkdown({ endpoint, filename, button, busyLabel, pendingLabel, successLabel }) {
907
  if (!button || button.disabled) return;
908
  const revision = sessionRevision;
909
+ const idleLabel = actionButtonLabel(button);
910
  button.disabled = true;
911
+ setActionButtonLabel(button, busyLabel);
912
  session.ui_status = pendingLabel;
913
  corrections.textContent = session.ui_status;
914
  saveSession();
 
929
  session.ui_status = `Export failed: ${error.message}`;
930
  corrections.textContent = session.ui_status;
931
  } finally {
932
+ setActionButtonLabel(button, idleLabel);
933
  if (!isCurrentSessionRevision(revision)) return;
934
  saveSession();
935
  setCommandDisabled(false);
 
937
  }
938
 
939
  function exportArtifact(artifact) {
940
+ const idleLabel = actionButtonLabel(exportButton);
941
  exportButton.disabled = true;
942
+ setActionButtonLabel(exportButton, "PNG...");
943
  session.ui_status = "Drawing PNG.";
944
  corrections.textContent = session.ui_status;
945
  saveSession();
 
959
  corrections.textContent = session.ui_status;
960
  } finally {
961
  saveSession();
962
+ setActionButtonLabel(exportButton, idleLabel || PNG_EXPORT_LABEL);
963
  setCommandDisabled(false);
964
  }
965
  }
 
1000
  ["Delight", seal.delight || 0],
1001
  ["AI Need", seal.ai_necessity || 0],
1002
  ["Feasible", seal.feasibility || 0],
1003
+ ["Goal Fit", seal.prize_fit || 0],
1004
  ];
1005
  rows.forEach(([label, value], index) => {
1006
  const y = 418 + index * 34;
 
1150
  .replace(/^\w/, (char) => char.toUpperCase());
1151
  }
1152
 
1153
+ function fieldPlaceholder(value) {
1154
+ const placeholders = {
1155
+ skills: "frontend, notebooks, prompt design",
1156
+ time: "one evening, weekend, 3 hours",
1157
+ preferences: "visual, playful, practical",
1158
+ constraints: "CPU-only, no paid APIs, solo build",
1159
+ };
1160
+ return placeholders[value] || "";
1161
+ }
1162
+
1163
  function boundedPercent(value) {
1164
  return Math.max(4, Math.min(96, Number(value || 50)));
1165
  }
static/index.html CHANGED
@@ -7,81 +7,227 @@
7
  <link rel="stylesheet" href="/static/styles.css" />
8
  </head>
9
  <body>
10
- <main class="shell">
11
- <section class="stage" aria-label="The Unwritten Almanac">
12
- <div class="canopy"></div>
13
- <div class="book">
14
- <section class="page page-left">
15
- <div class="kicker">The Unwritten Almanac</div>
16
- <h1>Mothback</h1>
17
- <p id="ink" class="ink">
18
- The book is open. The next page waits for its first line.
19
- </p>
20
- <form id="turn-form" class="prompt-row">
21
- <input
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  id="message"
23
  name="message"
 
 
24
  autocomplete="off"
25
- placeholder="A local-first agent for..."
26
- />
27
- <button id="submit" type="submit" title="Ink the page">Ink</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  </form>
29
- <div class="command-row" aria-label="Advisor commands">
30
- <button type="button" id="load-demo" title="Load a sample idea">Example</button>
31
- <button type="button" data-command="write bolder and find whitespace" title="Find a gold margin">
32
- Gap
33
- </button>
34
- <button type="button" data-command="make a build plan" title="Draft a build plan">Plan</button>
35
- <button type="button" data-command="compare ideas" title="Compare the idea board">Rank</button>
36
- <button type="button" id="export-notes" title="Export build notes" disabled>Notes</button>
37
- <button type="button" id="export-chapter" title="Export the Almanac chapter" disabled>Chapter</button>
38
- <button type="button" id="export-artifact" title="Export the current fate page" disabled>PNG</button>
39
- <button type="button" id="reset-session" title="Clear the saved session">Reset</button>
40
- </div>
41
- <div id="corrections" class="corrections" aria-live="polite"></div>
42
- </section>
43
 
44
- <section class="page page-right">
45
- <div class="seal" id="seal">
46
- <span id="verdict">UNWRITTEN</span>
47
- <strong id="overall">0.0</strong>
48
- </div>
49
- <div id="provenance" class="provenance"></div>
50
- <div id="score" class="score-grid" aria-label="Seal quadrants"></div>
51
- <div class="panels">
52
- <article class="wide-panel">
53
- <h2>You vs the Wood</h2>
54
- <div id="wood-map" class="wood-map"></div>
55
- </article>
56
- <article>
57
- <h2>Goals</h2>
58
- <div id="targets" class="target-list"></div>
59
- </article>
60
- <article>
61
- <h2>Profile</h2>
62
- <div id="profile" class="profile-grid"></div>
63
- </article>
64
- <article>
65
- <h2>Idea Board</h2>
66
- <div id="ideas" class="idea-list"></div>
67
- </article>
68
- <article>
69
- <h2>Echoes</h2>
70
- <div id="projects" class="project-list"></div>
71
- </article>
72
- <article>
73
- <h2>Gold Margins</h2>
74
- <div id="whitespace" class="whitespace-list"></div>
75
- </article>
76
- <article>
77
- <h2>Build Plan</h2>
78
- <ol id="plan" class="plan-list"></ol>
79
- </article>
80
- </div>
81
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  </div>
83
  </section>
84
  </main>
 
85
  <script type="module" src="/static/app.js"></script>
86
  </body>
87
  </html>
 
7
  <link rel="stylesheet" href="/static/styles.css" />
8
  </head>
9
  <body>
10
+ <svg class="icon-sprite" aria-hidden="true">
11
+ <symbol id="icon-quill" viewBox="0 0 24 24">
12
+ <path d="M20 4c-6 1-11 5-13 11l-2 5 5-2c6-2 10-7 11-13z" />
13
+ <path d="M9 15c2-3 4-5 7-7" />
14
+ </symbol>
15
+ <symbol id="icon-gap" viewBox="0 0 24 24">
16
+ <path d="M12 3v4M12 17v4M3 12h4M17 12h4" />
17
+ <circle cx="12" cy="12" r="3.2" />
18
+ </symbol>
19
+ <symbol id="icon-plan" viewBox="0 0 24 24">
20
+ <path d="M5 4h11l3 3v13H5z" />
21
+ <path d="M9 9h6M9 13h6M9 17h3" />
22
+ </symbol>
23
+ <symbol id="icon-rank" viewBox="0 0 24 24">
24
+ <path d="M6 20V10M12 20V4M18 20v-7" />
25
+ </symbol>
26
+ <symbol id="icon-example" viewBox="0 0 24 24">
27
+ <path d="M12 3l2.2 5.6L20 9l-4 4 1 6-5-3-5 3 1-6-4-4 5.8-.4z" />
28
+ </symbol>
29
+ <symbol id="icon-download" viewBox="0 0 24 24">
30
+ <path d="M12 4v11M7 11l5 5 5-5M5 20h14" />
31
+ </symbol>
32
+ <symbol id="icon-reset" viewBox="0 0 24 24">
33
+ <path d="M4 5v5h5" />
34
+ <path d="M5.5 14a7 7 0 1 0 1.2-6.7L4 10" />
35
+ </symbol>
36
+ <symbol id="icon-check" viewBox="0 0 24 24">
37
+ <path d="M5 12l4 4 10-11" />
38
+ </symbol>
39
+ </svg>
40
+
41
+ <div class="desk-glow"></div>
42
+ <main class="almanac">
43
+ <section class="sheet" aria-label="The Unwritten Almanac">
44
+ <header class="masthead">
45
+ <div class="masthead-l">
46
+ <svg class="crest" viewBox="0 0 64 64" aria-hidden="true">
47
+ <g
48
+ fill="none"
49
+ stroke="currentColor"
50
+ stroke-width="1.5"
51
+ stroke-linecap="round"
52
+ stroke-linejoin="round"
53
+ >
54
+ <path d="M32 30c-6-12-18-16-26-10 2 9 9 17 20 18" />
55
+ <path d="M32 30c6-12 18-16 26-10-2 9-9 17-20 18" />
56
+ <path d="M32 32c-5 9-14 12-21 9 2-6 7-11 15-12" opacity=".6" />
57
+ <path d="M32 32c5 9 14 12 21 9-2-6-7-11-15-12" opacity=".6" />
58
+ <path
59
+ d="M32 20c-2.4 0-4 2-4 6s1.6 14 4 18c2.4-4 4-14 4-18s-1.6-6-4-6z"
60
+ fill="currentColor"
61
+ fill-opacity=".08"
62
+ />
63
+ <circle cx="28.5" cy="24.5" r="2.3" fill="currentColor" fill-opacity=".15" />
64
+ <circle cx="35.5" cy="24.5" r="2.3" fill="currentColor" fill-opacity=".15" />
65
+ <circle cx="28.5" cy="24.5" r=".7" fill="currentColor" />
66
+ <circle cx="35.5" cy="24.5" r=".7" fill="currentColor" />
67
+ <path d="M30 19c-1-2-2-3.4-3.6-4M34 19c1-2 2-3.4 3.6-4" />
68
+ </g>
69
+ </svg>
70
+ <div>
71
+ <h1>The Unwritten Almanac</h1>
72
+ <div class="sub">Originality map and build plan for your next idea</div>
73
+ </div>
74
+ </div>
75
+ <div id="provenance" class="provenance" aria-live="polite">Opening the current map.</div>
76
+ </header>
77
+
78
+ <nav class="mobile-nav" aria-label="Sections">
79
+ <button type="button" class="active" data-tab="page">Page</button>
80
+ <button type="button" data-tab="proof">Proof</button>
81
+ <button type="button" data-tab="almanac">Board</button>
82
+ </nav>
83
+
84
+ <div id="spread" class="spread" data-tab="page">
85
+ <aside class="col col-margin" aria-label="Idea board and setup">
86
+ <section class="section">
87
+ <div class="eyebrow">Idea board <span id="idea-count" class="count">0</span></div>
88
+ <div id="ideas" class="idea-list"></div>
89
+ </section>
90
+
91
+ <section class="section">
92
+ <div class="eyebrow">Builder profile</div>
93
+ <div id="profile" class="profile-grid"></div>
94
+ </section>
95
+
96
+ <section class="section">
97
+ <div class="eyebrow">Goals <span id="target-count" class="count">0</span></div>
98
+ <div id="targets" class="target-list"></div>
99
+ </section>
100
+ </aside>
101
+
102
+ <section class="col col-page" aria-label="Current idea">
103
+ <form id="turn-form" class="composer">
104
+ <label class="sr-only" for="message">Describe your idea</label>
105
+ <textarea
106
  id="message"
107
  name="message"
108
+ class="prompt"
109
+ rows="2"
110
  autocomplete="off"
111
+ placeholder="A local-first agent that helps..."
112
+ ></textarea>
113
+ <div class="underline"></div>
114
+
115
+ <div class="toolbar command-row" aria-label="Advisor commands">
116
+ <button id="submit" class="btn btn-ink" type="submit" title="Score this idea">
117
+ <svg class="icon"><use href="#icon-quill"></use></svg>
118
+ Ink
119
+ </button>
120
+ <button
121
+ type="button"
122
+ class="btn"
123
+ data-command="write bolder and find whitespace"
124
+ title="Find an under-explored direction"
125
+ >
126
+ <svg class="icon"><use href="#icon-gap"></use></svg>
127
+ Gap
128
+ </button>
129
+ <button
130
+ type="button"
131
+ class="btn"
132
+ data-command="make a build plan"
133
+ title="Draft a build plan for the current idea"
134
+ >
135
+ <svg class="icon"><use href="#icon-plan"></use></svg>
136
+ Plan
137
+ </button>
138
+ <button
139
+ type="button"
140
+ class="btn"
141
+ data-command="compare ideas"
142
+ title="Rank the saved idea board"
143
+ >
144
+ <svg class="icon"><use href="#icon-rank"></use></svg>
145
+ Compare
146
+ </button>
147
+ <span class="spacer"></span>
148
+ <button type="button" id="load-demo" class="btn btn-ghost" title="Load a sample session">
149
+ <svg class="icon"><use href="#icon-example"></use></svg>
150
+ Example
151
+ </button>
152
+ <button type="button" id="export-notes" class="btn btn-ghost" title="Export build notes" disabled>
153
+ <svg class="icon"><use href="#icon-download"></use></svg>
154
+ Notes
155
+ </button>
156
+ <button
157
+ type="button"
158
+ id="export-chapter"
159
+ class="btn btn-ghost"
160
+ title="Export the idea-board chapter"
161
+ disabled
162
+ >
163
+ <svg class="icon"><use href="#icon-download"></use></svg>
164
+ Chapter
165
+ </button>
166
+ <button type="button" id="export-artifact" class="btn btn-ghost" title="Export the current page" disabled>
167
+ <svg class="icon"><use href="#icon-download"></use></svg>
168
+ PNG
169
+ </button>
170
+ <button type="button" id="reset-session" class="btn btn-ghost btn-icon" title="Reset the session">
171
+ <svg class="icon"><use href="#icon-reset"></use></svg>
172
+ <span class="sr-only">Reset</span>
173
+ </button>
174
+ </div>
175
  </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
+ <div id="corrections" class="marginalia" aria-live="polite"></div>
178
+
179
+ <article class="fate">
180
+ <span id="verdict-stamp" class="verdict-stamp verdict-ready">
181
+ <span class="seal-dot"></span>
182
+ <span id="verdict">READY</span>
183
+ </span>
184
+ <p id="ink" class="prophecy">
185
+ The book is open. Describe a project idea, compare it against the current map, then turn the result into
186
+ a build plan.
187
+ </p>
188
+ </article>
189
+
190
+ <section class="section plan">
191
+ <div class="eyebrow">Build path</div>
192
+ <ol id="plan" class="plan-list"></ol>
193
+ </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  </section>
195
+
196
+ <aside class="col col-proof" aria-label="Score and evidence">
197
+ <section class="section">
198
+ <div class="eyebrow">Score</div>
199
+ <div class="seal-wrap">
200
+ <div id="seal" class="seal">
201
+ <span id="overall" class="seal-overall">0.0</span>
202
+ <span class="seal-of">out of ten</span>
203
+ </div>
204
+ <div class="seal-meta">
205
+ <div id="seal-verdict" class="v ready">READY</div>
206
+ <div id="seal-copy" class="t">No idea has been scored yet.</div>
207
+ </div>
208
+ </div>
209
+ <div id="score" class="quadrants" aria-label="Score breakdown"></div>
210
+ </section>
211
+
212
+ <section class="section">
213
+ <div class="eyebrow">You vs the Wood</div>
214
+ <div id="wood-map" class="wood-map"></div>
215
+ </section>
216
+
217
+ <section class="section">
218
+ <div class="eyebrow">Closest echoes</div>
219
+ <div id="projects" class="project-list"></div>
220
+ </section>
221
+
222
+ <section class="section">
223
+ <div class="eyebrow">Gold margins</div>
224
+ <div id="whitespace" class="whitespace-list"></div>
225
+ </section>
226
+ </aside>
227
  </div>
228
  </section>
229
  </main>
230
+
231
  <script type="module" src="/static/app.js"></script>
232
  </body>
233
  </html>
static/styles.css CHANGED
@@ -1,13 +1,25 @@
1
  :root {
2
  color-scheme: dark;
3
- --ink: #24160e;
4
- --muted-ink: #6b4e35;
5
- --paper: #d9bd83;
6
- --paper-deep: #b48a4b;
7
- --gold: #e6bd3f;
8
- --red: #8d2d26;
9
- --leaf: #2f7a49;
10
- --night: #121612;
 
 
 
 
 
 
 
 
 
 
 
 
11
  }
12
 
13
  * {
@@ -16,127 +28,448 @@
16
 
17
  html,
18
  body {
19
- margin: 0;
20
  min-height: 100%;
21
- background: radial-gradient(circle at 50% 16%, #33442c 0, #182117 42%, #080b08 100%);
22
- color: #f5ead2;
23
- font-family:
24
- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
 
 
 
 
 
 
 
25
  }
26
 
27
  button,
28
- input {
 
29
  font: inherit;
 
30
  }
31
 
32
- .shell {
33
- min-height: 100vh;
34
- display: grid;
35
- place-items: center;
36
- padding: clamp(16px, 3vw, 42px);
37
  }
38
 
39
- .stage {
40
- position: relative;
41
- width: min(1180px, 100%);
42
- min-height: min(760px, calc(100vh - 40px));
43
- display: grid;
44
- place-items: center;
45
  }
46
 
47
- .canopy {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  position: absolute;
 
 
 
 
 
 
 
 
 
 
 
 
49
  inset: 0;
 
50
  background:
51
- linear-gradient(90deg, rgba(0, 0, 0, 0.5), transparent 24%, transparent 76%, rgba(0, 0, 0, 0.55)),
52
- radial-gradient(circle at 50% 0, rgba(231, 191, 71, 0.18), transparent 42%);
53
- border-radius: 8px;
54
  }
55
 
56
- .book {
57
  position: relative;
58
- display: grid;
59
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
60
- width: min(1100px, 100%);
61
- min-height: 680px;
62
- background:
63
- linear-gradient(90deg, rgba(71, 42, 23, 0.55), transparent 49%, rgba(62, 34, 20, 0.7) 50%, transparent 51%),
64
- url("/static/assets/parchment.png");
65
- background-size: cover;
 
 
 
66
  color: var(--ink);
67
- border: 1px solid rgba(255, 232, 169, 0.28);
68
- border-radius: 8px;
 
 
 
69
  box-shadow:
70
- 0 30px 80px rgba(0, 0, 0, 0.62),
71
- inset 0 0 80px rgba(93, 48, 16, 0.38);
72
- overflow: hidden;
73
  }
74
 
75
- .page {
76
- min-width: 0;
77
- padding: clamp(22px, 4vw, 56px);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  }
79
 
80
- .page-left {
 
 
81
  display: flex;
82
- flex-direction: column;
 
 
 
 
83
  }
84
 
85
- .kicker {
86
- color: var(--muted-ink);
87
- font-size: 0.78rem;
88
- font-weight: 800;
89
- letter-spacing: 0.08em;
90
- text-transform: uppercase;
91
  }
92
 
93
- h1 {
94
- margin: 10px 0 22px;
95
- font-family: Georgia, "Times New Roman", serif;
96
- font-size: clamp(3rem, 8vw, 6.4rem);
97
- line-height: 0.9;
 
 
 
 
 
 
 
98
  font-weight: 700;
99
  letter-spacing: 0;
100
  }
101
 
102
- h2 {
103
- margin: 0 0 10px;
104
- color: var(--muted-ink);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  font-size: 0.78rem;
106
- line-height: 1.2;
107
- font-weight: 900;
108
- letter-spacing: 0.08em;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  text-transform: uppercase;
110
  }
111
 
112
- .ink {
113
- min-height: 214px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  margin: 0;
115
- color: #2a170d;
116
- font-family: Georgia, "Times New Roman", serif;
117
- font-size: clamp(1.1rem, 1.9vw, 1.55rem);
118
- line-height: 1.48;
 
 
119
  }
120
 
121
- .ink.bleed {
122
- color: var(--red);
 
 
 
 
 
 
 
 
 
123
  }
124
 
125
- .ink.gold {
126
- color: #6a4b00;
127
- text-shadow: 0 0 18px rgba(230, 189, 63, 0.42);
128
  }
129
 
130
- .ink.thinking {
131
- color: var(--muted-ink);
 
 
 
 
 
 
 
 
 
132
  font-style: italic;
133
- animation: ink-pulse 1.6s ease-in-out infinite;
134
  }
135
 
136
- @keyframes ink-pulse {
137
  0%,
138
  100% {
139
- opacity: 0.62;
140
  }
141
 
142
  50% {
@@ -144,438 +477,799 @@ h2 {
144
  }
145
  }
146
 
147
- .prompt-row {
148
  display: grid;
149
- grid-template-columns: minmax(0, 1fr) 84px;
150
- gap: 10px;
151
- margin-top: auto;
 
 
 
152
  }
153
 
154
- .prompt-row input {
155
- min-width: 0;
156
- height: 48px;
157
- border: 1px solid rgba(80, 47, 22, 0.38);
158
- border-radius: 8px;
159
- padding: 0 14px;
160
- background: rgba(255, 243, 203, 0.55);
161
  color: var(--ink);
162
- outline: none;
163
- }
164
-
165
- .prompt-row input:focus {
166
- border-color: rgba(141, 45, 38, 0.72);
167
- box-shadow: 0 0 0 3px rgba(141, 45, 38, 0.15);
168
- }
169
-
170
- .prompt-row button {
171
- height: 48px;
172
- border: 0;
173
- border-radius: 8px;
174
- background: #51311d;
175
- color: #fff3cc;
176
- font-weight: 800;
177
- cursor: pointer;
178
  }
179
 
180
- button:disabled {
181
- opacity: 0.58;
182
- cursor: wait;
183
  }
184
 
185
- .command-row {
 
 
 
 
186
  display: grid;
187
- grid-template-columns: repeat(4, minmax(0, 1fr));
188
- gap: 8px;
189
- margin-top: 10px;
 
 
 
 
 
 
 
 
190
  }
191
 
192
- .command-row button {
193
- min-width: 0;
194
- height: 38px;
195
- border: 1px solid rgba(80, 47, 22, 0.34);
196
- border-radius: 8px;
197
- background: rgba(255, 243, 203, 0.46);
198
- color: #2a170d;
199
- font-size: 0.86rem;
200
- font-weight: 850;
201
- cursor: pointer;
202
  }
203
 
204
- .command-row button:hover:not(:disabled) {
205
- background: rgba(255, 243, 203, 0.68);
206
  }
207
 
208
- .corrections {
209
- min-height: 30px;
210
- padding-top: 10px;
211
- color: var(--muted-ink);
212
- font-size: 0.88rem;
213
  }
214
 
215
  .seal {
216
- width: min(220px, 52vw);
217
- aspect-ratio: 1;
218
- margin: 0 auto 28px;
219
  display: grid;
 
 
 
220
  place-items: center;
221
  align-content: center;
222
- gap: 3px;
 
 
 
 
223
  border-radius: 50%;
 
 
 
 
 
 
 
 
224
  background:
225
- radial-gradient(circle at 36% 30%, rgba(255, 226, 134, 0.72), transparent 26%),
226
- radial-gradient(circle, #9b2f27 0 58%, #6e211f 59% 100%);
227
- color: #ffe9a0;
228
  box-shadow:
229
- 0 14px 30px rgba(91, 22, 16, 0.35),
230
- inset 0 0 24px rgba(53, 11, 7, 0.45);
231
- transform: rotate(-4deg);
232
  }
233
 
234
- .seal span {
235
- max-width: 150px;
236
- text-align: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  font-size: 0.82rem;
238
- font-weight: 900;
 
239
  letter-spacing: 0.08em;
 
240
  }
241
 
242
- .seal strong {
243
- font-family: Georgia, "Times New Roman", serif;
244
- font-size: 3.4rem;
245
- line-height: 1;
246
  }
247
 
248
- .provenance {
249
- margin: -10px 0 18px;
250
- color: var(--muted-ink);
251
- text-align: center;
252
- font-size: 0.72rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  font-weight: 800;
254
- line-height: 1.3;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  }
256
 
257
- .score-grid {
258
- display: grid;
259
- gap: 7px;
260
- margin: 0 0 22px;
261
  }
262
 
263
- .score-row {
264
- display: grid;
265
- grid-template-columns: 88px minmax(0, 1fr) 28px;
266
- align-items: center;
267
- gap: 9px;
268
- color: var(--muted-ink);
269
- font-size: 0.8rem;
270
- font-weight: 800;
271
  }
272
 
273
- .score-row meter {
274
- width: 100%;
275
- height: 9px;
 
 
 
276
  }
277
 
278
- .score-row meter::-webkit-meter-bar {
279
- border: 0;
280
- border-radius: 999px;
281
- background: rgba(80, 47, 22, 0.2);
 
 
 
 
 
282
  }
283
 
284
- .score-row meter::-webkit-meter-optimum-value {
285
- border-radius: 999px;
286
- background: linear-gradient(90deg, var(--red), var(--gold), var(--leaf));
 
 
 
 
 
 
287
  }
288
 
289
- .panels {
290
- display: grid;
291
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
292
- gap: 16px;
 
 
 
 
 
 
 
 
 
 
 
 
293
  }
294
 
295
- .wide-panel {
296
- grid-column: 1 / -1;
 
 
297
  }
298
 
299
  .project-list,
300
  .whitespace-list,
301
  .idea-list,
302
  .target-list,
303
- .profile-grid,
304
- .wood-map {
305
  display: grid;
306
  gap: 9px;
307
  }
308
 
309
  .project,
 
310
  .gap,
311
- .idea,
312
- .target-toggle,
313
- .profile-field {
314
- border-left: 3px solid rgba(80, 47, 22, 0.48);
315
- padding: 8px 10px;
316
- background: rgba(255, 241, 196, 0.34);
317
- border-radius: 0 8px 8px 0;
318
- }
319
-
320
- .idea {
321
  width: 100%;
322
- border-top: 0;
323
- border-right: 0;
324
- border-bottom: 0;
325
- appearance: none;
 
 
326
  text-align: left;
327
- cursor: pointer;
 
328
  }
329
 
330
- .idea:hover,
331
- .idea:focus-visible,
332
- .idea.current {
333
- border-left-color: var(--leaf);
334
- background: rgba(255, 241, 196, 0.56);
335
- }
336
-
337
- .idea:disabled,
338
- .target-toggle:has(input:disabled),
339
- .profile-field:has(input:disabled) {
340
- opacity: 0.64;
341
- cursor: wait;
342
  }
343
 
344
- .idea:disabled {
345
- cursor: wait;
 
346
  }
347
 
348
- .idea:focus-visible {
349
- outline: 2px solid rgba(47, 122, 73, 0.5);
350
- outline-offset: 2px;
 
 
351
  }
352
 
353
  .project strong,
 
354
  .gap strong,
355
- .idea strong {
356
  display: block;
357
- color: #2a170d;
 
358
  font-size: 0.98rem;
359
- line-height: 1.25;
 
360
  }
361
 
362
  .project p,
 
363
  .gap p,
364
- .idea p {
365
  margin: 4px 0 0;
366
- color: var(--muted-ink);
367
- font-size: 0.86rem;
368
- line-height: 1.35;
 
369
  }
370
 
371
- .project span {
 
372
  display: inline-block;
373
  margin-top: 6px;
374
- color: #70401e;
375
- font-size: 0.74rem;
376
- line-height: 1.25;
377
- font-weight: 900;
 
 
378
  }
379
 
380
- .idea span {
381
- display: inline-block;
382
  margin-top: 6px;
383
- color: #70401e;
384
- font-size: 0.76rem;
385
- font-weight: 900;
386
- letter-spacing: 0.04em;
 
 
 
 
387
  }
388
 
389
- .idea small {
 
 
 
 
 
390
  display: block;
391
- margin-top: 4px;
392
- color: #5f6d38;
393
- font-size: 0.72rem;
394
- line-height: 1.25;
395
- font-weight: 900;
 
 
 
 
 
 
396
  }
397
 
398
- .target-list {
399
- grid-template-columns: 1fr;
400
- gap: 7px;
 
401
  }
402
 
403
- .target-toggle {
404
- min-width: 0;
405
- min-height: 50px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  display: flex;
407
- align-items: flex-start;
 
408
  gap: 8px;
409
- color: #2a170d;
410
- font-size: 0.76rem;
411
- line-height: 1.2;
412
- font-weight: 900;
413
- cursor: pointer;
414
  }
415
 
416
- .target-toggle input {
 
 
 
 
 
 
 
 
417
  flex: 0 0 auto;
418
- width: 16px;
419
- height: 16px;
420
- margin-top: 2px;
421
- accent-color: var(--leaf);
 
422
  }
423
 
424
- .target-copy {
425
- min-width: 0;
426
- display: grid;
427
- gap: 3px;
 
 
428
  }
429
 
430
- .target-copy strong {
431
- color: #2a170d;
432
- font-size: 0.8rem;
433
- line-height: 1.2;
 
 
 
 
 
434
  }
435
 
436
- .target-copy small {
437
- color: var(--muted-ink);
438
- font-size: 0.72rem;
439
- line-height: 1.25;
 
 
 
 
 
 
 
 
 
 
440
  font-weight: 800;
 
441
  }
442
 
443
  .profile-field {
444
- min-width: 0;
445
  display: grid;
446
- grid-template-columns: 78px minmax(0, 1fr);
447
- align-items: center;
448
- gap: 8px;
449
  }
450
 
451
  .profile-field span {
452
- color: var(--muted-ink);
453
- font-size: 0.76rem;
454
- line-height: 1.2;
455
- font-weight: 900;
 
 
456
  }
457
 
458
  .profile-field input {
459
- min-width: 0;
460
- height: 32px;
461
- border: 1px solid rgba(80, 47, 22, 0.32);
462
- border-radius: 8px;
463
- padding: 0 9px;
464
- background: rgba(255, 243, 203, 0.48);
465
  color: var(--ink);
466
- outline: none;
 
 
 
 
 
 
467
  }
468
 
469
  .profile-field input:focus {
470
- border-color: rgba(47, 122, 73, 0.72);
471
- box-shadow: 0 0 0 3px rgba(47, 122, 73, 0.13);
 
472
  }
473
 
474
- .wood-map-field {
475
  position: relative;
476
- min-height: 138px;
477
- border: 1px solid rgba(80, 47, 22, 0.3);
478
- border-radius: 8px;
479
- background:
480
- linear-gradient(rgba(80, 47, 22, 0.12) 1px, transparent 1px),
481
- linear-gradient(90deg, rgba(80, 47, 22, 0.12) 1px, transparent 1px),
482
- rgba(255, 241, 196, 0.28);
483
- background-size: 28px 28px;
484
- overflow: hidden;
485
  }
486
 
487
- .wood-dot {
488
- position: absolute;
489
- display: block;
490
- border-radius: 50%;
491
- transform: translate(-50%, -50%);
492
  }
493
 
494
- .wood-dot.inked {
495
- background: rgba(80, 47, 22, 0.38);
 
496
  }
497
 
498
- .wood-dot.echo {
499
- background: var(--red);
500
- box-shadow: 0 0 0 2px rgba(255, 240, 181, 0.48);
 
501
  }
502
 
503
- .wood-dot.idea {
504
- background: var(--leaf);
505
- box-shadow:
506
- 0 0 0 3px #fff0b5,
507
- 0 0 18px rgba(47, 122, 73, 0.42);
 
 
 
 
 
 
508
  }
509
 
510
- .wood-dot.idea.echo-idea {
511
- background: var(--red);
 
 
512
  }
513
 
514
- .wood-map-caption {
515
- margin: 0;
516
- color: var(--muted-ink);
517
- font-size: 0.84rem;
518
- line-height: 1.35;
519
- font-weight: 800;
520
  }
521
 
522
- .plan-list {
523
  display: grid;
524
- gap: 7px;
525
- min-height: 31px;
526
- margin: 0;
527
- padding-left: 20px;
528
  }
529
 
530
- .plan-list li {
531
- color: #2a170d;
532
- font-size: 0.86rem;
533
- line-height: 1.32;
 
 
 
 
 
 
 
 
 
 
534
  }
535
 
536
  .empty {
537
- color: var(--muted-ink);
538
  font-size: 0.95rem;
 
 
539
  }
540
 
541
- @media (max-width: 820px) {
542
- .shell {
543
- display: block;
544
- padding: 0;
 
 
 
545
  }
546
 
547
- .stage {
548
- min-height: 100vh;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
  }
550
 
551
- .book {
552
  min-height: 100vh;
553
- grid-template-columns: 1fr;
 
554
  border-radius: 0;
555
- overflow: visible;
556
  }
557
 
558
- .page {
559
- padding: 24px;
560
  }
561
 
562
- .ink {
563
- min-height: 168px;
 
564
  }
565
 
566
- .panels {
567
- grid-template-columns: 1fr;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  }
569
 
570
- .target-list {
571
  grid-template-columns: 1fr;
572
  }
573
 
574
- .command-row {
575
- grid-template-columns: repeat(3, minmax(0, 1fr));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
576
  }
 
577
 
578
- .score-row {
579
- grid-template-columns: 82px minmax(0, 1fr) 26px;
 
 
 
 
580
  }
581
  }
 
1
  :root {
2
  color-scheme: dark;
3
+ --paper: #efe4c9;
4
+ --paper-2: #e7d7b7;
5
+ --paper-3: #f7eed9;
6
+ --edge: #d3bd92;
7
+ --ink: #271a0e;
8
+ --ink-soft: #5d4528;
9
+ --ink-faint: #8a714c;
10
+ --rule: rgba(73, 49, 22, 0.22);
11
+ --rule-soft: rgba(73, 49, 22, 0.12);
12
+ --oxblood: #9a2b22;
13
+ --oxblood-2: #74201b;
14
+ --gold: #b07d12;
15
+ --gold-2: #d8a226;
16
+ --gold-glow: rgba(216, 162, 38, 0.38);
17
+ --leaf: #2f6b41;
18
+ --leaf-2: #3f8453;
19
+ --night: #14130d;
20
+ --night-2: #202012;
21
+ --serif: Georgia, "Times New Roman", serif;
22
+ --label: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
23
  }
24
 
25
  * {
 
28
 
29
  html,
30
  body {
 
31
  min-height: 100%;
32
+ margin: 0;
33
+ }
34
+
35
+ body {
36
+ color: var(--ink);
37
+ background:
38
+ linear-gradient(180deg, rgba(65, 68, 41, 0.22), transparent 22%),
39
+ linear-gradient(115deg, #202012 0%, #14130d 48%, #0c0b07 100%);
40
+ font-family: var(--serif);
41
+ -webkit-font-smoothing: antialiased;
42
+ text-rendering: optimizeLegibility;
43
  }
44
 
45
  button,
46
+ input,
47
+ textarea {
48
  font: inherit;
49
+ color: inherit;
50
  }
51
 
52
+ button {
53
+ cursor: pointer;
 
 
 
54
  }
55
 
56
+ button:disabled,
57
+ input:disabled,
58
+ textarea:disabled {
59
+ cursor: wait;
 
 
60
  }
61
 
62
+ .icon-sprite {
63
+ display: none;
64
+ }
65
+
66
+ .icon {
67
+ width: 14px;
68
+ height: 14px;
69
+ flex: 0 0 auto;
70
+ fill: none;
71
+ stroke: currentColor;
72
+ stroke-width: 1.7;
73
+ stroke-linecap: round;
74
+ stroke-linejoin: round;
75
+ }
76
+
77
+ .sr-only {
78
  position: absolute;
79
+ width: 1px;
80
+ height: 1px;
81
+ padding: 0;
82
+ margin: -1px;
83
+ overflow: hidden;
84
+ clip: rect(0, 0, 0, 0);
85
+ white-space: nowrap;
86
+ border: 0;
87
+ }
88
+
89
+ .desk-glow {
90
+ position: fixed;
91
  inset: 0;
92
+ pointer-events: none;
93
  background:
94
+ linear-gradient(90deg, rgba(0, 0, 0, 0.22), transparent 22%, transparent 78%, rgba(0, 0, 0, 0.28)),
95
+ linear-gradient(180deg, rgba(216, 162, 38, 0.08), transparent 34%);
 
96
  }
97
 
98
+ .almanac {
99
  position: relative;
100
+ z-index: 1;
101
+ width: min(1320px, 100%);
102
+ min-height: 100vh;
103
+ margin: 0 auto;
104
+ padding: 32px;
105
+ }
106
+
107
+ .sheet {
108
+ position: relative;
109
+ min-height: calc(100vh - 64px);
110
+ overflow: hidden;
111
  color: var(--ink);
112
+ background:
113
+ linear-gradient(180deg, var(--paper-3), var(--paper) 16%, var(--paper) 84%, var(--paper-2)),
114
+ var(--paper);
115
+ border: 1px solid var(--edge);
116
+ border-radius: 4px;
117
  box-shadow:
118
+ 0 40px 90px -30px rgba(0, 0, 0, 0.72),
119
+ 0 4px 14px rgba(0, 0, 0, 0.35);
 
120
  }
121
 
122
+ .sheet::before {
123
+ content: "";
124
+ position: absolute;
125
+ inset: 0;
126
+ pointer-events: none;
127
+ background-image:
128
+ linear-gradient(rgba(73, 49, 22, 0.018) 1px, transparent 1px),
129
+ linear-gradient(90deg, rgba(73, 49, 22, 0.014) 1px, transparent 1px);
130
+ background-size: 34px 34px;
131
+ mix-blend-mode: multiply;
132
+ }
133
+
134
+ .sheet::after {
135
+ content: "";
136
+ position: absolute;
137
+ inset: 0;
138
+ pointer-events: none;
139
+ box-shadow:
140
+ inset 0 0 120px rgba(99, 58, 20, 0.2),
141
+ inset 0 0 0 1px rgba(255, 247, 224, 0.26);
142
  }
143
 
144
+ .masthead {
145
+ position: relative;
146
+ z-index: 2;
147
  display: flex;
148
+ align-items: flex-end;
149
+ justify-content: space-between;
150
+ gap: 20px;
151
+ padding: 30px 42px 18px;
152
+ border-bottom: 1px solid var(--rule);
153
  }
154
 
155
+ .masthead-l {
156
+ display: flex;
157
+ min-width: 0;
158
+ align-items: center;
159
+ gap: 18px;
 
160
  }
161
 
162
+ .crest {
163
+ width: 52px;
164
+ height: 52px;
165
+ flex: 0 0 auto;
166
+ color: var(--ink);
167
+ }
168
+
169
+ .masthead h1 {
170
+ margin: 0;
171
+ font-family: var(--serif);
172
+ font-size: 2.5rem;
173
+ line-height: 0.98;
174
  font-weight: 700;
175
  letter-spacing: 0;
176
  }
177
 
178
+ .masthead .sub {
179
+ margin-top: 7px;
180
+ color: var(--ink-faint);
181
+ font-family: var(--label);
182
+ font-size: 0.64rem;
183
+ font-weight: 800;
184
+ line-height: 1.4;
185
+ letter-spacing: 0.18em;
186
+ text-transform: uppercase;
187
+ }
188
+
189
+ .provenance {
190
+ max-width: 420px;
191
+ color: var(--ink-faint);
192
+ font-family: var(--label);
193
+ font-size: 0.68rem;
194
+ font-weight: 750;
195
+ line-height: 1.55;
196
+ letter-spacing: 0.03em;
197
+ text-align: right;
198
+ }
199
+
200
+ .spread {
201
+ position: relative;
202
+ z-index: 2;
203
+ display: grid;
204
+ grid-template-columns: 270px minmax(0, 1fr) 350px;
205
+ }
206
+
207
+ .col {
208
+ min-width: 0;
209
+ padding: 30px;
210
+ }
211
+
212
+ .col-margin,
213
+ .col-proof {
214
+ background: linear-gradient(180deg, rgba(255, 247, 224, 0.2), rgba(73, 49, 22, 0.045));
215
+ }
216
+
217
+ .col-margin,
218
+ .col-page {
219
+ border-right: 1px solid var(--rule);
220
+ }
221
+
222
+ .eyebrow {
223
+ display: flex;
224
+ align-items: center;
225
+ gap: 9px;
226
+ margin: 0 0 14px;
227
+ color: var(--ink-faint);
228
+ font-family: var(--label);
229
+ font-size: 0.66rem;
230
+ font-weight: 850;
231
+ letter-spacing: 0.16em;
232
+ text-transform: uppercase;
233
+ }
234
+
235
+ .eyebrow::after {
236
+ content: "";
237
+ flex: 1;
238
+ height: 1px;
239
+ background: var(--rule);
240
+ }
241
+
242
+ .eyebrow .count {
243
+ padding: 1px 8px;
244
+ color: var(--ink-soft);
245
+ background: rgba(73, 49, 22, 0.1);
246
+ border-radius: 999px;
247
+ font-size: 0.6rem;
248
+ letter-spacing: 0.04em;
249
+ font-variant-numeric: tabular-nums;
250
+ }
251
+
252
+ .section + .section {
253
+ margin-top: 30px;
254
+ }
255
+
256
+ .composer {
257
+ margin: 0 0 18px;
258
+ }
259
+
260
+ .prompt {
261
+ display: block;
262
+ width: 100%;
263
+ min-height: 72px;
264
+ padding: 4px 0 12px;
265
+ color: var(--ink);
266
+ background: transparent;
267
+ border: 0;
268
+ outline: 0;
269
+ resize: vertical;
270
+ font-family: var(--serif);
271
+ font-size: 1.32rem;
272
+ line-height: 1.42;
273
+ }
274
+
275
+ .prompt::placeholder {
276
+ color: var(--ink-faint);
277
+ font-style: italic;
278
+ }
279
+
280
+ .underline {
281
+ width: 100%;
282
+ height: 2px;
283
+ background: linear-gradient(90deg, var(--ink-soft), transparent);
284
+ opacity: 0.55;
285
+ transition: opacity 0.2s, background 0.2s;
286
+ }
287
+
288
+ .composer:focus-within .underline {
289
+ background: linear-gradient(90deg, var(--ink), var(--ink-soft) 60%, transparent);
290
+ opacity: 1;
291
+ }
292
+
293
+ .toolbar {
294
+ display: flex;
295
+ flex-wrap: wrap;
296
+ align-items: center;
297
+ gap: 8px;
298
+ margin-top: 16px;
299
+ }
300
+
301
+ .toolbar .spacer {
302
+ flex: 1 1 20px;
303
+ }
304
+
305
+ .btn {
306
+ display: inline-flex;
307
+ min-height: 36px;
308
+ align-items: center;
309
+ justify-content: center;
310
+ gap: 7px;
311
+ padding: 8px 13px;
312
+ color: var(--ink);
313
+ background: var(--paper-3);
314
+ border: 1px solid var(--edge);
315
+ border-radius: 7px;
316
+ font-family: var(--label);
317
  font-size: 0.78rem;
318
+ font-weight: 800;
319
+ line-height: 1;
320
+ letter-spacing: 0.03em;
321
+ white-space: nowrap;
322
+ transition: transform 0.12s, background 0.2s, border-color 0.2s, opacity 0.2s;
323
+ }
324
+
325
+ .btn:hover:not(:disabled) {
326
+ background: #fff6e2;
327
+ border-color: var(--ink-faint);
328
+ transform: translateY(-1px);
329
+ }
330
+
331
+ .btn:active:not(:disabled) {
332
+ transform: translateY(0);
333
+ }
334
+
335
+ .btn:disabled {
336
+ opacity: 0.42;
337
+ cursor: wait;
338
+ }
339
+
340
+ .btn-ink {
341
+ padding-inline: 18px;
342
+ color: #f6ecd2;
343
+ background: var(--ink);
344
+ border-color: var(--ink);
345
+ }
346
+
347
+ .btn-ink:hover:not(:disabled) {
348
+ background: #38260f;
349
+ border-color: #38260f;
350
+ }
351
+
352
+ .btn-ghost {
353
+ color: var(--ink-soft);
354
+ background: transparent;
355
+ border-color: transparent;
356
+ }
357
+
358
+ .btn-ghost:hover:not(:disabled) {
359
+ background: rgba(73, 49, 22, 0.08);
360
+ border-color: transparent;
361
+ }
362
+
363
+ .btn-icon {
364
+ width: 36px;
365
+ padding: 8px;
366
+ }
367
+
368
+ .marginalia {
369
+ min-height: 22px;
370
+ margin: 8px 0 22px;
371
+ color: var(--leaf);
372
+ font-family: var(--label);
373
+ font-size: 0.74rem;
374
+ font-weight: 750;
375
+ line-height: 1.4;
376
+ letter-spacing: 0.02em;
377
+ }
378
+
379
+ .marginalia.warn {
380
+ color: var(--oxblood);
381
+ }
382
+
383
+ .fate {
384
+ position: relative;
385
+ min-height: 245px;
386
+ }
387
+
388
+ .verdict-stamp {
389
+ display: inline-flex;
390
+ align-items: center;
391
+ gap: 10px;
392
+ padding: 7px 14px 7px 12px;
393
+ margin-bottom: 18px;
394
+ border: 1.5px solid currentColor;
395
+ border-radius: 999px;
396
+ color: var(--ink-faint);
397
+ background: rgba(73, 49, 22, 0.06);
398
+ font-family: var(--label);
399
+ font-size: 0.72rem;
400
+ font-weight: 850;
401
+ letter-spacing: 0.12em;
402
  text-transform: uppercase;
403
  }
404
 
405
+ .verdict-stamp .seal-dot {
406
+ width: 8px;
407
+ height: 8px;
408
+ border-radius: 50%;
409
+ background: currentColor;
410
+ }
411
+
412
+ .verdict-unwritten {
413
+ color: var(--gold);
414
+ background: rgba(216, 162, 38, 0.1);
415
+ }
416
+
417
+ .verdict-echo {
418
+ color: var(--oxblood);
419
+ background: rgba(154, 43, 34, 0.09);
420
+ }
421
+
422
+ .verdict-ready {
423
+ color: var(--ink-faint);
424
+ }
425
+
426
+ .prophecy {
427
+ min-height: 150px;
428
  margin: 0;
429
+ color: var(--ink);
430
+ font-family: var(--serif);
431
+ font-size: 1.55rem;
432
+ line-height: 1.52;
433
+ font-weight: 400;
434
+ text-wrap: pretty;
435
  }
436
 
437
+ .prophecy::first-letter {
438
+ float: left;
439
+ padding: 7px 12px 0 0;
440
+ color: var(--ink);
441
+ font-size: 4.1em;
442
+ line-height: 0.76;
443
+ font-weight: 700;
444
+ }
445
+
446
+ .prophecy.bleed {
447
+ color: var(--oxblood-2);
448
  }
449
 
450
+ .prophecy.bleed::first-letter {
451
+ color: var(--oxblood);
 
452
  }
453
 
454
+ .prophecy.gold {
455
+ color: #6a4a08;
456
+ }
457
+
458
+ .prophecy.gold::first-letter {
459
+ color: var(--gold);
460
+ text-shadow: 0 0 22px var(--gold-glow);
461
+ }
462
+
463
+ .prophecy.thinking {
464
+ color: var(--ink-faint);
465
  font-style: italic;
466
+ animation: breathe 1.7s ease-in-out infinite;
467
  }
468
 
469
+ @keyframes breathe {
470
  0%,
471
  100% {
472
+ opacity: 0.58;
473
  }
474
 
475
  50% {
 
477
  }
478
  }
479
 
480
+ .plan-list {
481
  display: grid;
482
+ gap: 2px;
483
+ min-height: 42px;
484
+ margin: 0;
485
+ padding: 0;
486
+ list-style: none;
487
+ counter-reset: step;
488
  }
489
 
490
+ .plan-list li {
491
+ position: relative;
492
+ counter-increment: step;
493
+ padding: 12px 4px 12px 44px;
 
 
 
494
  color: var(--ink);
495
+ border-bottom: 1px solid var(--rule-soft);
496
+ font-size: 1rem;
497
+ line-height: 1.45;
498
+ text-wrap: pretty;
 
 
 
 
 
 
 
 
 
 
 
 
499
  }
500
 
501
+ .plan-list li:last-child {
502
+ border-bottom: 0;
 
503
  }
504
 
505
+ .plan-list li::before {
506
+ content: counter(step);
507
+ position: absolute;
508
+ top: 11px;
509
+ left: 0;
510
  display: grid;
511
+ width: 28px;
512
+ height: 28px;
513
+ place-items: center;
514
+ color: var(--gold);
515
+ background: rgba(216, 162, 38, 0.08);
516
+ border: 1.5px solid var(--gold);
517
+ border-radius: 50%;
518
+ font-family: var(--label);
519
+ font-size: 0.82rem;
520
+ font-weight: 850;
521
+ font-variant-numeric: tabular-nums;
522
  }
523
 
524
+ .plan-list li.empty {
525
+ padding-left: 0;
526
+ color: var(--ink-faint);
527
+ font-style: italic;
528
+ border-bottom: 0;
 
 
 
 
 
529
  }
530
 
531
+ .plan-list li.empty::before {
532
+ display: none;
533
  }
534
 
535
+ .seal-wrap {
536
+ display: flex;
537
+ align-items: center;
538
+ gap: 18px;
539
+ margin-bottom: 6px;
540
  }
541
 
542
  .seal {
543
+ position: relative;
 
 
544
  display: grid;
545
+ width: 124px;
546
+ height: 124px;
547
+ flex: 0 0 auto;
548
  place-items: center;
549
  align-content: center;
550
+ gap: 1px;
551
+ color: #f6ecd2;
552
+ background:
553
+ linear-gradient(145deg, rgba(255, 247, 224, 0.24), transparent 34%),
554
+ linear-gradient(180deg, #7c6849, #4d3924);
555
  border-radius: 50%;
556
+ box-shadow:
557
+ 0 12px 26px rgba(91, 62, 35, 0.32),
558
+ inset 0 0 22px rgba(43, 25, 11, 0.42);
559
+ transform: rotate(-5deg);
560
+ }
561
+
562
+ .seal.unwritten {
563
+ color: #4a3404;
564
  background:
565
+ linear-gradient(145deg, rgba(255, 240, 190, 0.82), transparent 32%),
566
+ linear-gradient(180deg, var(--gold-2), var(--gold));
 
567
  box-shadow:
568
+ 0 12px 26px rgba(176, 125, 18, 0.36),
569
+ inset 0 0 22px rgba(120, 80, 8, 0.4),
570
+ 0 0 34px var(--gold-glow);
571
  }
572
 
573
+ .seal.echo {
574
+ color: #ffe9a8;
575
+ background:
576
+ linear-gradient(145deg, rgba(255, 228, 150, 0.55), transparent 30%),
577
+ linear-gradient(180deg, var(--oxblood), var(--oxblood-2));
578
+ box-shadow:
579
+ 0 12px 26px rgba(91, 22, 16, 0.4),
580
+ inset 0 0 22px rgba(53, 11, 7, 0.5);
581
+ }
582
+
583
+ .seal .seal-overall {
584
+ font-family: var(--serif);
585
+ font-size: 2.5rem;
586
+ font-weight: 700;
587
+ line-height: 1;
588
+ font-variant-numeric: tabular-nums;
589
+ }
590
+
591
+ .seal .seal-of {
592
+ font-family: var(--label);
593
+ font-size: 0.54rem;
594
+ font-weight: 800;
595
+ letter-spacing: 0.16em;
596
+ text-transform: uppercase;
597
+ opacity: 0.86;
598
+ }
599
+
600
+ .seal-meta {
601
+ min-width: 0;
602
+ }
603
+
604
+ .seal-meta .v {
605
+ color: var(--ink-faint);
606
+ font-family: var(--label);
607
  font-size: 0.82rem;
608
+ font-weight: 850;
609
+ line-height: 1.25;
610
  letter-spacing: 0.08em;
611
+ text-transform: uppercase;
612
  }
613
 
614
+ .seal-meta .v.unwritten {
615
+ color: var(--gold);
 
 
616
  }
617
 
618
+ .seal-meta .v.echo {
619
+ color: var(--oxblood);
620
+ }
621
+
622
+ .seal-meta .t {
623
+ margin-top: 4px;
624
+ color: var(--ink-soft);
625
+ font-size: 0.92rem;
626
+ font-style: italic;
627
+ line-height: 1.35;
628
+ text-wrap: pretty;
629
+ }
630
+
631
+ .quadrants {
632
+ display: grid;
633
+ gap: 9px;
634
+ margin-top: 18px;
635
+ }
636
+
637
+ .quad {
638
+ display: grid;
639
+ grid-template-columns: 76px minmax(0, 1fr) 24px;
640
+ align-items: center;
641
+ gap: 10px;
642
+ }
643
+
644
+ .quad .ql {
645
+ color: var(--ink-soft);
646
+ font-family: var(--label);
647
+ font-size: 0.68rem;
648
  font-weight: 800;
649
+ line-height: 1.15;
650
+ letter-spacing: 0.04em;
651
+ text-transform: uppercase;
652
+ }
653
+
654
+ .quad .qbar {
655
+ display: block;
656
+ height: 9px;
657
+ overflow: hidden;
658
+ background: rgba(73, 49, 22, 0.16);
659
+ border-radius: 999px;
660
+ }
661
+
662
+ .quad .qfill {
663
+ display: block;
664
+ width: 0;
665
+ height: 100%;
666
+ background: linear-gradient(90deg, var(--oxblood) 0%, var(--gold) 55%, var(--leaf-2) 100%);
667
+ border-radius: 999px;
668
+ transition: width 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
669
+ }
670
+
671
+ .quad .qv {
672
+ color: var(--ink);
673
+ font-family: var(--label);
674
+ font-size: 0.76rem;
675
+ font-weight: 850;
676
+ text-align: right;
677
+ font-variant-numeric: tabular-nums;
678
+ }
679
+
680
+ .wood-map {
681
+ display: grid;
682
+ gap: 10px;
683
+ }
684
+
685
+ .wood,
686
+ .wood-map-field {
687
+ position: relative;
688
+ height: 188px;
689
+ overflow: hidden;
690
+ background:
691
+ linear-gradient(rgba(73, 49, 22, 0.08) 1px, transparent 1px),
692
+ linear-gradient(90deg, rgba(73, 49, 22, 0.08) 1px, transparent 1px),
693
+ rgba(255, 245, 222, 0.42);
694
+ background-size: 26px 26px;
695
+ border: 1px solid var(--rule);
696
+ border-radius: 8px;
697
+ }
698
+
699
+ .wood-empty {
700
+ position: absolute;
701
+ inset: 0;
702
+ display: grid;
703
+ place-items: center;
704
+ padding: 20px;
705
+ text-align: center;
706
+ }
707
+
708
+ .wood-dot {
709
+ position: absolute;
710
+ display: block;
711
+ border-radius: 50%;
712
+ transform: translate(-50%, -50%);
713
+ transition: opacity 0.5s;
714
+ }
715
+
716
+ .wood-dot.inked {
717
+ background: rgba(73, 49, 22, 0.34);
718
  }
719
 
720
+ .wood-dot.echo {
721
+ background: var(--oxblood);
722
+ box-shadow: 0 0 0 2px rgba(255, 240, 181, 0.5);
723
+ animation: echo-pulse 2.4s ease-in-out infinite;
724
  }
725
 
726
+ .wood-dot.idea {
727
+ z-index: 2;
728
+ background: var(--leaf);
729
+ box-shadow:
730
+ 0 0 0 3px #fff0b5,
731
+ 0 0 20px rgba(47, 107, 65, 0.5);
 
 
732
  }
733
 
734
+ .wood-dot.idea.bleed,
735
+ .wood-dot.idea.echo-idea {
736
+ background: var(--oxblood);
737
+ box-shadow:
738
+ 0 0 0 3px #fff0b5,
739
+ 0 0 20px rgba(154, 43, 34, 0.5);
740
  }
741
 
742
+ @keyframes echo-pulse {
743
+ 0%,
744
+ 100% {
745
+ opacity: 0.82;
746
+ }
747
+
748
+ 50% {
749
+ opacity: 1;
750
+ }
751
  }
752
 
753
+ .wood-cap,
754
+ .wood-map-caption {
755
+ margin: 0 2px;
756
+ color: var(--ink-faint);
757
+ font-family: var(--label);
758
+ font-size: 0.68rem;
759
+ font-weight: 750;
760
+ line-height: 1.45;
761
+ letter-spacing: 0.02em;
762
  }
763
 
764
+ .wood-legend {
765
+ display: flex;
766
+ flex-wrap: wrap;
767
+ gap: 14px;
768
+ }
769
+
770
+ .wood-legend span {
771
+ display: inline-flex;
772
+ align-items: center;
773
+ gap: 5px;
774
+ color: var(--ink-soft);
775
+ font-family: var(--label);
776
+ font-size: 0.62rem;
777
+ font-weight: 850;
778
+ letter-spacing: 0.04em;
779
+ text-transform: uppercase;
780
  }
781
 
782
+ .wood-legend i {
783
+ width: 8px;
784
+ height: 8px;
785
+ border-radius: 50%;
786
  }
787
 
788
  .project-list,
789
  .whitespace-list,
790
  .idea-list,
791
  .target-list,
792
+ .profile-grid {
 
793
  display: grid;
794
  gap: 9px;
795
  }
796
 
797
  .project,
798
+ .echo-item,
799
  .gap,
800
+ .gap-item {
801
+ display: block;
 
 
 
 
 
 
 
 
802
  width: 100%;
803
+ padding: 9px 0 9px 13px;
804
+ color: inherit;
805
+ background: transparent;
806
+ border: 0;
807
+ border-left: 2px solid var(--rule);
808
+ font: inherit;
809
  text-align: left;
810
+ text-decoration: none;
811
+ transition: background 0.2s, border-color 0.2s, padding-left 0.2s;
812
  }
813
 
814
+ .project,
815
+ .echo-item {
816
+ border-left-color: rgba(154, 43, 34, 0.45);
 
 
 
 
 
 
 
 
 
817
  }
818
 
819
+ .gap,
820
+ .gap-item {
821
+ border-left-color: rgba(176, 125, 18, 0.5);
822
  }
823
 
824
+ .project:hover,
825
+ .echo-item:hover,
826
+ .gap-item:hover {
827
+ padding-left: 16px;
828
+ background: rgba(255, 247, 224, 0.58);
829
  }
830
 
831
  .project strong,
832
+ .echo-item strong,
833
  .gap strong,
834
+ .gap-item strong {
835
  display: block;
836
+ color: var(--ink);
837
+ font-family: var(--serif);
838
  font-size: 0.98rem;
839
+ font-weight: 700;
840
+ line-height: 1.22;
841
  }
842
 
843
  .project p,
844
+ .echo-item p,
845
  .gap p,
846
+ .gap-item p {
847
  margin: 4px 0 0;
848
+ color: var(--ink-soft);
849
+ font-family: var(--label);
850
+ font-size: 0.8rem;
851
+ line-height: 1.4;
852
  }
853
 
854
+ .project span,
855
+ .echo-item .matched {
856
  display: inline-block;
857
  margin-top: 6px;
858
+ color: var(--ink-faint);
859
+ font-family: var(--label);
860
+ font-size: 0.64rem;
861
+ font-weight: 800;
862
+ line-height: 1.3;
863
+ letter-spacing: 0.02em;
864
  }
865
 
866
+ .gap-item .use {
867
+ display: block;
868
  margin-top: 6px;
869
+ color: var(--gold);
870
+ font-family: var(--label);
871
+ font-size: 0.62rem;
872
+ font-weight: 850;
873
+ letter-spacing: 0.08em;
874
+ text-transform: uppercase;
875
+ opacity: 0;
876
+ transition: opacity 0.2s;
877
  }
878
 
879
+ .gap-item:hover .use {
880
+ opacity: 1;
881
+ }
882
+
883
+ .idea-card,
884
+ .idea {
885
  display: block;
886
+ width: 100%;
887
+ padding: 12px 13px;
888
+ color: inherit;
889
+ background: var(--paper-3);
890
+ border: 1px solid var(--edge);
891
+ border-left: 3px solid var(--edge);
892
+ border-radius: 0 7px 7px 0;
893
+ font: inherit;
894
+ text-align: left;
895
+ cursor: pointer;
896
+ transition: border-color 0.2s, transform 0.12s, box-shadow 0.2s, opacity 0.2s;
897
  }
898
 
899
+ .idea-card:hover:not(:disabled),
900
+ .idea:hover:not(:disabled) {
901
+ box-shadow: 0 4px 14px -8px rgba(0, 0, 0, 0.42);
902
+ transform: translateX(2px);
903
  }
904
 
905
+ .idea-card.current,
906
+ .idea.current {
907
+ background: #fff7e3;
908
+ border-left-color: var(--leaf);
909
+ }
910
+
911
+ .idea.current.bleed {
912
+ border-left-color: var(--oxblood);
913
+ }
914
+
915
+ .idea:disabled,
916
+ .gap-item:disabled,
917
+ .target-toggle:has(input:disabled),
918
+ .profile-field:has(input:disabled) {
919
+ opacity: 0.64;
920
+ }
921
+
922
+ .idea:focus-visible,
923
+ .gap-item:focus-visible,
924
+ .target-toggle:focus-within,
925
+ .profile-field input:focus-visible,
926
+ .btn:focus-visible,
927
+ .prompt:focus-visible {
928
+ outline: 2px solid rgba(47, 107, 65, 0.55);
929
+ outline-offset: 2px;
930
+ }
931
+
932
+ .idea .ihead {
933
  display: flex;
934
+ align-items: baseline;
935
+ justify-content: space-between;
936
  gap: 8px;
 
 
 
 
 
937
  }
938
 
939
+ .idea strong {
940
+ color: var(--ink);
941
+ font-family: var(--serif);
942
+ font-size: 0.98rem;
943
+ font-weight: 700;
944
+ line-height: 1.22;
945
+ }
946
+
947
+ .idea .iscore {
948
  flex: 0 0 auto;
949
+ color: var(--ink-soft);
950
+ font-family: var(--label);
951
+ font-size: 0.82rem;
952
+ font-weight: 850;
953
+ font-variant-numeric: tabular-nums;
954
  }
955
 
956
+ .idea p {
957
+ margin: 5px 0 0;
958
+ color: var(--ink-soft);
959
+ font-family: var(--label);
960
+ font-size: 0.76rem;
961
+ line-height: 1.4;
962
  }
963
 
964
+ .idea .iverdict {
965
+ display: inline-block;
966
+ margin-top: 8px;
967
+ color: var(--ink-faint);
968
+ font-family: var(--label);
969
+ font-size: 0.6rem;
970
+ font-weight: 850;
971
+ letter-spacing: 0.09em;
972
+ text-transform: uppercase;
973
  }
974
 
975
+ .idea .iverdict.unwritten {
976
+ color: var(--gold);
977
+ }
978
+
979
+ .idea .iverdict.echo {
980
+ color: var(--oxblood);
981
+ }
982
+
983
+ .idea small {
984
+ display: block;
985
+ margin-top: 5px;
986
+ color: var(--leaf);
987
+ font-family: var(--label);
988
+ font-size: 0.7rem;
989
  font-weight: 800;
990
+ line-height: 1.3;
991
  }
992
 
993
  .profile-field {
 
994
  display: grid;
995
+ gap: 5px;
 
 
996
  }
997
 
998
  .profile-field span {
999
+ color: var(--ink-faint);
1000
+ font-family: var(--label);
1001
+ font-size: 0.64rem;
1002
+ font-weight: 800;
1003
+ letter-spacing: 0.08em;
1004
+ text-transform: uppercase;
1005
  }
1006
 
1007
  .profile-field input {
1008
+ width: 100%;
1009
+ min-height: 36px;
1010
+ padding: 8px 10px;
 
 
 
1011
  color: var(--ink);
1012
+ background: rgba(255, 247, 224, 0.52);
1013
+ border: 1px solid var(--rule);
1014
+ border-radius: 6px;
1015
+ outline: 0;
1016
+ font-family: var(--label);
1017
+ font-size: 0.84rem;
1018
+ transition: border-color 0.2s, background 0.2s;
1019
  }
1020
 
1021
  .profile-field input:focus {
1022
+ background: #fff8e6;
1023
+ border-color: var(--leaf-2);
1024
+ box-shadow: 0 0 0 3px rgba(47, 107, 65, 0.12);
1025
  }
1026
 
1027
+ .target-toggle {
1028
  position: relative;
1029
+ display: flex;
1030
+ align-items: flex-start;
1031
+ gap: 10px;
1032
+ padding: 9px 10px;
1033
+ background: transparent;
1034
+ border: 1px solid transparent;
1035
+ border-radius: 7px;
1036
+ cursor: pointer;
1037
+ transition: background 0.2s, border-color 0.2s, opacity 0.2s;
1038
  }
1039
 
1040
+ .target-toggle:hover {
1041
+ background: rgba(255, 247, 224, 0.52);
 
 
 
1042
  }
1043
 
1044
+ .target-toggle.on {
1045
+ background: rgba(47, 107, 65, 0.07);
1046
+ border-color: rgba(47, 107, 65, 0.4);
1047
  }
1048
 
1049
+ .target-toggle input {
1050
+ position: absolute;
1051
+ opacity: 0;
1052
+ pointer-events: none;
1053
  }
1054
 
1055
+ .target-toggle .check {
1056
+ display: grid;
1057
+ width: 18px;
1058
+ height: 18px;
1059
+ flex: 0 0 auto;
1060
+ place-items: center;
1061
+ margin-top: 1px;
1062
+ color: transparent;
1063
+ border: 1.5px solid var(--ink-faint);
1064
+ border-radius: 5px;
1065
+ transition: background 0.2s, border-color 0.2s, color 0.2s;
1066
  }
1067
 
1068
+ .target-toggle.on .check {
1069
+ color: #f6ecd2;
1070
+ background: var(--leaf);
1071
+ border-color: var(--leaf);
1072
  }
1073
 
1074
+ .target-toggle .check .icon {
1075
+ width: 11px;
1076
+ height: 11px;
1077
+ stroke-width: 2.4;
 
 
1078
  }
1079
 
1080
+ .target-copy {
1081
  display: grid;
1082
+ min-width: 0;
1083
+ gap: 3px;
 
 
1084
  }
1085
 
1086
+ .target-copy strong {
1087
+ color: var(--ink);
1088
+ font-family: var(--label);
1089
+ font-size: 0.8rem;
1090
+ font-weight: 850;
1091
+ line-height: 1.2;
1092
+ }
1093
+
1094
+ .target-copy small {
1095
+ color: var(--ink-faint);
1096
+ font-family: var(--label);
1097
+ font-size: 0.7rem;
1098
+ font-weight: 750;
1099
+ line-height: 1.35;
1100
  }
1101
 
1102
  .empty {
1103
+ color: var(--ink-faint);
1104
  font-size: 0.95rem;
1105
+ font-style: italic;
1106
+ line-height: 1.45;
1107
  }
1108
 
1109
+ .mobile-nav {
1110
+ display: none;
1111
+ }
1112
+
1113
+ @media (max-width: 1080px) {
1114
+ .almanac {
1115
+ padding: 20px;
1116
  }
1117
 
1118
+ .spread {
1119
+ grid-template-columns: 250px minmax(0, 1fr);
1120
+ }
1121
+
1122
+ .col-proof {
1123
+ grid-column: 1 / -1;
1124
+ border-top: 1px solid var(--rule);
1125
+ }
1126
+
1127
+ .col-page {
1128
+ border-right: 0;
1129
+ }
1130
+ }
1131
+
1132
+ @media (max-width: 760px) {
1133
+ .almanac {
1134
+ width: 100%;
1135
+ padding: 0;
1136
  }
1137
 
1138
+ .sheet {
1139
  min-height: 100vh;
1140
+ border-right: 0;
1141
+ border-left: 0;
1142
  border-radius: 0;
 
1143
  }
1144
 
1145
+ .masthead {
1146
+ padding: 18px 18px 14px;
1147
  }
1148
 
1149
+ .masthead h1 {
1150
+ font-size: 1.58rem;
1151
+ line-height: 1.05;
1152
  }
1153
 
1154
+ .masthead .sub {
1155
+ max-width: 290px;
1156
+ font-size: 0.58rem;
1157
+ letter-spacing: 0.12em;
1158
+ }
1159
+
1160
+ .crest {
1161
+ width: 44px;
1162
+ height: 44px;
1163
+ }
1164
+
1165
+ .provenance {
1166
+ display: none;
1167
+ }
1168
+
1169
+ .mobile-nav {
1170
+ position: sticky;
1171
+ top: 0;
1172
+ z-index: 20;
1173
+ display: flex;
1174
+ gap: 4px;
1175
+ padding: 6px;
1176
+ background: var(--paper-2);
1177
+ border-bottom: 1px solid var(--rule);
1178
+ }
1179
+
1180
+ .mobile-nav button {
1181
+ flex: 1;
1182
+ padding: 9px 4px;
1183
+ color: var(--ink-faint);
1184
+ background: transparent;
1185
+ border: 0;
1186
+ border-radius: 6px;
1187
+ font-family: var(--label);
1188
+ font-size: 0.68rem;
1189
+ font-weight: 850;
1190
+ letter-spacing: 0.05em;
1191
+ text-transform: uppercase;
1192
+ }
1193
+
1194
+ .mobile-nav button.active {
1195
+ color: var(--paper);
1196
+ background: var(--ink);
1197
  }
1198
 
1199
+ .spread {
1200
  grid-template-columns: 1fr;
1201
  }
1202
 
1203
+ .col {
1204
+ padding: 20px 18px 28px;
1205
+ border-right: 0;
1206
+ }
1207
+
1208
+ .col-page {
1209
+ order: 1;
1210
+ }
1211
+
1212
+ .col-proof {
1213
+ order: 2;
1214
+ border-top: 1px solid var(--rule);
1215
+ }
1216
+
1217
+ .col-margin {
1218
+ order: 3;
1219
+ border-top: 1px solid var(--rule);
1220
+ }
1221
+
1222
+ .spread[data-tab="page"] .col-proof,
1223
+ .spread[data-tab="page"] .col-margin,
1224
+ .spread[data-tab="proof"] .col-page,
1225
+ .spread[data-tab="proof"] .col-margin,
1226
+ .spread[data-tab="almanac"] .col-page,
1227
+ .spread[data-tab="almanac"] .col-proof {
1228
+ display: none;
1229
+ }
1230
+
1231
+ .prompt {
1232
+ min-height: 88px;
1233
+ font-size: 1.12rem;
1234
+ }
1235
+
1236
+ .prophecy {
1237
+ min-height: 170px;
1238
+ font-size: 1.2rem;
1239
+ }
1240
+
1241
+ .prophecy::first-letter {
1242
+ font-size: 3.2em;
1243
+ }
1244
+
1245
+ .toolbar .spacer {
1246
+ display: none;
1247
+ }
1248
+
1249
+ .btn {
1250
+ min-height: 38px;
1251
+ }
1252
+
1253
+ .seal {
1254
+ width: 104px;
1255
+ height: 104px;
1256
+ }
1257
+
1258
+ .seal .seal-overall {
1259
+ font-size: 2.1rem;
1260
+ }
1261
+
1262
+ .wood,
1263
+ .wood-map-field {
1264
+ height: 170px;
1265
  }
1266
+ }
1267
 
1268
+ @media (prefers-reduced-motion: reduce) {
1269
+ *,
1270
+ *::before,
1271
+ *::after {
1272
+ animation-duration: 0.001ms !important;
1273
+ transition-duration: 0.001ms !important;
1274
  }
1275
  }
tests/test_model_runtime.py CHANGED
@@ -49,6 +49,20 @@ def test_rule_planner_defaults_blank_to_list_projects() -> None:
49
  assert resolution.call.name == "list_projects"
50
 
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  def test_render_context_includes_state() -> None:
53
  context = render_context(
54
  "make a plan",
 
49
  assert resolution.call.name == "list_projects"
50
 
51
 
52
+ def test_rule_planner_splits_explicit_idea_pitch() -> None:
53
+ planner = RuleBasedPlanner()
54
+
55
+ resolution = planner.plan(
56
+ "idea: Hands-on science coach -- A lab-notebook companion for household experiments.",
57
+ {},
58
+ )
59
+
60
+ assert resolution.status == "valid"
61
+ assert resolution.call.name == "save_idea"
62
+ assert resolution.call.arguments["title"] == "Hands-on science coach"
63
+ assert resolution.call.arguments["pitch"] == "A lab-notebook companion for household experiments."
64
+
65
+
66
  def test_render_context_includes_state() -> None:
67
  context = render_context(
68
  "make a plan",
tests/test_tools.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from hackathon_advisor.tools import idea_from_text
2
+
3
+
4
+ def test_idea_from_text_splits_explicit_title_and_pitch() -> None:
5
+ title, pitch = idea_from_text(
6
+ "idea: Hands-on science coach -- A lab-notebook companion for household experiments."
7
+ )
8
+
9
+ assert title == "Hands-on science coach"
10
+ assert pitch == "A lab-notebook companion for household experiments."