TEMPLATE.html
A supporting file of the journey-mapper skill, shown as source. It is not opened as a web page on this site.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Journey Maps</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<style>
:root {
--bg: #f5f5f4;
--panel: #fafaf9;
--panel-2: #fafaf9;
--panel-3: #f5f5f4;
--border: #e7e5e4;
--border-strong: #d6d3d1;
--divider: #a8a29e;
--text: #1c1917;
--muted: #78716c;
--muted-2: #a8a29e;
--accent: #fcd34d;
--accent-soft: #fef3c7;
--green: #15803d;
--red: #b91c1c;
--focus: #fbbf24;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: "Inter", system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
textarea, input, button { font-family: inherit; color: inherit; }
.app {
display: grid;
grid-template-columns: 280px 1fr;
gap: 16px;
padding: 20px;
min-height: 100vh;
max-width: 1900px;
margin: 0 auto;
}
/* Left rail */
.rail {
position: sticky;
top: 20px;
align-self: start;
max-height: calc(100vh - 40px);
overflow: auto;
background: var(--bg);
border: none;
border-right: 1px solid var(--border);
border-radius: 0;
padding: 14px;
}
.rail h1 {
font-size: 14px;
font-weight: 700;
margin: 0 0 4px 0;
letter-spacing: -0.01em;
color: var(--text);
}
.rail .tagline {
font-size: 12px;
color: var(--muted);
margin: 0 0 14px 0;
}
.toolbar {
display: flex;
gap: 6px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.tool-btn {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 9px;
font-size: 11px;
cursor: pointer;
flex: 1;
min-width: 0;
transition: border-color 150ms ease, background 150ms ease;
}
.tool-btn:hover { color: var(--text); border-color: var(--border-strong); background: var(--panel); }
.save-status {
font-size: 11px;
color: var(--muted-2);
margin-bottom: 14px;
min-height: 14px;
}
.save-status.flash { color: var(--green); }
.category-header {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
color: var(--muted);
text-transform: uppercase;
padding: 12px 2px 4px 2px;
}
.category-header:first-of-type { padding-top: 0; }
.journey-item {
background: transparent;
border: none;
border-left: 3px solid transparent;
border-radius: 0;
padding: 8px 10px;
text-align: left;
color: var(--text);
cursor: pointer;
font-size: 13px;
width: 100%;
margin-bottom: 2px;
transition: border-color 150ms ease, background 150ms ease;
}
.journey-item:hover { background: var(--border); }
.journey-item.active {
border-left-color: var(--accent);
background: var(--accent-soft);
}
.journey-item .j-title { font-weight: 600; }
.journey-item .j-actor {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
}
/* Main work area */
.main {
display: flex;
flex-direction: column;
gap: 24px;
min-width: 0;
}
.journey-header {
background: transparent;
border: none;
border-radius: 0;
padding: 0;
}
.journey-header h2 {
margin: 0 0 4px 0;
font-size: clamp(26px, 3vw, 36px);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.journey-header .actor-line {
font-size: 13px;
color: var(--muted);
margin-bottom: 18px;
}
.actor-chip {
display: inline-block;
font-size: 11px;
color: var(--text);
background: var(--accent-soft);
border: 1px solid var(--accent);
padding: 1px 7px;
border-radius: 4px;
margin-left: 4px;
}
.header-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.field-block label {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 4px;
}
.field-block textarea {
width: 100%;
min-height: 50px;
background: #ffffff;
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
line-height: 1.5;
resize: vertical;
transition: border-color 150ms ease;
}
.field-block textarea:focus { outline: none; border-color: var(--focus); }
.field-block textarea::placeholder { color: var(--muted-2); }
/* Grid */
.grid-wrap {
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 6px;
padding: 14px;
overflow-x: auto;
}
.grid {
display: grid;
--label-col: 130px;
--moment-col: 260px;
grid-template-columns: var(--label-col) repeat(var(--moments, 1), var(--moment-col));
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
width: max-content;
min-width: 100%;
}
.cell {
background: var(--panel);
padding: 10px 11px;
min-width: 0;
}
.cell.label {
background: var(--panel-3);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
color: var(--muted);
text-transform: uppercase;
display: flex;
align-items: center;
}
.cell.label .accent-dot {
display: none;
}
.cell.stage {
background: var(--panel-3);
font-size: 11px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 8px 11px;
text-align: center;
border-left: 1px solid var(--border);
}
.cell.moment-header {
background: var(--panel-3);
font-weight: 600;
font-size: 13px;
color: var(--text);
padding: 10px 11px;
}
.cell.moment-header .moment-num {
display: inline-block;
width: 18px; height: 18px;
border-radius: 50%;
background: var(--accent);
color: var(--text);
font-size: 10.5px;
font-weight: 700;
text-align: center;
line-height: 18px;
margin-right: 6px;
}
.cell.readonly {
color: var(--text);
font-size: 13px;
line-height: 1.5;
}
.cell.readonly .empty { color: var(--muted-2); font-style: italic; font-size: 12px; }
/* Per-row semantic colours */
.cell[data-row="row-pain"] { color: var(--red); }
.cell[data-row="row-pain"] textarea { color: var(--red); }
.cell[data-row="row-oppty"] { color: var(--green); }
.cell[data-row="row-oppty"] textarea { color: var(--green); }
/* Service blueprint rows: subtly greyer */
.cell[data-row="row-front"],
.cell[data-row="row-back"],
.cell[data-row="row-supp"] { background: var(--panel-3); }
.cell textarea {
width: 100%;
min-height: 120px;
background: #ffffff;
border: 1px solid var(--border);
border-radius: 5px;
padding: 7px 9px;
font-size: 13px;
line-height: 1.5;
resize: vertical;
transition: border-color 150ms ease;
}
.cell textarea:focus { outline: none; border-color: var(--focus); }
.cell textarea::placeholder { color: var(--muted-2); }
/* Divider rows */
.cell.divider {
background: transparent;
grid-column: 1 / -1;
padding: 6px 4px;
display: flex;
align-items: center;
gap: 10px;
}
.cell.divider .line {
flex: 1;
height: 1px;
background: var(--border-strong);
}
.cell.divider .label-text {
font-size: 10.5px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.16em;
font-weight: 600;
white-space: nowrap;
}
/* Feeling cells */
.feeling-picker {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.feeling-btn {
width: 30px;
height: 30px;
border: 1px solid var(--border);
background: #ffffff;
border-radius: 5px;
cursor: pointer;
font-size: 17px;
line-height: 1;
padding: 0;
transition: border-color 150ms ease, background 150ms ease, opacity 150ms ease;
opacity: 0.45;
}
.feeling-btn:hover { border-color: var(--border-strong); opacity: 0.7; }
.feeling-btn.selected {
border-color: var(--focus);
background: var(--accent-soft);
opacity: 1;
}
/* Emotion curve */
.curve-wrap {
background: transparent;
border: none;
border-top: 1px solid var(--border);
border-radius: 0;
padding: 16px 0;
}
.curve-wrap h3 {
margin: 0 0 4px 0;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
color: var(--muted);
text-transform: uppercase;
}
.curve-wrap .curve-empty {
color: var(--muted-2);
font-size: 13px;
font-style: italic;
padding: 18px 0;
text-align: center;
}
.curve-svg {
width: 100%;
height: 110px;
display: block;
}
.curve-svg .grid-line { stroke: var(--border); stroke-width: 1; }
.curve-svg .axis-label { fill: var(--muted-2); font-size: 9.5px; }
.curve-svg .curve-path { fill: none; stroke: #292524; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.curve-svg .curve-area { fill: rgba(252,211,77,0.15); stroke: none; }
.curve-svg .curve-pt { fill: var(--accent); stroke: #292524; stroke-width: 2; }
/* Free-form notes */
.notes-block {
background: transparent;
border: none;
border-top: 1px solid var(--border);
border-radius: 0;
padding: 16px 0;
}
.notes-block label {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 6px;
}
.notes-block textarea {
width: 100%;
min-height: 90px;
background: #ffffff;
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
font-size: 14px;
line-height: 1.5;
resize: vertical;
}
.notes-block textarea:focus { outline: none; border-color: var(--focus); }
.notes-block textarea::placeholder { color: var(--muted-2); }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
/* Help modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(28,25,23,0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
pointer-events: none;
transition: opacity 200ms ease;
}
.modal-overlay.open {
opacity: 1;
pointer-events: all;
}
.modal {
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 8px;
padding: 28px 32px;
max-width: 580px;
width: calc(100% - 40px);
max-height: calc(100vh - 80px);
overflow-y: auto;
box-shadow: 0 8px 24px rgba(28,25,23,0.12);
transform: translateY(8px);
transition: transform 200ms ease;
}
.modal-overlay.open .modal { transform: translateY(0); }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.modal-header h2 {
margin: 0;
font-size: 17px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text);
}
.modal-close {
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--muted);
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
flex-shrink: 0;
transition: border-color 150ms ease, color 150ms ease;
}
.modal-close:hover { color: var(--text); border-color: var(--border-strong); }
.modal h3 {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
margin: 20px 0 8px 0;
}
.modal h3:first-of-type { margin-top: 0; }
.modal p {
margin: 0 0 8px 0;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.modal p strong { color: var(--text); font-weight: 600; }
.modal ul {
margin: 0 0 10px 0;
padding-left: 18px;
color: var(--muted);
font-size: 13px;
line-height: 1.7;
}
.modal ul li strong { color: var(--text); }
.modal .chip-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.modal .chip {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--border-strong);
background: var(--panel);
color: var(--text);
}
.modal .chip.cyan,
.modal .chip.violet,
.modal .chip.orange,
.modal .chip.blue { color: var(--text); border-color: var(--border-strong); }
.modal .chip.pink { color: var(--red); border-color: var(--border-strong); }
.modal .chip.green { color: var(--green); border-color: var(--border-strong); }
.modal .divider {
border: none;
border-top: 1px solid var(--border);
margin: 16px 0;
}
.help-btn {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 9px;
font-size: 11px;
cursor: pointer;
transition: border-color 150ms ease, color 150ms ease;
}
.help-btn:hover { color: var(--text); border-color: var(--border-strong); }
</style>
</head>
<body>
<!-- Help modal -->
<div class="modal-overlay" id="help-modal">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div class="modal-header">
<h2 id="modal-title">How to use Journey Maps</h2>
<button class="modal-close" id="help-modal-close" aria-label="Close">✕</button>
</div>
<h3>What this is</h3>
<p>An NN/g customer journey map and service blueprint combined into a single grid — one per user journey. The top half is the customer view; the bottom half is the service layer. Both share the same columns (stages and moments).</p>
<hr class="divider" />
<h3>Navigating journeys</h3>
<p>Select any journey from the left rail. Journeys are grouped by category.</p>
<hr class="divider" />
<h3>Reading the grid</h3>
<p>Columns are <strong>stages</strong>, each containing one or more <strong>moments</strong>. Within each moment column, rows are stacked top-to-bottom:</p>
<div class="chip-row">
<span class="chip cyan">Doing</span>
<span class="chip violet">Thinking</span>
<span class="chip orange">Feeling</span>
<span class="chip pink">Pain points</span>
<span class="chip green">Opportunities</span>
<span class="chip blue">Evidence</span>
</div>
<p>A <strong>line of interaction</strong> separates the customer journey from the service blueprint. Below it:</p>
<div class="chip-row">
<span class="chip cyan">Frontstage</span>
<span class="chip violet">Backstage</span>
<span class="chip">Support processes</span>
</div>
<hr class="divider" />
<h3>What you can edit</h3>
<ul>
<li><strong>Scenario & User goal</strong> — editable in the journey header. Your changes shadow the built-in defaults.</li>
<li><strong>Thinking</strong> — free-text per moment. What is the user asking themselves at this point?</li>
<li><strong>Feeling</strong> — pick one emoji per moment. Drives the emotion curve below the grid.</li>
<li><strong>Pain points & Opportunities</strong> — your research findings. One item per line works well.</li>
<li><strong>Evidence</strong> — link to a source: an insight, a session recording, a ticket ID.</li>
<li><strong>Notes</strong> — a full-width freeform textarea below the grid for anything that doesn't fit a cell.</li>
</ul>
<p>The <strong>Doing, Frontstage, Backstage, and Support</strong> rows are pre-filled and read-only in the UI. Their content lives in the JSON block at the bottom of the file; edit the JSON directly to change them.</p>
<hr class="divider" />
<h3>Emotion curve</h3>
<p>As soon as two or more moments in a journey have a feeling set, a curve renders automatically below the grid. It traces sentiment across stages so you can spot where the journey dips.</p>
<hr class="divider" />
<h3>Saving & sharing</h3>
<ul>
<li><strong>Autosave</strong> — every edit saves to <em>this browser's</em> localStorage after a 350 ms pause. The rail shows "Saved" when it commits.</li>
<li><strong>Export</strong> — downloads a timestamped JSON of all annotations. Back it up or send it to a teammate.</li>
<li><strong>Import</strong> — loads a JSON file. You'll be asked whether to <em>replace</em> your local state or <em>merge</em> a colleague's annotations into yours (safe for collaboration).</li>
</ul>
<hr class="divider" />
<h3>Keyboard shortcut</h3>
<p>Press <strong>?</strong> anywhere on the page to open or close this guide.</p>
</div>
</div>
<div class="app">
<aside class="rail">
<h1>Journey Maps</h1>
<p class="tagline">NN/g customer journey + service blueprint, per journey. Edits autosave to this browser.</p>
<div class="toolbar">
<button class="tool-btn" id="export-btn">Export</button>
<button class="tool-btn" id="import-btn">Import</button>
<button class="help-btn" id="help-btn" title="How to use this tool">?</button>
</div>
<div class="save-status" id="save-status"></div>
<input type="file" id="import-file" accept="application/json" style="display:none" />
<div id="journey-list"></div>
</aside>
<main class="main" id="main">
<div class="empty-main">Pick a journey on the left.</div>
</main>
</div>
<!--
Schema:
actors[] : { id, name, description }
categories[]: { id, title }
journeys[] : {
id, title, actor, category, scenario, goal,
stages[] : {
title,
moments[] : { id, title, doing?, frontstage?, backstage?, support?, thinking?, pain?, opportunity?, evidence?, feeling? }
}
}
Stable ids on moments are required — annotations are keyed by moment id.
Prefix a cell value with [Assumption] to flag it as unvalidated.
-->
<script type="application/json" id="journeys-data">
{
"actors": [
{ "id": "customer", "name": "Customer", "description": "A registered user of the product." },
{ "id": "new-user", "name": "New user", "description": "Someone signing up for the first time." }
],
"categories": [
{ "id": "onboarding", "title": "Onboarding" },
{ "id": "core", "title": "Core workflow" }
],
"journeys": [
{
"id": "sign-up",
"title": "Sign up for an account",
"actor": "new-user",
"category": "onboarding",
"scenario": "A prospective user has heard about the product and wants to create an account to try it out.",
"goal": "Get from zero to a working account with access to the dashboard.",
"stages": [
{
"title": "Discover",
"moments": [
{
"id": "land-page",
"title": "Lands on the signup page",
"doing": "Arrives from a marketing link or referral and sees the signup form.",
"thinking": "[Assumption] Is this going to take long? Can I look around first?",
"feeling": 3,
"pain": "[Assumption] No preview of the product before committing to an account.",
"opportunity": "Offer a quick tour or sandbox before requiring signup.",
"evidence": "",
"frontstage": "Signup form with email, name, and password fields.",
"backstage": "Static page served from CDN. No API calls yet.",
"support": "CDN serves the static build."
},
{
"id": "submit-form",
"title": "Submits the signup form",
"doing": "Fills in their details and clicks Sign up.",
"thinking": "Another password to remember.",
"feeling": 3,
"pain": "",
"opportunity": "Support social login so users can skip the password step.",
"evidence": "",
"frontstage": "Loading spinner, then a confirmation message.",
"backstage": "Backend creates the user record and sends a verification email.",
"support": "Email service delivers the verification message."
}
]
},
{
"title": "Verify",
"moments": [
{
"id": "verify-email",
"title": "Verifies their email",
"doing": "Opens the verification email and clicks the link or enters the code.",
"thinking": "[Assumption] Where is the email? Should I check spam?",
"feeling": 2,
"pain": "Email delivery can be slow; no clear fallback if it never arrives.",
"opportunity": "Magic link instead of code; visible resend countdown.",
"evidence": "",
"frontstage": "Verification code input or magic-link click.",
"backstage": "Backend validates the token and marks the account as verified.",
"support": "Email delivery service."
}
]
}
]
},
{
"id": "first-task",
"title": "Complete the first task",
"actor": "customer",
"category": "core",
"scenario": "A verified user logs in for the first time and wants to accomplish the product's core action.",
"goal": "Successfully complete one end-to-end task and see the result.",
"stages": [
{
"title": "Orient",
"moments": [
{
"id": "empty-state",
"title": "Sees the empty dashboard",
"doing": "Lands on the dashboard and looks for what to do next.",
"thinking": "OK, I'm in. Now what?",
"feeling": 3,
"pain": "Empty state offers little guidance.",
"opportunity": "Guided checklist with the two or three highest-value first steps.",
"evidence": "",
"frontstage": "Dashboard home with empty-state messaging.",
"backstage": "API returns empty collections for this new user.",
"support": "Backend API + database."
}
]
},
{
"title": "Act",
"moments": [
{
"id": "create-item",
"title": "Creates their first item",
"doing": "Clicks the primary action button and fills in the creation form.",
"thinking": "Let me try the main thing this product does.",
"feeling": 4,
"pain": "",
"opportunity": "Pre-fill example data so the user can see a result immediately.",
"evidence": "",
"frontstage": "Creation form with fields and a submit button.",
"backstage": "Backend validates and persists the new item.",
"support": "Backend API + database."
},
{
"id": "see-result",
"title": "Sees the result",
"doing": "Views the newly created item in the dashboard.",
"thinking": "That worked. This might be useful.",
"feeling": 5,
"pain": "",
"opportunity": "Celebrate the first success with a brief confirmation.",
"evidence": "",
"frontstage": "Dashboard updates to show the new item.",
"backstage": "Real-time or polling update reflects the new data.",
"support": "Backend API."
}
]
}
]
}
]
}
</script>
<script>
(function () {
const data = JSON.parse(document.getElementById("journeys-data").textContent);
const STORAGE_KEY = "journey-map-annotations";
const FEELINGS = [
{ score: 1, emoji: "\u{1F621}", label: "Very frustrated" },
{ score: 2, emoji: "\u{1F641}", label: "Frustrated" },
{ score: 3, emoji: "\u{1F610}", label: "Neutral" },
{ score: 4, emoji: "\u{1F642}", label: "Pleased" },
{ score: 5, emoji: "\u{1F604}", label: "Delighted" }
];
const actorById = Object.fromEntries(data.actors.map(a => [a.id, a]));
// ---- Storage ----
function loadAll() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch { return {}; }
}
function saveAll(state) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
let state = loadAll();
function ensureJourneyState(jid) {
if (!state[jid]) state[jid] = { _meta: {}, moments: {} };
if (!state[jid].moments) state[jid].moments = {};
if (!state[jid]._meta) state[jid]._meta = {};
return state[jid];
}
let saveTimer = null;
const saveStatusEl = document.getElementById("save-status");
function flashSaved() {
saveStatusEl.textContent = "Saved · " + new Date().toLocaleTimeString();
saveStatusEl.classList.add("flash");
clearTimeout(flashSaved._t);
flashSaved._t = setTimeout(() => saveStatusEl.classList.remove("flash"), 1200);
}
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => { saveAll(state); flashSaved(); }, 350);
}
// ---- Journey list ----
const listEl = document.getElementById("journey-list");
const journeysByCategory = {};
data.journeys.forEach(j => {
(journeysByCategory[j.category] = journeysByCategory[j.category] || []).push(j);
});
let activeJourneyId = null;
data.categories.forEach(cat => {
const flows = journeysByCategory[cat.id];
if (!flows || !flows.length) return;
const h = document.createElement("div");
h.className = "category-header";
h.textContent = cat.title;
listEl.appendChild(h);
flows.forEach(j => {
const btn = document.createElement("button");
btn.className = "journey-item";
btn.dataset.id = j.id;
const actor = actorById[j.actor];
btn.innerHTML = `
<div class="j-title">${j.title}</div>
<div class="j-actor">${actor ? actor.name : ""}</div>
`;
btn.addEventListener("click", () => selectJourney(j.id));
listEl.appendChild(btn);
});
});
// ---- Render journey ----
const mainEl = document.getElementById("main");
function selectJourney(id) {
activeJourneyId = id;
document.querySelectorAll(".journey-item").forEach(el => {
el.classList.toggle("active", el.dataset.id === id);
});
const j = data.journeys.find(x => x.id === id);
if (!j) return;
renderJourney(j);
}
function renderJourney(j) {
const actor = actorById[j.actor] || {};
const jstate = ensureJourneyState(j.id);
// Flatten all moments to know total count
const allMoments = [];
j.stages.forEach(stage => {
stage.moments.forEach(m => {
allMoments.push({ stage, moment: m });
});
});
mainEl.innerHTML = "";
// Header block
const header = document.createElement("section");
header.className = "journey-header";
header.innerHTML = `
<h2>${j.title}</h2>
<div class="actor-line">Primary actor <span class="actor-chip">${actor.name || ""}</span></div>
<div class="header-fields">
<div class="field-block">
<label>Scenario</label>
<textarea data-meta="scenario" placeholder="Who is this user, what's bringing them here?"></textarea>
</div>
<div class="field-block">
<label>User goal</label>
<textarea data-meta="goal" placeholder="What does success look like for them?"></textarea>
</div>
</div>
`;
mainEl.appendChild(header);
// Prefill meta fields (use stored value if any, otherwise fall back to JSON's scenario/goal)
const scenarioTA = header.querySelector('[data-meta="scenario"]');
const goalTA = header.querySelector('[data-meta="goal"]');
scenarioTA.value = jstate._meta.scenario != null ? jstate._meta.scenario : (j.scenario || "");
goalTA.value = jstate._meta.goal != null ? jstate._meta.goal : (j.goal || "");
[scenarioTA, goalTA].forEach(ta => {
ta.addEventListener("input", () => {
jstate._meta[ta.dataset.meta] = ta.value;
scheduleSave();
autoSize(ta);
});
autoSize(ta);
});
// Grid
const gridWrap = document.createElement("section");
gridWrap.className = "grid-wrap";
const grid = document.createElement("div");
grid.className = "grid";
grid.style.setProperty("--moments", allMoments.length);
gridWrap.appendChild(grid);
// Row 1: Stage headers (with colspan via repeating cells -- each stage spans its moments count)
grid.appendChild(cell("label", "Stage"));
j.stages.forEach(stage => {
stage.moments.forEach((m, i) => {
const c = document.createElement("div");
c.className = "cell stage";
if (i === 0) c.textContent = stage.title;
if (i > 0) c.style.borderLeft = "none";
grid.appendChild(c);
});
});
// Row 2: Moment headers
grid.appendChild(cell("label", "Moment"));
let momentIdx = 0;
allMoments.forEach(({ moment }) => {
momentIdx++;
const c = document.createElement("div");
c.className = "cell moment-header";
c.innerHTML = `<span class="moment-num">${momentIdx}</span>${moment.title}`;
grid.appendChild(c);
});
// -------- Customer journey rows --------
addReadonlyRow(grid, allMoments, "Doing", "row-doing", m => m.doing);
addEditableRow(grid, j, allMoments, "Thinking", "row-think", "thinking", "What is going through their head?");
addFeelingRow(grid, j, allMoments);
addEditableRow(grid, j, allMoments, "Pain points", "row-pain", "pain", "What hurts here?");
addEditableRow(grid, j, allMoments, "Opportunities", "row-oppty", "opportunity", "What could we improve?");
addEditableRow(grid, j, allMoments, "Evidence", "row-evid", "evidence", "Artefacts they take away (email, doc, screen).");
// Divider -- line of interaction
addDivider(grid, "Line of interaction");
// -------- Service blueprint rows --------
addReadonlyRow(grid, allMoments, "Frontstage", "row-front", m => m.frontstage);
addDivider(grid, "Line of visibility");
addReadonlyRow(grid, allMoments, "Backstage", "row-back", m => m.backstage);
addDivider(grid, "Line of internal interaction");
addReadonlyRow(grid, allMoments, "Support processes", "row-supp", m => m.support);
mainEl.appendChild(gridWrap);
// Emotion curve
const curveWrap = document.createElement("section");
curveWrap.className = "curve-wrap";
curveWrap.innerHTML = `<h3>Emotion curve</h3><div id="curve-body"></div>`;
mainEl.appendChild(curveWrap);
renderCurve(j, allMoments);
// Notes
const notes = document.createElement("section");
notes.className = "notes-block";
notes.innerHTML = `
<label>Free-form notes for this journey</label>
<textarea data-meta="notes" placeholder="Hypotheses, research links, things to test, anything else."></textarea>
`;
const notesTA = notes.querySelector("textarea");
notesTA.value = jstate._meta.notes || "";
notesTA.addEventListener("input", () => {
jstate._meta.notes = notesTA.value;
scheduleSave();
autoSize(notesTA);
});
autoSize(notesTA);
mainEl.appendChild(notes);
}
function cell(extraClass, text) {
const c = document.createElement("div");
c.className = "cell " + (extraClass || "");
if (text != null) {
if (extraClass && extraClass.includes("label")) {
c.innerHTML = `<span class="accent-dot"></span>${text}`;
} else {
c.textContent = text;
}
}
return c;
}
function addReadonlyRow(grid, allMoments, labelText, rowColor, getter) {
const lbl = cell("label", labelText);
lbl.dataset.row = rowColor;
grid.appendChild(lbl);
allMoments.forEach(({ moment }) => {
const c = document.createElement("div");
c.className = "cell readonly";
c.dataset.row = rowColor;
const v = getter(moment);
if (v && v.trim()) c.textContent = v;
else c.innerHTML = `<span class="empty">—</span>`;
grid.appendChild(c);
});
}
function addEditableRow(grid, journey, allMoments, labelText, rowColor, field, placeholder) {
const lbl = cell("label", labelText);
lbl.dataset.row = rowColor;
grid.appendChild(lbl);
const jstate = ensureJourneyState(journey.id);
allMoments.forEach(({ moment }) => {
const c = document.createElement("div");
c.className = "cell";
c.dataset.row = rowColor;
const ta = document.createElement("textarea");
ta.placeholder = placeholder || "";
const mstate = jstate.moments[moment.id] = jstate.moments[moment.id] || {};
ta.value = mstate[field] != null ? mstate[field] : (moment[field] || "");
autoSize(ta);
ta.addEventListener("input", () => {
mstate[field] = ta.value;
scheduleSave();
autoSize(ta);
});
c.appendChild(ta);
grid.appendChild(c);
});
}
function addFeelingRow(grid, journey, allMoments) {
const lbl = cell("label", "Feeling");
grid.appendChild(lbl);
const jstate = ensureJourneyState(journey.id);
allMoments.forEach(({ moment }) => {
const c = document.createElement("div");
c.className = "cell";
const picker = document.createElement("div");
picker.className = "feeling-picker";
const mstate = jstate.moments[moment.id] = jstate.moments[moment.id] || {};
const effectiveFeeling = () => (mstate.feeling != null ? mstate.feeling : moment.feeling);
FEELINGS.forEach(f => {
const btn = document.createElement("button");
btn.className = "feeling-btn" + (effectiveFeeling() === f.score ? " selected" : "");
btn.title = f.label;
btn.textContent = f.emoji;
btn.addEventListener("click", () => {
const current = effectiveFeeling();
mstate.feeling = (current === f.score) ? null : f.score;
picker.querySelectorAll(".feeling-btn").forEach((b, i) => {
b.classList.toggle("selected", FEELINGS[i].score === effectiveFeeling());
});
scheduleSave();
renderCurve(journey, allMoments);
});
picker.appendChild(btn);
});
c.appendChild(picker);
grid.appendChild(c);
});
}
function addDivider(grid, text) {
const d = document.createElement("div");
d.className = "cell divider";
d.innerHTML = `<span class="line"></span><span class="label-text">${text}</span><span class="line"></span>`;
grid.appendChild(d);
}
function renderCurve(j, allMoments) {
const body = document.getElementById("curve-body");
if (!body) return;
body.innerHTML = "";
const jstate = ensureJourneyState(j.id);
const points = allMoments.map(({ moment }, i) => {
const stored = (jstate.moments[moment.id] || {}).feeling;
const f = stored != null ? stored : moment.feeling;
return f != null ? { x: i, score: f } : null;
});
const present = points.filter(Boolean);
if (present.length < 2) {
body.innerHTML = `<div class="curve-empty">Pick a feeling on at least two moments to see the curve.</div>`;
return;
}
const W = body.clientWidth || 800;
const H = 110;
const padL = 30, padR = 16, padT = 14, padB = 18;
const innerW = W - padL - padR;
const innerH = H - padT - padB;
const xFor = i => padL + (allMoments.length === 1 ? innerW/2 : (i / (allMoments.length - 1)) * innerW);
const yFor = score => padT + (1 - (score - 1) / 4) * innerH;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "curve-svg");
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.setAttribute("preserveAspectRatio", "none");
// Grid lines
[1, 2, 3, 4, 5].forEach(s => {
const y = yFor(s);
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("class", "grid-line");
line.setAttribute("x1", padL);
line.setAttribute("x2", W - padR);
line.setAttribute("y1", y);
line.setAttribute("y2", y);
svg.appendChild(line);
const lbl = document.createElementNS("http://www.w3.org/2000/svg", "text");
lbl.setAttribute("class", "axis-label");
lbl.setAttribute("x", 4);
lbl.setAttribute("y", y + 3);
lbl.textContent = FEELINGS[s-1].emoji;
svg.appendChild(lbl);
});
// Curve path through known points (skip nulls, draw straight segments between known)
let d = "";
let first = true;
points.forEach((p, i) => {
if (p == null) return;
const x = xFor(i);
const y = yFor(p.score);
d += (first ? "M" : " L") + " " + x.toFixed(1) + " " + y.toFixed(1);
first = false;
});
if (d) {
// Area fill under the curve
const firstPt = present[0];
const lastPt = present[present.length - 1];
const areaD = d + ` L ${xFor(lastPt.x).toFixed(1)} ${yFor(1).toFixed(1)} L ${xFor(firstPt.x).toFixed(1)} ${yFor(1).toFixed(1)} Z`;
const area = document.createElementNS("http://www.w3.org/2000/svg", "path");
area.setAttribute("class", "curve-area");
area.setAttribute("d", areaD);
svg.appendChild(area);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("class", "curve-path");
path.setAttribute("d", d);
svg.appendChild(path);
}
// Points
points.forEach((p, i) => {
if (p == null) return;
const c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
c.setAttribute("class", "curve-pt");
c.setAttribute("cx", xFor(i));
c.setAttribute("cy", yFor(p.score));
c.setAttribute("r", 4);
svg.appendChild(c);
});
body.appendChild(svg);
}
function autoSize(ta) {
ta.style.height = "auto";
ta.style.height = Math.max(120, ta.scrollHeight + 2) + "px";
}
// ---- Toolbar ----
document.getElementById("export-btn").addEventListener("click", () => {
const blob = new Blob([JSON.stringify(state, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const stamp = new Date().toISOString().slice(0, 16).replace(":", "");
a.href = url;
a.download = `journey-annotations-${stamp}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
const importInput = document.getElementById("import-file");
document.getElementById("import-btn").addEventListener("click", () => importInput.click());
importInput.addEventListener("change", () => {
const file = importInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const incoming = JSON.parse(reader.result);
if (typeof incoming !== "object" || incoming === null) throw new Error("Not an object");
if (!confirm("Replace your current annotations with the imported file? (Cancel to merge instead.)")) {
// Merge per-journey
Object.keys(incoming).forEach(jid => {
const cur = ensureJourneyState(jid);
const inc = incoming[jid] || {};
cur._meta = Object.assign({}, cur._meta, inc._meta || {});
cur.moments = Object.assign({}, cur.moments, inc.moments || {});
});
} else {
state = incoming;
}
saveAll(state);
flashSaved();
if (activeJourneyId) {
const j = data.journeys.find(x => x.id === activeJourneyId);
if (j) renderJourney(j);
}
} catch (e) {
alert("Couldn't import: " + e.message);
} finally {
importInput.value = "";
}
};
reader.readAsText(file);
});
// Auto-select first journey
if (data.journeys[0]) selectJourney(data.journeys[0].id);
// Help modal
const helpModal = document.getElementById("help-modal");
const helpBtn = document.getElementById("help-btn");
const helpClose = document.getElementById("help-modal-close");
function openHelp() { helpModal.classList.add("open"); }
function closeHelp() { helpModal.classList.remove("open"); }
helpBtn.addEventListener("click", openHelp);
helpClose.addEventListener("click", closeHelp);
helpModal.addEventListener("click", e => { if (e.target === helpModal) closeHelp(); });
document.addEventListener("keydown", e => {
if (e.key === "?" && !e.target.matches("input, textarea")) openHelp();
if (e.key === "Escape") closeHelp();
});
})();
</script>
</body>
</html>