journey

SkillMedia

Self-contained HTML user-journey map (boxes/arrows with per-page modals) — preview between PRD and tech-design.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the journey skill

What this skill tells your AI

The instructions your AI receives, as published by me2resh/apexyard in .claude/skills/journey/SKILL.md and read by ahel’s review.

Writing rule

When this skill writes a durable artifact, read .claude/rules/writing-standard.md. Use the controlled technical writing profile.

/journey — User-Journey HTML with Modal-Per-Page Design Preview

Generate a single self-contained HTML file at projects/<name>/journeys/<feature-slug>.html mapping the user journey as clickable boxes (each opening a modal with the page's content). Companion skill to /c4 (architecture preview) and /threat-model (security preview) — closes the gap on flow-level preview before any implementation.

When to use

TriggerUse /journey?
PRD approved, about to start tech designYes — visualises the flow the PRD describes, surfaces missing states
Stakeholder review of a new feature flowYes — single-file HTML, opens in any browser, shareable as an attachment
Quick "what does this look like as a flow?" sketchYes — conversational mode is 5 questions
Per-page visual design (high-fidelity mockups)No — /journey is flow-first; per-page mockups are designer territory
Architecture diagramsNo — use /c4 for L1/L2 architecture
Production deliverable HTMLNo — the artifact is a draft preview, not production code

Activated role

When /journey runs, activate the UX Designer role — they own user flows and information architecture. For PRD parsing in mode (a), the Product Manager is the supporting role (they wrote the PRD). See .claude/rules/role-triggers.md for the full activation protocol.

Path resolution

Read the registry path via portfolio_registry, the per-project docs dir via portfolio_projects_dir, and the ideas backlog via portfolio_ideas_backlog — all from .claude/hooks/_lib-portfolio-paths.sh. Source the helper at the top of any bash block that touches those paths:

source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-read-config.sh"
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-portfolio-paths.sh"
projects_dir=$(portfolio_projects_dir)

Defaults match today's single-fork layout (./apexyard.projects.yaml, ./projects). Adopters in split-portfolio mode override the portfolio.{registry, projects_dir} keys in .claude/project-config.json. Don't hardcode literal projects/ paths in bash blocks — the helper resolves whichever mode the adopter is in. See docs/multi-project.md.

Write targets (see me2resh/apexyard#373 + #443): paths documented as projects/<name>/X in this skill are canonical adopter-facing forms — implement them in bash as "${projects_dir}/<name>/X". Never construct from "${PWD}/projects/...", "$(git rev-parse --show-toplevel)/projects/...", or a literal ./projects/... — those break in split-portfolio v2 mode where projects_dir resolves to a sibling repo.

REQUIRED per-block preamble (see #443): Claude executes each bash block as a separate shell invocation. The projects_dir assignment from the Path resolution section above does NOT carry into later blocks. Every bash block that writes to a projects/<name>/X path MUST start with this three-line preamble so it's self-contained:

source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-read-config.sh"
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-portfolio-paths.sh"
projects_dir=$(portfolio_projects_dir)
# ... now write to "${projects_dir}/<name>/X"

The Path resolution section's example sources the helper once for documentation purposes; it does not absolve later blocks from sourcing it themselves. Treat each bash fence as a fresh process.

Usage

/journey                                        # conversational — asks for project, feature, pages, transitions
/journey checkout-v2                            # conversational, feature pre-named
/journey checkout-v2 --from-prd projects/example-app/prds/checkout.md
/journey checkout-v2 --from-yaml projects/example-app/journeys/checkout-v2.yaml
/journey checkout-v2 --update                   # regenerate HTML from existing YAML
/journey checkout-v2 --wireframe                # ask for inline HTML wireframe sketches in modals (v1, optional)

Argument shape:

FormBehaviour
No argsConversational mode; ask for the project + feature first
<feature-slug> onlyConversational mode for that feature (slug becomes the filename)
--from-prd <path>Parse pages + transitions from a PRD file
--from-yaml <path>Load structured journey data from a YAML file
--updateRe-render the HTML from <feature-slug>.yaml without re-asking
--wireframe(Default OFF) ask the agent to sketch HTML wireframes inline in each page's modal

--from-prd and --from-yaml are mutually exclusive. --update is mutually exclusive with both — it always reads from the existing YAML.

Output location

Files are written under the per-project docs dir (resolved via portfolio_projects_dir):

FilePathPurpose
YAML source-of-truth<projects_dir>/<name>/journeys/<feature-slug>.yamlEditable, diff-reviewable, regenerable input
HTML build artifact<projects_dir>/<name>/journeys/<feature-slug>.htmlSingle self-contained file — no external deps

Both are committed. The YAML so reviewers can diff flow changes across PRs; the HTML so reviewers don't need to re-render to see it.

Process

1. Resolve the target project

Same pattern as /feature, /bug, /c4:

  • If invoked from workspace/<name>/<name> is the project.
  • If invoked from the ops-fork root → ask which project this journey is for (list registered projects from apexyard.projects.yaml).
  • If exactly one project is registered → use it without asking.
  • If a <feature-slug> argument was passed and a journey already exists at <projects_dir>/<name>/journeys/<feature-slug>.yaml, infer the project from that path.

2. Resolve the feature slug

The slug becomes the filename (without extension). Prefer kebab-case, max 40 chars.

  • If <feature-slug> was passed as the first positional arg → use it.
  • If --from-prd <path> and the PRD has a clear title → derive a slug from the title.
  • Otherwise → ask: "What's the feature name? (used as the filename)".

3. Source the journey data

Three input modes — pick whichever the operator's flags imply, or ask if ambiguous.

Mode (a) — From a PRD

Triggered by --from-prd <path> (relative to the ops-fork root or absolute).

Read the PRD. Look for these structural cues to extract pages and transitions:

CueInferred
## User Flow / ## User Journey / ## Flow headingSection to parse for pages
Numbered steps under "Flow" / "Journey" / "Steps"Each step is a page candidate
- User clicks X → goes to Y patternsA transition from current page to Y
## Pages / ## Screens headingEach subheading is a page
## Acceptance Criteria with Given/When/ThenPre-conditions / post-conditions = transitions
Mermaid graph or flowchart blocksDirect edge list

If the PRD is too prose-heavy to parse mechanically, fall back to mode (b) but pre-fill the conversation with the PRD's title, problem statement, and any explicit flow notes.

Mode (b) — Conversational

Default mode when no --from-* flag is given. A 5-question micro-interview:

  1. Pages — "What pages / screens / states are in this flow? (one per line, name + 1-line description)"
  2. Entry point — "Which page does the user land on first?"
  3. Transitions — "What are the transitions? Format: <from> -> <to> on <trigger> (e.g. Login -> Dashboard on submit). One per line."
  4. Personas — "Are there multiple personas (e.g. user / admin / system)? Optional — leave blank for single-persona."
  5. Notes — "Any global notes about the flow? (e.g. 'all pages require auth except Login')"

Capture answers verbatim into the YAML structure below.

Mode (c) — From YAML

Triggered by --from-yaml <path> or --update (which uses the canonical path <projects_dir>/<name>/journeys/<feature-slug>.yaml).

Load the YAML as-is. Validate it has the required top-level keys; if not, surface what's missing and stop.

4. Validate the journey graph

Before writing files, sanity-check:

  • Every page is reachable. Walk transitions from the entry page; flag any unreachable page.
  • Every to: and from: references a page that exists. Typos here are the most common mistake.
  • Hard cap at 12 pages. A v1 journey with more than 12 pages is almost certainly two journeys; ask the operator to split.
  • Hard cap at 30 transitions. Same reasoning — too dense to render readably.

If validation fails, list the issues and ask the operator how to fix (the conversation is cheap; rendering an unreadable graph is expensive).

5. Persist the YAML

Write <projects_dir>/<name>/journeys/<feature-slug>.yaml with this shape:

version: 1
feature: <feature-slug>
project: <name>
title: <human-readable title>
description: <one-sentence description>
generated_at: <ISO-8601>
generated_by: /journey

# Optional persona dimension. When absent, single-persona flow.
personas: []  # or: [user, admin, system]

# Pages = nodes in the graph.
pages:
  - id: <kebab-case id>
    title: <Display Name>
    persona: <persona-id, optional>
    description: <one-line summary>
    contents:
      - <bullet describing what's on the page>
      - <another bullet>
    success_state: <what "success" looks like on this page, optional>
    error_state: <what error/edge/empty looks like, optional>
    image: <relative path or URL to an image, optional>
    wireframe_html: <inline HTML when --wireframe is on, optional>

# Transitions = directed edges.
transitions:
  - from: <page-id>
    to: <page-id>
    trigger: <user-readable description, e.g. "submits form">

# Entry point: the first page the user lands on.
entry: <page-id>

# Optional notes shown on the journey overview.
notes: |
  Multi-line notes about the flow, prerequisites, etc.

Write the file. If it already exists and --update was NOT passed, ask before overwriting (the YAML may have hand edits the operator wants to keep).

6. Render the HTML

Generate <projects_dir>/<name>/journeys/<feature-slug>.html — a single self-contained file with all CSS and JS inline. No <script src=…>, no <link rel="stylesheet" href=…>, no CDN references.

The HTML structure:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{title} — User Journey</title>
  <style>/* inline CSS — see § "Inline CSS" below */</style>
</head>
<body>
  <header>
    <div class="meta">
      <div class="project">{project}</div>
      <h1>{title}</h1>
      <div class="description">{description}</div>
      <div class="disclaimer">DRAFT — preview before implementation. Not a production deliverable.</div>
      <div class="timestamp">Generated {generated_at}</div>
    </div>
  </header>

  <main>
    <section class="graph">
      <svg viewBox="0 0 {width} {height}" role="img" aria-label="User journey graph">
        <!-- Boxes (one <g> per page, with id="page-{page.id}") -->
        <!-- Arrows (one <path> per transition) -->
        <!-- Click handlers attach to each <g> via inline JS below -->
      </svg>
    </section>

    {modals}  <!-- One <div class="modal" id="modal-{page.id}"> per page -->
  </main>

  <footer>
    <div>Source: <code>{yaml_path}</code></div>
    <div>Regenerate: <code>/journey {feature-slug} --update</code></div>
  </footer>

  <script>/* inline JS — see § "Inline JS" below */</script>
</body>
</html>
Layout algorithm (v1: vertical flowchart)

Compute box positions before emitting SVG:

  1. Identify the entry page (entry: field).
  2. Topologically sort pages by BFS distance from entry. Pages at the same BFS depth are in the same row.
  3. Within a row, distribute boxes evenly across the canvas width.
  4. Box dimensions: width 220px, height 80px. Row spacing: 140px. Column spacing: 40px (between boxes in the same row).
  5. Cycles → break the cycle at the longest back-edge for layout purposes; the back-edge is still drawn as an arrow with a curved path.
  6. Total canvas: width = max(rowWidths), height = (numRows - 1) * rowSpacing + boxHeight + 80px (top + bottom padding).

For v1, this layout is intentionally simple. A page with 8 outgoing transitions will look messy — that's an acceptable v1 trade-off; complex graphs are a v2 concern.

Boxes

Each page renders as an SVG <g> with a <rect> and centered <text>:

<g class="page-box" id="page-{id}" data-page-id="{id}" tabindex="0" role="button" aria-label="Open details for {title}">
  <rect x="..." y="..." width="220" height="80" rx="8" />
  <text x="..." y="..." text-anchor="middle" dominant-baseline="middle">
    <tspan class="page-title" x="...">{title}</tspan>
    <tspan class="page-persona" x="..." dy="18">{persona}</tspan>  <!-- if persona set -->
  </text>
</g>

Persona styling: if the page has a persona field, give the <g> a data-persona="{persona}" attribute and use CSS to colour-code (one colour per distinct persona, picked deterministically from a small palette). Without persona, all boxes use the default neutral fill.

Arrows

Each transition renders as an SVG <path> with a <text> label centered along the path:

<defs>
  <marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
    <path d="M0,0 L10,5 L0,10 z"/>
  </marker>
</defs>

<path class="transition-arrow"
      d="M{x1},{y1} C{cx1},{cy1} {cx2},{cy2} {x2},{y2}"
      marker-end="url(#arrowhead)" />
<text class="transition-label" x="..." y="...">{trigger}</text>

For straight downward arrows (parent row → next row), use a simple cubic curve with control points at the midpoint. For back-edges (cycles), use a wide curve that arcs to the side.

Modals

One modal per page, hidden by default:

<div class="modal" id="modal-{id}" role="dialog" aria-modal="true" aria-labelledby="modal-title-{id}" hidden>
  <div class="modal-backdrop" data-close-modal="{id}"></div>
  <div class="modal-content">
    <header>
      <h2 id="modal-title-{id}">{title}</h2>
      {persona-pill if persona set}
      <button class="modal-close" data-close-modal="{id}" aria-label="Close">×</button>
    </header>
    <section class="modal-description">{description}</section>
    <section class="modal-contents">
      <h3>Page contents</h3>
      <ul>
        {one <li> per contents bullet}
      </ul>
    </section>
    {modal-success-state section if set}
    {modal-error-state section if set}
    <section class="modal-transitions-in">
      <h3>Transitions in</h3>
      <ul>
        {one <li> per incoming transition: "from {other-page-title} on {trigger}"}
      </ul>
    </section>
    <section class="modal-transitions-out">
      <h3>Transitions out</h3>
      <ul>
        {one <li> per outgoing transition: "to {other-page-title} on {trigger}"}
      </ul>
    </section>
    {modal-image section if image set}
    {modal-wireframe section if wireframe_html set — inline HTML wrapped in a sandboxed wireframe container}
  </div>
</div>
Inline CSS (sketch — keep under ~100 lines)
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
       margin: 0; color: #1f2937; background: #f9fafb; }
header { padding: 24px 32px; background: #fff; border-bottom: 1px solid #e5e7eb; }
header h1 { margin: 0 0 4px; font-size: 1.5rem; }
header .project { font-size: 0.85rem; color: #6b7280; text-transform: uppercase; letter-spacing: 0.04em; }
header .description { color: #4b5563; margin-top: 6px; }
header .disclaimer { display: inline-block; margin-top: 12px; padding: 4px 10px;
                     background: #fef3c7; color: #92400e; font-size: 0.8rem; border-radius: 4px; font-weight: 600; }
header .timestamp { font-size: 0.75rem; color: #9ca3af; margin-top: 8px; }
main { padding: 32px; }
section.graph { display: flex; justify-content: center; }
svg { max-width: 100%; height: auto; }
.page-box rect { fill: #fff; stroke: #6366f1; stroke-width: 2; cursor: pointer;
                 transition: fill 0.15s, stroke 0.15s; }
.page-box:hover rect, .page-box:focus rect { fill: #eef2ff; stroke: #4338ca; }
.page-box text { font-size: 14px; pointer-events: none; }
.page-title { font-weight: 600; }
.page-persona { font-size: 11px; fill: #6b7280; }
.transition-arrow { fill: none; stroke: #9ca3af; stroke-width: 1.5; }
.transition-label { font-size: 11px; fill: #4b5563; pointer-events: none; }
.modal[hidden] { display: none; }
.modal { position: fixed; inset: 0; z-index: 1000; }
.modal-backdrop { position: absolute; inset: 0; background: rgba(17, 24, 39, 0.5); }
.modal-content { position: relative; max-width: 640px; margin: 5vh auto; max-height: 90vh;
                 overflow-y: auto; background: #fff; border-radius: 8px; padding: 24px;
                 box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); }
.modal-content header { display: flex; align-items: center; gap: 12px; padding: 0 0 16px;
                        border-bottom: 1px solid #e5e7eb; background: transparent; }
.modal-content header h2 { margin: 0; font-size: 1.25rem; flex: 1; }
.modal-close { background: none; border: 0; font-size: 1.5rem; cursor: pointer; color: #6b7280; }
.modal-close:hover { color: #1f2937; }
.modal-content section { margin-top: 16px; }
.modal-content h3 { font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.05em;
                    color: #6b7280; margin: 0 0 8px; }
.modal-content ul { padding-left: 20px; margin: 0; }
.persona-pill { display: inline-block; padding: 2px 8px; font-size: 0.75rem;
                background: #eef2ff; color: #4338ca; border-radius: 999px; }
footer { padding: 24px 32px; font-size: 0.8rem; color: #6b7280;
         border-top: 1px solid #e5e7eb; background: #fff; }
footer code { background: #f3f4f6; padding: 2px 6px; border-radius: 3px; font-size: 0.85em; }

Persona colour rotation (only when personas is non-empty): cycle through #6366f1, #10b981, #f59e0b, #ec4899, #8b5cf6 — apply via attribute selector .page-box[data-persona="X"] rect { stroke: ...; }.

Inline JS (sketch — keep under ~50 lines)
(function () {
  const pages = document.querySelectorAll('.page-box');
  const modals = document.querySelectorAll('.modal');

  function openModal(id) {
    const m = document.getElementById('modal-' + id);
    if (!m) return;
    m.hidden = false;
    document.body.style.overflow = 'hidden';
    const closeBtn = m.querySelector('.modal-close');
    if (closeBtn) closeBtn.focus();
  }

  function closeModal(id) {
    const m = document.getElementById('modal-' + id);
    if (!m) return;
    m.hidden = true;
    document.body.style.overflow = '';
    const trigger = document.getElementById('page-' + id);
    if (trigger) trigger.focus();
  }

  pages.forEach((p) => {
    const id = p.dataset.pageId;
    p.addEventListener('click', () => openModal(id));
    p.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openModal(id); }
    });
  });

  document.querySelectorAll('[data-close-modal]').forEach((el) => {
    el.addEventListener('click', () => closeModal(el.dataset.closeModal));
  });

  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') {
      modals.forEach((m) => { if (!m.hidden) closeModal(m.id.replace(/^modal-/, '')); });
    }
  });
})();

7. Confirm to the user

✓ <project>: Journey written

  YAML: <projects_dir>/<name>/journeys/<feature-slug>.yaml  (source of truth)
  HTML: <projects_dir>/<name>/journeys/<feature-slug>.html  (preview)

  Pages: <N>      Transitions: <M>      Personas: <P or "single">

Open the HTML directly in a browser. To edit, change the YAML and run:
  /journey <feature-slug> --update

If this was a --from-prd run, append a one-line tip:

  Source PRD: <path>

Rules

  1. Single self-contained HTML — no external CSS, JS, or font references. Everything inline. The whole point is "open the file, see the journey" — a fetch failure on a CDN should never break the artifact.
  2. YAML is the source of truth — never hand-edit the HTML. --update regenerates from YAML; the HTML is replaced entirely on each run.
  3. Hard cap at 12 pages, 30 transitions — bigger journeys are usually two journeys. Ask the operator to split.
  4. Modal-per-page is mandatory — even if the modal only has a one-line description. The interaction model is "click a box, see the detail"; without modals the artifact is just an unstyled flowchart.
  5. Don't fabricate page contents — in mode (b), if the operator didn't list contents for a page, the modal shows the description only. Don't invent bullets the operator didn't specify.
  6. --wireframe is opt-in (v1) — default to prose-only modals. Wireframe sketches are bounded by what the agent can produce in plain HTML; document this in the modal so reviewers don't mistake a sketch for a real design.
  7. Disclaimer banner is mandatory — every generated HTML carries the "DRAFT — preview before implementation" line. The artifact is a flow-mapping tool, not a design deliverable.
  8. No JS frameworks, no build step — vanilla DOM API only. The reader must be able to open the HTML in any browser without a server.

Out of scope (v1)

  • Swimlane layout — single-persona flowchart only. Multi-persona swimlanes are a v2 question (gated on the personas: field).
  • Live preview / hot-reload--update is the only regen path.
  • Cross-project journeys — one project per invocation. Portfolio-spanning flows (e.g. shared auth across projects) are a v2 concern.
  • Real designtool integration (Figma API, Sketch export) — v1 takes paths or URLs only; deeper integration is v3.
  • A11y / responsive testing of the generated HTML — the journey is a preview artifact, not a production deliverable; basic keyboard + ARIA is provided but not exhaustively tested.
  • Image embedding via base64 — v1 references images by relative path or URL only. Base64 inflates the file size; if the operator wants a self-contained build, they reference images on the same disk and copy both files together.

Relationship to other skills

SkillRelationship
/write-specProduces the PRD that feeds /journey --from-prd. Sequential pair.
/c4Sibling preview-before-build skill. /c4 shows architecture; /journey shows user flow.
/threat-modelSibling skill. /threat-model shows attack surface; /journey shows user flow.
/feature / /bug / /taskTracker creation. /journey is sometimes attached to a feature ticket as a flow preview.

Part of ApexYard — multi-project SDLC framework for Claude Code · MIT.

Signals

GitHub stars
501
Forks
274
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
journey-me2resh
Source
github.com/me2resh/apexyard