snappy-walkthrough skill
annotate out-dirwrite-reversiblecapture recipewrite-reversiblegates out-dirreadlesson out-dirreadrun recipewrite-reversiblestatusread$ npx snappy-skills install snappy-walkthrough
$ npx snappy-skills install --all
$ npx snappy-skills update
Capture step-by-step walkthroughs (Claude Code setup, skill install, MCP
wiring) as annotated PNG sequences + markdown lesson blocks. Recipe-driven.
The setpoint for "premium" lives in contract-gates.md -- every hand
review tightens it. The skill does NOT auto-tune.
One recipe, one pipeline, one gate file. Recipes declare steps.
Each step has a source (where the image comes from) and annotations (boxes,
arrows, callouts, pins) in pixel coordinates. The pipeline is
capture -> annotate -> runGates -> buildLessonBlock. The gate file is the
memory of quality across runs.
When you review output and spot a defect: add a rule to
contract-gates.md, wire it in api.ts's GATES array, re-run. That
defect now can't happen again silently.
typescriptimport {
loadWalkthroughRecipe,
runRecipe,
captureStep,
annotateStep,
runGates,
buildLessonBlock,
} from "../snappy-walkthrough/api.ts";
| Function | What it does |
|---|---|
loadWalkthroughRecipe(path) |
Parse JSON recipe, validate required fields, return Recipe |
runRecipe(recipe) |
Full pipeline: capture -> annotate -> gates -> lesson. Returns output dir |
captureStep(step, outDir) |
Dispatch to source handler; writes raw PNG + returns CaptureResult |
annotateStep(step, capture) |
Write SVG overlay + composited PNG + meta.json |
runGates(outDir) |
Read meta.json files, evaluate GATES array, write gates.log |
buildLessonBlock(outDir, recipe?) |
Emit lesson.md ready for snappy-course |
CLI:
bashnpx tsx ~/.claude/skills/snappy-walkthrough/api.ts run recipes/claude-code-first-session.json
npx tsx ~/.claude/skills/snappy-walkthrough/api.ts capture recipes/<name>.json
npx tsx ~/.claude/skills/snappy-walkthrough/api.ts annotate out/<recipe>/<stamp>
npx tsx ~/.claude/skills/snappy-walkthrough/api.ts gates out/<recipe>/<stamp>
npx tsx ~/.claude/skills/snappy-walkthrough/api.ts lesson out/<recipe>/<stamp>
| Source | Status | How |
|---|---|---|
macos-window |
WIRED (v0) | peekaboo image --app or screencapture -l |
browser |
v1 planned | delegates to snappy-browse (agent-browser) |
file-render |
v1 planned | silicon CLI for code->PNG |
terminal-exec |
v1 planned | run command, pipe stdout through silicon |
The skill throws a clear "Source X not wired in v0" error if a recipe
uses an unwired source. That is a boundary failure, not a silent pass.
When wait_for_enter: true, after you press ENTER the skill sleeps 3 seconds before capturing. Use that window to CMD+TAB or bring the target window to the front. This exists because the controller (Claude Code in Terminal.app) and the demo target (another Terminal.app window) share an app -- peekaboo's "frontmost of Terminal" will pick whichever window you touched most recently. The 3-second delay lets you move focus after committing the ENTER.
If the controller and target are in different apps (e.g. Claude Code in iTerm2 driving a demo in Terminal.app), peekaboo targets by --app cleanly and the delay is still harmless.
peekaboo (preferred) takes an app name directly:
bashpeekaboo image --app "Terminal" --path /tmp/step.png
Fallback via screencapture -l <id> requires a CGWindowID. The skill
resolves it from window_title via AppleScript at capture time:
bashosascript -e 'id of window 1 of application "Terminal"'
Set window_title in the recipe -- the skill picks the right primitive.
All four kinds use image-pixel coordinates. Origin is top-left.
| Kind | Fields | Renders as |
|---|---|---|
box |
box: [x, y, w, h] |
Rounded outline (stroke width 4) |
arrow |
from: [x, y], to: [x, y] |
Line with filled arrowhead |
callout |
at: [x, y], text: "..." |
Rounded white bubble w/ colored border |
pin |
at: [x, y], number? |
Filled circle w/ white number |
Optional color: "#hex" on any annotation; default is snappy primary orange.
The annotate layer writes:
step-NN.svg -- overlay SVG containing the base image + annotation shapesstep-NN.overlay.svg -- annotations only, used for ImageMagick compositingstep-NN.png -- flat composited PNG (falls back to raw if ImageMagick missing)step-NN.meta.json -- step definition, dimensions, timestampBrew deps: brew install imagemagick peekaboo (both optional -- skill
degrades to SVG-only + screencapture fallback).
contract-gates.md holds the rubric. api.ts GATES array implements
each rule. v0 rules:
| Rule ID | What |
|---|---|
step-ids-monotonic |
Step IDs unique and sorted |
caption-length |
Captions <=12 words |
annotation-in-bounds |
All coordinates inside the image |
png-exists |
Every step produced a PNG |
banned-phrases |
No "let's/simply/just/easily/seamlessly" |
To add a rule: review a run, identify a defect, add an object to
GATES in api.ts, document it in contract-gates.md, re-run. Human
drives the tune -- the skill is the enforcer.
out/<recipe-name>/<YYYY-MM-DD-HH-MM-SS>/
recipe.json # copy of the recipe run
step-01.raw.png # unannotated capture
step-01.png # composited (ImageMagick)
step-01.svg # overlay with base image (editable)
step-01.overlay.svg # overlay only (used for compositing)
step-01.meta.json # step + dims + capturedAt
...
lesson.md # ready for snappy-course
gates.log # PASS/FAIL lines; this is the certificate
gates.log is the verification certificate for the run -- produced by a
fresh filesystem read after capture + annotate finish. Actor (capture) is
not auditor (gates).
First run on a new machine:
bashbrew install imagemagick peekaboo
cd ~/.claude/skills/snappy-walkthrough
npx tsx api.ts run recipes/claude-code-first-session.json
# ENTER to capture each step when Terminal.app is in the right state
Iterate on coordinates after a first capture: edit the recipe JSON, then
re-run only annotate + gates + lesson:
bashnpx tsx api.ts annotate out/<recipe>/<stamp>
npx tsx api.ts gates out/<recipe>/<stamp>
npx tsx api.ts lesson out/<recipe>/<stamp>
This reuses the raw PNGs -- no re-capture needed.
Review the gate report:
bashcat out/<recipe>/<stamp>/gates.log
Any FAIL lines are defects the skill wants you to address before this run
counts as shippable.
Nothing in the collection names this skill.
<!-- SNAPPY-CONTRACT-VERBS-START -->
Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
annotate |
out-dir |
write-reversible |
npx tsx ~/.claude/skills/snappy-walkthrough/api.ts annotate <out-dir> |
capture |
recipe |
write-reversible |
npx tsx ~/.claude/skills/snappy-walkthrough/api.ts capture <recipe> |
gates |
out-dir |
read |
npx tsx ~/.claude/skills/snappy-walkthrough/api.ts gates <out-dir> |
lesson |
out-dir |
read |
npx tsx ~/.claude/skills/snappy-walkthrough/api.ts lesson <out-dir> |
run |
recipe |
write-reversible |
npx tsx ~/.claude/skills/snappy-walkthrough/api.ts run <recipe> |
status |
— | read |
npx tsx ~/.claude/skills/snappy-walkthrough/api.ts status |
When an answer carries face_hint, show it with one snappy_present(<answer>) call.
See /snappy-faces for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
The system spec lives at ~/.claude/skills/snappy-settings/skill-spec.md. It defines the entire Snappy operating system. You enforce it.
1. Fix gaps (P -- proportional). MANDATORY. If this AGENTS.md didn't cover your case -- if you had to read another file, run a command not documented here, work around a wrong selector, or discover a quirk -- you MUST attempt an Edit before you can log.
2. Validate structure. Check this skill against the spec:
api.ts exists with typed exports, CLI mode, import { env } from "../snappy-settings/load.ts" (if credentials are needed)api.ts calls APIs directly (no Xano proxy, unless this skill IS a Xano DB skill)AGENTS.md has API module section with import + function table + CLIcertificate: block (premises / action / trace / evidence / conclusion). Same-session toasts are NOT evidence -- must be a fresh independent read. For this skill the certificate is gates.log, written after capture+annotate by a fresh filesystem read.~/.claude/skills/snappy-settings/scripts/dry-check.sh <name-or-keyword>. If it matches an existing skill, extend that one instead.2b. If you just reported an action as successful, verify your certificate. You may not claim PASS unless the evidence: line is a fresh independent read. If your evidence is a toast, a return value, or a same-session DOM read, downgrade the claim to INCONCLUSIVE and log to ~/.claude/logs/verification-gaps.log.
3. Log always.
bashecho "[$(date -u +%FT%TZ)] snappy-walkthrough: <what was missing or fixed> [FIXED|LOGGED]" >> ~/.claude/logs/agents-md-feedback.log
FIXED = you patched it. LOGGED = too large for inline fix, queued for drain-skill-regen.sh.
Do not skip this. Every agent run must leave the system better than it found it.
Show produced work with snappy-faces: call draw for image channels or lang for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-walkthrough Index]|root: ~/.claude/skills/snappy-walkthrough|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md,contract-gates.md}|out:{smoke-test/manual/lesson.md}
<!-- SKILL-INDEX-END -->
---
name: snappy-walkthrough
role: Recipe-driven annotated tutorial capture with contract-gate PID loop
loaded-by: PreToolUse hook (auto-injected when "snappy-walkthrough" is mentioned)
---
# snappy-walkthrough -- Agent Loader
Capture step-by-step walkthroughs (Claude Code setup, skill install, MCP
wiring) as annotated PNG sequences + markdown lesson blocks. Recipe-driven.
The setpoint for "premium" lives in `contract-gates.md` -- every hand
review tightens it. The skill does NOT auto-tune.
## Operating principle
**One recipe, one pipeline, one gate file.** Recipes declare steps.
Each step has a source (where the image comes from) and annotations (boxes,
arrows, callouts, pins) in pixel coordinates. The pipeline is
`capture -> annotate -> runGates -> buildLessonBlock`. The gate file is the
memory of quality across runs.
When you review output and spot a defect: add a rule to
`contract-gates.md`, wire it in `api.ts`'s `GATES` array, re-run. That
defect now can't happen again silently.
## API module
```typescript
import {
loadWalkthroughRecipe,
runRecipe,
captureStep,
annotateStep,
runGates,
buildLessonBlock,
} from "../snappy-walkthrough/api.ts";
```
| Function | What it does |
|----------|-------------|
| `loadWalkthroughRecipe(path)` | Parse JSON recipe, validate required fields, return Recipe |
| `runRecipe(recipe)` | Full pipeline: capture -> annotate -> gates -> lesson. Returns output dir |
| `captureStep(step, outDir)` | Dispatch to source handler; writes raw PNG + returns CaptureResult |
| `annotateStep(step, capture)` | Write SVG overlay + composited PNG + meta.json |
| `runGates(outDir)` | Read meta.json files, evaluate GATES array, write `gates.log` |
| `buildLessonBlock(outDir, recipe?)` | Emit `lesson.md` ready for snappy-course |
CLI:
```bash
npx tsx ~/.claude/skills/snappy-walkthrough/api.ts run recipes/claude-code-first-session.json
npx tsx ~/.claude/skills/snappy-walkthrough/api.ts capture recipes/<name>.json
npx tsx ~/.claude/skills/snappy-walkthrough/api.ts annotate out/<recipe>/<stamp>
npx tsx ~/.claude/skills/snappy-walkthrough/api.ts gates out/<recipe>/<stamp>
npx tsx ~/.claude/skills/snappy-walkthrough/api.ts lesson out/<recipe>/<stamp>
```
## Sources (v0 wired, v1+ planned)
| Source | Status | How |
|----------------|-------------|--------------------------------------------|
| `macos-window` | WIRED (v0) | `peekaboo image --app` or `screencapture -l` |
| `browser` | v1 planned | delegates to `snappy-browse` (agent-browser) |
| `file-render` | v1 planned | `silicon` CLI for code->PNG |
| `terminal-exec`| v1 planned | run command, pipe stdout through silicon |
The skill throws a clear `"Source X not wired in v0"` error if a recipe
uses an unwired source. That is a boundary failure, not a silent pass.
### Interactive capture flow (important)
When `wait_for_enter: true`, after you press ENTER the skill **sleeps 3 seconds before capturing**. Use that window to CMD+TAB or bring the target window to the front. This exists because the controller (Claude Code in Terminal.app) and the demo target (another Terminal.app window) share an app -- peekaboo's "frontmost of Terminal" will pick whichever window you touched most recently. The 3-second delay lets you move focus after committing the ENTER.
If the controller and target are in different apps (e.g. Claude Code in iTerm2 driving a demo in Terminal.app), peekaboo targets by `--app` cleanly and the delay is still harmless.
### macos-window resolution
`peekaboo` (preferred) takes an app name directly:
```bash
peekaboo image --app "Terminal" --path /tmp/step.png
```
Fallback via `screencapture -l <id>` requires a CGWindowID. The skill
resolves it from `window_title` via AppleScript at capture time:
```bash
osascript -e 'id of window 1 of application "Terminal"'
```
Set `window_title` in the recipe -- the skill picks the right primitive.
## Annotations
All four kinds use image-pixel coordinates. Origin is top-left.
| Kind | Fields | Renders as |
|------|--------|-----------|
| `box` | `box: [x, y, w, h]` | Rounded outline (stroke width 4) |
| `arrow` | `from: [x, y]`, `to: [x, y]` | Line with filled arrowhead |
| `callout` | `at: [x, y]`, `text: "..."` | Rounded white bubble w/ colored border |
| `pin` | `at: [x, y]`, `number?` | Filled circle w/ white number |
Optional `color: "#hex"` on any annotation; default is snappy primary orange.
The annotate layer writes:
- `step-NN.svg` -- overlay SVG containing the base image + annotation shapes
- `step-NN.overlay.svg` -- annotations only, used for ImageMagick compositing
- `step-NN.png` -- flat composited PNG (falls back to raw if ImageMagick missing)
- `step-NN.meta.json` -- step definition, dimensions, timestamp
Brew deps: `brew install imagemagick peekaboo` (both optional -- skill
degrades to SVG-only + screencapture fallback).
## Contract gates (the PID setpoint)
`contract-gates.md` holds the rubric. `api.ts` `GATES` array implements
each rule. v0 rules:
| Rule ID | What |
|------------------------|------|
| `step-ids-monotonic` | Step IDs unique and sorted |
| `caption-length` | Captions <=12 words |
| `annotation-in-bounds` | All coordinates inside the image |
| `png-exists` | Every step produced a PNG |
| `banned-phrases` | No "let's/simply/just/easily/seamlessly" |
**To add a rule**: review a run, identify a defect, add an object to
`GATES` in `api.ts`, document it in `contract-gates.md`, re-run. Human
drives the tune -- the skill is the enforcer.
## Output layout
```
out/<recipe-name>/<YYYY-MM-DD-HH-MM-SS>/
recipe.json # copy of the recipe run
step-01.raw.png # unannotated capture
step-01.png # composited (ImageMagick)
step-01.svg # overlay with base image (editable)
step-01.overlay.svg # overlay only (used for compositing)
step-01.meta.json # step + dims + capturedAt
...
lesson.md # ready for snappy-course
gates.log # PASS/FAIL lines; this is the certificate
```
`gates.log` is the verification certificate for the run -- produced by a
fresh filesystem read after capture + annotate finish. Actor (capture) is
not auditor (gates).
## Common operations
**First run on a new machine:**
```bash
brew install imagemagick peekaboo
cd ~/.claude/skills/snappy-walkthrough
npx tsx api.ts run recipes/claude-code-first-session.json
# ENTER to capture each step when Terminal.app is in the right state
```
**Iterate on coordinates after a first capture**: edit the recipe JSON, then
re-run only annotate + gates + lesson:
```bash
npx tsx api.ts annotate out/<recipe>/<stamp>
npx tsx api.ts gates out/<recipe>/<stamp>
npx tsx api.ts lesson out/<recipe>/<stamp>
```
This reuses the raw PNGs -- no re-capture needed.
**Review the gate report:**
```bash
cat out/<recipe>/<stamp>/gates.log
```
Any FAIL lines are defects the skill wants you to address before this run
counts as shippable.
## Used by
Nothing in the collection names this skill.
<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs
Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `annotate` | `out-dir` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-walkthrough/api.ts annotate <out-dir>` |
| `capture` | `recipe` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-walkthrough/api.ts capture <recipe>` |
| `gates` | `out-dir` | `read` | `npx tsx ~/.claude/skills/snappy-walkthrough/api.ts gates <out-dir>` |
| `lesson` | `out-dir` | `read` | `npx tsx ~/.claude/skills/snappy-walkthrough/api.ts lesson <out-dir>` |
| `run` | `recipe` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-walkthrough/api.ts run <recipe>` |
| `status` | — | `read` | `npx tsx ~/.claude/skills/snappy-walkthrough/api.ts status` |
## Show the result
When an answer carries `face_hint`, show it with one `snappy_present(<answer>)` call.
See `/snappy-faces` for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
---
## Self-correcting loader (PID feedback)
The system spec lives at `~/.claude/skills/snappy-settings/skill-spec.md`. It defines the entire Snappy operating system. You enforce it.
### Before you finish, do two things:
**1. Fix gaps (P -- proportional). MANDATORY.** If this AGENTS.md didn't cover your case -- if you had to read another file, run a command not documented here, work around a wrong selector, or discover a quirk -- you MUST attempt an `Edit` before you can log.
- **Default action: edit this AGENTS.md.** Surgical. Add the missing line, table row, or code example where it belongs. No restructuring.
- **Only LOGGED is allowed when:** the fix needs >10 lines, spans multiple files, or requires a structural rewrite (in which case the drain-skill-regen.sh queue picks it up).
- "I didn't have time" / "it's minor" / "the next agent will figure it out" are NOT valid reasons to skip the Edit. The whole point of this loop is that minor gaps compound.
- The goal: the next agent never has to leave the loader.
**2. Validate structure.** Check this skill against the spec:
- `api.ts` exists with typed exports, CLI mode, `import { env } from "../snappy-settings/load.ts"` (if credentials are needed)
- `api.ts` calls APIs directly (no Xano proxy, unless this skill IS a Xano DB skill)
- `AGENTS.md` has API module section with import + function table + CLI
- No shell fallback paths, no hardcoded tokens anywhere
- **Every action row carries a `certificate:` block** (premises / action / trace / evidence / conclusion). Same-session toasts are NOT evidence -- must be a fresh independent read. For this skill the certificate is `gates.log`, written after capture+annotate by a fresh filesystem read.
- **DRY:** before you add a new function or new skill, run `~/.claude/skills/snappy-settings/scripts/dry-check.sh <name-or-keyword>`. If it matches an existing skill, extend that one instead.
- If any check fails -> fix it or log it
**2b. If you just reported an action as successful, verify your certificate.** You may not claim PASS unless the `evidence:` line is a fresh independent read. If your evidence is a toast, a return value, or a same-session DOM read, downgrade the claim to INCONCLUSIVE and log to `~/.claude/logs/verification-gaps.log`.
**3. Log always.**
```bash
echo "[$(date -u +%FT%TZ)] snappy-walkthrough: <what was missing or fixed> [FIXED|LOGGED]" >> ~/.claude/logs/agents-md-feedback.log
```
`FIXED` = you patched it. `LOGGED` = too large for inline fix, queued for drain-skill-regen.sh.
**Do not skip this.** Every agent run must leave the system better than it found it.
Show produced work with `snappy-faces`: call `draw` for image channels or `lang` for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-walkthrough Index]|root: ~/.claude/skills/snappy-walkthrough|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md,contract-gates.md}|out:{smoke-test/manual/lesson.md}
<!-- SKILL-INDEX-END -->
Capture and annotate the walkthroughs Robert gives on calls as premium
still sequences + markdown lesson blocks. Every call where he screen-shares
a setup flow is a candidate: Claude Code install, skill install, MCP wiring,
hook authoring.
Recipe-driven: a JSON recipe declares each step's source (where the
image comes from) and annotations (boxes, arrows, callouts, pins). The
skill runs capture -> annotate -> gate-check -> lesson-block as a pipeline.
The setpoint for "premium" is contract-gates.md. The loop:
contract-gates.mdThe skill does not auto-tune the gates. The gate file IS the long-term
memory of quality -- it's how the PID loop accumulates taste over time.
| Phase | Sources wired | Outputs | State |
|---|---|---|---|
| v0 | macos-window (peekaboo / screencapture -l) |
stills + lesson.md | SHIPPED |
| v1 | + browser (snappy-browse), + file-render (silicon), strip composite |
same + strip | planned |
| v2 | + terminal-exec, animated walkthrough |
+ MP4 via Remotion/ffmpeg | planned |
Deferred with rationale:
when a second recipe justifies the ergonomic win.
driven from Terminal.app. v1 delegates to snappy-browse (which wraps
agent-browser/Playwright). Storage state goes in
state/<recipe>.auth.json.
preferred, brew install). Shipped when a recipe actually needs them.
rules; the skill enforces them.
do it live anyway
Not for:
json{
"name": "claude-code-first-session",
"title": "From zero to your first Claude Code session",
"summary": "One-paragraph intro used as the lesson block preamble.",
"steps": [
{
"id": "01",
"title": "Install Claude Code",
"caption": "Run the global install once per machine",
"source": "macos-window",
"window_title": "Terminal",
"wait_for_enter": true,
"annotations": [
{ "kind": "callout", "at": [60, 60], "text": "Global install, done once" }
]
}
]
}
Field reference:
| Field | Required | What | |||
|---|---|---|---|---|---|
name |
yes | Slug used as out/<name>/<timestamp>/ directory |
|||
title |
yes | Human title for the lesson block | |||
summary |
yes | One-paragraph intro in the lesson block | |||
steps[] |
yes | 1+ steps, executed in order | |||
steps[].id |
yes | Zero-padded sort key: "01", "02", ... |
|||
steps[].title |
yes | Short step heading | |||
steps[].caption |
yes | <=12 words (gate-checked), the image alt text | |||
steps[].source |
yes | macos-window (v0) \ |
browser \ |
file-render \ |
terminal-exec |
steps[].wait_for_enter |
no | Pause before capture so Robert can set the scene | |||
steps[].annotations[] |
yes | 0+ annotations in pixel coordinates |
Source-specific fields:
window_title (matched against frontmost app window) orwindow_id (pre-resolved CGWindowID). window_title is preferred --
resolved live at capture time.
All four kinds use pixel coordinates against the captured image.
| Kind | Required fields | Draws |
|---|---|---|
box |
box: [x, y, w, h] |
Rounded rectangle outline in primary color |
arrow |
from: [x, y], to: [x, y] |
Line with filled arrowhead |
callout |
at: [x, y], text: "..." |
Rounded text bubble |
pin |
at: [x, y], optional number |
Numbered filled circle |
Optional on any annotation:
color: "#hex" -- defaults to snappy primary orangeThe annotate layer writes an SVG overlay next to each raw PNG so humans can
hand-edit and re-composite. If ImageMagick is installed, the skill also
composites a flat PNG for distribution. Without ImageMagick the SVG is the
authoritative output.
contract-gates.md holds the current rubric. api.ts implements each rule
in the GATES array; the markdown file is the human-readable source of
truth that evolves per review.
v0 rules:
step-ids-monotonic -- step IDs unique and sortedcaption-length -- captions <=12 wordsannotation-in-bounds -- every coordinate inside the imagepng-exists -- every step produced a PNGbanned-phrases -- no "let's / simply / just / easily / seamlessly" incaptions
Workflow: review -> spot defect -> add rule -> re-run. See
contract-gates.md for the add-a-rule procedure.
bashcd ~/.claude/skills/snappy-walkthrough
npx tsx api.ts run recipes/claude-code-first-session.json
First run will prompt you to set each scene in Terminal.app, then press
ENTER to capture. Outputs land in out/<recipe>/<timestamp>/ with:
step-01.raw.png # unannotated capture
step-01.png # composited (if ImageMagick installed)
step-01.svg # annotation overlay (editable)
step-01.meta.json # dimensions, source, timestamps
...
lesson.md # ready to paste into snappy-course
gates.log # PASS/FAIL per rule
recipe.json # copy of the recipe used
typescriptimport {
loadWalkthroughRecipe,
runRecipe,
captureStep,
annotateStep,
runGates,
buildLessonBlock,
} from "../snappy-walkthrough/api.ts";
See AGENTS.md for the function table and CLI.
This skill's outputs are files, not external actions. The certificate
equivalent is gates.log -- written by runGates() after capture and
annotate complete. Each line is produced by a fresh filesystem read
(existsSync, readFileSync) on files written by independent child
processes (screencapture, magick). That satisfies "actor is not
auditor": the capture layer writes, the gates layer reads.
Same-session toasts are not evidence here because the skill never produces
toasts -- every verification is a file read.
snappy-browse -- v1 browser source delegates here (agent-browser / Playwright)snappy-image -- palette tokens, uploadToCdn for later publishing,provider-picker table lists the underlying capture primitives
snappy-course -- consumer of lesson.md outputssnappy-positioning -- banned-phrase vocabulary for the caption gatesnappy-settings -- env() loader if/when browser source needs credsvideo-pipeline / remotion-best-practices -- v2 walkthrough video targetRobert walks people through the same setups repeatedly on calls. Every call
is a lesson trying to get out. The goal is to make "capturing the walk" a
one-command operation with a self-tightening quality bar, so the fifth
person who needs "how do I set up Claude Code" gets a premium artifact
instead of another 20 minutes of Robert's time.
Skills whose description overlaps this one enough that a reader could pick the
wrong door. Each row is that skill's own first sentence about itself, so the
choice is made on its words, not on a summary written here.
| Skill | Reach for it instead when |
|---|---|
snappy-agent-host |
Run the REAL Claude Code, Codex, and Gemini CLIs through ACP via the skills MCP, with durable… |
snappy-cleanshot |
CleanShot X local capture primitive. |
snappy-transcripts |
Transcript retrieval, search, and processing for Snappy. |
snappy-voice-control |
Voice control on macOS, extracted from two shipping open-source agents (fazm by mediar-ai; Ag… |
---
name: snappy-walkthrough
description: >
Recipe-driven capture and annotation of step-by-step tutorials. Turns the
walkthroughs Robert gives on calls ("how I set up Claude Code", "how I
install a skill", "how I wire an MCP") into premium annotated still
sequences, ready-to-paste markdown lesson blocks, and (phase 2) animated
walkthrough videos. The PID setpoint for "premium" lives in
contract-gates.md -- every hand-review tightens the rubric.
Triggers on: walkthrough, annotated tutorial, step-by-step capture,
annotated screenshot, boxes and arrows, click-through tutorial,
capture my call, record setup steps, tutorial capture, snappy walkthrough,
how-to capture, annotated screen recording, window screenshot annotation.
---
# snappy-walkthrough -- Annotated step tutorials, contract-gated
## Purpose
Capture and annotate the walkthroughs Robert gives on calls as premium
still sequences + markdown lesson blocks. Every call where he screen-shares
a setup flow is a candidate: Claude Code install, skill install, MCP wiring,
hook authoring.
Recipe-driven: a JSON recipe declares each step's **source** (where the
image comes from) and **annotations** (boxes, arrows, callouts, pins). The
skill runs `capture -> annotate -> gate-check -> lesson-block` as a pipeline.
The setpoint for "premium" is `contract-gates.md`. The loop:
1. Run a recipe -> capture + annotate + lesson block
2. Robert hand-reviews outputs
3. For every defect he saw, he adds a rule to `contract-gates.md`
4. Next run the gate catches that defect automatically
The skill does not auto-tune the gates. The gate file IS the long-term
memory of quality -- it's how the PID loop accumulates taste over time.
## Phase status
| Phase | Sources wired | Outputs | State |
|-------|------------------------------|---------------------------|-------|
| v0 | `macos-window` (peekaboo / screencapture -l) | stills + lesson.md | SHIPPED |
| v1 | + `browser` (snappy-browse), + `file-render` (silicon), strip composite | same + strip | planned |
| v2 | + `terminal-exec`, animated walkthrough | + MP4 via Remotion/ffmpeg | planned |
Deferred with rationale:
- **YAML recipes** -- v0 uses JSON to avoid an extra dependency. Upgrade
when a second recipe justifies the ergonomic win.
- **Browser source** -- v0 skips this because Robert's first flow is
driven from Terminal.app. v1 delegates to `snappy-browse` (which wraps
agent-browser/Playwright). Storage state goes in
`state/<recipe>.auth.json`.
- **File-render / terminal-exec** -- need a code->PNG renderer (silicon
preferred, brew install). Shipped when a recipe actually needs them.
- **Contract gate auto-tuning** -- explicitly out of scope. Humans add
rules; the skill enforces them.
## When to use
- Robert is about to explain the same setup flow on a call for the 5th time
- A lesson in snappy-course needs a screenshot sequence and he's going to
do it live anyway
- Any "how I set up X" content where the setup is the lesson
Not for:
- Pure code walkthroughs (use snappy-course with inline code blocks)
- Video demos that need narration (use video-pipeline)
- One-off debugging screenshots (just use screencapture directly)
## Recipe format (JSON)
```json
{
"name": "claude-code-first-session",
"title": "From zero to your first Claude Code session",
"summary": "One-paragraph intro used as the lesson block preamble.",
"steps": [
{
"id": "01",
"title": "Install Claude Code",
"caption": "Run the global install once per machine",
"source": "macos-window",
"window_title": "Terminal",
"wait_for_enter": true,
"annotations": [
{ "kind": "callout", "at": [60, 60], "text": "Global install, done once" }
]
}
]
}
```
Field reference:
| Field | Required | What |
|-------|----------|------|
| `name` | yes | Slug used as `out/<name>/<timestamp>/` directory |
| `title` | yes | Human title for the lesson block |
| `summary` | yes | One-paragraph intro in the lesson block |
| `steps[]` | yes | 1+ steps, executed in order |
| `steps[].id` | yes | Zero-padded sort key: `"01"`, `"02"`, ... |
| `steps[].title` | yes | Short step heading |
| `steps[].caption` | yes | <=12 words (gate-checked), the image alt text |
| `steps[].source` | yes | `macos-window` (v0) \| `browser` \| `file-render` \| `terminal-exec` |
| `steps[].wait_for_enter` | no | Pause before capture so Robert can set the scene |
| `steps[].annotations[]` | yes | 0+ annotations in pixel coordinates |
Source-specific fields:
- **macos-window**: `window_title` (matched against frontmost app window) or
`window_id` (pre-resolved CGWindowID). `window_title` is preferred --
resolved live at capture time.
## Annotations
All four kinds use pixel coordinates against the captured image.
| Kind | Required fields | Draws |
|------|-----------------|-------|
| `box` | `box: [x, y, w, h]` | Rounded rectangle outline in primary color |
| `arrow` | `from: [x, y]`, `to: [x, y]` | Line with filled arrowhead |
| `callout` | `at: [x, y]`, `text: "..."` | Rounded text bubble |
| `pin` | `at: [x, y]`, optional `number` | Numbered filled circle |
Optional on any annotation:
- `color: "#hex"` -- defaults to snappy primary orange
The annotate layer writes an SVG overlay next to each raw PNG so humans can
hand-edit and re-composite. If ImageMagick is installed, the skill also
composites a flat PNG for distribution. Without ImageMagick the SVG is the
authoritative output.
## Contract gates -- the PID setpoint
`contract-gates.md` holds the current rubric. `api.ts` implements each rule
in the `GATES` array; the markdown file is the human-readable source of
truth that evolves per review.
v0 rules:
- `step-ids-monotonic` -- step IDs unique and sorted
- `caption-length` -- captions <=12 words
- `annotation-in-bounds` -- every coordinate inside the image
- `png-exists` -- every step produced a PNG
- `banned-phrases` -- no "let's / simply / just / easily / seamlessly" in
captions
Workflow: review -> spot defect -> add rule -> re-run. See
`contract-gates.md` for the add-a-rule procedure.
## Quick start
```bash
cd ~/.claude/skills/snappy-walkthrough
npx tsx api.ts run recipes/claude-code-first-session.json
```
First run will prompt you to set each scene in Terminal.app, then press
ENTER to capture. Outputs land in `out/<recipe>/<timestamp>/` with:
```
step-01.raw.png # unannotated capture
step-01.png # composited (if ImageMagick installed)
step-01.svg # annotation overlay (editable)
step-01.meta.json # dimensions, source, timestamps
...
lesson.md # ready to paste into snappy-course
gates.log # PASS/FAIL per rule
recipe.json # copy of the recipe used
```
## API
```typescript
import {
loadWalkthroughRecipe,
runRecipe,
captureStep,
annotateStep,
runGates,
buildLessonBlock,
} from "../snappy-walkthrough/api.ts";
```
See `AGENTS.md` for the function table and CLI.
## Verification certificates
This skill's outputs are files, not external actions. The certificate
equivalent is `gates.log` -- written by `runGates()` after capture and
annotate complete. Each line is produced by a fresh filesystem read
(`existsSync`, `readFileSync`) on files written by independent child
processes (`screencapture`, `magick`). That satisfies "actor is not
auditor": the capture layer writes, the gates layer reads.
Same-session toasts are not evidence here because the skill never produces
toasts -- every verification is a file read.
## Related skills
- `snappy-browse` -- v1 browser source delegates here (agent-browser / Playwright)
- `snappy-image` -- palette tokens, `uploadToCdn` for later publishing,
provider-picker table lists the underlying capture primitives
- `snappy-course` -- consumer of `lesson.md` outputs
- `snappy-positioning` -- banned-phrase vocabulary for the caption gate
- `snappy-settings` -- `env()` loader if/when browser source needs creds
- `video-pipeline` / `remotion-best-practices` -- v2 walkthrough video target
## Why this skill exists
Robert walks people through the same setups repeatedly on calls. Every call
is a lesson trying to get out. The goal is to make "capturing the walk" a
one-command operation with a self-tightening quality bar, so the fifth
person who needs "how do I set up Claude Code" gets a premium artifact
instead of another 20 minutes of Robert's time.
## Near neighbours
Skills whose description overlaps this one enough that a reader could pick the
wrong door. Each row is that skill's own first sentence about itself, so the
choice is made on its words, not on a summary written here.
| Skill | Reach for it instead when |
|---|---|
| `snappy-agent-host` | Run the REAL Claude Code, Codex, and Gemini CLIs through ACP via the skills MCP, with durable… |
| `snappy-cleanshot` | CleanShot X local capture primitive. |
| `snappy-transcripts` | Transcript retrieval, search, and processing for Snappy. |
| `snappy-voice-control` | Voice control on macOS, extracted from two shipping open-source agents (fazm by mediar-ai; Ag… |
#!/usr/bin/env npx tsx
/**
* snappy-walkthrough/api.ts -- Recipe-driven annotated tutorial capture.
*
* The setpoint for "premium" lives in contract-gates.md. Every hand-review
* tightens that file; the skill does not auto-tune.
*
* Usage:
* npx tsx api.ts run recipes/claude-code-first-session.json
* npx tsx api.ts capture recipes/claude-code-first-session.json
* npx tsx api.ts annotate out/<recipe>/<stamp>
* npx tsx api.ts gates out/<recipe>/<stamp>
* npx tsx api.ts lesson out/<recipe>/<stamp>
*/
import { execSync, spawnSync } from "child_process";
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
readdirSync,
copyFileSync,
unlinkSync,
realpathSync,
} from "fs";
import { dirname, join, basename, resolve } from "path";
import { fileURLToPath, pathToFileURL } from "url";
import { env } from "../snappy-settings/load.ts";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
// Reserved for v1 browser source: storage-state encryption key, auth cookies, etc.
// Imported now so the skill complies with the snappy env() contract from day one.
void env;
// --- Types ---
export type SourceKind = "macos-window" | "browser" | "file-render" | "terminal-exec";
export type AnnotationKind = "box" | "arrow" | "callout" | "pin";
export interface Annotation {
kind: AnnotationKind;
color?: string;
box?: [number, number, number, number]; // x, y, w, h
from?: [number, number]; // arrow start
to?: [number, number]; // arrow end
at?: [number, number]; // callout / pin anchor
text?: string; // callout text
number?: number; // pin number
}
export interface Step {
id: string;
title: string;
caption: string;
source: SourceKind;
window_title?: string;
window_id?: number;
wait_for_enter?: boolean;
annotations: Annotation[];
}
export interface Recipe {
name: string;
title: string;
summary: string;
steps: Step[];
}
export interface CaptureResult {
step: Step;
pngPath: string;
width: number;
height: number;
capturedAt: string;
}
export interface GateResult {
rule: string;
pass: boolean;
detail?: string;
stepId?: string;
}
interface StepMeta {
step: Step;
width: number;
height: number;
capturedAt: string;
}
// --- Palette (snappy primary / secondary; stable defaults) ---
const PALETTE = {
primary: "#FF4500",
secondary: "#1E90FF",
text: "#0B0C0F",
bg: "#FFFFFF",
};
// --- Recipe loading ---
export function loadWalkthroughRecipe(path: string): Recipe {
const raw = readFileSync(path, "utf-8");
const parsed = JSON.parse(raw) as Recipe;
if (!parsed.name || !parsed.title || !Array.isArray(parsed.steps) || parsed.steps.length === 0) {
throw new Error(`Invalid recipe at ${path}: missing name/title/steps`);
}
for (const s of parsed.steps) {
if (!s.id || !s.title || !s.caption || !s.source) {
throw new Error(`Invalid step in ${path}: ${JSON.stringify(s)}`);
}
if (!Array.isArray(s.annotations)) {
s.annotations = [];
}
}
return parsed;
}
// --- Capture sources ---
function commandExists(cmd: string): boolean {
return spawnSync("which", [cmd], { stdio: "ignore" }).status === 0;
}
function waitForEnter(step: Step): void {
if (!process.stdin.isTTY) return;
process.stderr.write(
`\n[step ${step.id}] ${step.title}\n ${step.caption}\n` +
`Press ENTER, then switch focus to the target window within 3 seconds...`,
);
spawnSync("bash", ["-c", "read -r _ </dev/tty"], { stdio: "inherit" });
for (let i = 3; i > 0; i--) {
process.stderr.write(`\r capturing in ${i}... `);
execSync("sleep 1");
}
process.stderr.write("\r capturing now! \n");
}
// When running under SSH, macOS TCC denies screen-recording to the SSH session,
// so direct calls to peekaboo / screencapture fail with "operation not permitted".
// We route the capture command through `osascript -> Terminal do script`, which
// spawns the command under the Aqua login session that holds the Screen Recording
// TCC grant. The dispatched script self-miniaturizes its window so it isn't in
// the frame, runs the capture, and touches a done-marker we poll from SSH.
// See ~/.claude/skills/mac-mini-remote-ops for the broader pattern.
function isSshSession(): boolean {
return !!(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY);
}
function runCaptureInAqua(cmd: string, outPath: string): void {
const tag = `snappy-wt-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
const done = `/tmp/${tag}.done`;
const err = `/tmp/${tag}.err`;
const scriptPath = `/tmp/${tag}.sh`;
for (const p of [done, err]) {
try { unlinkSync(p); } catch { /* ignore */ }
}
const body = [
"#!/bin/bash",
`exec 2> ${JSON.stringify(err)}`,
// Hide the dispatched Terminal window so it never lands in a full-screen capture
// and so peekaboo's "frontmost Terminal" targets the demo window, not this one.
`osascript -e 'tell application "Terminal" to set miniaturized of front window to true' 2>/dev/null || true`,
"sleep 0.7",
cmd,
`rc=$?`,
`echo $rc > ${JSON.stringify(done)}`,
// Close the dispatched window so it doesn't pile up across runs.
`osascript -e 'tell application "Terminal" to close (first window whose tty is (do shell script "tty"))' 2>/dev/null || true`,
`exit $rc`,
].join("\n");
writeFileSync(scriptPath, body, { mode: 0o755 });
const osa = `tell application "Terminal" to do script "bash ${scriptPath}"`;
execSync(`osascript -e ${JSON.stringify(osa)}`, { stdio: "ignore" });
const deadlineMs = Date.now() + 25_000;
while (Date.now() < deadlineMs) {
if (existsSync(done)) break;
spawnSync("sleep", ["0.25"]);
}
if (!existsSync(done)) {
throw new Error(
`Aqua-dispatched capture timed out after 25s. Inspect ${scriptPath} / ${err}`,
);
}
const rc = readFileSync(done, "utf-8").trim();
if (rc !== "0") {
const errTxt = existsSync(err) ? readFileSync(err, "utf-8") : "";
throw new Error(`Aqua capture exited ${rc}. stderr:\n${errTxt}`);
}
if (!existsSync(outPath)) {
const errTxt = existsSync(err) ? readFileSync(err, "utf-8") : "";
throw new Error(`Aqua capture reported success but ${outPath} missing. stderr:\n${errTxt}`);
}
}
function runCaptureCommand(cmd: string, outPath: string): void {
if (isSshSession()) {
runCaptureInAqua(cmd, outPath);
} else {
execSync(cmd, { stdio: "inherit" });
}
}
function captureMacosWindow(step: Step, outPath: string): CaptureResult {
if (step.wait_for_enter) waitForEnter(step);
// Preferred path: peekaboo by app name (cleaner, no windowID dance).
if (commandExists("peekaboo") && step.window_title) {
const cmd = `peekaboo image --app ${JSON.stringify(step.window_title)} --path ${JSON.stringify(outPath)}`;
runCaptureCommand(cmd, outPath);
} else {
// Fallback: resolve CGWindowID via AppleScript, then screencapture -l.
let windowId = step.window_id;
if (!windowId && step.window_title) {
const script = `tell application "System Events" to return id of (first window of (first process whose name is "${step.window_title.replace(/"/g, '\\"')}"))`;
try {
const out = execSync(`osascript -e ${JSON.stringify(script)}`, {
encoding: "utf-8",
}).trim();
windowId = Number(out);
} catch (e) {
throw new Error(
`Could not resolve window for "${step.window_title}". Install peekaboo (brew install peekaboo) or set window_id explicitly. Original error: ${(e as Error).message}`,
);
}
}
if (!windowId) {
throw new Error(
`Step ${step.id}: macos-window source needs window_title or window_id`,
);
}
const cmd = `screencapture -l ${windowId} -o -x ${JSON.stringify(outPath)}`;
runCaptureCommand(cmd, outPath);
}
if (!existsSync(outPath)) {
throw new Error(`Capture produced no file at ${outPath}`);
}
const dims = imageDims(outPath);
return {
step,
pngPath: outPath,
width: dims.w,
height: dims.h,
capturedAt: new Date().toISOString(),
};
}
export function captureStep(step: Step, outDir: string): CaptureResult {
const outPath = join(outDir, `step-${step.id}.raw.png`);
switch (step.source) {
case "macos-window":
return captureMacosWindow(step, outPath);
case "browser":
case "file-render":
case "terminal-exec":
throw new Error(
`Source "${step.source}" is not wired in v0. See snappy-walkthrough/SKILL.md "Phase status".`,
);
default:
throw new Error(`Unknown source: ${step.source as string}`);
}
}
function imageDims(path: string): { w: number; h: number } {
// sips is built into macOS -- no extra deps.
const out = execSync(`sips -g pixelWidth -g pixelHeight ${JSON.stringify(path)}`, {
encoding: "utf-8",
});
const w = Number(out.match(/pixelWidth:\s*(\d+)/)?.[1] ?? 0);
const h = Number(out.match(/pixelHeight:\s*(\d+)/)?.[1] ?? 0);
if (!w || !h) throw new Error(`sips could not read dimensions of ${path}`);
return { w, h };
}
// --- Annotation ---
function escapeXml(s: string): string {
return s.replace(
/[<>&"']/g,
(c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c]!,
);
}
function annotationToSvg(a: Annotation, idx: number): string {
const color = a.color || PALETTE.primary;
const sw = 4;
switch (a.kind) {
case "box": {
if (!a.box) throw new Error(`box annotation ${idx} missing .box`);
const [x, y, w, h] = a.box;
return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="${color}" stroke-width="${sw}" rx="8" />`;
}
case "arrow": {
if (!a.from || !a.to) throw new Error(`arrow annotation ${idx} missing from/to`);
const [x1, y1] = a.from;
const [x2, y2] = a.to;
return [
`<defs><marker id="arrowhead-${idx}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="${color}" /></marker></defs>`,
`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${color}" stroke-width="${sw}" marker-end="url(#arrowhead-${idx})" />`,
].join("");
}
case "callout": {
if (!a.at || !a.text) throw new Error(`callout annotation ${idx} missing at/text`);
const [x, y] = a.at;
const tw = Math.max(140, a.text.length * 11);
const th = 44;
return [
`<rect x="${x}" y="${y}" width="${tw}" height="${th}" rx="10" fill="${PALETTE.bg}" stroke="${color}" stroke-width="${sw}" />`,
`<text x="${x + 14}" y="${y + 29}" font-family="-apple-system, SF Pro Text, Helvetica, sans-serif" font-size="18" fill="${PALETTE.text}">${escapeXml(a.text)}</text>`,
].join("");
}
case "pin": {
if (!a.at) throw new Error(`pin annotation ${idx} missing at`);
const [x, y] = a.at;
const n = a.number ?? idx + 1;
return [
`<circle cx="${x}" cy="${y}" r="24" fill="${color}" stroke="${PALETTE.bg}" stroke-width="3" />`,
`<text x="${x}" y="${y + 8}" text-anchor="middle" font-family="-apple-system, SF Pro Text, Helvetica, sans-serif" font-size="24" font-weight="700" fill="${PALETTE.bg}">${n}</text>`,
].join("");
}
}
}
export function annotateStep(
step: Step,
capture: CaptureResult,
): { svgPath: string; pngPath: string; metaPath: string } {
const outDir = dirname(capture.pngPath);
const shapes = step.annotations.map(annotationToSvg).join("\n ");
// Full SVG with the base image as an href -- human-editable artifact.
const fullSvg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${capture.width}" height="${capture.height}" viewBox="0 0 ${capture.width} ${capture.height}">\n` +
` <image href="${basename(capture.pngPath)}" width="${capture.width}" height="${capture.height}" />\n` +
` ${shapes}\n` +
`</svg>\n`;
const svgPath = join(outDir, `step-${step.id}.svg`);
writeFileSync(svgPath, fullSvg);
// Overlay-only SVG -- used for ImageMagick compositing.
const overlaySvg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${capture.width}" height="${capture.height}">\n` +
` ${shapes}\n` +
`</svg>\n`;
const overlayPath = join(outDir, `step-${step.id}.overlay.svg`);
writeFileSync(overlayPath, overlaySvg);
const pngPath = join(outDir, `step-${step.id}.png`);
if (commandExists("magick")) {
execSync(
`magick ${JSON.stringify(capture.pngPath)} \\( ${JSON.stringify(overlayPath)} \\) -composite ${JSON.stringify(pngPath)}`,
{ stdio: "inherit" },
);
} else {
// Fallback: flat PNG is just a copy of the raw capture; SVG is authoritative.
copyFileSync(capture.pngPath, pngPath);
process.stderr.write(
`[annotate] ImageMagick not found; step-${step.id}.svg is authoritative. brew install imagemagick to composite.\n`,
);
}
const metaPath = join(outDir, `step-${step.id}.meta.json`);
writeFileSync(
metaPath,
JSON.stringify(
{
step,
width: capture.width,
height: capture.height,
capturedAt: capture.capturedAt,
},
null,
2,
),
);
return { svgPath, pngPath, metaPath };
}
// --- Gates ---
interface GateRule {
id: string;
description: string;
check: (outDir: string, metas: StepMeta[]) => GateResult[];
}
const BANNED = [/\blet'?s\b/i, /\bsimply\b/i, /\bjust\b/i, /\beasily\b/i, /\bseamlessly\b/i];
const GATES: GateRule[] = [
{
id: "step-ids-monotonic",
description: "Step IDs are unique and sorted",
check: (_d, metas) => {
const ids = metas.map((m) => m.step.id);
const sorted = [...ids].sort();
const unique = new Set(ids).size === ids.length;
const monotonic = ids.every((v, i) => v === sorted[i]);
return [
{
rule: "step-ids-monotonic",
pass: unique && monotonic,
detail: `ids=${ids.join(",")}`,
},
];
},
},
{
id: "caption-length",
description: "Captions are <=12 words",
check: (_d, metas) =>
metas.map((m) => {
const words = m.step.caption.trim().split(/\s+/).length;
return {
rule: "caption-length",
stepId: m.step.id,
pass: words <= 12,
detail: `${words} words: ${m.step.caption}`,
};
}),
},
{
id: "annotation-in-bounds",
description: "Every annotation coordinate lies inside the image",
check: (_d, metas) =>
metas.flatMap((m) =>
m.step.annotations.map((a, i) => {
const pts: [number, number][] = [];
if (a.box)
pts.push([a.box[0], a.box[1]], [a.box[0] + a.box[2], a.box[1] + a.box[3]]);
if (a.from) pts.push(a.from);
if (a.to) pts.push(a.to);
if (a.at) pts.push(a.at);
const ok = pts.every(([x, y]) => x >= 0 && y >= 0 && x <= m.width && y <= m.height);
return {
rule: "annotation-in-bounds",
stepId: m.step.id,
pass: ok,
detail: `annotation[${i}] ${a.kind}`,
};
}),
),
},
{
id: "png-exists",
description: "Every step produced a composited PNG",
check: (d, metas) =>
metas.map((m) => ({
rule: "png-exists",
stepId: m.step.id,
pass: existsSync(join(d, `step-${m.step.id}.png`)),
})),
},
{
id: "banned-phrases",
description: "Captions avoid let's/simply/just/easily/seamlessly",
check: (_d, metas) =>
metas.map((m) => {
const hit = BANNED.find((r) => r.test(m.step.caption));
return {
rule: "banned-phrases",
stepId: m.step.id,
pass: !hit,
detail: hit ? `banned: ${hit.source}` : m.step.caption,
};
}),
},
];
export function runGates(outDir: string): GateResult[] {
const metaFiles = readdirSync(outDir)
.filter((f) => f.endsWith(".meta.json"))
.sort();
const metas: StepMeta[] = metaFiles.map((f) =>
JSON.parse(readFileSync(join(outDir, f), "utf-8")),
);
const results = GATES.flatMap((g) => g.check(outDir, metas));
const lines = results.map(
(r) =>
`${r.pass ? "PASS" : "FAIL"} ${r.rule}${r.stepId ? ` [step ${r.stepId}]` : ""}${r.detail ? ` -- ${r.detail}` : ""}`,
);
writeFileSync(join(outDir, "gates.log"), lines.join("\n") + "\n");
return results;
}
// --- Lesson block ---
export function buildLessonBlock(outDir: string, recipe?: Recipe): string {
const metaFiles = readdirSync(outDir)
.filter((f) => f.endsWith(".meta.json"))
.sort();
const metas: StepMeta[] = metaFiles.map((f) =>
JSON.parse(readFileSync(join(outDir, f), "utf-8")),
);
const recipePath = join(outDir, "recipe.json");
if (!recipe && existsSync(recipePath)) {
recipe = JSON.parse(readFileSync(recipePath, "utf-8"));
}
const title = recipe?.title ?? "Walkthrough";
const summary = recipe?.summary ?? "";
const body = metas
.map((m) => {
const img = `step-${m.step.id}.png`;
return `### ${m.step.title}\n\n\n\n${m.step.caption}\n`;
})
.join("\n");
const md = `# ${title}\n\n${summary}\n\n${body}`;
writeFileSync(join(outDir, "lesson.md"), md);
return md;
}
/* ── THE FACE THIS READ DRAWS ⟨lane family-reads, 2026-09-09⟩ ──────────────
* The `walkthrough` family draws `walkthrough-steps` — "a walkthrough is the
* one output where the SHOT is the claim and the words are the caption" — and
* NO read reached it. This hand IS `snappy-walkthrough`, so the runner's name
* route found the hand and then found nothing to fold: `status` answers
* `{ready, ssh_session}`, which is a health line and not a walkthrough, and
* every verb that HAS the steps — `capture`, `annotate`, `lesson` — writes.
*
* So `steps <out-dir>` is the read, and it is the only verb here that writes
* NOTHING: it opens a captured run and prints what is in it. `lesson` was the
* near miss — it reads the same directory and then writes `lesson.md`, so a
* host calling it to LOOK would change the run it was looking at.
*
* A STEP WITH NO SHOT SAYS SO, and this fold never invents one: the face
* writes "No screenshot was taken of this step" over a missing `shotUrl`,
* which is the truth about a capture that did not land, and a placeholder
* frame would read as "the screen looked like this" ⟨CLAUDE.md §10⟩.
*/
export interface WalkthroughFaceStep {
readonly what: string;
readonly did?: string;
readonly shotUrl?: string;
readonly result?: string;
readonly at?: string;
}
export interface WalkthroughStepsAnswer {
readonly title: string;
readonly where?: string;
readonly steps: readonly WalkthroughFaceStep[];
}
/** THE ONE FOLD into the drawable kind ⟨snappy-faces/dist/build-report.json:
* `walkthrough-steps` → WalkthroughSteps{title, steps[], where?}⟩, over a run
* `capture` already wrote. Nothing here writes. */
export function walkthroughStepsFace(outDir: string): WalkthroughStepsAnswer {
const recipePath = join(outDir, "recipe.json");
const recipe: Recipe | null = existsSync(recipePath)
? JSON.parse(readFileSync(recipePath, "utf-8")) as Recipe
: null;
const metas: StepMeta[] = readdirSync(outDir)
.filter((file) => file.endsWith(".meta.json"))
.sort()
.map((file) => JSON.parse(readFileSync(join(outDir, file), "utf-8")) as StepMeta);
const steps: WalkthroughFaceStep[] = metas.map((meta) => {
// The ANNOTATED shot is the claim; the raw one is the fallback; neither
// present means no shot, and the face says exactly that.
const annotated = join(outDir, `step-${meta.step.id}.png`);
const raw = join(outDir, `step-${meta.step.id}.raw.png`);
const shot = existsSync(annotated) ? annotated : existsSync(raw) ? raw : null;
return {
what: meta.step.title,
...(meta.step.caption ? { did: meta.step.caption } : {}),
...(shot === null ? {} : { shotUrl: pathToFileURL(shot).href }),
// WHAT CAME BACK is the artifact and its measured size — the half that
// makes a walkthrough evidence rather than a story about one.
...(shot === null ? {} : { result: `${basename(shot)} · ${meta.width}×${meta.height}` }),
// An empty `capturedAt` is what an un-stamped capture wrote; absent beats
// an empty <time> element.
...(meta.capturedAt ? { at: meta.capturedAt } : {}),
};
});
// WHERE IT HAPPENED, from the windows the steps were actually taken from —
// never the recipe's summary, which is prose about the walkthrough and not a
// place.
const windows = [...new Set((recipe?.steps ?? []).map((step) => step.window_title).filter((word): word is string => typeof word === "string" && word !== ""))];
const sources = [...new Set((recipe?.steps ?? []).map((step) => String(step.source)).filter(Boolean))];
const where = windows.length > 0 ? windows.join(" · ") : sources.length > 0 ? sources.join(" · ") : undefined;
return {
title: recipe?.title ?? basename(outDir),
...(where === undefined ? {} : { where }),
steps,
};
}
// --- Full pipeline ---
function timestamp(): string {
return new Date()
.toISOString()
.replace(/[:T]/g, "-")
.replace(/\..+/, "");
}
export function runRecipe(recipe: Recipe, opts?: { outRoot?: string }): string {
const outRoot = opts?.outRoot ?? join(__dirname, "out");
const outDir = join(outRoot, recipe.name, timestamp());
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, "recipe.json"), JSON.stringify(recipe, null, 2));
for (const step of recipe.steps) {
const cap = captureStep(step, outDir);
annotateStep(step, cap);
}
const gateResults = runGates(outDir);
buildLessonBlock(outDir, recipe);
const fails = gateResults.filter((r) => !r.pass);
process.stderr.write(`\nOutputs: ${outDir}\n`);
process.stderr.write(
`Gates: ${gateResults.length - fails.length}/${gateResults.length} passed` +
(fails.length ? ` -- see gates.log for FAIL lines\n` : `\n`),
);
return outDir;
}
// --- CLI ---
function main(): void {
const [cmd, ...args] = process.argv.slice(2);
switch (cmd) {
case "status": {
process.stdout.write(JSON.stringify({ ready: true, ssh_session: Boolean(process.env.SSH_CONNECTION) }) + "\n");
break;
}
case "run": {
if (!args[0]) throw new Error("usage: run <recipe.json>");
runRecipe(loadWalkthroughRecipe(resolve(args[0])));
break;
}
case "capture": {
if (!args[0]) throw new Error("usage: capture <recipe.json>");
const recipe = loadWalkthroughRecipe(resolve(args[0]));
const outDir = join(__dirname, "out", recipe.name, timestamp());
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, "recipe.json"), JSON.stringify(recipe, null, 2));
for (const step of recipe.steps) captureStep(step, outDir);
process.stdout.write(outDir + "\n");
break;
}
case "annotate": {
if (!args[0]) throw new Error("usage: annotate <out-dir>");
const outDir = resolve(args[0]);
const recipePath = join(outDir, "recipe.json");
if (!existsSync(recipePath)) {
throw new Error(`No recipe.json in ${outDir}`);
}
const recipe: Recipe = JSON.parse(readFileSync(recipePath, "utf-8"));
for (const step of recipe.steps) {
const raw = join(outDir, `step-${step.id}.raw.png`);
if (!existsSync(raw)) {
process.stderr.write(`[annotate] missing ${raw}, skipping\n`);
continue;
}
const dims = imageDims(raw);
annotateStep(step, {
step,
pngPath: raw,
width: dims.w,
height: dims.h,
capturedAt: "",
});
}
break;
}
case "gates": {
if (!args[0]) throw new Error("usage: gates <out-dir>");
const res = runGates(resolve(args[0]));
const fails = res.filter((r) => !r.pass);
process.stdout.write(`${res.length - fails.length}/${res.length} gates passed\n`);
process.exit(fails.length ? 1 : 0);
}
case "steps": {
if (!args[0]) throw new Error("usage: steps <out-dir>");
process.stdout.write(JSON.stringify(walkthroughStepsFace(resolve(args[0])), null, 2) + "\n");
break;
}
case "lesson": {
if (!args[0]) throw new Error("usage: lesson <out-dir>");
process.stdout.write(buildLessonBlock(resolve(args[0])));
break;
}
default:
process.stderr.write(
`usage: api.ts {status|run|capture|annotate|gates|lesson|steps} <path>\n`,
);
process.exit(2);
}
}
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
/** THE HOST-FACING FACTS ⟨lane CONTRACTS N–Z, 2026-09-09⟩. `class` is the
* closed effect set snappy-tool-design rule 18 grades; `annotations` are
* DERIVED from it by the ONE derivation in
* `snappy-settings/tool-annotations.ts`, never written per verb, so a class
* and its published hints cannot disagree; `refusals` projects the ONE closed
* table in `snappy-settings/refusal-codes.ts`; `requires` is exactly the
* credential keys this file's own executable reads name, and nothing else. */
export const HAND_CONTRACT = {
skill: "snappy-walkthrough",
description: "Recipe-driven capture and annotation of step-by-step tutorials. Turns the walkthroughs Robert gives on calls (\"how I set up Claude Code\", \"how I install a skill\", \"how I wire an MCP\") into premium annotated still sequences, ready-to-paste markdown lesson blocks, and (phase 2) animated walkthrough videos. The PID setpoint for \"premium\" lives in contract-gates.md -- every hand-review tightens the rubric. Triggers on: walkthrough, annotated tutorial, step-by-step capture, annotated screenshot, boxes and arrows, click-through tutorial, capture my call, record setup steps, tutorial capture, snappy walkthrough, how-to capture, annotated screen recording, window screenshot annotation.",
managed: true,
requires: [] as string[],
refusals: refusalTable("missing_argument", "unknown_verb"),
verbs: {
annotate: {
args: ["out-dir"], effect: "write-reversible", class: "additive-write", openWorld: false,
annotations: annotationsForClass("additive-write", { openWorld: false }),
inputSchema: { properties: { "out-dir": { type: "string", description: "Directory the generated files are written to" } } },
},
capture: {
args: ["recipe"], effect: "write-reversible", class: "additive-write", openWorld: false,
annotations: annotationsForClass("additive-write", { openWorld: false }),
inputSchema: { properties: { recipe: { type: "string", description: "Walkthrough recipe name to capture" } } },
},
gates: {
args: ["out-dir"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: annotationsForClass("read", { openWorld: false }),
inputSchema: { properties: { "out-dir": { type: "string", description: "Directory the generated files are written to" } } },
},
lesson: {
args: ["out-dir"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: annotationsForClass("read", { openWorld: false }),
inputSchema: { properties: { "out-dir": { type: "string", description: "Directory the generated files are written to" } } },
},
run: {
args: ["recipe"], effect: "write-reversible", class: "additive-write", openWorld: false,
annotations: annotationsForClass("additive-write", { openWorld: false }),
inputSchema: { properties: { recipe: { type: "string", description: "Recipe id from `snappy-ops recipes`" } } },
},
status: {
args: [], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: annotationsForClass("read", { openWorld: false }),
},
steps: {
// THE FACE THIS READ DRAWS, NAMED BY THE HAND ⟨2026-09-09⟩. The name
// route found this hand and then found nothing to fold: `status` answers
// a health line, and every verb that HAS the steps writes. This verb is
// the read — it opens a captured run and writes nothing.
face: "walkthrough-steps",
args: ["out-dir"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: annotationsForClass("read", { openWorld: false }),
inputSchema: { properties: { "out-dir": { type: "string", description: "Directory a `capture` run wrote its steps into" } } },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) main();
#!/usr/bin/env npx tsx
/**
* snappy-walkthrough/api.ts -- Recipe-driven annotated tutorial capture.
*
* The setpoint for "premium" lives in contract-gates.md. Every hand-review
* tightens that file; the skill does not auto-tune.
*
* Usage:
* npx tsx api.ts run recipes/claude-code-first-session.json
* npx tsx api.ts capture recipes/claude-code-first-session.json
* npx tsx api.ts annotate out/<recipe>/<stamp>
* npx tsx api.ts gates out/<recipe>/<stamp>
* npx tsx api.ts lesson out/<recipe>/<stamp>
*/
import { execSync, spawnSync } from "child_process";
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
readdirSync,
copyFileSync,
unlinkSync,
realpathSync,
} from "fs";
import { dirname, join, basename, resolve } from "path";
import { fileURLToPath, pathToFileURL } from "url";
import { env } from "../snappy-settings/load.ts";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
// Reserved for v1 browser source: storage-state encryption key, auth cookies, etc.
// Imported now so the skill complies with the snappy env() contract from day one.
void env;
// --- Types ---
export type SourceKind = "macos-window" | "browser" | "file-render" | "terminal-exec";
export type AnnotationKind = "box" | "arrow" | "callout" | "pin";
export interface Annotation {
kind: AnnotationKind;
color?: string;
box?: [number, number, number, number]; // x, y, w, h
from?: [number, number]; // arrow start
to?: [number, number]; // arrow end
at?: [number, number]; // callout / pin anchor
text?: string; // callout text
number?: number; // pin number
}
export interface Step {
id: string;
title: string;
caption: string;
source: SourceKind;
window_title?: string;
window_id?: number;
wait_for_enter?: boolean;
annotations: Annotation[];
}
export interface Recipe {
name: string;
title: string;
summary: string;
steps: Step[];
}
export interface CaptureResult {
step: Step;
pngPath: string;
width: number;
height: number;
capturedAt: string;
}
export interface GateResult {
rule: string;
pass: boolean;
detail?: string;
stepId?: string;
}
interface StepMeta {
step: Step;
width: number;
height: number;
capturedAt: string;
}
// --- Palette (snappy primary / secondary; stable defaults) ---
const PALETTE = {
primary: "#FF4500",
secondary: "#1E90FF",
text: "#0B0C0F",
bg: "#FFFFFF",
};
// --- Recipe loading ---
export function loadWalkthroughRecipe(path: string): Recipe {
const raw = readFileSync(path, "utf-8");
const parsed = JSON.parse(raw) as Recipe;
if (!parsed.name || !parsed.title || !Array.isArray(parsed.steps) || parsed.steps.length === 0) {
throw new Error(`Invalid recipe at ${path}: missing name/title/steps`);
}
for (const s of parsed.steps) {
if (!s.id || !s.title || !s.caption || !s.source) {
throw new Error(`Invalid step in ${path}: ${JSON.stringify(s)}`);
}
if (!Array.isArray(s.annotations)) {
s.annotations = [];
}
}
return parsed;
}
// --- Capture sources ---
function commandExists(cmd: string): boolean {
return spawnSync("which", [cmd], { stdio: "ignore" }).status === 0;
}
function waitForEnter(step: Step): void {
if (!process.stdin.isTTY) return;
process.stderr.write(
`\n[step ${step.id}] ${step.title}\n ${step.caption}\n` +
`Press ENTER, then switch focus to the target window within 3 seconds...`,
);
spawnSync("bash", ["-c", "read -r _ </dev/tty"], { stdio: "inherit" });
for (let i = 3; i > 0; i--) {
process.stderr.write(`\r capturing in ${i}... `);
execSync("sleep 1");
}
process.stderr.write("\r capturing now! \n");
}
// When running under SSH, macOS TCC denies screen-recording to the SSH session,
// so direct calls to peekaboo / screencapture fail with "operation not permitted".
// We route the capture command through `osascript -> Terminal do script`, which
// spawns the command under the Aqua login session that holds the Screen Recording
// TCC grant. The dispatched script self-miniaturizes its window so it isn't in
// the frame, runs the capture, and touches a done-marker we poll from SSH.
// See ~/.claude/skills/mac-mini-remote-ops for the broader pattern.
function isSshSession(): boolean {
return !!(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY);
}
function runCaptureInAqua(cmd: string, outPath: string): void {
const tag = `snappy-wt-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
const done = `/tmp/${tag}.done`;
const err = `/tmp/${tag}.err`;
const scriptPath = `/tmp/${tag}.sh`;
for (const p of [done, err]) {
try { unlinkSync(p); } catch { /* ignore */ }
}
const body = [
"#!/bin/bash",
`exec 2> ${JSON.stringify(err)}`,
// Hide the dispatched Terminal window so it never lands in a full-screen capture
// and so peekaboo's "frontmost Terminal" targets the demo window, not this one.
`osascript -e 'tell application "Terminal" to set miniaturized of front window to true' 2>/dev/null || true`,
"sleep 0.7",
cmd,
`rc=$?`,
`echo $rc > ${JSON.stringify(done)}`,
// Close the dispatched window so it doesn't pile up across runs.
`osascript -e 'tell application "Terminal" to close (first window whose tty is (do shell script "tty"))' 2>/dev/null || true`,
`exit $rc`,
].join("\n");
writeFileSync(scriptPath, body, { mode: 0o755 });
const osa = `tell application "Terminal" to do script "bash ${scriptPath}"`;
execSync(`osascript -e ${JSON.stringify(osa)}`, { stdio: "ignore" });
const deadlineMs = Date.now() + 25_000;
while (Date.now() < deadlineMs) {
if (existsSync(done)) break;
spawnSync("sleep", ["0.25"]);
}
if (!existsSync(done)) {
throw new Error(
`Aqua-dispatched capture timed out after 25s. Inspect ${scriptPath} / ${err}`,
);
}
const rc = readFileSync(done, "utf-8").trim();
if (rc !== "0") {
const errTxt = existsSync(err) ? readFileSync(err, "utf-8") : "";
throw new Error(`Aqua capture exited ${rc}. stderr:\n${errTxt}`);
}
if (!existsSync(outPath)) {
const errTxt = existsSync(err) ? readFileSync(err, "utf-8") : "";
throw new Error(`Aqua capture reported success but ${outPath} missing. stderr:\n${errTxt}`);
}
}
function runCaptureCommand(cmd: string, outPath: string): void {
if (isSshSession()) {
runCaptureInAqua(cmd, outPath);
} else {
execSync(cmd, { stdio: "inherit" });
}
}
function captureMacosWindow(step: Step, outPath: string): CaptureResult {
if (step.wait_for_enter) waitForEnter(step);
// Preferred path: peekaboo by app name (cleaner, no windowID dance).
if (commandExists("peekaboo") && step.window_title) {
const cmd = `peekaboo image --app ${JSON.stringify(step.window_title)} --path ${JSON.stringify(outPath)}`;
runCaptureCommand(cmd, outPath);
} else {
// Fallback: resolve CGWindowID via AppleScript, then screencapture -l.
let windowId = step.window_id;
if (!windowId && step.window_title) {
const script = `tell application "System Events" to return id of (first window of (first process whose name is "${step.window_title.replace(/"/g, '\\"')}"))`;
try {
const out = execSync(`osascript -e ${JSON.stringify(script)}`, {
encoding: "utf-8",
}).trim();
windowId = Number(out);
} catch (e) {
throw new Error(
`Could not resolve window for "${step.window_title}". Install peekaboo (brew install peekaboo) or set window_id explicitly. Original error: ${(e as Error).message}`,
);
}
}
if (!windowId) {
throw new Error(
`Step ${step.id}: macos-window source needs window_title or window_id`,
);
}
const cmd = `screencapture -l ${windowId} -o -x ${JSON.stringify(outPath)}`;
runCaptureCommand(cmd, outPath);
}
if (!existsSync(outPath)) {
throw new Error(`Capture produced no file at ${outPath}`);
}
const dims = imageDims(outPath);
return {
step,
pngPath: outPath,
width: dims.w,
height: dims.h,
capturedAt: new Date().toISOString(),
};
}
export function captureStep(step: Step, outDir: string): CaptureResult {
const outPath = join(outDir, `step-${step.id}.raw.png`);
switch (step.source) {
case "macos-window":
return captureMacosWindow(step, outPath);
case "browser":
case "file-render":
case "terminal-exec":
throw new Error(
`Source "${step.source}" is not wired in v0. See snappy-walkthrough/SKILL.md "Phase status".`,
);
default:
throw new Error(`Unknown source: ${step.source as string}`);
}
}
function imageDims(path: string): { w: number; h: number } {
// sips is built into macOS -- no extra deps.
const out = execSync(`sips -g pixelWidth -g pixelHeight ${JSON.stringify(path)}`, {
encoding: "utf-8",
});
const w = Number(out.match(/pixelWidth:\s*(\d+)/)?.[1] ?? 0);
const h = Number(out.match(/pixelHeight:\s*(\d+)/)?.[1] ?? 0);
if (!w || !h) throw new Error(`sips could not read dimensions of ${path}`);
return { w, h };
}
// --- Annotation ---
function escapeXml(s: string): string {
return s.replace(
/[<>&"']/g,
(c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c]!,
);
}
function annotationToSvg(a: Annotation, idx: number): string {
const color = a.color || PALETTE.primary;
const sw = 4;
switch (a.kind) {
case "box": {
if (!a.box) throw new Error(`box annotation ${idx} missing .box`);
const [x, y, w, h] = a.box;
return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="${color}" stroke-width="${sw}" rx="8" />`;
}
case "arrow": {
if (!a.from || !a.to) throw new Error(`arrow annotation ${idx} missing from/to`);
const [x1, y1] = a.from;
const [x2, y2] = a.to;
return [
`<defs><marker id="arrowhead-${idx}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="${color}" /></marker></defs>`,
`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${color}" stroke-width="${sw}" marker-end="url(#arrowhead-${idx})" />`,
].join("");
}
case "callout": {
if (!a.at || !a.text) throw new Error(`callout annotation ${idx} missing at/text`);
const [x, y] = a.at;
const tw = Math.max(140, a.text.length * 11);
const th = 44;
return [
`<rect x="${x}" y="${y}" width="${tw}" height="${th}" rx="10" fill="${PALETTE.bg}" stroke="${color}" stroke-width="${sw}" />`,
`<text x="${x + 14}" y="${y + 29}" font-family="-apple-system, SF Pro Text, Helvetica, sans-serif" font-size="18" fill="${PALETTE.text}">${escapeXml(a.text)}</text>`,
].join("");
}
case "pin": {
if (!a.at) throw new Error(`pin annotation ${idx} missing at`);
const [x, y] = a.at;
const n = a.number ?? idx + 1;
return [
`<circle cx="${x}" cy="${y}" r="24" fill="${color}" stroke="${PALETTE.bg}" stroke-width="3" />`,
`<text x="${x}" y="${y + 8}" text-anchor="middle" font-family="-apple-system, SF Pro Text, Helvetica, sans-serif" font-size="24" font-weight="700" fill="${PALETTE.bg}">${n}</text>`,
].join("");
}
}
}
export function annotateStep(
step: Step,
capture: CaptureResult,
): { svgPath: string; pngPath: string; metaPath: string } {
const outDir = dirname(capture.pngPath);
const shapes = step.annotations.map(annotationToSvg).join("\n ");
// Full SVG with the base image as an href -- human-editable artifact.
const fullSvg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${capture.width}" height="${capture.height}" viewBox="0 0 ${capture.width} ${capture.height}">\n` +
` <image href="${basename(capture.pngPath)}" width="${capture.width}" height="${capture.height}" />\n` +
` ${shapes}\n` +
`</svg>\n`;
const svgPath = join(outDir, `step-${step.id}.svg`);
writeFileSync(svgPath, fullSvg);
// Overlay-only SVG -- used for ImageMagick compositing.
const overlaySvg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${capture.width}" height="${capture.height}">\n` +
` ${shapes}\n` +
`</svg>\n`;
const overlayPath = join(outDir, `step-${step.id}.overlay.svg`);
writeFileSync(overlayPath, overlaySvg);
const pngPath = join(outDir, `step-${step.id}.png`);
if (commandExists("magick")) {
execSync(
`magick ${JSON.stringify(capture.pngPath)} \\( ${JSON.stringify(overlayPath)} \\) -composite ${JSON.stringify(pngPath)}`,
{ stdio: "inherit" },
);
} else {
// Fallback: flat PNG is just a copy of the raw capture; SVG is authoritative.
copyFileSync(capture.pngPath, pngPath);
process.stderr.write(
`[annotate] ImageMagick not found; step-${step.id}.svg is authoritative. brew install imagemagick to composite.\n`,
);
}
const metaPath = join(outDir, `step-${step.id}.meta.json`);
writeFileSync(
metaPath,
JSON.stringify(
{
step,
width: capture.width,
height: capture.height,
capturedAt: capture.capturedAt,
},
null,
2,
),
);
return { svgPath, pngPath, metaPath };
}
// --- Gates ---
interface GateRule {
id: string;
description: string;
check: (outDir: string, metas: StepMeta[]) => GateResult[];
}
const BANNED = [/\blet'?s\b/i, /\bsimply\b/i, /\bjust\b/i, /\beasily\b/i, /\bseamlessly\b/i];
const GATES: GateRule[] = [
{
id: "step-ids-monotonic",
description: "Step IDs are unique and sorted",
check: (_d, metas) => {
const ids = metas.map((m) => m.step.id);
const sorted = [...ids].sort();
const unique = new Set(ids).size === ids.length;
const monotonic = ids.every((v, i) => v === sorted[i]);
return [
{
rule: "step-ids-monotonic",
pass: unique && monotonic,
detail: `ids=${ids.join(",")}`,
},
];
},
},
{
id: "caption-length",
description: "Captions are <=12 words",
check: (_d, metas) =>
metas.map((m) => {
const words = m.step.caption.trim().split(/\s+/).length;
return {
rule: "caption-length",
stepId: m.step.id,
pass: words <= 12,
detail: `${words} words: ${m.step.caption}`,
};
}),
},
{
id: "annotation-in-bounds",
description: "Every annotation coordinate lies inside the image",
check: (_d, metas) =>
metas.flatMap((m) =>
m.step.annotations.map((a, i) => {
const pts: [number, number][] = [];
if (a.box)
pts.push([a.box[0], a.box[1]], [a.box[0] + a.box[2], a.box[1] + a.box[3]]);
if (a.from) pts.push(a.from);
if (a.to) pts.push(a.to);
if (a.at) pts.push(a.at);
const ok = pts.every(([x, y]) => x >= 0 && y >= 0 && x <= m.width && y <= m.height);
return {
rule: "annotation-in-bounds",
stepId: m.step.id,
pass: ok,
detail: `annotation[${i}] ${a.kind}`,
};
}),
),
},
{
id: "png-exists",
description: "Every step produced a composited PNG",
check: (d, metas) =>
metas.map((m) => ({
rule: "png-exists",
stepId: m.step.id,
pass: existsSync(join(d, `step-${m.step.id}.png`)),
})),
},
{
id: "banned-phrases",
description: "Captions avoid let's/simply/just/easily/seamlessly",
check: (_d, metas) =>
metas.map((m) => {
const hit = BANNED.find((r) => r.test(m.step.caption));
return {
rule: "banned-phrases",
stepId: m.step.id,
pass: !hit,
detail: hit ? `banned: ${hit.source}` : m.step.caption,
};
}),
},
];
export function runGates(outDir: string): GateResult[] {
const metaFiles = readdirSync(outDir)
.filter((f) => f.endsWith(".meta.json"))
.sort();
const metas: StepMeta[] = metaFiles.map((f) =>
JSON.parse(readFileSync(join(outDir, f), "utf-8")),
);
const results = GATES.flatMap((g) => g.check(outDir, metas));
const lines = results.map(
(r) =>
`${r.pass ? "PASS" : "FAIL"} ${r.rule}${r.stepId ? ` [step ${r.stepId}]` : ""}${r.detail ? ` -- ${r.detail}` : ""}`,
);
writeFileSync(join(outDir, "gates.log"), lines.join("\n") + "\n");
return results;
}
// --- Lesson block ---
export function buildLessonBlock(outDir: string, recipe?: Recipe): string {
const metaFiles = readdirSync(outDir)
.filter((f) => f.endsWith(".meta.json"))
.sort();
const metas: StepMeta[] = metaFiles.map((f) =>
JSON.parse(readFileSync(join(outDir, f), "utf-8")),
);
const recipePath = join(outDir, "recipe.json");
if (!recipe && existsSync(recipePath)) {
recipe = JSON.parse(readFileSync(recipePath, "utf-8"));
}
const title = recipe?.title ?? "Walkthrough";
const summary = recipe?.summary ?? "";
const body = metas
.map((m) => {
const img = `step-${m.step.id}.png`;
return `### ${m.step.title}\n\n\n\n${m.step.caption}\n`;
})
.join("\n");
const md = `# ${title}\n\n${summary}\n\n${body}`;
writeFileSync(join(outDir, "lesson.md"), md);
return md;
}
/* ── THE FACE THIS READ DRAWS ⟨lane family-reads, 2026-09-09⟩ ──────────────
* The `walkthrough` family draws `walkthrough-steps` — "a walkthrough is the
* one output where the SHOT is the claim and the words are the caption" — and
* NO read reached it. This hand IS `snappy-walkthrough`, so the runner's name
* route found the hand and then found nothing to fold: `status` answers
* `{ready, ssh_session}`, which is a health line and not a walkthrough, and
* every verb that HAS the steps — `capture`, `annotate`, `lesson` — writes.
*
* So `steps <out-dir>` is the read, and it is the only verb here that writes
* NOTHING: it opens a captured run and prints what is in it. `lesson` was the
* near miss — it reads the same directory and then writes `lesson.md`, so a
* host calling it to LOOK would change the run it was looking at.
*
* A STEP WITH NO SHOT SAYS SO, and this fold never invents one: the face
* writes "No screenshot was taken of this step" over a missing `shotUrl`,
* which is the truth about a capture that did not land, and a placeholder
* frame would read as "the screen looked like this" ⟨CLAUDE.md §10⟩.
*/
export interface WalkthroughFaceStep {
readonly what: string;
readonly did?: string;
readonly shotUrl?: string;
readonly result?: string;
readonly at?: string;
}
export interface WalkthroughStepsAnswer {
readonly title: string;
readonly where?: string;
readonly steps: readonly WalkthroughFaceStep[];
}
/** THE ONE FOLD into the drawable kind ⟨snappy-faces/dist/build-report.json:
* `walkthrough-steps` → WalkthroughSteps{title, steps[], where?}⟩, over a run
* `capture` already wrote. Nothing here writes. */
export function walkthroughStepsFace(outDir: string): WalkthroughStepsAnswer {
const recipePath = join(outDir, "recipe.json");
const recipe: Recipe | null = existsSync(recipePath)
? JSON.parse(readFileSync(recipePath, "utf-8")) as Recipe
: null;
const metas: StepMeta[] = readdirSync(outDir)
.filter((file) => file.endsWith(".meta.json"))
.sort()
.map((file) => JSON.parse(readFileSync(join(outDir, file), "utf-8")) as StepMeta);
const steps: WalkthroughFaceStep[] = metas.map((meta) => {
// The ANNOTATED shot is the claim; the raw one is the fallback; neither
// present means no shot, and the face says exactly that.
const annotated = join(outDir, `step-${meta.step.id}.png`);
const raw = join(outDir, `step-${meta.step.id}.raw.png`);
const shot = existsSync(annotated) ? annotated : existsSync(raw) ? raw : null;
return {
what: meta.step.title,
...(meta.step.caption ? { did: meta.step.caption } : {}),
...(shot === null ? {} : { shotUrl: pathToFileURL(shot).href }),
// WHAT CAME BACK is the artifact and its measured size — the half that
// makes a walkthrough evidence rather than a story about one.
...(shot === null ? {} : { result: `${basename(shot)} · ${meta.width}×${meta.height}` }),
// An empty `capturedAt` is what an un-stamped capture wrote; absent beats
// an empty <time> element.
...(meta.capturedAt ? { at: meta.capturedAt } : {}),
};
});
// WHERE IT HAPPENED, from the windows the steps were actually taken from —
// never the recipe's summary, which is prose about the walkthrough and not a
// place.
const windows = [...new Set((recipe?.steps ?? []).map((step) => step.window_title).filter((word): word is string => typeof word === "string" && word !== ""))];
const sources = [...new Set((recipe?.steps ?? []).map((step) => String(step.source)).filter(Boolean))];
const where = windows.length > 0 ? windows.join(" · ") : sources.length > 0 ? sources.join(" · ") : undefined;
return {
title: recipe?.title ?? basename(outDir),
...(where === undefined ? {} : { where }),
steps,
};
}
// --- Full pipeline ---
function timestamp(): string {
return new Date()
.toISOString()
.replace(/[:T]/g, "-")
.replace(/\..+/, "");
}
export function runRecipe(recipe: Recipe, opts?: { outRoot?: string }): string {
const outRoot = opts?.outRoot ?? join(__dirname, "out");
const outDir = join(outRoot, recipe.name, timestamp());
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, "recipe.json"), JSON.stringify(recipe, null, 2));
for (const step of recipe.steps) {
const cap = captureStep(step, outDir);
annotateStep(step, cap);
}
const gateResults = runGates(outDir);
buildLessonBlock(outDir, recipe);
const fails = gateResults.filter((r) => !r.pass);
process.stderr.write(`\nOutputs: ${outDir}\n`);
process.stderr.write(
`Gates: ${gateResults.length - fails.length}/${gateResults.length} passed` +
(fails.length ? ` -- see gates.log for FAIL lines\n` : `\n`),
);
return outDir;
}
// --- CLI ---
function main(): void {
const [cmd, ...args] = process.argv.slice(2);
switch (cmd) {
case "status": {
process.stdout.write(JSON.stringify({ ready: true, ssh_session: Boolean(process.env.SSH_CONNECTION) }) + "\n");
break;
}
case "run": {
if (!args[0]) throw new Error("usage: run <recipe.json>");
runRecipe(loadWalkthroughRecipe(resolve(args[0])));
break;
}
case "capture": {
if (!args[0]) throw new Error("usage: capture <recipe.json>");
const recipe = loadWalkthroughRecipe(resolve(args[0]));
const outDir = join(__dirname, "out", recipe.name, timestamp());
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, "recipe.json"), JSON.stringify(recipe, null, 2));
for (const step of recipe.steps) captureStep(step, outDir);
process.stdout.write(outDir + "\n");
break;
}
case "annotate": {
if (!args[0]) throw new Error("usage: annotate <out-dir>");
const outDir = resolve(args[0]);
const recipePath = join(outDir, "recipe.json");
if (!existsSync(recipePath)) {
throw new Error(`No recipe.json in ${outDir}`);
}
const recipe: Recipe = JSON.parse(readFileSync(recipePath, "utf-8"));
for (const step of recipe.steps) {
const raw = join(outDir, `step-${step.id}.raw.png`);
if (!existsSync(raw)) {
process.stderr.write(`[annotate] missing ${raw}, skipping\n`);
continue;
}
const dims = imageDims(raw);
annotateStep(step, {
step,
pngPath: raw,
width: dims.w,
height: dims.h,
capturedAt: "",
});
}
break;
}
case "gates": {
if (!args[0]) throw new Error("usage: gates <out-dir>");
const res = runGates(resolve(args[0]));
const fails = res.filter((r) => !r.pass);
process.stdout.write(`${res.length - fails.length}/${res.length} gates passed\n`);
process.exit(fails.length ? 1 : 0);
}
case "steps": {
if (!args[0]) throw new Error("usage: steps <out-dir>");
process.stdout.write(JSON.stringify(walkthroughStepsFace(resolve(args[0])), null, 2) + "\n");
break;
}
case "lesson": {
if (!args[0]) throw new Error("usage: lesson <out-dir>");
process.stdout.write(buildLessonBlock(resolve(args[0])));
break;
}
default:
process.stderr.write(
`usage: api.ts {status|run|capture|annotate|gates|lesson|steps} <path>\n`,
);
process.exit(2);
}
}
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
/** THE HOST-FACING FACTS ⟨lane CONTRACTS N–Z, 2026-09-09⟩. `class` is the
* closed effect set snappy-tool-design rule 18 grades; `annotations` are
* DERIVED from it by the ONE derivation in
* `snappy-settings/tool-annotations.ts`, never written per verb, so a class
* and its published hints cannot disagree; `refusals` projects the ONE closed
* table in `snappy-settings/refusal-codes.ts`; `requires` is exactly the
* credential keys this file's own executable reads name, and nothing else. */
export const HAND_CONTRACT = {
skill: "snappy-walkthrough",
description: "Recipe-driven capture and annotation of step-by-step tutorials. Turns the walkthroughs Robert gives on calls (\"how I set up Claude Code\", \"how I install a skill\", \"how I wire an MCP\") into premium annotated still sequences, ready-to-paste markdown lesson blocks, and (phase 2) animated walkthrough videos. The PID setpoint for \"premium\" lives in contract-gates.md -- every hand-review tightens the rubric. Triggers on: walkthrough, annotated tutorial, step-by-step capture, annotated screenshot, boxes and arrows, click-through tutorial, capture my call, record setup steps, tutorial capture, snappy walkthrough, how-to capture, annotated screen recording, window screenshot annotation.",
managed: true,
requires: [] as string[],
refusals: refusalTable("missing_argument", "unknown_verb"),
verbs: {
annotate: {
args: ["out-dir"], effect: "write-reversible", class: "additive-write", openWorld: false,
annotations: annotationsForClass("additive-write", { openWorld: false }),
inputSchema: { properties: { "out-dir": { type: "string", description: "Directory the generated files are written to" } } },
},
capture: {
args: ["recipe"], effect: "write-reversible", class: "additive-write", openWorld: false,
annotations: annotationsForClass("additive-write", { openWorld: false }),
inputSchema: { properties: { recipe: { type: "string", description: "Walkthrough recipe name to capture" } } },
},
gates: {
args: ["out-dir"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: annotationsForClass("read", { openWorld: false }),
inputSchema: { properties: { "out-dir": { type: "string", description: "Directory the generated files are written to" } } },
},
lesson: {
args: ["out-dir"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: annotationsForClass("read", { openWorld: false }),
inputSchema: { properties: { "out-dir": { type: "string", description: "Directory the generated files are written to" } } },
},
run: {
args: ["recipe"], effect: "write-reversible", class: "additive-write", openWorld: false,
annotations: annotationsForClass("additive-write", { openWorld: false }),
inputSchema: { properties: { recipe: { type: "string", description: "Recipe id from `snappy-ops recipes`" } } },
},
status: {
args: [], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: annotationsForClass("read", { openWorld: false }),
},
steps: {
// THE FACE THIS READ DRAWS, NAMED BY THE HAND ⟨2026-09-09⟩. The name
// route found this hand and then found nothing to fold: `status` answers
// a health line, and every verb that HAS the steps writes. This verb is
// the read — it opens a captured run and writes nothing.
face: "walkthrough-steps",
args: ["out-dir"], effect: "read", class: "read", execution: "call", openWorld: false,
annotations: annotationsForClass("read", { openWorld: false }),
inputSchema: { properties: { "out-dir": { type: "string", description: "Directory a `capture` run wrote its steps into" } } },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) main();
This file is the PID setpoint for "premium walkthrough". Every rule listed
in the Active table is evaluated by runGates() in api.ts. Rules are
added by humans after reviewing real outputs -- the skill does not auto-tune.
**The rule: when you review a run and spot a defect, add a rule that would
have caught it.** That rule then holds the line forever. Over time the gate
file accumulates Robert's taste, not a generic rubric.
| Rule ID | What it checks | Added |
|---|---|---|
step-ids-monotonic |
Step IDs are unique and sorted | v0 |
caption-length |
Captions are <=12 words | v0 |
annotation-in-bounds |
Every annotation coordinate lies inside the image | v0 |
png-exists |
Every step produced a composited PNG in the output directory | v0 |
banned-phrases |
Captions avoid "let's / simply / just / easily / seamlessly" | v0 |
Implementation lives in the GATES array of api.ts. This file is the
human-readable source of truth.
These are rules we think we want but haven't needed yet. The cost to
implement is real; don't wire them speculatively. Promote to Active the
first time a real run would have benefited.
arrow-head-in-target -- arrow head coordinate lies inside a declaredtarget bounding box. Requires each arrow step to carry an explicit
target_box field. Implementation: trivial if the field exists.
annotation-contrast -- SVG annotation stroke color has >=4.5:1luminance contrast against the mean pixel color of the region it covers.
Requires sampling the underlying PNG. Implementation: sharp or sips
region read + WCAG contrast math.
no-overlap -- no two annotation bounding rectangles overlap by >20%area. Catches cluttered steps. Implementation: axis-aligned intersection
math on box / text bubble / arrow bounds.
dimension-budget -- captured image dimensions fall inside thesnappy-course image rules (see snappy-course/SKILL.md#image-rules).
Prevents 4K laptop captures from shipping raw.
positioning-vocabulary -- captions avoid the fullsnappy-positioning banned list (not just the five v0 words).
Implementation: load the positioning vocab file and regex-check.
alt-text-semantic-match -- lesson.md alt text for each imagematches the step caption. Trivial, catches copy drift.
step-count-range -- recipe has between 3 and 12 steps. Walkthroughsshorter than 3 don't justify a walkthrough; longer than 12 should be
split.
on every capture. Rejected because the point of macos-window captures
is to show the real UI, not a stylized version. Palette is for
annotations only.
grader. Rejected for v0 because the gates should be mechanical and
cheap. If the mechanical rules saturate and we still have taste defects,
add a single optional vision-review gate gated behind a flag.
out/<recipe>/<stamp>/.or does it need pixel sampling / LLM judgment? If the latter, weigh
the cost.
api.ts. Add a new object to the GATES array:typescript {
id: "my-new-rule",
description: "One-sentence why it exists",
check: (outDir, metas) => metas.map(m => ({
rule: "my-new-rule",
stepId: m.step.id,
pass: /* your boolean */,
detail: /* optional context */,
})),
}
"let's / simply / just / easily / seamlessly" are the five words that turn
call-walkthrough captions into stock-photo alt text. Catching them in v0
forces real writing from day one instead of letting the default drift in.
Auto-tuning a rubric from examples requires a ground-truth notion of
"premium" the system doesn't have. Robert has that notion. His job is to
encode it; the skill's job is to enforce it. Every manual rule addition is
cheap and precise -- auto-tuning would be expensive and vague.
The whole skill is this file. Everything else is plumbing.
# snappy-walkthrough -- Contract Gates
This file is the PID setpoint for "premium walkthrough". Every rule listed
in the **Active** table is evaluated by `runGates()` in `api.ts`. Rules are
added by humans after reviewing real outputs -- the skill does not auto-tune.
**The rule: when you review a run and spot a defect, add a rule that would
have caught it.** That rule then holds the line forever. Over time the gate
file accumulates Robert's taste, not a generic rubric.
## Active rules (v0)
| Rule ID | What it checks | Added |
|--------------------------|--------------------------------------------------------------------|-------|
| `step-ids-monotonic` | Step IDs are unique and sorted | v0 |
| `caption-length` | Captions are <=12 words | v0 |
| `annotation-in-bounds` | Every annotation coordinate lies inside the image | v0 |
| `png-exists` | Every step produced a composited PNG in the output directory | v0 |
| `banned-phrases` | Captions avoid "let's / simply / just / easily / seamlessly" | v0 |
Implementation lives in the `GATES` array of `api.ts`. This file is the
human-readable source of truth.
## Pending -- defects worth catching once we see them
These are rules we think we want but haven't needed yet. The cost to
implement is real; don't wire them speculatively. Promote to Active the
first time a real run would have benefited.
- **`arrow-head-in-target`** -- arrow head coordinate lies inside a declared
target bounding box. Requires each arrow step to carry an explicit
`target_box` field. Implementation: trivial if the field exists.
- **`annotation-contrast`** -- SVG annotation stroke color has >=4.5:1
luminance contrast against the mean pixel color of the region it covers.
Requires sampling the underlying PNG. Implementation: sharp or sips
region read + WCAG contrast math.
- **`no-overlap`** -- no two annotation bounding rectangles overlap by >20%
area. Catches cluttered steps. Implementation: axis-aligned intersection
math on box / text bubble / arrow bounds.
- **`dimension-budget`** -- captured image dimensions fall inside the
snappy-course image rules (see `snappy-course/SKILL.md#image-rules`).
Prevents 4K laptop captures from shipping raw.
- **`positioning-vocabulary`** -- captions avoid the full
`snappy-positioning` banned list (not just the five v0 words).
Implementation: load the positioning vocab file and regex-check.
- **`alt-text-semantic-match`** -- lesson.md alt text for each image
matches the step caption. Trivial, catches copy drift.
- **`step-count-range`** -- recipe has between 3 and 12 steps. Walkthroughs
shorter than 3 don't justify a walkthrough; longer than 12 should be
split.
## Rules we considered and rejected (so we stop re-considering)
- **Auto color-grading** -- tempting to enforce a fixed palette transform
on every capture. Rejected because the point of macos-window captures
is to show the real UI, not a stylized version. Palette is for
annotations only.
- **LLM-scored "is this clear?"** -- considered an optional vision-model
grader. Rejected for v0 because the gates should be mechanical and
cheap. If the mechanical rules saturate and we still have taste defects,
add a single optional `vision-review` gate gated behind a flag.
## How to add a rule
1. Review a run in `out/<recipe>/<stamp>/`.
2. Write down the specific defect in one sentence.
3. Decide: is this mechanical (deterministic check on metadata or files),
or does it need pixel sampling / LLM judgment? If the latter, weigh
the cost.
4. Open `api.ts`. Add a new object to the `GATES` array:
```typescript
{
id: "my-new-rule",
description: "One-sentence why it exists",
check: (outDir, metas) => metas.map(m => ({
rule: "my-new-rule",
stepId: m.step.id,
pass: /* your boolean */,
detail: /* optional context */,
})),
}
```
5. Add the row to the **Active rules** table above with today's date.
6. Re-run the recipe. The new rule now enforces your standard.
## Why "banned phrases" are in v0
"let's / simply / just / easily / seamlessly" are the five words that turn
call-walkthrough captions into stock-photo alt text. Catching them in v0
forces real writing from day one instead of letting the default drift in.
## Why the skill does not auto-tune
Auto-tuning a rubric from examples requires a ground-truth notion of
"premium" the system doesn't have. Robert has that notion. His job is to
encode it; the skill's job is to enforce it. Every manual rule addition is
cheap and precise -- auto-tuning would be expensive and vague.
The whole skill is this file. Everything else is plumbing.
/* components/walkthrough-faces.css — THE WALKTHROUGH'S INK.
*
* Tokens at the family root ⟨owner order A9⟩. The shot is the claim, so it gets
* the width; the number is a rail down the left so a person can scan positions
* without reading, and the RESULT line is the accent because that is the half
* that makes a walkthrough evidence rather than a slideshow. */
.wt-surface {
--wt-accent: oklch(0.5 0.14 264);
--wt-ink: oklch(0.24 0.01 264);
--wt-ink-dim: oklch(0.53 0.01 264);
--wt-line: oklch(0.92 0.004 264);
max-width: 640px;
border: 1px solid var(--wt-line);
border-radius: 12px;
background: oklch(1 0 0);
color: var(--wt-ink);
font-size: 15px;
line-height: 1.5;
overflow: hidden;
}
.wt-surface h2 { margin: 0; font-size: 17px; font-weight: 650; letter-spacing: -0.01em; }
.wt-surface p { margin: 0; }
.wt-surface time { display: block; margin-top: 4px; color: var(--wt-ink-dim); font-size: 11px; }
.wt-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 14px 16px; border-bottom: 1px solid var(--wt-line); background: oklch(0.985 0.002 264);
}
.wt-sub { color: var(--wt-ink-dim); font-size: 13px; }
.wt-count { color: var(--wt-ink-dim); font-size: 12px; white-space: nowrap; }
.wt-quiet { padding: 16px; color: var(--wt-ink-dim); }
.wt-list { margin: 0; padding: 0; list-style: none; }
.wt-list li { display: flex; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--wt-line); }
.wt-list li:last-child { border-bottom: none; }
.wt-n {
flex: none; display: inline-flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: 50%; background: oklch(0.95 0.02 264);
color: var(--wt-accent); font-size: 12px; font-weight: 650; font-variant-numeric: tabular-nums;
}
.wt-body { min-width: 0; flex: 1; }
.wt-what { font-weight: 620; overflow-wrap: anywhere; }
.wt-did { color: var(--wt-ink-dim); font-size: 13px; margin-top: 2px; }
.wt-shot { display: block; width: 100%; margin-top: 8px; border: 1px solid var(--wt-line); border-radius: 8px; }
.wt-noshot { margin-top: 8px; color: var(--wt-ink-dim); font-size: 12px; font-style: italic; }
/* THE RESULT IS THE ACCENT: it is what makes this evidence. */
.wt-result { margin-top: 8px; padding-left: 10px; border-left: 2px solid var(--wt-accent); font-size: 14px; }
/* components/walkthrough-faces.css — THE WALKTHROUGH'S INK.
*
* Tokens at the family root ⟨owner order A9⟩. The shot is the claim, so it gets
* the width; the number is a rail down the left so a person can scan positions
* without reading, and the RESULT line is the accent because that is the half
* that makes a walkthrough evidence rather than a slideshow. */
.wt-surface {
--wt-accent: oklch(0.5 0.14 264);
--wt-ink: oklch(0.24 0.01 264);
--wt-ink-dim: oklch(0.53 0.01 264);
--wt-line: oklch(0.92 0.004 264);
max-width: 640px;
border: 1px solid var(--wt-line);
border-radius: 12px;
background: oklch(1 0 0);
color: var(--wt-ink);
font-size: 15px;
line-height: 1.5;
overflow: hidden;
}
.wt-surface h2 { margin: 0; font-size: 17px; font-weight: 650; letter-spacing: -0.01em; }
.wt-surface p { margin: 0; }
.wt-surface time { display: block; margin-top: 4px; color: var(--wt-ink-dim); font-size: 11px; }
.wt-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 14px 16px; border-bottom: 1px solid var(--wt-line); background: oklch(0.985 0.002 264);
}
.wt-sub { color: var(--wt-ink-dim); font-size: 13px; }
.wt-count { color: var(--wt-ink-dim); font-size: 12px; white-space: nowrap; }
.wt-quiet { padding: 16px; color: var(--wt-ink-dim); }
.wt-list { margin: 0; padding: 0; list-style: none; }
.wt-list li { display: flex; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--wt-line); }
.wt-list li:last-child { border-bottom: none; }
.wt-n {
flex: none; display: inline-flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: 50%; background: oklch(0.95 0.02 264);
color: var(--wt-accent); font-size: 12px; font-weight: 650; font-variant-numeric: tabular-nums;
}
.wt-body { min-width: 0; flex: 1; }
.wt-what { font-weight: 620; overflow-wrap: anywhere; }
.wt-did { color: var(--wt-ink-dim); font-size: 13px; margin-top: 2px; }
.wt-shot { display: block; width: 100%; margin-top: 8px; border: 1px solid var(--wt-line); border-radius: 8px; }
.wt-noshot { margin-top: 8px; color: var(--wt-ink-dim); font-size: 12px; font-style: italic; }
/* THE RESULT IS THE ACCENT: it is what makes this evidence. */
.wt-result { margin-top: 8px; padding-left: 10px; border-left: 2px solid var(--wt-accent); font-size: 14px; }
// components/walkthrough-faces.tsx — HOW IT WAS DONE, STEP BY STEP.
//
// ⟨the owner, 2026-09-09 01:4x: "I want the damn internet in there"⟩
// `snappy-walkthrough` records annotated steps — a shot, what was clicked, what
// happened — and the library drew none of them, so a walkthrough arrived as a
// numbered paragraph. A walkthrough is the one output where the SHOT is the
// claim and the words are the caption; a paragraph inverts that.
//
// EVERY STEP IS NUMBERED BY ITS POSITION, never by a field. A `step: 3` a caller
// typed can disagree with where the row actually sits, and then the face says
// two different things about one list.
//
// A STEP WITH NO SHOT SAYS SO. There is no placeholder frame: a grey rectangle
// where a screenshot should be reads as "the screen looked like this", which is
// a claim nobody made.
import { defineComponent } from "@openuidev/react-lang";
import { z } from "zod";
import type { JSX } from "react";
import "./walkthrough-faces.css";
export interface WalkthroughStep {
readonly what: string;
readonly shotUrl?: string | null;
/** What the person actually did — "clicked Publish", "typed the invoice no." */
readonly did?: string | null;
/** What came back. The half that makes a walkthrough evidence. */
readonly result?: string | null;
readonly at?: string | null;
}
export interface WalkthroughStepsViewProps {
readonly title: string;
readonly steps?: readonly WalkthroughStep[];
readonly where?: string | null;
readonly clampAt?: number;
}
export function WalkthroughStepsView(props: WalkthroughStepsViewProps): JSX.Element {
const steps = (props.steps ?? []).slice(0, props.clampAt ?? 20);
return (
<section className="wt-surface" aria-label={props.title}>
<header className="wt-head">
<div>
<h2>{props.title}</h2>
{props.where == null ? null : <p className="wt-sub">{props.where}</p>}
</div>
<span className="wt-count">{steps.length} {steps.length === 1 ? "step" : "steps"}</span>
</header>
{steps.length === 0
? <p className="wt-quiet">Nothing was recorded for this walkthrough.</p>
: <ol className="wt-list">
{steps.map((step, index) => (
<li key={index}>
<span className="wt-n" aria-hidden="true">{index + 1}</span>
<div className="wt-body">
<p className="wt-what">{step.what}</p>
{step.did == null ? null : <p className="wt-did">{step.did}</p>}
{step.shotUrl == null
? <p className="wt-noshot">No screenshot was taken of this step.</p>
: <img className="wt-shot" src={step.shotUrl} alt={step.what} />}
{step.result == null ? null : <p className="wt-result">{step.result}</p>}
{step.at == null ? null : <time>{step.at}</time>}
</div>
</li>
))}
</ol>}
</section>
);
}
export const WalkthroughStepsComponent = defineComponent({
name: "WalkthroughSteps",
description: "USE FOR: 'show me how it did that', 'walk me through the run', any recorded walkthrough — the annotated steps with their screenshots. Draws each step in order with what was done, the shot of it, and what came back. Compact call: WalkthroughSteps(title, steps) where steps is [{what, shotUrl?, did?, result?, at?}]. Positional after that: where (the app or site it happened in). Steps are numbered BY POSITION, so do not pass a step number. A step with no shotUrl says no screenshot was taken rather than drawing an empty frame.",
props: z.object({
title: z.string(),
steps: z.array(z.object({
what: z.string(), shotUrl: z.string().nullish(), did: z.string().nullish(),
result: z.string().nullish(), at: z.string().nullish(),
})).nullish(),
where: z.string().nullish(),
}),
component: ({ props }): JSX.Element => (
<WalkthroughStepsView title={props.title} steps={props.steps ?? undefined} where={props.where} />
),
});
// components/walkthrough-faces.tsx — HOW IT WAS DONE, STEP BY STEP.
//
// ⟨the owner, 2026-09-09 01:4x: "I want the damn internet in there"⟩
// `snappy-walkthrough` records annotated steps — a shot, what was clicked, what
// happened — and the library drew none of them, so a walkthrough arrived as a
// numbered paragraph. A walkthrough is the one output where the SHOT is the
// claim and the words are the caption; a paragraph inverts that.
//
// EVERY STEP IS NUMBERED BY ITS POSITION, never by a field. A `step: 3` a caller
// typed can disagree with where the row actually sits, and then the face says
// two different things about one list.
//
// A STEP WITH NO SHOT SAYS SO. There is no placeholder frame: a grey rectangle
// where a screenshot should be reads as "the screen looked like this", which is
// a claim nobody made.
import { defineComponent } from "@openuidev/react-lang";
import { z } from "zod";
import type { JSX } from "react";
import "./walkthrough-faces.css";
export interface WalkthroughStep {
readonly what: string;
readonly shotUrl?: string | null;
/** What the person actually did — "clicked Publish", "typed the invoice no." */
readonly did?: string | null;
/** What came back. The half that makes a walkthrough evidence. */
readonly result?: string | null;
readonly at?: string | null;
}
export interface WalkthroughStepsViewProps {
readonly title: string;
readonly steps?: readonly WalkthroughStep[];
readonly where?: string | null;
readonly clampAt?: number;
}
export function WalkthroughStepsView(props: WalkthroughStepsViewProps): JSX.Element {
const steps = (props.steps ?? []).slice(0, props.clampAt ?? 20);
return (
<section className="wt-surface" aria-label={props.title}>
<header className="wt-head">
<div>
<h2>{props.title}</h2>
{props.where == null ? null : <p className="wt-sub">{props.where}</p>}
</div>
<span className="wt-count">{steps.length} {steps.length === 1 ? "step" : "steps"}</span>
</header>
{steps.length === 0
? <p className="wt-quiet">Nothing was recorded for this walkthrough.</p>
: <ol className="wt-list">
{steps.map((step, index) => (
<li key={index}>
<span className="wt-n" aria-hidden="true">{index + 1}</span>
<div className="wt-body">
<p className="wt-what">{step.what}</p>
{step.did == null ? null : <p className="wt-did">{step.did}</p>}
{step.shotUrl == null
? <p className="wt-noshot">No screenshot was taken of this step.</p>
: <img className="wt-shot" src={step.shotUrl} alt={step.what} />}
{step.result == null ? null : <p className="wt-result">{step.result}</p>}
{step.at == null ? null : <time>{step.at}</time>}
</div>
</li>
))}
</ol>}
</section>
);
}
export const WalkthroughStepsComponent = defineComponent({
name: "WalkthroughSteps",
description: "USE FOR: 'show me how it did that', 'walk me through the run', any recorded walkthrough — the annotated steps with their screenshots. Draws each step in order with what was done, the shot of it, and what came back. Compact call: WalkthroughSteps(title, steps) where steps is [{what, shotUrl?, did?, result?, at?}]. Positional after that: where (the app or site it happened in). Steps are numbered BY POSITION, so do not pass a step number. A step with no shotUrl says no screenshot was taken rather than drawing an empty frame.",
props: z.object({
title: z.string(),
steps: z.array(z.object({
what: z.string(), shotUrl: z.string().nullish(), did: z.string().nullish(),
result: z.string().nullish(), at: z.string().nullish(),
})).nullish(),
where: z.string().nullish(),
}),
component: ({ props }): JSX.Element => (
<WalkthroughStepsView title={props.title} steps={props.steps ?? undefined} where={props.where} />
),
});
/** families/walkthrough.tsx — HOW IT WAS DONE, as its own chunk. */
import type { FaceFamilyModule } from "../../snappy-faces/face-family.ts";
import { WalkthroughStepsView } from "./components/walkthrough-faces.tsx";
export const FAMILY: FaceFamilyModule = {
slug: "walkthrough",
mounts: { "walkthrough-steps": WalkthroughStepsView },
};
/** families/walkthrough.tsx — HOW IT WAS DONE, as its own chunk. */
import type { FaceFamilyModule } from "../../snappy-faces/face-family.ts";
import { WalkthroughStepsView } from "./components/walkthrough-faces.tsx";
export const FAMILY: FaceFamilyModule = {
slug: "walkthrough",
mounts: { "walkthrough-steps": WalkthroughStepsView },
};
{
"title": "Sending the rollout note to Harbourline",
"where": "Statechange · Quillworks",
"steps": [
{ "what": "Opened the Harbourline space", "did": "clicked Harbourline in the sidebar", "result": "1,240 members, 6 posts this week", "at": "9:41" },
{ "what": "Started a post", "did": "clicked Write something", "result": "the composer opened with the space pre-filled", "at": "9:41" },
{ "what": "Pasted the rollout note", "did": "typed the title, pasted the body", "result": "1,180 characters, no formatting lost", "at": "9:43" },
{ "what": "Stopped before publishing", "did": "nothing — the post was staged, not sent", "result": "staged for approval; nothing has been posted", "at": "9:43" }
]
}
{
"title": "Sending the rollout note to Harbourline",
"where": "Statechange · Quillworks",
"steps": [
{ "what": "Opened the Harbourline space", "did": "clicked Harbourline in the sidebar", "result": "1,240 members, 6 posts this week", "at": "9:41" },
{ "what": "Started a post", "did": "clicked Write something", "result": "the composer opened with the space pre-filled", "at": "9:41" },
{ "what": "Pasted the rollout note", "did": "typed the title, pasted the body", "result": "1,180 characters, no formatting lost", "at": "9:43" },
{ "what": "Stopped before publishing", "did": "nothing — the post was staged, not sent", "result": "staged for approval; nothing has been posted", "at": "9:43" }
]
}
PASS step-ids-monotonic -- ids=01
PASS caption-length [step 01] -- 9 words: A test image standing in for a real capture
PASS annotation-in-bounds [step 01] -- annotation[0] box
PASS annotation-in-bounds [step 01] -- annotation[1] arrow
PASS annotation-in-bounds [step 01] -- annotation[2] callout
PASS annotation-in-bounds [step 01] -- annotation[3] pin
PASS png-exists [step 01]
PASS banned-phrases [step 01] -- A test image standing in for a real capture
PASS step-ids-monotonic -- ids=01 PASS caption-length [step 01] -- 9 words: A test image standing in for a real capture PASS annotation-in-bounds [step 01] -- annotation[0] box PASS annotation-in-bounds [step 01] -- annotation[1] arrow PASS annotation-in-bounds [step 01] -- annotation[2] callout PASS annotation-in-bounds [step 01] -- annotation[3] pin PASS png-exists [step 01] PASS banned-phrases [step 01] -- A test image standing in for a real capture
Proves the annotate / gates / lesson-block path works end-to-end against a fake capture. The capture primitive is blocked by macOS SSH restrictions and is tested separately.

A test image standing in for a real capture
# Pipeline smoke test Proves the annotate / gates / lesson-block path works end-to-end against a fake capture. The capture primitive is blocked by macOS SSH restrictions and is tested separately. ### Fake capture for pipeline test  A test image standing in for a real capture
{
"name": "smoke-test",
"title": "Pipeline smoke test",
"summary": "Proves the annotate / gates / lesson-block path works end-to-end against a fake capture. The capture primitive is blocked by macOS SSH restrictions and is tested separately.",
"steps": [
{
"id": "01",
"title": "Fake capture for pipeline test",
"caption": "A test image standing in for a real capture",
"source": "macos-window",
"window_title": "Terminal",
"annotations": [
{ "kind": "box", "box": [40, 40, 200, 200] },
{ "kind": "arrow", "from": [300, 100], "to": [260, 140] },
{ "kind": "callout", "at": [340, 80], "text": "Smoke test annotation" },
{ "kind": "pin", "at": [120, 500], "number": 1 }
]
}
]
}
{
"name": "smoke-test",
"title": "Pipeline smoke test",
"summary": "Proves the annotate / gates / lesson-block path works end-to-end against a fake capture. The capture primitive is blocked by macOS SSH restrictions and is tested separately.",
"steps": [
{
"id": "01",
"title": "Fake capture for pipeline test",
"caption": "A test image standing in for a real capture",
"source": "macos-window",
"window_title": "Terminal",
"annotations": [
{ "kind": "box", "box": [40, 40, 200, 200] },
{ "kind": "arrow", "from": [300, 100], "to": [260, 140] },
{ "kind": "callout", "at": [340, 80], "text": "Smoke test annotation" },
{ "kind": "pin", "at": [120, 500], "number": 1 }
]
}
]
}
{
"step": {
"id": "01",
"title": "Fake capture for pipeline test",
"caption": "A test image standing in for a real capture",
"source": "macos-window",
"window_title": "Terminal",
"annotations": [
{
"kind": "box",
"box": [
40,
40,
200,
200
]
},
{
"kind": "arrow",
"from": [
300,
100
],
"to": [
260,
140
]
},
{
"kind": "callout",
"at": [
340,
80
],
"text": "Smoke test annotation"
},
{
"kind": "pin",
"at": [
120,
500
],
"number": 1
}
]
},
"width": 512,
"height": 512,
"capturedAt": ""
}{
"step": {
"id": "01",
"title": "Fake capture for pipeline test",
"caption": "A test image standing in for a real capture",
"source": "macos-window",
"window_title": "Terminal",
"annotations": [
{
"kind": "box",
"box": [
40,
40,
200,
200
]
},
{
"kind": "arrow",
"from": [
300,
100
],
"to": [
260,
140
]
},
{
"kind": "callout",
"at": [
340,
80
],
"text": "Smoke test annotation"
},
{
"kind": "pin",
"at": [
120,
500
],
"number": 1
}
]
},
"width": 512,
"height": 512,
"capturedAt": ""
}<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512">
<rect x="40" y="40" width="200" height="200" fill="none" stroke="#FF4500" stroke-width="4" rx="8" />
<defs><marker id="arrowhead-1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="#FF4500" /></marker></defs><line x1="300" y1="100" x2="260" y2="140" stroke="#FF4500" stroke-width="4" marker-end="url(#arrowhead-1)" />
<rect x="340" y="80" width="231" height="44" rx="10" fill="#FFFFFF" stroke="#FF4500" stroke-width="4" /><text x="354" y="109" font-family="-apple-system, SF Pro Text, Helvetica, sans-serif" font-size="18" fill="#0B0C0F">Smoke test annotation</text>
<circle cx="120" cy="500" r="24" fill="#FF4500" stroke="#FFFFFF" stroke-width="3" /><text x="120" y="508" text-anchor="middle" font-family="-apple-system, SF Pro Text, Helvetica, sans-serif" font-size="24" font-weight="700" fill="#FFFFFF">1</text>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512"> <rect x="40" y="40" width="200" height="200" fill="none" stroke="#FF4500" stroke-width="4" rx="8" /> <defs><marker id="arrowhead-1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="#FF4500" /></marker></defs><line x1="300" y1="100" x2="260" y2="140" stroke="#FF4500" stroke-width="4" marker-end="url(#arrowhead-1)" /> <rect x="340" y="80" width="231" height="44" rx="10" fill="#FFFFFF" stroke="#FF4500" stroke-width="4" /><text x="354" y="109" font-family="-apple-system, SF Pro Text, Helvetica, sans-serif" font-size="18" fill="#0B0C0F">Smoke test annotation</text> <circle cx="120" cy="500" r="24" fill="#FF4500" stroke="#FFFFFF" stroke-width="3" /><text x="120" y="508" text-anchor="middle" font-family="-apple-system, SF Pro Text, Helvetica, sans-serif" font-size="24" font-weight="700" fill="#FFFFFF">1</text> </svg>
�PNG
IHDR �x�� bKGD � � ����� IDATx���g�eU���oQE�
�T�&H0`$5�P�9��
�W��Y�3�jf�M���7z��^�G����(��� �Dl%�� Aɡ(��Ŷ�
�������s������x��<������I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I ̊������O ���.:DR�G��4�X��xGǹ��?�?�L��Nt �489:D�������B`��������i<�����<�)�G�qx�|k�\P�ӣ�̦�!�!�pqt���LJ��������۸��A�݈?����*�[�
��:���>
l��7G�Hh�%QG �b6pjt����'�Ύ��",�%M�A�/u�y<��� �g�?ǁ�r@]�h4Y��$���I��4vo��i�o�z@zn/�EƱ�Ҩ+��+ �ixq>��� ��E�Hh6pJt�`�Q�a<=��+l��:����/mi'p(/Hz���q|�b�50� h6Mז���C4�q�k�!��3+iƶ ���fh��Q� |��c�q���b�5 � hm�i,���'/��j��� �c���@k����̡_C��k�A�KRg%~)s��>loC�d|�b�5� hMl$��VJ6t��")?Ò:�x��o0C_Zہ�O,2��7V̷� �Ω�z�!ġ��
p9pwt���'E�P�, �:6��a���|�=/:DR6J���/]:^w� �J�K���c���VϹ�U�q�-��F�h�C�[b-?Ӓ�j�Y⿱8^;.\�A��?�s��+�[=�
�Vv�at��#�������C$�pBt��@+���Ms��C4�el�uFt I���K��Տ۱`+`)��#�حb��S� ��ljێ���!p?pEt�����, ����Q�!�V����DH�X�u�!� �p�Pێ�nn({�&:DB�E�P, ���,s� 8>:D��G�H�Ϻ �T��9:�Fv+�
�6���E�����C(�|::�:��5:D������� �g��pI9#�q��̋�X :��3����*�ht��|�G �dR�(E��-�f�Z~��&�a{7��L9���;�[k��%:�b�0l~�����C4��u�!�r�� .w����뜌���p �!:�f�xʓC�%�w�!�8<:�bX ����08::D^ >")�e��0�4�/?��� �Z��-:����a:/�}�;��g���C$e3� Y �\�����a3`�S���Ch�, ��p���!4v�`77�E��!�84:���`xl��M�âC4�9J��<7������S��{���!���u��wE�tx!�����>�N�!p�� ��f�A�b0s(���o�����B�a0o���;������� mB�a06��f�!�!�pqt��<W�M�ðp/0;:���J���!����2J��}�A4Y� ��x����E�h���4@�u��d, �o66�
��ܯ8':@R��)��(��:�5�nn(�%>K���8l�9W �φ�aZ�'p�'��D�H�sG���oo~My�������!�7pmt���<�C�
@�-Ƌ��}x[t��+�u_c�2�k �e'����
gGH�'�z�����,�i�� ���!z+�������G [��рǁ�E�H�sIOY �����$����9��%�~:��+ ��WwF�HhpRt��@�̢���V�C)
�n9�����x�x@��#�U�!Ԝ{)�,�l�~\!���*�z����~�Vek���-���Iyn��~�84:��� ������Ch|, ��`��j�!�=��r�W�!��B�c�/6�iM�N�рe�X�f��@���hwo�t��e���.:�f������F�-�Ot�<D�-Q�y�� �~�OiБF� ����Q��Ch�, ��d`��J�H����6��� 8!:�f����W]��8�˔]���4s6���J�����-�n���n�
�!T������;{D�h����!��ܓ�@nC(-O��̀u�^B�, r;�8:��:���Ny,P�lB�, r��fb���
X�̀�<%f`^�n���n��-
�6���������C�;����tt ������!ppMt��GP��6�{o�q��O�P2 9lB�q,vs\<"���4RJ 9��M�48&:D� ����礄l��ݔ�-i�~�� ; �㹱�{�[�Cht� �c��I�x_t������$�X �>n�ɱ�������&c��Q��C��N�nn�/����&���!4:�\\��$ͧ�C�<���Iy�J�F�<vn�c��R`Nt�d~���Ͷ��S���#:����<�ſ��M^��xWt���Qt��f��Ch4��h$s)��x��Ŕ"w�� ɼ|':D��D�HhJ��rt����������m,k O��{�N�C��$��#��̀ x k�;���/��GI�n��Y���IyΒ�`�RZtE�i<
l��9\�@�l�e���c�q,���oM�+ �;�SWQ��.^��������^�5f��煥ms(o�R7���?K)���� ��̀u�F���:����l�V����
d�6�[�\���S���㰊�֔��6i�;�r�Y�)/R��6�р��/E�H�s�Ta+ʛ�+�L�%`���?6�1۸|
�9$"�Xd/o��oM�+ �Z�/j����k��?�R$ht�nn(o�9:DB�`/I�, �485:DB+7����i�Yx_am_Z�E�eFف�/�e1��?�m����]^��,��#�8�b�5a� ��ƙ�Υ�L��w��L8K�,�8��W�C$�9M��(���+�lc�s��țm|����������%�-� r�=�p�������{TP��I�憲ɔ��v7���cЖY�!�qv��^�-P����6�Y��i�>A�R]��$�a�\��@�l�~���x���q�W1ߚ����(��E�s��Ŕ�.�nKʭ��{�Zt��<�I�����
=�صf����ȟmx�+>J���8�P�� W �q*>k��ρf��{/���7G�h�� wF�Hh]��*, �0�� �n�6���z��q����X@�:�U��)
�G�_��6^�X3�+���[��{��x�n&���1�C�c��:e���:�RLht[S��o)P�;�y
� ��)pht��Ƶ��8nT�x�� I�xV�4 �N֏�̃�w�����!���!p9�� �N�1t �l���\��1���R^r��ͥ�C��k��Aۋ�f�lc�]�d���7�ߖm܅'p(��,%�xd{V̷���X�G��ʅg�Φݶ�>�!� ��Iydg>pdt��&��z/� ��>�^�X�h���!�� �I�mb3dO�L��{/��#�͢C4�[�GRc���Ce���.�n�Q}��~����P�Rϋ����iڍ�曌�5���?��e�c3 �V�X;f���*�������q
��� �����WF�H�sb ��Gi|Q7gM����<���'�����.:��X L� ���!�YB��?-�M�w��1��
p)�Pt���Q� M����M����M��]�u�p\t�,�l0��<7���C|�MƱ_�d��g�w�㖪��m(OD�����J� Lק�$� pU������]�C4��+���� Cb0=�=��0ލFup_����e��f�:�E�
��9�$:D2ˉ����l����8�����G�C$48*:�PX L�ߌ��
�;����u5��D��->")ϕ�w�\�q��U~%��m�P5������>�{*�[�0V��=Ay�/����x_t���~t��F��[7K�q�|tAP
���뜂��N��� �!:DB�<���}a_��'p�/aYc��}g0y.�ww+�������$4��������Iy�Tj6Ս���� ���y�6��]���c�u�T1��+ �u:��Ջ�E�!V��[���3:D~\"�Ӣ�����N���%Lw�Q�Ҕ��̀�̀uN�W �s�yt��Z����c�ٜDyf�.����f�!�!��`rl`��W��D�X�V���m
��_����R��5nZ3��f��hp��m\]3�=��EƱخb��� L��8�]e�|g9�߽ƾ��
���$�f�p"�H���[��͕��~�lb�'��xa3`�Ӏu�C����9:DBY�?@٥P�,�nn(/z&:DB��C���ٰ���K�Ct��Xi�f���!�4pqt��<��i[K�o��6��f��%~��k&��>D���8^�V1�Z
W �k10;:DB�F�h nT�c���!�#��� ���$ce0>�)o�R7?n�Q�� ���
o#�Y�_�Ԡ�_"�8��f�q#��m<���P��}���q�8&� ��
*ݽ@����oq�- ��р'(�^�;ϵjʛ(���+�l゚�n�|�W3.����s�E�����J\�E��Y��� 3�$��� }
���n���������5��b�
��:��Y���:����|��%����Lv�� �n��PvK|���q�W1�z+���!��e���!�d9p^t�����C�8��Iy�U�-���f|�f��|dͰ��(��"�XBy�D�\��S���F��{>\"�q�,���к���!2� �7�҈�n�b�?��n�b9�+���ʹX����q�Ϛ�N`.���m܃_D�ۉ3�v�n������g�L��^��fk�=��-�\��Ś�M�牯|���k&;�w?���5��C�$�Xd/o����s������!��}�n���!���!p9p_t���'D�������=|1:���ș���)�!��~j��̀����_��8�z�e��
�j�/��I�S�n�Y1߃�
@w6���7���/G�Hh{��xC� ���Iyn�D��kݸ�a}�ۇ�9�8�o=.,2���W��`���I���!:���k��n5�6�рoQ�.�n6 ����@76�u�2��f�7��vsC�87:DRgDP?�J�W�qY�d���%��?۸�f�{��X;v���Ar`t6���;��"���=�C4�����!��\���G�l���2��'�d��LvA���8��<���p`4�G�H�s�e��:e%@�l��E�Hh�oHka0�������Y�հ��X
�")�����_��8~P3�=���"㸥f�{h�+���G���W ��������~�(:DB;�р{�k�C$�8:@�, �l�� =�[���P���)�q��DH�`���k!��X���_k�39��dOc�-��!�xd�v���p`��R�൞�
�j��],axo��᪲��k�ᶮ��6�u�������v��b���ճ���9��^�:��� ���P�v�")�W�`��N���R���j�EH�M�
��Bi�Fr"��Vǥ5�= [P��F�l� ܆�E�w����]&W��U�q���k�[���!����%kyN�H��f���0���gT��2��j&���M���:v���^s�O��]�5>˰7��7q��{�р���Fh��k�N���y���Q���܅̀u�E�P��&~�*�py�o3Ս�(O��F������^�Wq�l����%�XcS���
x�Bt��<�k��^&�B�6|_{�ӈ?v��5��C �Xdˀ�*滗\x��85�H)ԍ�Vg�-��� pSt��fa3�y�+�P���\����uf�W8+:@R��#�z�È_��8��G&gb�a��(vsC���6�u�Њ��W
C�E�0���ۢC$�pPt�<
|):DR�����g��+�l�%`A�|����2��n�d�������e�m��+� ���̎��e���jf��7(��8�mt���%:DB�`3�� ����7���X����
7᪳��
��/EeQ���xD�1�8�nn�M(O�D��c���� �R�<J߄����o�C$������� ���Iy
���+ЌcNJ�֚���5㸬f�{�ω?�K��+�����k\Ky�_�u儤n>�5:D�n������Q�Z �@Tφ�ɸwU�a#�+�l�Y�p�����KO�������hN!�g�c77��_ �xd����Z���Q�B���=v1��K�lI)��q���I
�0���s�q���W��� |> �C�ͣCL���p#�?��1 �`��A���U����\�#��
������5:DBn�],��j�3qw�^�0��&Njpy,���3�{ޗ�U�'�c��ӝ��>,�'��
����YJѥn�>���j
�1�`S��I��8]�� �֠N�k`3`�#(��0��d`�� =�{�G��s(ޮ��/:DB�'F���! 6��9x9:� }��u3�R��2,"k��3{�\�q,���o����7�q�O�P^�����q�Q1��e���u���G:�r2R7�Sv��([M��A\3�P �����/��u/�Sw�8���f�:� ��1iCX&�K��D�Hh孉�E�OR���yx�� �� ��̅��+��F����D��$��1�����,2�[j&;��� �ﯗ��v��!�V�[�2��]�G����� xP���ݣC4�הM��]��!}. �Q9$
W�O��X�8�u�!&�������!$�:�$:D�<"�^��s`�/iʷ��[
|.:DR����� pg��)i$��рm�;��y���4:ĸ�u���$5cg���!p7pMt��G��> .�IZYo�q;��� ���!ƭ��M?�VfSp���� ���1n}, ��%�����,.��T�-}k� �-:��&� ��;RΓ};�Oû��G���� �@R�v��w ?��T���T �GiԐ��Y�6�9 X?:ĸ�� 8�,:����D��+\�>:DB��G��> �kА4v�)_��y���I��Zӗ&��)�����#ir���� ߘZ����S��
��x�4���wF�h���'#���� �Ї`.prtI��X�Xg!��<�> ��G��������{.����f���!f�@o2$Mͦ�a�!��Pw�=��o
�E?
I�u5��
� ��� -�N�e1������o�c_�DCw#pSt��f��0��spjtIi��f�Ί��"`���2 o�!)�SH|��g�C$� �?:D��@�I� �р')�Vwi�EY� �~��!)�ˁOD�h��K`�e�6�}�A�ʺ�/��������!�C|5p�uHڏ�� H;ْ�4�)+� ��_J3�8 �,:��^yx+�Rt�`� ��5 ����+ i.$5k��
x�jt���]��� , ~���H�ˀ��C4`��� -��&:Ȩ�� �~邤�}�r`�G�����t�
���]�Դ�$;�O�9��J�z�L� ��BR��O�d��� �6�{���":�(�T*$l����V�%�
�IDAT��=\"�4ת,�f#i:Ҝ�'�3��:�<:�(� ��R���87�
�+:DBs)�L5/K`�i�C���-�f�Z�I�c�|@`_�����^`;�F/C�W��Ky��YV �'iڶ>���k5�j� �8<:��Aj�>%6�9���ެ������CH��H��=a�%�^�
X8!:Ě�^ ,� i��R���2�&�֙4�k�l0`O�����N���!�� ��o����C�J�+ ��m`��
x �Nt������Z ���!I4|�2��lbUZ- N6�!I�n�7D�h�7��C$�p|t�Ui� X@��`}ʗ��[
�"�3��J�M�� 7D���W�xGt�lM���/�-��1:ī�x��&�5;�G�h�����!�j���Z086:�$�Bs'� 6�9�8:ī�V Gc$Ip,�vsO٥�=��<��h� �Ԫ
(_R��%���I5u�k� pg�������рm�;i������F���V �|LB�^eg��!p7��u߰fso� piMRM-���N3/�k� 8x}tI� ج�e��� 5��V
+jIY���р%��C$��5�������FI�����C4`G�6<�x�����3�GR.��р;ht��G�. ֣�O��l���f3`��(M��. �6� I5N��n�`���Ц����&!$��|ʗ��{�0:DR����{�IJRv�{G�h�or����H�ˈ_�`������7D�Hh��$Q�����A�?�K���C�W��W���_��D�h�bl����
�pe���}\W1���l,#��;�x��n�F��$�xd!�$Q� l��wvt ��/��G�HjS���
x�(:DR��&nMY����2���䚜����gWU�w}��c�q,�4�OU�
����_��$\<"�o�рid��dfQ
���xp�g����Iz�R���n�Ɯ �E���!&�P�Z��;�IM�����g�����S���G�qH�|W��
�`&�,��4I?���� �р'�/E�H����-���WX�K���S.U����gߩ��>ڃ�c�q���b��Ls`10{���O�AY^���s��S��G@7w�~ �[t���a�}r�* ���C�DР<\"�Yx�[�f�:���| �K+Y�C��'�i�3[?~��Y�M(O�D��c����lZ+ �ml��s)��4}��_��{S:�7� ��!���5s��0����4H/D�H�/=���9x��4
�����Z�R���"��i�Oo�р��G�Hhpʤɤ�������t�Uw��p�s�$u:�_��q�)��'���O�4V'�Y�:~MϺ�+m�@���8>V1�#�tu�}�zQ:h�H_�4s���(_���1���!���5t��f�Z̙p���y�.��㗠�f�:��O�O� X�7���g?n�!���(�f
�� \ �"����p1�@u����� �"�9L���rl�u:�v�ݗ�扬�E���g\���&���u�M�n�1Y ,!�xd�T��ZM���^��<BZ����T�m|8:D�"��\S'Q �8|?w(�ߪ=NٕRu�RT�X�HJc�XM� 8X?w.�!���i�����=&���z� ����( O�gŹ�w�K-�.��6�n��܉,�f�ZgFX�=�o��:��u�ri�����J�qɺ�'dK����#�ؽb�Wk�+ ��w
>'���C)V���^�!� �V���5v��|��1������2���Û��%���α�&��a�, N 6���'�K�CH#�EU���<)5t�~"�
������Y ��W�B��҈��>:DR�3�nb3`�OGX�.�7Gd�>�R�"�s�u�Z1�}�5婧��q��1� �k��pct�#{V��Pt��\"��\s�Q �������Hnn���_�
��l<�2��8�uc�9C��r�_���� ����;�K�G�C$4�/��( �d�}
x,:�T�� �ֆ�"`� ���T��wg�!2���O�Ԕ/�9�:��RlKy�T���8�W1�4����Mܰ�F��N�z�>�w�˥j-�ɿ<�`�/$�sp��w%��6�˸���Ή��|3) �^?�Ȗ�G���`�Ý�C7w|x4:DB�)o��2��ʵ�U�e/���
�j��=T�4�~>:DRS��M3�:Q�\A��*��Q�|��ו��Ί��^8������?�O|3`�݀�F�h���C$U�XS �Gi<P���CHcv ��b&N����L�h����`��O�ߔ�G/E�H�d�N�b�i�Ʀ�a]�����z�?�!M����ͧ|���/D�Hj��f��4��ݧ\J�&�?gYǵ��G�a�n,��2�]W l���n����W���wD�h�����C$4��̀]
���)���վ����6��̌^��#�N�s�Q$]
�C��;��
�3]C�c�3s
N�=��� m4�?ܥ ���ޯ���CHS�m�zU��=�e�Iu7�k�֔�k������>�RZ������e�Z�B���8�Q��j��E�Y����hh��fW3�a���!�c|l��,��Q��Q.�s��3I3pW�v����7���Y��Z���h�� ��3�3\��=�K�3��N��y�� - >��h���z��F�����K�-����cOR^��f|��XJ|SC��O��s�G��f��>彴��"�xx�&vm+ ���k�g�z�E�-��_��~��E�h���G�Hh����`l������CH�l⪷6���Q��T~�ߟ�%���/�O��K?&��u���$�н�����q��pM+ 6��{�F�V�I�zob�n��pIt��:_�P��EW.Y��N��c�g��\f_�>彴/��"�x��<ʿ����ԙ��'�Oⷷ�؟�ts�5���!��jv�]U0��f�^�{�!��x��l܊}�s�$u:#�����/Yd�y�I�fp'�Ϭ���H6��n_ �xd[y2WU��Won�"��r�k&��|9�ǰ'��Z��VW3�\�K�|��L�W�Oy/�J]7^6�D���Xo
�5�>��z�G�H�`�c�Cw%pWt���'�����@u.�!5�"��j��f96�:�ҏ�'�!~�"��k�xI�[�#�^�������|WM������?$��f�'i� D�Hl�q��a칪�'��M�爯L���2� x'����ݧ��ܯ�n<O�[�+ � t�x���5����!:Db�o�р� �E�Hh}�Dx� X�%�灋�CH����zs���C4`p^t��>���%����.�xn4�q����-���c�u��o�\���{
�rt��v ��р��F�H��9�M�jR�-�5:���n������6:DB�E�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�4��=�s�2� IEND�B`��PNG
IHDR �x�� bKGD � � ����� IDATx���g�eU���oQE�
�T�&H0`$5�P�9��
�W��Y�3�jf�M���7z��^�G����(��� �Dl%�� Aɡ(��Ŷ�
�������s������x��<������I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I ̊������O ���.:DR�G��4�X��xGǹ��?�?�L��Nt �489:D�������B`��������i<�����<�)�G�qx�|k�\P�ӣ�̦�!�!�pqt���LJ��������۸��A�݈?����*�[�
��:���>
l��7G�Hh�%QG �b6pjt����'�Ύ��",�%M�A�/u�y<��� �g�?ǁ�r@]�h4Y��$���I��4vo��i�o�z@zn/�EƱ�Ҩ+��+ �ixq>��� ��E�Hh6pJt�`�Q�a<=��+l��:����/mi'p(/Hz���q|�b�50� h6Mז���C4�q�k�!��3+iƶ ���fh��Q� |��c�q���b�5 � hm�i,���'/��j��� �c���@k����̡_C��k�A�KRg%~)s��>loC�d|�b�5� hMl$��VJ6t��")?Ò:�x��o0C_Zہ�O,2��7V̷� �Ω�z�!ġ��
p9pwt���'E�P�, �:6��a���|�=/:DR6J���/]:^w� �J�K���c���VϹ�U�q�-��F�h�C�[b-?Ӓ�j�Y⿱8^;.\�A��?�s��+�[=�
�Vv�at��#�������C$�pBt��@+���Ms��C4�el�uFt I���K��Տ۱`+`)��#�حb��S� ��ljێ���!p?pEt�����, ����Q�!�V����DH�X�u�!� �p�Pێ�nn({�&:DB�E�P, ���,s� 8>:D��G�H�Ϻ �T��9:�Fv+�
�6���E�����C(�|::�:��5:D������� �g��pI9#�q��̋�X :��3����*�ht��|�G �dR�(E��-�f�Z~��&�a{7��L9���;�[k��%:�b�0l~�����C4��u�!�r�� .w����뜌���p �!:�f�xʓC�%�w�!�8<:�bX ����08::D^ >")�e��0�4�/?��� �Z��-:����a:/�}�;��g���C$e3� Y �\�����a3`�S���Ch�, ��p���!4v�`77�E��!�84:���`xl��M�âC4�9J��<7������S��{���!���u��wE�tx!�����>�N�!p�� ��f�A�b0s(���o�����B�a0o���;������� mB�a06��f�!�!�pqt��<W�M�ðp/0;:���J���!����2J��}�A4Y� ��x����E�h���4@�u��d, �o66�
��ܯ8':@R��)��(��:�5�nn(�%>K���8l�9W �φ�aZ�'p�'��D�H�sG���oo~My�������!�7pmt���<�C�
@�-Ƌ��}x[t��+�u_c�2�k �e'����
gGH�'�z�����,�i�� ���!z+�������G [��рǁ�E�H�sIOY �����$����9��%�~:��+ ��WwF�HhpRt��@�̢���V�C)
�n9�����x�x@��#�U�!Ԝ{)�,�l�~\!���*�z����~�Vek���-���Iyn��~�84:��� ������Ch|, ��`��j�!�=��r�W�!��B�c�/6�iM�N�рe�X�f��@���hwo�t��e���.:�f������F�-�Ot�<D�-Q�y�� �~�OiБF� ����Q��Ch�, ��d`��J�H����6��� 8!:�f����W]��8�˔]���4s6���J�����-�n���n�
�!T������;{D�h����!��ܓ�@nC(-O��̀u�^B�, r;�8:��:���Ny,P�lB�, r��fb���
X�̀�<%f`^�n���n��-
�6���������C�;����tt ������!ppMt��GP��6�{o�q��O�P2 9lB�q,vs\<"���4RJ 9��M�48&:D� ����礄l��ݔ�-i�~�� ; �㹱�{�[�Cht� �c��I�x_t������$�X �>n�ɱ�������&c��Q��C��N�nn�/����&���!4:�\\��$ͧ�C�<���Iy�J�F�<vn�c��R`Nt�d~���Ͷ��S���#:����<�ſ��M^��xWt���Qt��f��Ch4��h$s)��x��Ŕ"w�� ɼ|':D��D�HhJ��rt����������m,k O��{�N�C��$��#��̀ x k�;���/��GI�n��Y���IyΒ�`�RZtE�i<
l��9\�@�l�e���c�q,���oM�+ �;�SWQ��.^��������^�5f��煥ms(o�R7���?K)���� ��̀u�F���:����l�V����
d�6�[�\���S���㰊�֔��6i�;�r�Y�)/R��6�р��/E�H�s�Ta+ʛ�+�L�%`���?6�1۸|
�9$"�Xd/o��oM�+ �Z�/j����k��?�R$ht�nn(o�9:DB�`/I�, �485:DB+7����i�Yx_am_Z�E�eFف�/�e1��?�m����]^��,��#�8�b�5a� ��ƙ�Υ�L��w��L8K�,�8��W�C$�9M��(���+�lc�s��țm|����������%�-� r�=�p�������{TP��I�憲ɔ��v7���cЖY�!�qv��^�-P����6�Y��i�>A�R]��$�a�\��@�l�~���x���q�W1ߚ����(��E�s��Ŕ�.�nKʭ��{�Zt��<�I�����
=�صf����ȟmx�+>J���8�P�� W �q*>k��ρf��{/���7G�h�� wF�Hh]��*, �0�� �n�6���z��q����X@�:�U��)
�G�_��6^�X3�+���[��{��x�n&���1�C�c��:e���:�RLht[S��o)P�;�y
� ��)pht��Ƶ��8nT�x�� I�xV�4 �N֏�̃�w�����!���!p9�� �N�1t �l���\��1���R^r��ͥ�C��k��Aۋ�f�lc�]�d���7�ߖm܅'p(��,%�xd{V̷���X�G��ʅg�Φݶ�>�!� ��Iydg>pdt��&��z/� ��>�^�X�h���!�� �I�mb3dO�L��{/��#�͢C4�[�GRc���Ce���.�n�Q}��~����P�Rϋ����iڍ�曌�5���?��e�c3 �V�X;f���*�������q
��� �����WF�H�sb ��Gi|Q7gM����<���'�����.:��X L� ���!�YB��?-�M�w��1��
p)�Pt���Q� M����M����M��]�u�p\t�,�l0��<7���C|�MƱ_�d��g�w�㖪��m(OD�����J� Lק�$� pU������]�C4��+���� Cb0=�=��0ލFup_����e��f�:�E�
��9�$:D2ˉ����l����8�����G�C$48*:�PX L�ߌ��
�;����u5��D��->")ϕ�w�\�q��U~%��m�P5������>�{*�[�0V��=Ay�/����x_t���~t��F��[7K�q�|tAP
���뜂��N��� �!:DB�<���}a_��'p�/aYc��}g0y.�ww+�������$4��������Iy�Tj6Ս���� ���y�6��]���c�u�T1��+ �u:��Ջ�E�!V��[���3:D~\"�Ӣ�����N���%Lw�Q�Ҕ��̀�̀uN�W �s�yt��Z����c�ٜDyf�.����f�!�!��`rl`��W��D�X�V���m
��_����R��5nZ3��f��hp��m\]3�=��EƱخb��� L��8�]e�|g9�߽ƾ��
���$�f�p"�H���[��͕��~�lb�'��xa3`�Ӏu�C����9:DBY�?@٥P�,�nn(/z&:DB��C���ٰ���K�Ct��Xi�f���!�4pqt��<��i[K�o��6��f��%~��k&��>D���8^�V1�Z
W �k10;:DB�F�h nT�c���!�#��� ���$ce0>�)o�R7?n�Q�� ���
o#�Y�_�Ԡ�_"�8��f�q#��m<���P��}���q�8&� ��
*ݽ@����oq�- ��р'(�^�;ϵjʛ(���+�l゚�n�|�W3.����s�E�����J\�E��Y��� 3�$��� }
���n���������5��b�
��:��Y���:����|��%����Lv�� �n��PvK|���q�W1�z+���!��e���!�d9p^t�����C�8��Iy�U�-���f|�f��|dͰ��(��"�XBy�D�\��S���F��{>\"�q�,���к���!2� �7�҈�n�b�?��n�b9�+���ʹX����q�Ϛ�N`.���m܃_D�ۉ3�v�n������g�L��^��fk�=��-�\��Ś�M�牯|���k&;�w?���5��C�$�Xd/o����s������!��}�n���!���!p9p_t���'D�������=|1:���ș���)�!��~j��̀����_��8�z�e��
�j�/��I�S�n�Y1߃�
@w6���7���/G�Hh{��xC� ���Iyn�D��kݸ�a}�ۇ�9�8�o=.,2���W��`���I���!:���k��n5�6�рoQ�.�n6 ����@76�u�2��f�7��vsC�87:DRgDP?�J�W�qY�d���%��?۸�f�{��X;v���Ar`t6���;��"���=�C4�����!��\���G�l���2��'�d��LvA���8��<���p`4�G�H�s�e��:e%@�l��E�Hh�oHka0�������Y�հ��X
�")�����_��8~P3�=���"㸥f�{h�+���G���W ��������~�(:DB;�р{�k�C$�8:@�, �l�� =�[���P���)�q��DH�`���k!��X���_k�39��dOc�-��!�xd�v���p`��R�൞�
�j��],axo��᪲��k�ᶮ��6�u�������v��b���ճ���9��^�:��� ���P�v�")�W�`��N���R���j�EH�M�
��Bi�Fr"��Vǥ5�= [P��F�l� ܆�E�w����]&W��U�q���k�[���!����%kyN�H��f���0���gT��2��j&���M���:v���^s�O��]�5>˰7��7q��{�р���Fh��k�N���y���Q���܅̀u�E�P��&~�*�py�o3Ս�(O��F������^�Wq�l����%�XcS���
x�Bt��<�k��^&�B�6|_{�ӈ?v��5��C �Xdˀ�*滗\x��85�H)ԍ�Vg�-��� pSt��fa3�y�+�P���\����uf�W8+:@R��#�z�È_��8��G&gb�a��(vsC���6�u�Њ��W
C�E�0���ۢC$�pPt�<
|):DR�����g��+�l�%`A�|����2��n�d�������e�m��+� ���̎��e���jf��7(��8�mt���%:DB�`3�� ����7���X����
7᪳��
��/EeQ���xD�1�8�nn�M(O�D��c���� �R�<J߄����o�C$������� ���Iy
���+ЌcNJ�֚���5㸬f�{�ω?�K��+�����k\Ky�_�u儤n>�5:D�n������Q�Z �@Tφ�ɸwU�a#�+�l�Y�p�����KO�������hN!�g�c77��_ �xd����Z���Q�B���=v1��K�lI)��q���I
�0���s�q���W��� |> �C�ͣCL���p#�?��1 �`��A���U����\�#��
������5:DBn�],��j�3qw�^�0��&Njpy,���3�{ޗ�U�'�c��ӝ��>,�'��
����YJѥn�>���j
�1�`S��I��8]�� �֠N�k`3`�#(��0��d`�� =�{�G��s(ޮ��/:DB�'F���! 6��9x9:� }��u3�R��2,"k��3{�\�q,���o����7�q�O�P^�����q�Q1��e���u���G:�r2R7�Sv��([M��A\3�P �����/��u/�Sw�8���f�:� ��1iCX&�K��D�Hh孉�E�OR���yx�� �� ��̅��+��F����D��$��1�����,2�[j&;��� �ﯗ��v��!�V�[�2��]�G����� xP���ݣC4�הM��]��!}. �Q9$
W�O��X�8�u�!&�������!$�:�$:D�<"�^��s`�/iʷ��[
|.:DR����� pg��)i$��рm�;��y���4:ĸ�u���$5cg���!p7pMt��G��> .�IZYo�q;��� ���!ƭ��M?�VfSp���� ���1n}, ��%�����,.��T�-}k� �-:��&� ��;RΓ};�Oû��G���� �@R�v��w ?��T���T �GiԐ��Y�6�9 X?:ĸ�� 8�,:����D��+\�>:DB��G��> �kА4v�)_��y���I��Zӗ&��)�����#ir���� ߘZ����S��
��x�4���wF�h���'#���� �Ї`.prtI��X�Xg!��<�> ��G��������{.����f���!f�@o2$Mͦ�a�!��Pw�=��o
�E?
I�u5��
� ��� -�N�e1������o�c_�DCw#pSt��f��0��spjtIi��f�Ί��"`���2 o�!)�SH|��g�C$� �?:D��@�I� �р')�Vwi�EY� �~��!)�ˁOD�h��K`�e�6�}�A�ʺ�/��������!�C|5p�uHڏ�� H;ْ�4�)+� ��_J3�8 �,:��^yx+�Rt�`� ��5 ����+ i.$5k��
x�jt���]��� , ~���H�ˀ��C4`��� -��&:Ȩ�� �~邤�}�r`�G�����t�
���]�Դ�$;�O�9��J�z�L� ��BR��O�d��� �6�{���":�(�T*$l����V�%�
�IDAT��=\"�4ת,�f#i:Ҝ�'�3��:�<:�(� ��R���87�
�+:DBs)�L5/K`�i�C���-�f�Z�I�c�|@`_�����^`;�F/C�W��Ky��YV �'iڶ>���k5�j� �8<:��Aj�>%6�9���ެ������CH��H��=a�%�^�
X8!:Ě�^ ,� i��R���2�&�֙4�k�l0`O�����N���!�� ��o����C�J�+ ��m`��
x �Nt������Z ���!I4|�2��lbUZ- N6�!I�n�7D�h�7��C$�p|t�Ui� X@��`}ʗ��[
�"�3��J�M�� 7D���W�xGt�lM���/�-��1:ī�x��&�5;�G�h�����!�j���Z086:�$�Bs'� 6�9�8:ī�V Gc$Ip,�vsO٥�=��<��h� �Ԫ
(_R��%���I5u�k� pg�������рm�;i������F���V �|LB�^eg��!p7��u߰fso� piMRM-���N3/�k� 8x}tI� ج�e��� 5��V
+jIY���р%��C$��5�������FI�����C4`G�6<�x�����3�GR.��р;ht��G�. ֣�O��l���f3`��(M��. �6� I5N��n�`���Ц����&!$��|ʗ��{�0:DR����{�IJRv�{G�h�or����H�ˈ_�`������7D�Hh��$Q�����A�?�K���C�W��W���_��D�h�bl����
�pe���}\W1���l,#��;�x��n�F��$�xd!�$Q� l��wvt ��/��G�HjS���
x�(:DR��&nMY����2���䚜����gWU�w}��c�q,�4�OU�
����_��$\<"�o�рid��dfQ
���xp�g����Iz�R���n�Ɯ �E���!&�P�Z��;�IM�����g�����S���G�qH�|W��
�`&�,��4I?���� �р'�/E�H����-���WX�K���S.U����gߩ��>ڃ�c�q���b��Ls`10{���O�AY^���s��S��G@7w�~ �[t���a�}r�* ���C�DР<\"�Yx�[�f�:���| �K+Y�C��'�i�3[?~��Y�M(O�D��c����lZ+ �ml��s)��4}��_��{S:�7� ��!���5s��0����4H/D�H�/=���9x��4
�����Z�R���"��i�Oo�р��G�Hhpʤɤ�������t�Uw��p�s�$u:�_��q�)��'���O�4V'�Y�:~MϺ�+m�@���8>V1�#�tu�}�zQ:h�H_�4s���(_���1���!���5t��f�Z̙p���y�.��㗠�f�:��O�O� X�7���g?n�!���(�f
�� \ �"����p1�@u����� �"�9L���rl�u:�v�ݗ�扬�E���g\���&���u�M�n�1Y ,!�xd�T��ZM���^��<BZ����T�m|8:D�"��\S'Q �8|?w(�ߪ=NٕRu�RT�X�HJc�XM� 8X?w.�!���i�����=&���z� ����( O�gŹ�w�K-�.��6�n��܉,�f�ZgFX�=�o��:��u�ri�����J�qɺ�'dK����#�ؽb�Wk�+ ��w
>'���C)V���^�!� �V���5v��|��1������2���Û��%���α�&��a�, N 6���'�K�CH#�EU���<)5t�~"�
������Y ��W�B��҈��>:DR�3�nb3`�OGX�.�7Gd�>�R�"�s�u�Z1�}�5婧��q��1� �k��pct�#{V��Pt��\"��\s�Q �������Hnn���_�
��l<�2��8�uc�9C��r�_���� ����;�K�G�C$4�/��( �d�}
x,:�T�� �ֆ�"`� ���T��wg�!2���O�Ԕ/�9�:��RlKy�T���8�W1�4����Mܰ�F��N�z�>�w�˥j-�ɿ<�`�/$�sp��w%��6�˸���Ή��|3) �^?�Ȗ�G���`�Ý�C7w|x4:DB�)o��2��ʵ�U�e/���
�j��=T�4�~>:DRS��M3�:Q�\A��*��Q�|��ו��Ί��^8������?�O|3`�݀�F�h���C$U�XS �Gi<P���CHcv ��b&N����L�h����`��O�ߔ�G/E�H�d�N�b�i�Ʀ�a]�����z�?�!M����ͧ|���/D�Hj��f��4��ݧ\J�&�?gYǵ��G�a�n,��2�]W l���n����W���wD�h�����C$4��̀]
���)���վ����6��̌^��#�N�s�Q$]
�C��;��
�3]C�c�3s
N�=��� m4�?ܥ ���ޯ���CHS�m�zU��=�e�Iu7�k�֔�k������>�RZ������e�Z�B���8�Q��j��E�Y����hh��fW3�a���!�c|l��,��Q��Q.�s��3I3pW�v����7���Y��Z���h�� ��3�3\��=�K�3��N��y�� - >��h���z��F�����K�-����cOR^��f|��XJ|SC��O��s�G��f��>彴��"�xx�&vm+ ���k�g�z�E�-��_��~��E�h���G�Hh����`l������CH�l⪷6���Q��T~�ߟ�%���/�O��K?&��u���$�н�����q��pM+ 6��{�F�V�I�zob�n��pIt��:_�P��EW.Y��N��c�g��\f_�>彴/��"�x��<ʿ����ԙ��'�Oⷷ�؟�ts�5���!��jv�]U0��f�^�{�!��x��l܊}�s�$u:#�����/Yd�y�I�fp'�Ϭ���H6��n_ �xd[y2WU��Won�"��r�k&��|9�ǰ'��Z��VW3�\�K�|��L�W�Oy/�J]7^6�D���Xo
�5�>��z�G�H�`�c�Cw%pWt���'�����@u.�!5�"��j��f96�:�ҏ�'�!~�"��k�xI�[�#�^�������|WM������?$��f�'i� D�Hl�q��a칪�'��M�爯L���2� x'����ݧ��ܯ�n<O�[�+ � t�x���5����!:Db�o�р� �E�Hh}�Dx� X�%�灋�CH����zs���C4`p^t��>���%����.�xn4�q����-���c�u��o�\���{
�rt��v ��р��F�H��9�M�jR�-�5:���n������6:DB�E�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�4��=�s�2� IEND�B`��PNG
IHDR �x�� bKGD � � ����� IDATx���g�eU���oQE�
�T�&H0`$5�P�9��
�W��Y�3�jf�M���7z��^�G����(��� �Dl%�� Aɡ(��Ŷ�
�������s������x��<������I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I ̊������O ���.:DR�G��4�X��xGǹ��?�?�L��Nt �489:D�������B`��������i<�����<�)�G�qx�|k�\P�ӣ�̦�!�!�pqt���LJ��������۸��A�݈?����*�[�
��:���>
l��7G�Hh�%QG �b6pjt����'�Ύ��",�%M�A�/u�y<��� �g�?ǁ�r@]�h4Y��$���I��4vo��i�o�z@zn/�EƱ�Ҩ+��+ �ixq>��� ��E�Hh6pJt�`�Q�a<=��+l��:����/mi'p(/Hz���q|�b�50� h6Mז���C4�q�k�!��3+iƶ ���fh��Q� |��c�q���b�5 � hm�i,���'/��j��� �c���@k����̡_C��k�A�KRg%~)s��>loC�d|�b�5� hMl$��VJ6t��")?Ò:�x��o0C_Zہ�O,2��7V̷� �Ω�z�!ġ��
p9pwt���'E�P�, �:6��a���|�=/:DR6J���/]:^w� �J�K���c���VϹ�U�q�-��F�h�C�[b-?Ӓ�j�Y⿱8^;.\�A��?�s��+�[=�
�Vv�at��#�������C$�pBt��@+���Ms��C4�el�uFt I���K��Տ۱`+`)��#�حb��S� ��ljێ���!p?pEt�����, ����Q�!�V����DH�X�u�!� �p�Pێ�nn({�&:DB�E�P, ���,s� 8>:D��G�H�Ϻ �T��9:�Fv+�
�6���E�����C(�|::�:��5:D������� �g��pI9#�q��̋�X :��3����*�ht��|�G �dR�(E��-�f�Z~��&�a{7��L9���;�[k��%:�b�0l~�����C4��u�!�r�� .w����뜌���p �!:�f�xʓC�%�w�!�8<:�bX ����08::D^ >")�e��0�4�/?��� �Z��-:����a:/�}�;��g���C$e3� Y �\�����a3`�S���Ch�, ��p���!4v�`77�E��!�84:���`xl��M�âC4�9J��<7������S��{���!���u��wE�tx!�����>�N�!p�� ��f�A�b0s(���o�����B�a0o���;������� mB�a06��f�!�!�pqt��<W�M�ðp/0;:���J���!����2J��}�A4Y� ��x����E�h���4@�u��d, �o66�
��ܯ8':@R��)��(��:�5�nn(�%>K���8l�9W �φ�aZ�'p�'��D�H�sG���oo~My�������!�7pmt���<�C�
@�-Ƌ��}x[t��+�u_c�2�k �e'����
gGH�'�z�����,�i�� ���!z+�������G [��рǁ�E�H�sIOY �����$����9��%�~:��+ ��WwF�HhpRt��@�̢���V�C)
�n9�����x�x@��#�U�!Ԝ{)�,�l�~\!���*�z����~�Vek���-���Iyn��~�84:��� ������Ch|, ��`��j�!�=��r�W�!��B�c�/6�iM�N�рe�X�f��@���hwo�t��e���.:�f������F�-�Ot�<D�-Q�y�� �~�OiБF� ����Q��Ch�, ��d`��J�H����6��� 8!:�f����W]��8�˔]���4s6���J�����-�n���n�
�!T������;{D�h����!��ܓ�@nC(-O��̀u�^B�, r;�8:��:���Ny,P�lB�, r��fb���
X�̀�<%f`^�n���n��-
�6���������C�;����tt ������!ppMt��GP��6�{o�q��O�P2 9lB�q,vs\<"���4RJ 9��M�48&:D� ����礄l��ݔ�-i�~�� ; �㹱�{�[�Cht� �c��I�x_t������$�X �>n�ɱ�������&c��Q��C��N�nn�/����&���!4:�\\��$ͧ�C�<���Iy�J�F�<vn�c��R`Nt�d~���Ͷ��S���#:����<�ſ��M^��xWt���Qt��f��Ch4��h$s)��x��Ŕ"w�� ɼ|':D��D�HhJ��rt����������m,k O��{�N�C��$��#��̀ x k�;���/��GI�n��Y���IyΒ�`�RZtE�i<
l��9\�@�l�e���c�q,���oM�+ �;�SWQ��.^��������^�5f��煥ms(o�R7���?K)���� ��̀u�F���:����l�V����
d�6�[�\���S���㰊�֔��6i�;�r�Y�)/R��6�р��/E�H�s�Ta+ʛ�+�L�%`���?6�1۸|
�9$"�Xd/o��oM�+ �Z�/j����k��?�R$ht�nn(o�9:DB�`/I�, �485:DB+7����i�Yx_am_Z�E�eFف�/�e1��?�m����]^��,��#�8�b�5a� ��ƙ�Υ�L��w��L8K�,�8��W�C$�9M��(���+�lc�s��țm|����������%�-� r�=�p�������{TP��I�憲ɔ��v7���cЖY�!�qv��^�-P����6�Y��i�>A�R]��$�a�\��@�l�~���x���q�W1ߚ����(��E�s��Ŕ�.�nKʭ��{�Zt��<�I�����
=�صf����ȟmx�+>J���8�P�� W �q*>k��ρf��{/���7G�h�� wF�Hh]��*, �0�� �n�6���z��q����X@�:�U��)
�G�_��6^�X3�+���[��{��x�n&���1�C�c��:e���:�RLht[S��o)P�;�y
� ��)pht��Ƶ��8nT�x�� I�xV�4 �N֏�̃�w�����!���!p9�� �N�1t �l���\��1���R^r��ͥ�C��k��Aۋ�f�lc�]�d���7�ߖm܅'p(��,%�xd{V̷���X�G��ʅg�Φݶ�>�!� ��Iydg>pdt��&��z/� ��>�^�X�h���!�� �I�mb3dO�L��{/��#�͢C4�[�GRc���Ce���.�n�Q}��~����P�Rϋ����iڍ�曌�5���?��e�c3 �V�X;f���*�������q
��� �����WF�H�sb ��Gi|Q7gM����<���'�����.:��X L� ���!�YB��?-�M�w��1��
p)�Pt���Q� M����M����M��]�u�p\t�,�l0��<7���C|�MƱ_�d��g�w�㖪��m(OD�����J� Lק�$� pU������]�C4��+���� Cb0=�=��0ލFup_����e��f�:�E�
��9�$:D2ˉ����l����8�����G�C$48*:�PX L�ߌ��
�;����u5��D��->")ϕ�w�\�q��U~%��m�P5������>�{*�[�0V��=Ay�/����x_t���~t��F��[7K�q�|tAP
���뜂��N��� �!:DB�<���}a_��'p�/aYc��}g0y.�ww+�������$4��������Iy�Tj6Ս���� ���y�6��]���c�u�T1��+ �u:��Ջ�E�!V��[���3:D~\"�Ӣ�����N���%Lw�Q�Ҕ��̀�̀uN�W �s�yt��Z����c�ٜDyf�.����f�!�!��`rl`��W��D�X�V���m
��_����R��5nZ3��f��hp��m\]3�=��EƱخb��� L��8�]e�|g9�߽ƾ��
���$�f�p"�H���[��͕��~�lb�'��xa3`�Ӏu�C����9:DBY�?@٥P�,�nn(/z&:DB��C���ٰ���K�Ct��Xi�f���!�4pqt��<��i[K�o��6��f��%~��k&��>D���8^�V1�Z
W �k10;:DB�F�h nT�c���!�#��� ���$ce0>�)o�R7?n�Q�� ���
o#�Y�_�Ԡ�_"�8��f�q#��m<���P��}���q�8&� ��
*ݽ@����oq�- ��р'(�^�;ϵjʛ(���+�l゚�n�|�W3.����s�E�����J\�E��Y��� 3�$��� }
���n���������5��b�
��:��Y���:����|��%����Lv�� �n��PvK|���q�W1�z+���!��e���!�d9p^t�����C�8��Iy�U�-���f|�f��|dͰ��(��"�XBy�D�\��S���F��{>\"�q�,���к���!2� �7�҈�n�b�?��n�b9�+���ʹX����q�Ϛ�N`.���m܃_D�ۉ3�v�n������g�L��^��fk�=��-�\��Ś�M�牯|���k&;�w?���5��C�$�Xd/o����s������!��}�n���!���!p9p_t���'D�������=|1:���ș���)�!��~j��̀����_��8�z�e��
�j�/��I�S�n�Y1߃�
@w6���7���/G�Hh{��xC� ���Iyn�D��kݸ�a}�ۇ�9�8�o=.,2���W��`���I���!:���k��n5�6�рoQ�.�n6 ����@76�u�2��f�7��vsC�87:DRgDP?�J�W�qY�d���%��?۸�f�{��X;v���Ar`t6���;��"���=�C4�����!��\���G�l���2��'�d��LvA���8��<���p`4�G�H�s�e��:e%@�l��E�Hh�oHka0�������Y�հ��X
�")�����_��8~P3�=���"㸥f�{h�+���G���W ��������~�(:DB;�р{�k�C$�8:@�, �l�� =�[���P���)�q��DH�`���k!��X���_k�39��dOc�-��!�xd�v���p`��R�൞�
�j��],axo��᪲��k�ᶮ��6�u�������v��b���ճ���9��^�:��� ���P�v�")�W�`��N���R���j�EH�M�
��Bi�Fr"��Vǥ5�= [P��F�l� ܆�E�w����]&W��U�q���k�[���!����%kyN�H��f���0���gT��2��j&���M���:v���^s�O��]�5>˰7��7q��{�р���Fh��k�N���y���Q���܅̀u�E�P��&~�*�py�o3Ս�(O��F������^�Wq�l����%�XcS���
x�Bt��<�k��^&�B�6|_{�ӈ?v��5��C �Xdˀ�*滗\x��85�H)ԍ�Vg�-��� pSt��fa3�y�+�P���\����uf�W8+:@R��#�z�È_��8��G&gb�a��(vsC���6�u�Њ��W
C�E�0���ۢC$�pPt�<
|):DR�����g��+�l�%`A�|����2��n�d�������e�m��+� ���̎��e���jf��7(��8�mt���%:DB�`3�� ����7���X����
7᪳��
��/EeQ���xD�1�8�nn�M(O�D��c���� �R�<J߄����o�C$������� ���Iy
���+ЌcNJ�֚���5㸬f�{�ω?�K��+�����k\Ky�_�u儤n>�5:D�n������Q�Z �@Tφ�ɸwU�a#�+�l�Y�p�����KO�������hN!�g�c77��_ �xd����Z���Q�B���=v1��K�lI)��q���I
�0���s�q���W��� |> �C�ͣCL���p#�?��1 �`��A���U����\�#��
������5:DBn�],��j�3qw�^�0��&Njpy,���3�{ޗ�U�'�c��ӝ��>,�'��
����YJѥn�>���j
�1�`S��I��8]�� �֠N�k`3`�#(��0��d`�� =�{�G��s(ޮ��/:DB�'F���! 6��9x9:� }��u3�R��2,"k��3{�\�q,���o����7�q�O�P^�����q�Q1��e���u���G:�r2R7�Sv��([M��A\3�P �����/��u/�Sw�8���f�:� ��1iCX&�K��D�Hh孉�E�OR���yx�� �� ��̅��+��F����D��$��1�����,2�[j&;��� �ﯗ��v��!�V�[�2��]�G����� xP���ݣC4�הM��]��!}. �Q9$
W�O��X�8�u�!&�������!$�:�$:D�<"�^��s`�/iʷ��[
|.:DR����� pg��)i$��рm�;��y���4:ĸ�u���$5cg���!p7pMt��G��> .�IZYo�q;��� ���!ƭ��M?�VfSp���� ���1n}, ��%�����,.��T�-}k� �-:��&� ��;RΓ};�Oû��G���� �@R�v��w ?��T���T �GiԐ��Y�6�9 X?:ĸ�� 8�,:����D��+\�>:DB��G��> �kА4v�)_��y���I��Zӗ&��)�����#ir���� ߘZ����S��
��x�4���wF�h���'#���� �Ї`.prtI��X�Xg!��<�> ��G��������{.����f���!f�@o2$Mͦ�a�!��Pw�=��o
�E?
I�u5��
� ��� -�N�e1������o�c_�DCw#pSt��f��0��spjtIi��f�Ί��"`���2 o�!)�SH|��g�C$� �?:D��@�I� �р')�Vwi�EY� �~��!)�ˁOD�h��K`�e�6�}�A�ʺ�/��������!�C|5p�uHڏ�� H;ْ�4�)+� ��_J3�8 �,:��^yx+�Rt�`� ��5 ����+ i.$5k��
x�jt���]��� , ~���H�ˀ��C4`��� -��&:Ȩ�� �~邤�}�r`�G�����t�
���]�Դ�$;�O�9��J�z�L� ��BR��O�d��� �6�{���":�(�T*$l����V�%�
�IDAT��=\"�4ת,�f#i:Ҝ�'�3��:�<:�(� ��R���87�
�+:DBs)�L5/K`�i�C���-�f�Z�I�c�|@`_�����^`;�F/C�W��Ky��YV �'iڶ>���k5�j� �8<:��Aj�>%6�9���ެ������CH��H��=a�%�^�
X8!:Ě�^ ,� i��R���2�&�֙4�k�l0`O�����N���!�� ��o����C�J�+ ��m`��
x �Nt������Z ���!I4|�2��lbUZ- N6�!I�n�7D�h�7��C$�p|t�Ui� X@��`}ʗ��[
�"�3��J�M�� 7D���W�xGt�lM���/�-��1:ī�x��&�5;�G�h�����!�j���Z086:�$�Bs'� 6�9�8:ī�V Gc$Ip,�vsO٥�=��<��h� �Ԫ
(_R��%���I5u�k� pg�������рm�;i������F���V �|LB�^eg��!p7��u߰fso� piMRM-���N3/�k� 8x}tI� ج�e��� 5��V
+jIY���р%��C$��5�������FI�����C4`G�6<�x�����3�GR.��р;ht��G�. ֣�O��l���f3`��(M��. �6� I5N��n�`���Ц����&!$��|ʗ��{�0:DR����{�IJRv�{G�h�or����H�ˈ_�`������7D�Hh��$Q�����A�?�K���C�W��W���_��D�h�bl����
�pe���}\W1���l,#��;�x��n�F��$�xd!�$Q� l��wvt ��/��G�HjS���
x�(:DR��&nMY����2���䚜����gWU�w}��c�q,�4�OU�
����_��$\<"�o�рid��dfQ
���xp�g����Iz�R���n�Ɯ �E���!&�P�Z��;�IM�����g�����S���G�qH�|W��
�`&�,��4I?���� �р'�/E�H����-���WX�K���S.U����gߩ��>ڃ�c�q���b��Ls`10{���O�AY^���s��S��G@7w�~ �[t���a�}r�* ���C�DР<\"�Yx�[�f�:���| �K+Y�C��'�i�3[?~��Y�M(O�D��c����lZ+ �ml��s)��4}��_��{S:�7� ��!���5s��0����4H/D�H�/=���9x��4
�����Z�R���"��i�Oo�р��G�Hhpʤɤ�������t�Uw��p�s�$u:�_��q�)��'���O�4V'�Y�:~MϺ�+m�@���8>V1�#�tu�}�zQ:h�H_�4s���(_���1���!���5t��f�Z̙p���y�.��㗠�f�:��O�O� X�7���g?n�!���(�f
�� \ �"����p1�@u����� �"�9L���rl�u:�v�ݗ�扬�E���g\���&���u�M�n�1Y ,!�xd�T��ZM���^��<BZ����T�m|8:D�"��\S'Q �8|?w(�ߪ=NٕRu�RT�X�HJc�XM� 8X?w.�!���i�����=&���z� ����( O�gŹ�w�K-�.��6�n��܉,�f�ZgFX�=�o��:��u�ri�����J�qɺ�'dK����#�ؽb�Wk�+ ��w
>'���C)V���^�!� �V���5v��|��1������2���Û��%���α�&��a�, N 6���'�K�CH#�EU���<)5t�~"�
������Y ��W�B��҈��>:DR�3�nb3`�OGX�.�7Gd�>�R�"�s�u�Z1�}�5婧��q��1� �k��pct�#{V��Pt��\"��\s�Q �������Hnn���_�
��l<�2��8�uc�9C��r�_���� ����;�K�G�C$4�/��( �d�}
x,:�T�� �ֆ�"`� ���T��wg�!2���O�Ԕ/�9�:��RlKy�T���8�W1�4����Mܰ�F��N�z�>�w�˥j-�ɿ<�`�/$�sp��w%��6�˸���Ή��|3) �^?�Ȗ�G���`�Ý�C7w|x4:DB�)o��2��ʵ�U�e/���
�j��=T�4�~>:DRS��M3�:Q�\A��*��Q�|��ו��Ί��^8������?�O|3`�݀�F�h���C$U�XS �Gi<P���CHcv ��b&N����L�h����`��O�ߔ�G/E�H�d�N�b�i�Ʀ�a]�����z�?�!M����ͧ|���/D�Hj��f��4��ݧ\J�&�?gYǵ��G�a�n,��2�]W l���n����W���wD�h�����C$4��̀]
���)���վ����6��̌^��#�N�s�Q$]
�C��;��
�3]C�c�3s
N�=��� m4�?ܥ ���ޯ���CHS�m�zU��=�e�Iu7�k�֔�k������>�RZ������e�Z�B���8�Q��j��E�Y����hh��fW3�a���!�c|l��,��Q��Q.�s��3I3pW�v����7���Y��Z���h�� ��3�3\��=�K�3��N��y�� - >��h���z��F�����K�-����cOR^��f|��XJ|SC��O��s�G��f��>彴��"�xx�&vm+ ���k�g�z�E�-��_��~��E�h���G�Hh����`l������CH�l⪷6���Q��T~�ߟ�%���/�O��K?&��u���$�н�����q��pM+ 6��{�F�V�I�zob�n��pIt��:_�P��EW.Y��N��c�g��\f_�>彴/��"�x��<ʿ����ԙ��'�Oⷷ�؟�ts�5���!��jv�]U0��f�^�{�!��x��l܊}�s�$u:#�����/Yd�y�I�fp'�Ϭ���H6��n_ �xd[y2WU��Won�"��r�k&��|9�ǰ'��Z��VW3�\�K�|��L�W�Oy/�J]7^6�D���Xo
�5�>��z�G�H�`�c�Cw%pWt���'�����@u.�!5�"��j��f96�:�ҏ�'�!~�"��k�xI�[�#�^�������|WM������?$��f�'i� D�Hl�q��a칪�'��M�爯L���2� x'����ݧ��ܯ�n<O�[�+ � t�x���5����!:Db�o�р� �E�Hh}�Dx� X�%�灋�CH����zs���C4`p^t��>���%����.�xn4�q����-���c�u��o�\���{
�rt��v ��р��F�H��9�M�jR�-�5:���n������6:DB�E�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�4��=�s�2� IEND�B`��PNG
IHDR �x�� bKGD � � ����� IDATx���g�eU���oQE�
�T�&H0`$5�P�9��
�W��Y�3�jf�M���7z��^�G����(��� �Dl%�� Aɡ(��Ŷ�
�������s������x��<������I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I ̊������O ���.:DR�G��4�X��xGǹ��?�?�L��Nt �489:D�������B`��������i<�����<�)�G�qx�|k�\P�ӣ�̦�!�!�pqt���LJ��������۸��A�݈?����*�[�
��:���>
l��7G�Hh�%QG �b6pjt����'�Ύ��",�%M�A�/u�y<��� �g�?ǁ�r@]�h4Y��$���I��4vo��i�o�z@zn/�EƱ�Ҩ+��+ �ixq>��� ��E�Hh6pJt�`�Q�a<=��+l��:����/mi'p(/Hz���q|�b�50� h6Mז���C4�q�k�!��3+iƶ ���fh��Q� |��c�q���b�5 � hm�i,���'/��j��� �c���@k����̡_C��k�A�KRg%~)s��>loC�d|�b�5� hMl$��VJ6t��")?Ò:�x��o0C_Zہ�O,2��7V̷� �Ω�z�!ġ��
p9pwt���'E�P�, �:6��a���|�=/:DR6J���/]:^w� �J�K���c���VϹ�U�q�-��F�h�C�[b-?Ӓ�j�Y⿱8^;.\�A��?�s��+�[=�
�Vv�at��#�������C$�pBt��@+���Ms��C4�el�uFt I���K��Տ۱`+`)��#�حb��S� ��ljێ���!p?pEt�����, ����Q�!�V����DH�X�u�!� �p�Pێ�nn({�&:DB�E�P, ���,s� 8>:D��G�H�Ϻ �T��9:�Fv+�
�6���E�����C(�|::�:��5:D������� �g��pI9#�q��̋�X :��3����*�ht��|�G �dR�(E��-�f�Z~��&�a{7��L9���;�[k��%:�b�0l~�����C4��u�!�r�� .w����뜌���p �!:�f�xʓC�%�w�!�8<:�bX ����08::D^ >")�e��0�4�/?��� �Z��-:����a:/�}�;��g���C$e3� Y �\�����a3`�S���Ch�, ��p���!4v�`77�E��!�84:���`xl��M�âC4�9J��<7������S��{���!���u��wE�tx!�����>�N�!p�� ��f�A�b0s(���o�����B�a0o���;������� mB�a06��f�!�!�pqt��<W�M�ðp/0;:���J���!����2J��}�A4Y� ��x����E�h���4@�u��d, �o66�
��ܯ8':@R��)��(��:�5�nn(�%>K���8l�9W �φ�aZ�'p�'��D�H�sG���oo~My�������!�7pmt���<�C�
@�-Ƌ��}x[t��+�u_c�2�k �e'����
gGH�'�z�����,�i�� ���!z+�������G [��рǁ�E�H�sIOY �����$����9��%�~:��+ ��WwF�HhpRt��@�̢���V�C)
�n9�����x�x@��#�U�!Ԝ{)�,�l�~\!���*�z����~�Vek���-���Iyn��~�84:��� ������Ch|, ��`��j�!�=��r�W�!��B�c�/6�iM�N�рe�X�f��@���hwo�t��e���.:�f������F�-�Ot�<D�-Q�y�� �~�OiБF� ����Q��Ch�, ��d`��J�H����6��� 8!:�f����W]��8�˔]���4s6���J�����-�n���n�
�!T������;{D�h����!��ܓ�@nC(-O��̀u�^B�, r;�8:��:���Ny,P�lB�, r��fb���
X�̀�<%f`^�n���n��-
�6���������C�;����tt ������!ppMt��GP��6�{o�q��O�P2 9lB�q,vs\<"���4RJ 9��M�48&:D� ����礄l��ݔ�-i�~�� ; �㹱�{�[�Cht� �c��I�x_t������$�X �>n�ɱ�������&c��Q��C��N�nn�/����&���!4:�\\��$ͧ�C�<���Iy�J�F�<vn�c��R`Nt�d~���Ͷ��S���#:����<�ſ��M^��xWt���Qt��f��Ch4��h$s)��x��Ŕ"w�� ɼ|':D��D�HhJ��rt����������m,k O��{�N�C��$��#��̀ x k�;���/��GI�n��Y���IyΒ�`�RZtE�i<
l��9\�@�l�e���c�q,���oM�+ �;�SWQ��.^��������^�5f��煥ms(o�R7���?K)���� ��̀u�F���:����l�V����
d�6�[�\���S���㰊�֔��6i�;�r�Y�)/R��6�р��/E�H�s�Ta+ʛ�+�L�%`���?6�1۸|
�9$"�Xd/o��oM�+ �Z�/j����k��?�R$ht�nn(o�9:DB�`/I�, �485:DB+7����i�Yx_am_Z�E�eFف�/�e1��?�m����]^��,��#�8�b�5a� ��ƙ�Υ�L��w��L8K�,�8��W�C$�9M��(���+�lc�s��țm|����������%�-� r�=�p�������{TP��I�憲ɔ��v7���cЖY�!�qv��^�-P����6�Y��i�>A�R]��$�a�\��@�l�~���x���q�W1ߚ����(��E�s��Ŕ�.�nKʭ��{�Zt��<�I�����
=�صf����ȟmx�+>J���8�P�� W �q*>k��ρf��{/���7G�h�� wF�Hh]��*, �0�� �n�6���z��q����X@�:�U��)
�G�_��6^�X3�+���[��{��x�n&���1�C�c��:e���:�RLht[S��o)P�;�y
� ��)pht��Ƶ��8nT�x�� I�xV�4 �N֏�̃�w�����!���!p9�� �N�1t �l���\��1���R^r��ͥ�C��k��Aۋ�f�lc�]�d���7�ߖm܅'p(��,%�xd{V̷���X�G��ʅg�Φݶ�>�!� ��Iydg>pdt��&��z/� ��>�^�X�h���!�� �I�mb3dO�L��{/��#�͢C4�[�GRc���Ce���.�n�Q}��~����P�Rϋ����iڍ�曌�5���?��e�c3 �V�X;f���*�������q
��� �����WF�H�sb ��Gi|Q7gM����<���'�����.:��X L� ���!�YB��?-�M�w��1��
p)�Pt���Q� M����M����M��]�u�p\t�,�l0��<7���C|�MƱ_�d��g�w�㖪��m(OD�����J� Lק�$� pU������]�C4��+���� Cb0=�=��0ލFup_����e��f�:�E�
��9�$:D2ˉ����l����8�����G�C$48*:�PX L�ߌ��
�;����u5��D��->")ϕ�w�\�q��U~%��m�P5������>�{*�[�0V��=Ay�/����x_t���~t��F��[7K�q�|tAP
���뜂��N��� �!:DB�<���}a_��'p�/aYc��}g0y.�ww+�������$4��������Iy�Tj6Ս���� ���y�6��]���c�u�T1��+ �u:��Ջ�E�!V��[���3:D~\"�Ӣ�����N���%Lw�Q�Ҕ��̀�̀uN�W �s�yt��Z����c�ٜDyf�.����f�!�!��`rl`��W��D�X�V���m
��_����R��5nZ3��f��hp��m\]3�=��EƱخb��� L��8�]e�|g9�߽ƾ��
���$�f�p"�H���[��͕��~�lb�'��xa3`�Ӏu�C����9:DBY�?@٥P�,�nn(/z&:DB��C���ٰ���K�Ct��Xi�f���!�4pqt��<��i[K�o��6��f��%~��k&��>D���8^�V1�Z
W �k10;:DB�F�h nT�c���!�#��� ���$ce0>�)o�R7?n�Q�� ���
o#�Y�_�Ԡ�_"�8��f�q#��m<���P��}���q�8&� ��
*ݽ@����oq�- ��р'(�^�;ϵjʛ(���+�l゚�n�|�W3.����s�E�����J\�E��Y��� 3�$��� }
���n���������5��b�
��:��Y���:����|��%����Lv�� �n��PvK|���q�W1�z+���!��e���!�d9p^t�����C�8��Iy�U�-���f|�f��|dͰ��(��"�XBy�D�\��S���F��{>\"�q�,���к���!2� �7�҈�n�b�?��n�b9�+���ʹX����q�Ϛ�N`.���m܃_D�ۉ3�v�n������g�L��^��fk�=��-�\��Ś�M�牯|���k&;�w?���5��C�$�Xd/o����s������!��}�n���!���!p9p_t���'D�������=|1:���ș���)�!��~j��̀����_��8�z�e��
�j�/��I�S�n�Y1߃�
@w6���7���/G�Hh{��xC� ���Iyn�D��kݸ�a}�ۇ�9�8�o=.,2���W��`���I���!:���k��n5�6�рoQ�.�n6 ����@76�u�2��f�7��vsC�87:DRgDP?�J�W�qY�d���%��?۸�f�{��X;v���Ar`t6���;��"���=�C4�����!��\���G�l���2��'�d��LvA���8��<���p`4�G�H�s�e��:e%@�l��E�Hh�oHka0�������Y�հ��X
�")�����_��8~P3�=���"㸥f�{h�+���G���W ��������~�(:DB;�р{�k�C$�8:@�, �l�� =�[���P���)�q��DH�`���k!��X���_k�39��dOc�-��!�xd�v���p`��R�൞�
�j��],axo��᪲��k�ᶮ��6�u�������v��b���ճ���9��^�:��� ���P�v�")�W�`��N���R���j�EH�M�
��Bi�Fr"��Vǥ5�= [P��F�l� ܆�E�w����]&W��U�q���k�[���!����%kyN�H��f���0���gT��2��j&���M���:v���^s�O��]�5>˰7��7q��{�р���Fh��k�N���y���Q���܅̀u�E�P��&~�*�py�o3Ս�(O��F������^�Wq�l����%�XcS���
x�Bt��<�k��^&�B�6|_{�ӈ?v��5��C �Xdˀ�*滗\x��85�H)ԍ�Vg�-��� pSt��fa3�y�+�P���\����uf�W8+:@R��#�z�È_��8��G&gb�a��(vsC���6�u�Њ��W
C�E�0���ۢC$�pPt�<
|):DR�����g��+�l�%`A�|����2��n�d�������e�m��+� ���̎��e���jf��7(��8�mt���%:DB�`3�� ����7���X����
7᪳��
��/EeQ���xD�1�8�nn�M(O�D��c���� �R�<J߄����o�C$������� ���Iy
���+ЌcNJ�֚���5㸬f�{�ω?�K��+�����k\Ky�_�u儤n>�5:D�n������Q�Z �@Tφ�ɸwU�a#�+�l�Y�p�����KO�������hN!�g�c77��_ �xd����Z���Q�B���=v1��K�lI)��q���I
�0���s�q���W��� |> �C�ͣCL���p#�?��1 �`��A���U����\�#��
������5:DBn�],��j�3qw�^�0��&Njpy,���3�{ޗ�U�'�c��ӝ��>,�'��
����YJѥn�>���j
�1�`S��I��8]�� �֠N�k`3`�#(��0��d`�� =�{�G��s(ޮ��/:DB�'F���! 6��9x9:� }��u3�R��2,"k��3{�\�q,���o����7�q�O�P^�����q�Q1��e���u���G:�r2R7�Sv��([M��A\3�P �����/��u/�Sw�8���f�:� ��1iCX&�K��D�Hh孉�E�OR���yx�� �� ��̅��+��F����D��$��1�����,2�[j&;��� �ﯗ��v��!�V�[�2��]�G����� xP���ݣC4�הM��]��!}. �Q9$
W�O��X�8�u�!&�������!$�:�$:D�<"�^��s`�/iʷ��[
|.:DR����� pg��)i$��рm�;��y���4:ĸ�u���$5cg���!p7pMt��G��> .�IZYo�q;��� ���!ƭ��M?�VfSp���� ���1n}, ��%�����,.��T�-}k� �-:��&� ��;RΓ};�Oû��G���� �@R�v��w ?��T���T �GiԐ��Y�6�9 X?:ĸ�� 8�,:����D��+\�>:DB��G��> �kА4v�)_��y���I��Zӗ&��)�����#ir���� ߘZ����S��
��x�4���wF�h���'#���� �Ї`.prtI��X�Xg!��<�> ��G��������{.����f���!f�@o2$Mͦ�a�!��Pw�=��o
�E?
I�u5��
� ��� -�N�e1������o�c_�DCw#pSt��f��0��spjtIi��f�Ί��"`���2 o�!)�SH|��g�C$� �?:D��@�I� �р')�Vwi�EY� �~��!)�ˁOD�h��K`�e�6�}�A�ʺ�/��������!�C|5p�uHڏ�� H;ْ�4�)+� ��_J3�8 �,:��^yx+�Rt�`� ��5 ����+ i.$5k��
x�jt���]��� , ~���H�ˀ��C4`��� -��&:Ȩ�� �~邤�}�r`�G�����t�
���]�Դ�$;�O�9��J�z�L� ��BR��O�d��� �6�{���":�(�T*$l����V�%�
�IDAT��=\"�4ת,�f#i:Ҝ�'�3��:�<:�(� ��R���87�
�+:DBs)�L5/K`�i�C���-�f�Z�I�c�|@`_�����^`;�F/C�W��Ky��YV �'iڶ>���k5�j� �8<:��Aj�>%6�9���ެ������CH��H��=a�%�^�
X8!:Ě�^ ,� i��R���2�&�֙4�k�l0`O�����N���!�� ��o����C�J�+ ��m`��
x �Nt������Z ���!I4|�2��lbUZ- N6�!I�n�7D�h�7��C$�p|t�Ui� X@��`}ʗ��[
�"�3��J�M�� 7D���W�xGt�lM���/�-��1:ī�x��&�5;�G�h�����!�j���Z086:�$�Bs'� 6�9�8:ī�V Gc$Ip,�vsO٥�=��<��h� �Ԫ
(_R��%���I5u�k� pg�������рm�;i������F���V �|LB�^eg��!p7��u߰fso� piMRM-���N3/�k� 8x}tI� ج�e��� 5��V
+jIY���р%��C$��5�������FI�����C4`G�6<�x�����3�GR.��р;ht��G�. ֣�O��l���f3`��(M��. �6� I5N��n�`���Ц����&!$��|ʗ��{�0:DR����{�IJRv�{G�h�or����H�ˈ_�`������7D�Hh��$Q�����A�?�K���C�W��W���_��D�h�bl����
�pe���}\W1���l,#��;�x��n�F��$�xd!�$Q� l��wvt ��/��G�HjS���
x�(:DR��&nMY����2���䚜����gWU�w}��c�q,�4�OU�
����_��$\<"�o�рid��dfQ
���xp�g����Iz�R���n�Ɯ �E���!&�P�Z��;�IM�����g�����S���G�qH�|W��
�`&�,��4I?���� �р'�/E�H����-���WX�K���S.U����gߩ��>ڃ�c�q���b��Ls`10{���O�AY^���s��S��G@7w�~ �[t���a�}r�* ���C�DР<\"�Yx�[�f�:���| �K+Y�C��'�i�3[?~��Y�M(O�D��c����lZ+ �ml��s)��4}��_��{S:�7� ��!���5s��0����4H/D�H�/=���9x��4
�����Z�R���"��i�Oo�р��G�Hhpʤɤ�������t�Uw��p�s�$u:�_��q�)��'���O�4V'�Y�:~MϺ�+m�@���8>V1�#�tu�}�zQ:h�H_�4s���(_���1���!���5t��f�Z̙p���y�.��㗠�f�:��O�O� X�7���g?n�!���(�f
�� \ �"����p1�@u����� �"�9L���rl�u:�v�ݗ�扬�E���g\���&���u�M�n�1Y ,!�xd�T��ZM���^��<BZ����T�m|8:D�"��\S'Q �8|?w(�ߪ=NٕRu�RT�X�HJc�XM� 8X?w.�!���i�����=&���z� ����( O�gŹ�w�K-�.��6�n��܉,�f�ZgFX�=�o��:��u�ri�����J�qɺ�'dK����#�ؽb�Wk�+ ��w
>'���C)V���^�!� �V���5v��|��1������2���Û��%���α�&��a�, N 6���'�K�CH#�EU���<)5t�~"�
������Y ��W�B��҈��>:DR�3�nb3`�OGX�.�7Gd�>�R�"�s�u�Z1�}�5婧��q��1� �k��pct�#{V��Pt��\"��\s�Q �������Hnn���_�
��l<�2��8�uc�9C��r�_���� ����;�K�G�C$4�/��( �d�}
x,:�T�� �ֆ�"`� ���T��wg�!2���O�Ԕ/�9�:��RlKy�T���8�W1�4����Mܰ�F��N�z�>�w�˥j-�ɿ<�`�/$�sp��w%��6�˸���Ή��|3) �^?�Ȗ�G���`�Ý�C7w|x4:DB�)o��2��ʵ�U�e/���
�j��=T�4�~>:DRS��M3�:Q�\A��*��Q�|��ו��Ί��^8������?�O|3`�݀�F�h���C$U�XS �Gi<P���CHcv ��b&N����L�h����`��O�ߔ�G/E�H�d�N�b�i�Ʀ�a]�����z�?�!M����ͧ|���/D�Hj��f��4��ݧ\J�&�?gYǵ��G�a�n,��2�]W l���n����W���wD�h�����C$4��̀]
���)���վ����6��̌^��#�N�s�Q$]
�C��;��
�3]C�c�3s
N�=��� m4�?ܥ ���ޯ���CHS�m�zU��=�e�Iu7�k�֔�k������>�RZ������e�Z�B���8�Q��j��E�Y����hh��fW3�a���!�c|l��,��Q��Q.�s��3I3pW�v����7���Y��Z���h�� ��3�3\��=�K�3��N��y�� - >��h���z��F�����K�-����cOR^��f|��XJ|SC��O��s�G��f��>彴��"�xx�&vm+ ���k�g�z�E�-��_��~��E�h���G�Hh����`l������CH�l⪷6���Q��T~�ߟ�%���/�O��K?&��u���$�н�����q��pM+ 6��{�F�V�I�zob�n��pIt��:_�P��EW.Y��N��c�g��\f_�>彴/��"�x��<ʿ����ԙ��'�Oⷷ�؟�ts�5���!��jv�]U0��f�^�{�!��x��l܊}�s�$u:#�����/Yd�y�I�fp'�Ϭ���H6��n_ �xd[y2WU��Won�"��r�k&��|9�ǰ'��Z��VW3�\�K�|��L�W�Oy/�J]7^6�D���Xo
�5�>��z�G�H�`�c�Cw%pWt���'�����@u.�!5�"��j��f96�:�ҏ�'�!~�"��k�xI�[�#�^�������|WM������?$��f�'i� D�Hl�q��a칪�'��M�爯L���2� x'����ݧ��ܯ�n<O�[�+ � t�x���5����!:Db�o�р� �E�Hh}�Dx� X�%�灋�CH����zs���C4`p^t��>���%����.�xn4�q����-���c�u��o�\���{
�rt��v ��р��F�H��9�M�jR�-�5:���n������6:DB�E�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�$I�4��=�s�2� IEND�B`�<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<image href="step-01.raw.png" width="512" height="512" />
<rect x="40" y="40" width="200" height="200" fill="none" stroke="#FF4500" stroke-width="4" rx="8" />
<defs><marker id="arrowhead-1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="#FF4500" /></marker></defs><line x1="300" y1="100" x2="260" y2="140" stroke="#FF4500" stroke-width="4" marker-end="url(#arrowhead-1)" />
<rect x="340" y="80" width="231" height="44" rx="10" fill="#FFFFFF" stroke="#FF4500" stroke-width="4" /><text x="354" y="109" font-family="-apple-system, SF Pro Text, Helvetica, sans-serif" font-size="18" fill="#0B0C0F">Smoke test annotation</text>
<circle cx="120" cy="500" r="24" fill="#FF4500" stroke="#FFFFFF" stroke-width="3" /><text x="120" y="508" text-anchor="middle" font-family="-apple-system, SF Pro Text, Helvetica, sans-serif" font-size="24" font-weight="700" fill="#FFFFFF">1</text>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"> <image href="step-01.raw.png" width="512" height="512" /> <rect x="40" y="40" width="200" height="200" fill="none" stroke="#FF4500" stroke-width="4" rx="8" /> <defs><marker id="arrowhead-1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="#FF4500" /></marker></defs><line x1="300" y1="100" x2="260" y2="140" stroke="#FF4500" stroke-width="4" marker-end="url(#arrowhead-1)" /> <rect x="340" y="80" width="231" height="44" rx="10" fill="#FFFFFF" stroke="#FF4500" stroke-width="4" /><text x="354" y="109" font-family="-apple-system, SF Pro Text, Helvetica, sans-serif" font-size="18" fill="#0B0C0F">Smoke test annotation</text> <circle cx="120" cy="500" r="24" fill="#FF4500" stroke="#FFFFFF" stroke-width="3" /><text x="120" y="508" text-anchor="middle" font-family="-apple-system, SF Pro Text, Helvetica, sans-serif" font-size="24" font-weight="700" fill="#FFFFFF">1</text> </svg>
{
"name": "claude-code-first-session",
"title": "From zero to your first Claude Code session",
"summary": "What happens from 'I've never touched Claude Code' to 'I watched it reason through my repo'. Five real steps, every one captured from the same Terminal window Robert uses on calls.",
"steps": [
{
"id": "01",
"title": "Install Claude Code globally",
"caption": "One global npm install, done once per machine",
"source": "macos-window",
"window_title": "Terminal",
"wait_for_enter": true,
"annotations": [
{
"kind": "callout",
"at": [80, 80],
"text": "One install -- never run it inside a project"
}
]
},
{
"id": "02",
"title": "Move into a real project",
"caption": "cd into the repo you actually want help with",
"source": "macos-window",
"window_title": "Terminal",
"wait_for_enter": true,
"annotations": [
{
"kind": "pin",
"at": [90, 120],
"number": 1
},
{
"kind": "callout",
"at": [130, 80],
"text": "Start in the repo, not your home folder"
}
]
},
{
"id": "03",
"title": "Launch Claude Code",
"caption": "Running claude drops you straight into the TUI",
"source": "macos-window",
"window_title": "Terminal",
"wait_for_enter": true,
"annotations": [
{
"kind": "box",
"box": [40, 200, 800, 120]
},
{
"kind": "callout",
"at": [60, 80],
"text": "The prompt box is where everything happens"
}
]
},
{
"id": "04",
"title": "First-run browser auth",
"caption": "Sign in once at claude.ai to link the CLI",
"source": "macos-window",
"window_title": "Claude",
"wait_for_enter": true,
"annotations": [
{
"kind": "callout",
"at": [80, 80],
"text": "First time only -- the CLI caches the session"
}
]
},
{
"id": "05",
"title": "Your first real prompt",
"caption": "Ask something grounded in the repo you opened",
"source": "macos-window",
"window_title": "Terminal",
"wait_for_enter": true,
"annotations": [
{
"kind": "box",
"box": [40, 200, 900, 160]
},
{
"kind": "arrow",
"from": [500, 100],
"to": [460, 220]
},
{
"kind": "callout",
"at": [200, 60],
"text": "Concrete questions beat generic ones"
}
]
}
]
}
{
"name": "claude-code-first-session",
"title": "From zero to your first Claude Code session",
"summary": "What happens from 'I've never touched Claude Code' to 'I watched it reason through my repo'. Five real steps, every one captured from the same Terminal window Robert uses on calls.",
"steps": [
{
"id": "01",
"title": "Install Claude Code globally",
"caption": "One global npm install, done once per machine",
"source": "macos-window",
"window_title": "Terminal",
"wait_for_enter": true,
"annotations": [
{
"kind": "callout",
"at": [80, 80],
"text": "One install -- never run it inside a project"
}
]
},
{
"id": "02",
"title": "Move into a real project",
"caption": "cd into the repo you actually want help with",
"source": "macos-window",
"window_title": "Terminal",
"wait_for_enter": true,
"annotations": [
{
"kind": "pin",
"at": [90, 120],
"number": 1
},
{
"kind": "callout",
"at": [130, 80],
"text": "Start in the repo, not your home folder"
}
]
},
{
"id": "03",
"title": "Launch Claude Code",
"caption": "Running claude drops you straight into the TUI",
"source": "macos-window",
"window_title": "Terminal",
"wait_for_enter": true,
"annotations": [
{
"kind": "box",
"box": [40, 200, 800, 120]
},
{
"kind": "callout",
"at": [60, 80],
"text": "The prompt box is where everything happens"
}
]
},
{
"id": "04",
"title": "First-run browser auth",
"caption": "Sign in once at claude.ai to link the CLI",
"source": "macos-window",
"window_title": "Claude",
"wait_for_enter": true,
"annotations": [
{
"kind": "callout",
"at": [80, 80],
"text": "First time only -- the CLI caches the session"
}
]
},
{
"id": "05",
"title": "Your first real prompt",
"caption": "Ask something grounded in the repo you opened",
"source": "macos-window",
"window_title": "Terminal",
"wait_for_enter": true,
"annotations": [
{
"kind": "box",
"box": [40, 200, 900, 160]
},
{
"kind": "arrow",
"from": [500, 100],
"to": [460, 220]
},
{
"kind": "callout",
"at": [200, 60],
"text": "Concrete questions beat generic ones"
}
]
}
]
}
/**
* COVERAGE FOR SNAPPY-WALKTHROUGH'S DECLARED REFUSAL CODES
* (snappy-tool-design rule 33: "refusal codes form one closed table and each
* row has coverage").
*
* Two things are graded here, and the second is the one that matters. The
* first is that the hand's table is a PROJECTION of the collection's one
* closed table in snappy-settings/refusal-codes.ts — same object, not a copy
* that can drift. The second is that every declared code is GROUNDED: the
* evidence that justified declaring it is re-checked here, because a refusal
* code with no path that emits it is a branch the reader waits for and never
* sees, and a table of those passes a lint while teaching a lie.
*
* The code list is spelled out rather than read from the contract: a test that
* iterates the thing it grades passes for an empty table.
*/
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES } from "../snappy-settings/refusal-codes.ts";
const SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "api.ts"), "utf8");
/** Every refusal code snappy-walkthrough declares. */
const DECLARED = [
"missing_argument",
"unknown_verb",
] as const;
test("snappy-walkthrough declares exactly these refusal codes", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals).sort(), [...DECLARED].sort());
});
test("every declared code is the SAME row as the one closed table's, never a copy", () => {
const table = HAND_CONTRACT.refusals as Record<string, unknown>;
for (const code of DECLARED) {
assert.equal(table[code], REFUSAL_CODES[code], `${code} is not the shared row`);
}
});
test("missing_argument is grounded: at least one verb has a required word", () => {
const required = Object.values(HAND_CONTRACT.verbs as Record<string, { args?: readonly string[] }>)
.flatMap((v) => (v.args ?? []).filter((a) => !a.endsWith("?")));
assert.ok(required.length > 0, "no verb has a required argument, so missing_argument can never fire");
});
test("unknown_verb is grounded: the contract closes the verb set, so a word outside it is refusable", () => {
assert.ok(Object.keys(HAND_CONTRACT.verbs).length >= 0);
assert.ok(!Object.keys(HAND_CONTRACT.verbs).includes("no-such-verb"));
});
/**
* COVERAGE FOR SNAPPY-WALKTHROUGH'S DECLARED REFUSAL CODES
* (snappy-tool-design rule 33: "refusal codes form one closed table and each
* row has coverage").
*
* Two things are graded here, and the second is the one that matters. The
* first is that the hand's table is a PROJECTION of the collection's one
* closed table in snappy-settings/refusal-codes.ts — same object, not a copy
* that can drift. The second is that every declared code is GROUNDED: the
* evidence that justified declaring it is re-checked here, because a refusal
* code with no path that emits it is a branch the reader waits for and never
* sees, and a table of those passes a lint while teaching a lie.
*
* The code list is spelled out rather than read from the contract: a test that
* iterates the thing it grades passes for an empty table.
*/
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES } from "../snappy-settings/refusal-codes.ts";
const SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "api.ts"), "utf8");
/** Every refusal code snappy-walkthrough declares. */
const DECLARED = [
"missing_argument",
"unknown_verb",
] as const;
test("snappy-walkthrough declares exactly these refusal codes", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals).sort(), [...DECLARED].sort());
});
test("every declared code is the SAME row as the one closed table's, never a copy", () => {
const table = HAND_CONTRACT.refusals as Record<string, unknown>;
for (const code of DECLARED) {
assert.equal(table[code], REFUSAL_CODES[code], `${code} is not the shared row`);
}
});
test("missing_argument is grounded: at least one verb has a required word", () => {
const required = Object.values(HAND_CONTRACT.verbs as Record<string, { args?: readonly string[] }>)
.flatMap((v) => (v.args ?? []).filter((a) => !a.endsWith("?")));
assert.ok(required.length > 0, "no verb has a required argument, so missing_argument can never fire");
});
test("unknown_verb is grounded: the contract closes the verb set, so a word outside it is refusable", () => {
assert.ok(Object.keys(HAND_CONTRACT.verbs).length >= 0);
assert.ok(!Object.keys(HAND_CONTRACT.verbs).includes("no-such-verb"));
});
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readdirSync, writeFileSync, rmSync, copyFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { HAND_CONTRACT, walkthroughStepsFace } from "./api.ts";
/** THE JOIN THE HAND DECLARES ⟨lane family-reads, 2026-09-09⟩. The name route
* found this hand and then found nothing to fold: `status` answers a health
* line, and every verb that HAS the steps — capture, annotate, lesson —
* writes. `steps` is the read, and it is the only verb here that writes
* nothing. */
test("snappy-walkthrough: steps declares the walkthrough-steps face", () => {
assert.equal(HAND_CONTRACT.verbs.steps.face, "walkthrough-steps");
});
function runDir(withShot: boolean): string {
const dir = mkdtempSync(join(tmpdir(), "wt-face-"));
writeFileSync(join(dir, "recipe.json"), JSON.stringify({
name: "quillworks-rollout", title: "Sending the rollout note to Harbourline", summary: "How it went out.",
steps: [{ id: "01", title: "Opened the Harbourline space", caption: "clicked Harbourline in the sidebar", source: "browser", window_title: "Statechange", annotations: [] }],
}));
writeFileSync(join(dir, "step-01.meta.json"), JSON.stringify({
step: { id: "01", title: "Opened the Harbourline space", caption: "clicked Harbourline in the sidebar", source: "browser", window_title: "Statechange", annotations: [] },
width: 1440, height: 900, capturedAt: "2026-09-08T09:41:00.000Z",
}));
if (withShot) copyFileSync(join(import.meta.dirname, "out/smoke-test/manual/step-01.png"), join(dir, "step-01.png"));
return dir;
}
test("snappy-walkthrough: the fold prints the keys WalkthroughSteps binds", () => {
const dir = runDir(true);
try {
const face = walkthroughStepsFace(dir);
assert.equal(face.title, "Sending the rollout note to Harbourline");
assert.equal(face.where, "Statechange");
assert.equal(face.steps.length, 1);
assert.equal(face.steps[0]!.what, "Opened the Harbourline space");
assert.equal(face.steps[0]!.did, "clicked Harbourline in the sidebar");
assert.match(face.steps[0]!.shotUrl ?? "", /^file:\/\/.*step-01\.png$/);
assert.equal(face.steps[0]!.result, "step-01.png · 1440×900");
assert.equal(face.steps[0]!.at, "2026-09-08T09:41:00.000Z");
} finally { rmSync(dir, { recursive: true, force: true }); }
});
/** A STEP WITH NO SHOT SAYS SO, and the fold never invents one: the face
* writes "No screenshot was taken of this step" over a missing `shotUrl`, and
* a placeholder frame would read as "the screen looked like this" — a claim
* nobody made ⟨CLAUDE.md §10⟩. A step with no shot has no `result` either,
* because the result IS the artifact. */
test("snappy-walkthrough: a step whose capture never landed claims no shot", () => {
const dir = runDir(false);
try {
const step = walkthroughStepsFace(dir).steps[0]!;
assert.equal("shotUrl" in step, false);
assert.equal("result" in step, false);
assert.equal(step.what, "Opened the Harbourline space");
} finally { rmSync(dir, { recursive: true, force: true }); }
});
/** A RUN WITH NO RECIPE STILL DRAWS. The directory's own name is the honest
* title; inventing one would name a walkthrough nobody named. */
test("snappy-walkthrough: a run with no recipe.json names itself by its folder", () => {
const dir = mkdtempSync(join(tmpdir(), "wt-bare-"));
try {
const face = walkthroughStepsFace(dir);
assert.equal(face.title, dir.split("/").at(-1));
assert.equal("where" in face, false);
assert.deepEqual(face.steps, []);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
/** THE READ WRITES NOTHING. `lesson` was the near miss: it reads the same
* directory and then writes `lesson.md`, so a host calling it to LOOK would
* change the run it was looking at. */
test("snappy-walkthrough: reading a run leaves the directory exactly as it was", () => {
const dir = runDir(true);
try {
const before = readdirSync(dir).sort();
walkthroughStepsFace(dir);
assert.deepEqual(readdirSync(dir).sort(), before);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readdirSync, writeFileSync, rmSync, copyFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { HAND_CONTRACT, walkthroughStepsFace } from "./api.ts";
/** THE JOIN THE HAND DECLARES ⟨lane family-reads, 2026-09-09⟩. The name route
* found this hand and then found nothing to fold: `status` answers a health
* line, and every verb that HAS the steps — capture, annotate, lesson —
* writes. `steps` is the read, and it is the only verb here that writes
* nothing. */
test("snappy-walkthrough: steps declares the walkthrough-steps face", () => {
assert.equal(HAND_CONTRACT.verbs.steps.face, "walkthrough-steps");
});
function runDir(withShot: boolean): string {
const dir = mkdtempSync(join(tmpdir(), "wt-face-"));
writeFileSync(join(dir, "recipe.json"), JSON.stringify({
name: "quillworks-rollout", title: "Sending the rollout note to Harbourline", summary: "How it went out.",
steps: [{ id: "01", title: "Opened the Harbourline space", caption: "clicked Harbourline in the sidebar", source: "browser", window_title: "Statechange", annotations: [] }],
}));
writeFileSync(join(dir, "step-01.meta.json"), JSON.stringify({
step: { id: "01", title: "Opened the Harbourline space", caption: "clicked Harbourline in the sidebar", source: "browser", window_title: "Statechange", annotations: [] },
width: 1440, height: 900, capturedAt: "2026-09-08T09:41:00.000Z",
}));
if (withShot) copyFileSync(join(import.meta.dirname, "out/smoke-test/manual/step-01.png"), join(dir, "step-01.png"));
return dir;
}
test("snappy-walkthrough: the fold prints the keys WalkthroughSteps binds", () => {
const dir = runDir(true);
try {
const face = walkthroughStepsFace(dir);
assert.equal(face.title, "Sending the rollout note to Harbourline");
assert.equal(face.where, "Statechange");
assert.equal(face.steps.length, 1);
assert.equal(face.steps[0]!.what, "Opened the Harbourline space");
assert.equal(face.steps[0]!.did, "clicked Harbourline in the sidebar");
assert.match(face.steps[0]!.shotUrl ?? "", /^file:\/\/.*step-01\.png$/);
assert.equal(face.steps[0]!.result, "step-01.png · 1440×900");
assert.equal(face.steps[0]!.at, "2026-09-08T09:41:00.000Z");
} finally { rmSync(dir, { recursive: true, force: true }); }
});
/** A STEP WITH NO SHOT SAYS SO, and the fold never invents one: the face
* writes "No screenshot was taken of this step" over a missing `shotUrl`, and
* a placeholder frame would read as "the screen looked like this" — a claim
* nobody made ⟨CLAUDE.md §10⟩. A step with no shot has no `result` either,
* because the result IS the artifact. */
test("snappy-walkthrough: a step whose capture never landed claims no shot", () => {
const dir = runDir(false);
try {
const step = walkthroughStepsFace(dir).steps[0]!;
assert.equal("shotUrl" in step, false);
assert.equal("result" in step, false);
assert.equal(step.what, "Opened the Harbourline space");
} finally { rmSync(dir, { recursive: true, force: true }); }
});
/** A RUN WITH NO RECIPE STILL DRAWS. The directory's own name is the honest
* title; inventing one would name a walkthrough nobody named. */
test("snappy-walkthrough: a run with no recipe.json names itself by its folder", () => {
const dir = mkdtempSync(join(tmpdir(), "wt-bare-"));
try {
const face = walkthroughStepsFace(dir);
assert.equal(face.title, dir.split("/").at(-1));
assert.equal("where" in face, false);
assert.deepEqual(face.steps, []);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
/** THE READ WRITES NOTHING. `lesson` was the near miss: it reads the same
* directory and then writes `lesson.md`, so a host calling it to LOOK would
* change the run it was looking at. */
test("snappy-walkthrough: reading a run leaves the directory exactly as it was", () => {
const dir = runDir(true);
try {
const before = readdirSync(dir).sort();
walkthroughStepsFace(dir);
assert.deepEqual(readdirSync(dir).sort(), before);
} finally { rmSync(dir, { recursive: true, force: true }); }
});