# Access map: inline rule editor that heals an open path

A JKCA spatial-canvas component. It shows who can reach a piece of data as pill nodes joined by lines on a dark dot-grid canvas. When a path is open, it is red and dots travel along it. The visitor clicks the risky rule, the node grows in place into a small editor, they pick a tighter rule and press Save. The map then heals: the red line fades out, the activity block folds away, the status pill turns calm.

| File | What it is |
|---|---|
| `access-map.html` | One self-contained file. Vanilla HTML/CSS/JS, no CDN, no build step. Open it in any browser. |
| `react/AccessMap.tsx` | React 18 + framer-motion (v10/v11) version. Typed props for nodes, edges, rules, options and layout, plus an `onSave` callback. It typechecks under `tsc --strict`. It was also bundled with esbuild and click-tested in Chromium and WebKit. |
| `check/` | Playwright screenshots and `shoot.py`, which takes the screenshots and runs the smoke checks. |

Nothing has been deployed. These are files on the box only.

**Source / credit:** the idea comes from a 13-second "Access settings" demo posted on X by **@postiauxcharles** (https://x.com/postiauxcharles/status/2113200319610122353). It was used as inspiration only. The code is an independent rebuild. His name, brand and copy are not used. The demo data is neutral: *Mok Cove newsletter* on `mokcove.com`, with admin `json`.

---

## The pattern

**Inline rule editor that heals an open path**

1. **Show the system, not a form.** Sources (Signup form, admin, Anyone) are on the left. The resource (`/api/subscribers`) and its rules are in the middle. The data (Subscribers table) is on the right. Edges are drawn from real DOM positions.
2. **Mark the one problem, factually.** A single rule row has a red outline and a lock icon (so the cue does not depend on colour alone). Its path is red with travelling dots. The status pill says "1 open path". There is no alarm copy.
3. **Fix it where it lives.** Clicking the row grows the node in place. It is not a modal or an overlay. Inside: a question ("Who can read Subscribers?"), three options with plain hints, and a Save button that stays disabled until the choice changes.
4. **Heal visibly, then go quiet.** Save collapses the editor on the same spring. Then the pill changes to *Admin*, the outline fades, the Anyone→read path fades out, the activity block fades and folds, the read→Subscribers path turns grey, the dots stop, and the status pill swaps to "No open paths".

This follows JKCA rules: progressive disclosure (the editor appears only after intent), calm motion (spring with no bounce, about 0.4% overshoot), one accent colour (#EF4444, used only for factual state), and no prices.

## Reuse it (swap the data model)

**Vanilla:** edit the `DATA MODEL` block at the top of the `<script>` in `access-map.html`:

```js
ACCESS  // choices a rule can take; { value, label, hint, open? }. open:true = internet-reachable
NODES   // { id, kind:'source'|'resource'|'table', title, sub, icon?, initials?, activity?, target?, columns? }
RULES   // rows in the resource node: { id, verb, value, sensitive? }
EDGES   // { id, from, to, label?, rule?, whenSafe?: 'hide'|'calm' }  (anchors: node id or 'rule:<id>')
LAYOUTS // wide + narrow world positions: id -> [x, y | 'below:<id>', width]
```

- An **open path** means `rule.sensitive && ACCESS[value].open`. The status pill counts these.
- An edge with `rule` is red while that rule is open. When the rule is safe it becomes hidden (`whenSafe:'hide'`) or grey (`'calm'`).
- `activity.rule` ties the child activity block to a rule. It is shown only while that rule is open.
- Persist the change in `saveRule()`. There is a marked `console.info` hook there. Put your `fetch` or server action at that spot.

**React:**

```tsx
import { AccessMap, DEMO_NODES, DEMO_RULES, DEMO_EDGES, DEMO_OPTIONS, DEMO_LAYOUT, DEMO_NARROW_LAYOUT } from "./AccessMap";

<AccessMap
  site="mokcove.com" scannedAt="08:42"
  nodes={DEMO_NODES} rules={DEMO_RULES} edges={DEMO_EDGES} options={DEMO_OPTIONS}
  layout={DEMO_LAYOUT} narrowLayout={DEMO_NARROW_LAYOUT}
  onSave={async (ruleId, value) => { await api.setRule(ruleId, value); }} // throw to keep the old value
/>
```

`onSave` runs after the collapse finishes. The map heals only when it resolves. If it rejects, the old rule stays. The height morph uses framer's `height: 0 ↔ "auto"` with `{ type: "spring", stiffness: 300, damping: 30 }`. That is a real layout change, so the edges stay exact on every frame. `layout` animation is used only on the status pill: transform-based layout animation on nodes would make the measured anchors wrong mid-animation. See the header comment in the file.

## How it works (both versions)

- **Edges** are cubic beziers drawn in an SVG that lives inside the panned/zoomed world. Anchors are measured with `offsetTop`/`offsetLeft` up to the owning node, so the pan/zoom transform does not affect them. The x value comes from the node edge and the y value from the row centre. If a target sits below its source rather than to the right (phone layout), the line loops into the target's right side.
- **Recompute:** in vanilla, one rAF scheduler redraws when something is dirty: every spring frame, resize, pan or zoom. In React, a `ResizeObserver` on every node plus the editor's `onUpdate` trigger a re-measure.
- **Dots:** 2 per red edge, linear and looping left→right, using `getPointAtLength` in rAF with an SVG blur glow.
- **Spring (vanilla):** a small semi-implicit Euler solver (240 Hz substeps, k=300, c=30). It animates the editor height FLIP-style (measure, spring, release to `auto`).
- **Pan/zoom:** drag (mouse or touch) to pan. Wheel, ctrl+wheel (trackpad pinch) or two-finger pinch to zoom around the cursor or fingers. A drag over 5px swallows the click, so panning across a row does not open it. The dot grid scales and moves with the view. Desktop starts at 108%. The zoom pill (bottom right) refits the view, and the keys `+`, `-` and `0` also zoom.

## Design tokens

| Token | Value | Use |
|---|---|---|
| canvas | `#111111` | background |
| dot | `#222222`, 20px grid | dot grid (scales with zoom) |
| node | `#1C1C1C`, 1px `#2C2C2C`, radius 16, padding 16 | all nodes |
| row hover / option hover | `#222222` / `#2A2A2A` | interactive rows |
| pill | `#262626`, 1px `#333333`, radius 999 | rule dropdown pills |
| text / muted / faint | `#F5F5F5` / `#888888` / `#666666` | type ramp |
| edge | `#555555`, 2px | calm paths |
| risk | `#EF4444` (status bg `#451A1A`) | open path: outline, path, dots, status |
| safe status | bg `#222222`, text `#C8C8C8` | "No open paths" |
| save (enabled) | bg `#3A3A3A`, 1px `#4A4A4A`, white text | the disabled state is dim `#1F1F1F` / `#5E5E5E` |
| type | Inter → system sans; mono for routes, verbs and columns | title 16/600, subtitle 13 `#888` |
| spring | stiffness 300, damping 30, mass 1 | grow + collapse |
| calm ease | `cubic-bezier(.2,.8,.2,1)`, 0.35–0.5s | colour and opacity fades |

## Accessibility

- **Rows are real `<button>`s** with `aria-expanded` and `aria-controls`. Enter or Space opens a row and moves focus into the options. A mouse click opens the row without stealing focus. Each row's `aria-label` reads, for example, "read: Anyone, open path. Edit rule".
- **Options** are `role="radiogroup"` (labelled by the question) with `role="radio"` and `aria-checked`, and they use a roving tabindex. Arrow keys (Up/Down/Left/Right, Home/End) move and select. Enter or Space selects. Tab goes to Save.
- **Esc** closes the editor and returns focus to its row.
- **Save** is a native disabled button until the choice differs from the current value.
- **Status pill** is `role="status"` with `aria-live="polite"`, so "No open paths" is announced after a save. The inactive label is `aria-hidden`.
- **Not colour alone:** the risky row also shows a lock icon, and the option hint says "Current · Open to the internet" in words.
- **`prefers-reduced-motion: reduce`:** the travelling dots are removed, springs jump straight to their end value, and slide or scale transforms are dropped. Short opacity fades remain.
- Focus rings use `:focus-visible` (2px `#9A9A9A`). Flag emoji are `aria-hidden`, and the country code is the text that gets read.

## Mobile notes (what `check/` found and what was fixed)

- At ≤760px the map switches to a **narrow layout**. Sources sit on the left, the resource on the right, and Subscribers is stacked below the resource. The stacked node follows the growing node's live height every frame, so it slides down with the spring and the edges follow.
- The view is **fitted to the phone width** (about 77% on a 390px iPhone) and is still pannable by touch and pinch-zoomable. Narrow type sizes are boosted, so the smallest on-screen text is about 9.6px and primary text is 11–12px.
- Fixed during checks:
  1. The read→Subscribers loop was clipped off the right edge. The fit now includes the edge overhang, and the phone loop is tighter.
  2. "Yesterday 22:47" was clipped in the activity block. The column was widened and the row gap reduced.
  3. "update, delete" wrapped. Verbs are now `nowrap`.
  4. The HUD collided with the map. On phones the status pill sits under the subtitle.
  5. Edge labels ("255 signups", "Everything") are hidden on phones because the gaps are too short.
  6. Programmatic focus could scroll the `overflow:hidden` viewport. It is pinned now, and focus uses `preventScroll`.
  7. iOS page-pinch is blocked (`gesturestart`), so the map's own pinch handles the gesture.
  8. Refits happen only on width changes, so URL-bar height jitter does not reset the user's pan.
- React build: nodes were missing `box-sizing: border-box`, which made phone nodes overlap. Fixed.

## Checks

```bash
cd /workspace/jkca-stack/handoffs/access-map-ux && python3 check/shoot.py
```

This takes desktop screenshots in Chromium 1440×900 and iPhone screenshots in WebKit 390×844 (iPhone 13 profile) for before, expanded, picked and after. It also runs smoke checks: status text, pill value, number of red edges, keyboard open/arrow/Esc, drag-pan, synthetic touch pan and pinch, minimum on-screen font size, and console errors. The last run had no errors on either engine.
