git:20260906.c323fc2 to git:20260906.2bc4a5f

2 added, 1 removed. Audit A to A.

# Working on `web/`
The site is two halves that share one ground, built by Astro and deployed to
Firebase Hosting at https://eklavya-run.web.app by `.github/workflows/firebase.yml`.
| Path | What it is |
|---|---|
| `public/index.html` | the landing page — the pitch. Hand-written HTML, copied verbatim into the build; there is no framework in it. |
| `public/styles.css` | landing-page components. |
| `public/tokens.css` | the design tokens: the accent ramp and type scale, then the two grounds. Read by **both** halves. |
| `public/app.js` | landing-page behaviour: the interactive terminal, the scroll reveals, the ground toggle. |
| `src/content/docs/docs/*.mdx` | the manual, one file per topic. The nesting is what puts them at `/docs/…`. |
| `src/styles/docs.css` | Starlight skinned onto our grounds — it re-points `--sl-color-*` at the roles in `tokens.css`. |
| `src/components/` | the three Starlight overrides: our wordmark, the ground toggle, and `PageTitle.astro` — the `<h1>` comes from frontmatter, so it paints the product's name itself. |
| `src/plugins/rehype-brand.mjs` | paints every prose mention of **Eklavya** in the accent, at build time, over the rendered tree. |
| `astro.config.mjs` | the sidebar, which is hand-ordered rather than autogenerated. |
```bash
npm run dev # localhost:4321, hot reload
npm run build # -> dist/
npm run preview # serve dist/ exactly as Firebase will
```
Firebase is the only host, and should stay that way: Astro's `base` is fixed at
build time, so one build cannot serve both a root host and a subfolder host. A
second host means a second build, which drifts.
`firebase.json` sets the cache policy, and the split matters: **HTML is
`no-cache`** (stored, but revalidated on every request) while `/_astro/**` is
immutable for a year. Firebase's default is `max-age=3600` on everything, which
meant a fix could ship, deploy green, and still be invisible to a reader for an
hour — their cached HTML points at the previous build's fingerprinted CSS. The
globs are deliberately non-overlapping: header precedence between two matching
`headers` entries is undocumented.
## The rule that matters
**The manual is a test of the source, not prose about it.** Every default, flag,
tier, threshold and command name in it was read out of the code. When you change
behaviour, update the matching page in the same commit — and read the value out
of the file below rather than copying what the page already says, or the page
compounds its own drift.
Every `.mdx` under `src/content/docs/docs/` has a row here, in sidebar order.
| Page | Source of truth |
|---|---|
| `index.mdx` | the loop as it actually runs: the six hooks in `mcp/src/hooks/` and `max_questions_per_task` in `mcp/src/config.ts`. It also carries the one prerequisite — `mcp/package.json` `engines` — because `before-you-start.mdx` was merged into it. Keep it short: it is the page that says what the tool is, not how it works |
| `installing.mdx` | `mcp/src/install.ts` (its numbered steps are the numbered steps on the page), `.mcp.json`, `hooks/run.mjs`. `README.md` points here rather than repeating it — keep it that way |
| `first-run.mdx` | `skills/setup/SKILL.md` |
| `first-session.mdx` | `hooks/hooks.json` and the six hooks in `mcp/src/hooks/` |
| `dials.mdx` | `Mode`, `Focus`, `Cadence`, `Difficulty` in `mcp/src/config.ts` — **and** `mcp/src/tools/get_session_quiz_plan.ts`, because the `interleaved` one-question cap and its exemptions live in the planner, not in the config |
| `levels-and-tiers.mdx` | `LEVEL_BANDS`, `LEVEL_UP_MIN_CONCEPTS`, `checkPromotion` in `mcp/src/srs.ts`; the tier labels are `TIER` in `mcp/src/assets/dashboard.html` and the tier table in `skills/tutor/SKILL.md` — `TIER_LABEL` left `ask.ts` with the settings line in 1.14 |
- | `commands.mdx` | one `###` per slash command — the skills under `skills/` carrying `disable-model-invocation: true`, seven of them; `skills/tutor/` is model-invocable only and gets no heading. Plus one for `user-skill/` |
+ | `commands.mdx` | one `###` per slash command — the skills under `skills/` carrying `disable-model-invocation: true`, eight of them; `skills/tutor/` is model-invocable only and gets no heading. Plus one for `user-skill/` |
| `cli.mdx` | the `USAGE` string in `mcp/src/cli.ts` |
| `configuration.mdx` | `DEFAULT_CONFIG` in `mcp/src/config.ts` — every key, no omissions |
+ | `packs.mdx` | `mcp/src/packs.ts` (the two directories, the merge order, the fail-open read) and `validateSeedGraph` in `mcp/src/seed.ts` for the field rules. `skills/pack/SKILL.md` is the command it documents |
| `commit-gate.mdx` | `mcp/src/store.ts` (`PASSING_GRADE`, `syncGate`, `gateRetryConcepts`), `cli/eklavya-gate`, `mcp/src/hooks/pre-tool-gate.ts`, `scripts/install-git-hook.sh` |
| `dashboard.mdx` | `DEFAULT_PORT`, `startDashboard` and `dashboardState` in `mcp/src/dashboard.ts`; the sections, routes and page sizes in `mcp/src/assets/dashboard.html` (`NAV`, `VIEWS`, `PER`) |
| `how-it-works.mdx` | the `mcp/` / `hooks/` / `skills/` split in the root `CLAUDE.md`, `mcp/package.json` `engines` and its `better-sqlite3` dependency, and `cli/eklavya-gate` for the one place `jq` and `sqlite3` are still needed. This is where architecture and rationale trimmed off `index.mdx` belongs |
| `grading-engine.mdx` | `mcp/src/srs.ts` constants; the `get_session_quiz_plan` and `record_attempt` tool descriptions; `mcp/src/mcq.ts` and `skills/tutor/SKILL.md` for the option count, the "Other" escape hatch and the MCQ grade ceiling — none of which are in `srs.ts` |
| `your-data.mdx` | `mcp/src/paths.ts`, `mcp/src/migrations/` |
| `troubleshooting.mdx` | `doctor` in `mcp/src/cli.ts`, and the fail-open paths — `mcp/src/hooks/lib.ts`, `cli/eklavya-gate` |
| `faq.mdx` | `mcp/src/paths.ts`, the `AttemptOutcome` handling in `mcp/src/store.ts` (what a decline does), `uninstall` in `mcp/src/install.ts`, and the `<link>` tags at the top of `mcp/src/assets/dashboard.html` — the dashboard fetches three webfonts from Google Fonts, so "nothing leaves your machine" needs that carve-out |
Adding a page means adding its row here **and** an entry in `astro.config.mjs`'s
sidebar — it is hand-ordered, so a new file that nobody links to is invisible.
## The landing page quotes real output
`public/index.html` is the half that went stale once: it shipped promising two
dials when four had landed. The root `CLAUDE.md` carries the section-by-section
map. What that table hides is that the hero terminal is not a mock-up — it
quotes **three separate pieces of real output**, each from its own file, and
each has to be re-quoted when that file changes:
| Terminal line | Where the string is built |
|---|---|
| the `[Eklavya] Learner profile: …` banner | `mcp/src/hooks/session-start.ts` — one line in the real hook, wrapped over two rows here the way a terminal wraps it |
| the `[EKLAVYA ambient · concept · interleaved · easy]` status bar | `statusLine` in `mcp/src/statusline.ts`, composed centrally so it cannot drift. There is no settings line above a question any more — do not draw one |
| the checkpoint instruction | `mcp/src/hooks/checkpoint-quiz.ts` |
`public/styles.css` holds a **no-JS fallback copy** of the terminal's grade,
explanation and done text, in `.term__screen:not([data-ready]) …::after` rules.
Edit `VERDICTS` in `app.js` without mirroring them and the two disagree
silently, for exactly the readers who cannot see the disagreement.
The git commit gate is **opt-in**: nothing in `eklavya install` puts a
`pre-commit` hook on a repo, `scripts/install-git-hook.sh` does, as a separate
step. Any line here promising a bare-terminal commit is held has to attach that
step, or it is a promise the default install does not keep.
## Writing conventions for the manual
- **Audience: a developer in their first week.** No prior MCP knowledge. Explain
the jargon the first time it appears, in one clause, then use it freely.
- **Say what happens, then why.** The why is one sentence, never a paragraph.
- Sentence case. No emoji. Second person. Present tense.
- **Numbers over adjectives.** "Four options, capped at grade 4" beats "a few
carefully chosen options".
- **Never document unshipped work.** `prd/` describes phases that are specified
but not built; those stay off the page until the code exists. "The code
exists" is not enough on its own — the acceptance criterion has to be ticked.
**Cursor is the standing example:** `eklavya export-rules` is implemented and
tested, and the git gate is genuinely editor-agnostic, but
`prd/phase-5-distribution.md` still has *"Clean machine: Cursor with one
`mcpServers` entry → tools reachable, git gate enforced"* unticked, and Cursor
gets no hooks at all — no session profile, no mid-task checkpoints, no
end-of-task sweep, which is the entire ambient loop. Documenting it was
promising the headline feature somewhere it does not run, so it stays out of
the manual, the landing page and `README.md`. Put it back when that box is
ticked, and lead with what Cursor does *not* get.
- Prefer a table when every row has the same shape, `<Steps>` when order
matters, and prose when neither is true. A two-row table is prose pretending
to be data.
- Every command block a reader would paste must be runnable verbatim.
Placeholders are `/path/to/eklavya` and `<topic>`, consistently.
- Do not invent Claude Code UI affordances. If a flow is not verified in
`docs/verified-schemas.md`, the README, or a skill, describe it as the
interactive `/plugin` menu rather than guessing a subcommand.
Starlight supplies the page title from frontmatter, the contents list from your
`##`/`###` headings, and prev/next from the sidebar order. Use `:::note`,
`:::tip` and `:::caution` for asides, `<Steps>` for ordered procedures, and a
fenced block's `title="…"` for a filename label.
## CSS
- **Name a role, never a scale step or a raw hex.** `--ink`, `--dim`, `--faint`,
`--line`, `--line-2`, `--panel`, `--mass`, `--spot`, `--spot-ink`. A raw hex or
a `--vd-*` step in a component pins it to one ground and the theme toggle
silently stops working for it. The one exception is the terminal, whose
palette is fixed on both grounds and declared at the top of `styles.css`.
- This check catches the whole class of mistake — it skips the two blocks
allowed to hold fixed values and reports anything else:
```bash
cd public && python3 - <<'EOF'
import re
src = open('styles.css').read().split('\n')
def span(a, b):
i = next(n for n, l in enumerate(src) if l.strip() == a)
j = next(n for n, l in enumerate(src) if l.strip() == b and n > i)
return range(i, j)
skip = set(span("THE TERMINAL'S OWN PALETTE", '}')) | set(span('THE TERMINAL', 'WHY'))
bad = [f'{n+1}: {l.strip()}' for n, l in enumerate(src)
if n not in skip and re.search(r'var\(--(neutral|green|vd)-|#[0-9a-fA-F]{3,8}\b', l)]
print('\n'.join(bad) if bad else 'clean')
EOF
```
- Also check that no rule paints text the same colour as its own fill. That is
what a collapsed token looks like, and it renders as an invisible label rather
than an error.
- `src/styles/docs.css` only re-points Starlight's `--sl-color-*` at our roles.
Starlight declares its accents inside its own `[data-theme]` blocks, which
outrank a bare `:root` — so an accent override has to appear in **both**
theme blocks or it silently loses in one of them.
- `.claude/skills/eklavya-design/SKILL.md` is the visual language: verdigris on
warm ink or warm paper, Archivo and Inter and JetBrains Mono, square chrome,
hairline rules, bow-and-arrow motifs, no emoji.
- **The hero terminal and the arrow flight are showpieces and are not to be
redesigned.** Recolour them onto the ground tokens if you must; leave the
geometry, the timing and the behaviour alone. The terminal deliberately keeps
one dark palette on *both* grounds — it is a picture of a terminal.
- **The product's name is accented wherever the manual says it.**
`src/plugins/rehype-brand.mjs` wraps prose `Eklavya` in `.brand-word`;
`PageTitle.astro` does the same for the frontmatter `<h1>`. Do not hand-write
the span — a page added later would miss it. The plugin deliberately skips
`code`, `pre`, `kbd` and `a`, and only matches the capitalised name standing
alone: lowercase `eklavya` is a binary, a slug or a config key.
- Tables: cells use `overflow-wrap: **break-word**`, never `anywhere`. Both
break a long slug rather than let it push the table, but `anywhere` also
shrinks a cell's *min-content* to one character — and the key column is
`width: 1%` ("as narrow as you can be"), so the browser obliged and the
troubleshooting table read `S Y M P T O M` down the page. The key column also
carries a `14ch` floor, and inline code wraps everywhere but the first column.
- Asides come back to our palette: note and tip take `--spot`, caution
`--warning`, danger `--error`. Starlight's own blue, purple, orange and red
are four hues that appear nowhere else on this site.
- `public/` assets carry a `?v=N` query. Bump it when you change one, or
returning readers get the cached copy.
- Grid columns holding code blocks need `minmax(0, 1fr)`, not `1fr` — an `auto`
track refuses to shrink below the widest `<pre>` and pushes the page sideways.
- Honour `prefers-reduced-motion` in anything animated.
## Before you commit
```bash
npm run build && npm run preview # localhost:4321
```
Check, every time:
1. **1280, 900 and 560px, in both grounds.** Use the toggle in the nav — a bug
that only shows on paper is the commonest kind now.
2. **No horizontal page scroll** at 560:
`document.documentElement.scrollWidth === document.documentElement.clientWidth`.
3. **No console errors**, the hero terminal boots and accepts input, and the
docs sidebar marks the current page.
4. **Every link and anchor resolves**, across the built site:
```bash
python3 - <<'EOF'
import re
from pathlib import Path
dist = Path('dist'); pages = list(dist.rglob('*.html'))
text = {f: f.read_text('utf8', errors='ignore') for f in pages}
ids = {f: set(re.findall(r'id="([^"]+)"', t)) for f, t in text.items()}
def resolve(href):
p = href.split('#')[0].split('?')[0].lstrip('/')
for c in (['index.html'] if p == '' else [p, p.rstrip('/') + '/index.html', p.rstrip('/') + '.html']):
if (dist / c).is_file():
return dist / c
bad = []
for f, t in text.items():
for h in set(re.findall(r'href="(/[^"]*)"', t)):
tgt = resolve(h)
if tgt is None: bad.append(f'{f.relative_to(dist)} -> {h} (no page)')
elif '#' in h and h.split('#', 1)[1] not in ids[tgt]:
bad.append(f'{f.relative_to(dist)} -> {h} (no anchor)')
for x in set(re.findall(r'href="#([^"]+)"', t)):
if x not in ids[f]: bad.append(f'{f.relative_to(dist)} -> #{x} (no anchor)')
print('\n'.join(bad) if bad else f'{len(pages)} pages, all links and anchors resolve')
EOF
```
Site-only work is a `docs:` commit — it does not cut a release.