RELEASE
planning/2.8.0/plans/TOOLDECK_PLAN.md
sha256 96d371adb1050416 · 30447 bytes ·
original held in the private archive
# TOOL DECK + QUICK-DRAW HOLSTER — Implementation Plan
> **Protocol 8, Stage 1 (Opus diagnose & plan).** No code was edited to produce this.
> Branch: `dev` (reset to `a247fa2`). Target unit: the command-cluster overhaul —
> **Option B "Tool Deck"** + the redesigned **"Quick-Draw Holster"** D-PAD, plus a new
> native state field `state.padBindings` (Protocol 4).
> Design source of truth: `planning/mockups/command-cluster-final.html` (+ `-notes.md`,
> screenshots `command-cluster-final-412-deck.png` / `-dpad.png` / `-dpad-bound.png` /
> `-desktop.png`). Owner has signed off on this mockup.
---
## 1. Objective
Replace the current always-present (desktop) / collapsible (mobile) **COMMAND CLUSTER**
sub-panel — the `.d-pad` block, `#macroTarget` input, and the seven bracket-buttons
(`[THREAT]`/`[VATS]`/`[TRADE]`/`[LOOT]`/`[CONSULT]`/`V.A.T.S. CALCULATOR`/`> TERMLINK CONSOLE`)
— with:
1. **Tool Deck** — zero standing footprint. A new dashed **◈ launcher key** in the composer
toolbar raises an in-glass bottom-sheet DECK: one shared TARGET/ITEM/TOPIC field + six
keycap-chip tool rows (THREAT · VATS · TRADE · LOOT · CONSULT · CALC). Scrim-tap / ✕ / Esc
dismiss. **TERMLINK and CROSSROADS are out of the deck** (retired from the cluster; both
remain typable native commands).
2. **Quick-Draw Holster** — the redesigned D-PAD: four labelled **gear-vector sockets** (▲◄►▼)
at the deck foot, each showing its direction glyph, an LED (lit = bound), and the bound
gear's name. Tap fires it (`[PAD: DIR]`); a **BIND ▸** key arms a two-tap holster flow
reusing the deck's target field (`[BIND: gear, DIR]`).
3. **`state.padBindings`** — a NEW native, player-controlled state field so the socket labels
have a real source of truth (today the bindings live nowhere durable — see §3).
The tools' invocation wiring is **reused, never forked** (Protocol 22).
---
## 2. Confirmed current-state trace (Protocol 27 — reproduce before fixing)
### 2.1 The cluster markup
`index.html` lines **2536–2660**: `<details class="sub-panel uplink-tray" data-sub-id="uplinkCommandTray">`
containing `.tactical-dashboard` → `.d-pad` (4 arrows) + `.macro-actions` (`#macroTarget`) +
`.macro-buttons` (the 7 buttons). It sits inside `.col-right > .chat-panel`, AFTER the
`.transcript-card` and the hidden `#imageInput`.
### 2.2 How each tool is invoked today
| Button | Handler (`index.html`) | Path |
|---|---|---|
| ▲◄►▼ | `macroCommand('[PAD: DIR]')` | → `transmitMessage()` → `_routeNativeCommand()` **no match** → **AI** |
| `[THREAT]` | `macroCommand('[THREAT]')` | → router `'[THREAT]'` → `renderThreat(target)` (native, sysModal) |
| `[VATS]` | `macroCommand('[VATS SIM]')` | → router `'[VATS SIM]'` → `showVATSOverlay()` (native) |
| `[TRADE]` | `expandPanelForCategory('trade')` | native panel (INV tab) |
| `[LOOT]` | `renderLoot()` | native salvage terminal |
| `[CONSULT]` | `macroCommand('[CONSULT]')` | → router `'[CONSULT]'` → `renderConsult(topic)` (native) |
| `V.A.T.S. CALCULATOR` | `showVATSOverlay()` | native overlay |
| `> TERMLINK CONSOLE` | `macroCommand('[TERMLINK]')` | → router `'[TERMLINK]'` → `showTermlinkConsole()` |
`macroCommand(actionStr)` (`ui-core.js:4207`): reads `#macroTarget`, appends it to `actionStr`
**unless** the command contains `PAD:`, injects `> {finalCmd}` into `#chatInput`, calls
`transmitMessage()`. **Only caller is the cluster** (no other JS references — verified).
### 2.3 CRITICAL: how `[PAD:]` / `[BIND:]` work today
- **Neither is in `NATIVE_COMMAND_ROUTER`** (`api.js:1065–1105`) and **neither appears anywhere
in `getSystemDirective()`** (grep of `api.js` for `PAD`/`BIND` → zero directive hits).
- So `[PAD: UP]` and `[BIND: X, DIR]` fall straight through `_routeNativeCommand()` to the
**Gemini AI as raw text**, with **no schema, no directive, no persisted binding**. The only
"memory" of a binding is whatever survives in the rolling chat history. Fable's finding
confirmed: **bindings are NOT native state.** They are effectively unsupported — the four
arrows are anonymous and the BIND command is undiscoverable (only surfaced in the `[FEATURES]`
help list, `ui-core.js:3404–3405`).
- Historical note: `state.js:1863` still deletes a legacy `s.macros` field ("D-Pad handles this
natively") — the D-Pad never actually got a native store. `padBindings` finally gives it one.
This is the architectural justification for the new field: **the player, not the AI, owns their
quick-slots** (player-authority principle; Protocol 24).
---
## 3. Acceptance criteria (Protocol 26 — Definition of Done)
Verified on the **real rendered artifact** at 360 / 412 / ≥1000px, deck exercised live:
1. At rest, the UPLINK view shows **only** the Overseer scope + transcript-card (with the ◈ key
in the composer toolbar). No cluster below the composer. `scrollWidth === innerWidth` at 360.
2. ◈ raises the deck (slide-up); scrim + ✕ + Esc + scrim-tap all close it; `aria-expanded`
toggles on ◈. Focus moves into `#deckTarget` on open, back to ◈ on close.
3. Each tool row launches its **existing** handler and closes the deck: THREAT/CONSULT read
`#deckTarget`; VATS/CALC → `showVATSOverlay()`; TRADE → `expandPanelForCategory('trade')`;
LOOT → `renderLoot(deckTarget)`.
4. Holster renders 4 sockets from `state.padBindings`: bound = LED lit + uppercased gear name;
empty = dimmed `EMPTY`, LED dark. Correct `aria-label` per socket.
5. **Deploy:** tapping a **bound** socket fires `_nativePadFire(dir)` → transmits a resolved
action (`Deploy <gear>`) to the Director and closes the deck. Tapping an **empty** socket
shows a hint and makes **no** AI call.
6. **Bind:** BIND ▸ arms (sockets dashed-pulse, hint line live); typing gear in `#deckTarget`
then tapping a socket writes `state.padBindings[dir]`, persists, re-renders the socket
(LED lights, name shows), logs a sys line, disarms. Tapping a socket with an empty target
field shows "type gear first" and does **not** clobber the existing binding.
7. Typed `[BIND: gear, DIR]` and `[PAD: DIR]` (either input mode) route natively to the same
`_nativePadBind` / `_nativePadFire` — the AI never sees them.
8. An AI response is **incapable** of altering `state.padBindings` (not in schema, not mapped in
`autoImportState`). A cloud/file save **round-trips** padBindings (sanitize→migrate→apply).
9. An existing save with no `padBindings` migrates to the all-empty default (sockets read EMPTY).
10. PWA intact (installable / offline / service-worker); reduced-motion neutralizes the deck
slide; desktop two-column shell unchanged; full gate (2028+ → new count) green in both runners.
---
## 4. The new native state field — `state.padBindings` (Protocol 4)
**Shape:** `padBindings: { up: <string|null>, down: <string|null>, left: <string|null>, right: <string|null> }`
— a fixed 4-key map, direction (lowercase) → gear name (trimmed string) or `null` when empty.
Chosen over an array so a direction is a stable named slot; extra/unknown keys are dropped on
sanitize.
Protocol-4 checklist, all in the **same commit**:
- [ ] **Default** in `let state = {…}` (`state.js:~1474`, near the other v2.x fields):
`padBindings: { up: null, down: null, left: null, right: null },`
(also captured by the existing `window._defaultState` snapshot at `state.js:1479`, so
`wipeTerminal()` resets it — no extra work).
- [ ] **Migration** in `migrateState()` (`state.js:~1863`, before `delete s.macros`):
```js
if (!s.padBindings || typeof s.padBindings !== 'object' || Array.isArray(s.padBindings))
s.padBindings = { up: null, down: null, left: null, right: null };
['up','down','left','right'].forEach(d => {
const v = s.padBindings[d];
s.padBindings[d] = (typeof v === 'string' && v.trim()) ? v.trim() : null;
});
// drop any stray non-direction keys
Object.keys(s.padBindings).forEach(k => {
if (!['up','down','left','right'].includes(k)) delete s.padBindings[k];
});
```
- [ ] **Sanitize** in `sanitizeImportedContainer()`'s `_sanitizeState` (`api.js:~486`, alongside
the `lincolnItems` block — array/object typed field, born-compliant): rebuild the 4-key
map, coercing each to a trimmed string or `null`, ignoring extras. This is the
**serialized-whole cloud-pull / file-import path** (Protocol 34).
- [ ] **autoImportState** (`api.js:540`): **deliberately NOT mapped.** Add an explicit comment
documenting that `padBindings` is player-authored and never imported from an AI response
(Protocol 24). Because `autoImportState` uses explicit field mapping (never a recursive
key transform), simply not referencing `parsed.padBindings` means the AI can never write
it. A Protocol-14 test asserts this (see §8).
- [ ] **AI contract / `getSystemDirective()`**: **no change** — see §5.
- [ ] **Cloud-sync path (declare in commit):** *serialized-whole.* `padBindings` lives inside the
`state` object, so it rides the additive `robco_v8` save automatically and returns through
`sanitizeImportedContainer()` → `migrateState()` on pull. No dedicated Firestore doc. Guarded
by a push→sanitize→migrate→apply round-trip test.
- [ ] **Render** (`renderHolster()` in `ui-render.js`) + call on deck-open and after each bind.
- [ ] **Panel/UI:** the field surfaces only inside the deck's holster (no standalone panel).
- [ ] **`CACHE_NAME` bump** (Protocol 1) — served files change (`index.html`, `css/`, `js/`).
- [ ] **`test.html`** KNOWN_KEYS tripwire gains `padBindings` (Protocol 40) + a round-trip assert.
- [ ] Lint, format, both runners at parity, docs (Protocol 2/2a).
---
## 5. AI-contract reconciliation (native is authoritative — Protocols 24, 14, 30)
**Decision: bindings are fully native; the AI is blind to `padBindings`.**
- `getSystemDirective()` is **unchanged** — `padBindings` is NOT added to the state schema. It
keeps the token budget lean (Protocol 28), and since the AI already had **zero** directive
support for PAD/BIND, nothing is lost. The AI cannot author or clobber a binding.
- The old AI-driven `[BIND:]` / `[PAD:]` "commands" are **retired in favor of native handling**
(same pattern as the VATS/THREAT/LOOT "still-AI" retirements): both become intercepts in
`_routeNativeCommand()` (regex, mirroring `[WAIT:]` at `api.js:1218`), so they run
deterministically offline and never reach Gemini. There is therefore **no conflict and no
clobber path** — there is only one writer of `padBindings` (the native bind flow), reachable
three ways that all funnel to `_nativePadBind`: the socket BIND flow, typed `[BIND:]`, and
(read side) `_nativePadFire`.
- Firing a bound vector (`_nativePadFire`) still hands a **resolved** action (`Deploy <gear>`) to
the Director for narrative resolution — the app now knows the gear name natively instead of the
AI having to remember it. This is strictly better than today and preserves the gameplay (using
a stimpak / readying a weapon is AI-narrated; there is no native consumption engine to build).
- No auth/redirect concerns (Protocol 30 untouched).
*Optional (flagged, not planned):* a one-line directive telling the AI "quick-draw bindings are a
native player system — do not compute or narrate them" could be added for belt-and-suspenders,
but is omitted to save tokens since the AI has no schema hook to write them anyway. See §11.
---
## 6. File-by-file changes (exact selectors + rationale)
### 6.1 `index.html`
- **Add the ◈ deck-key** to `.composer-toolbar` (between `+` at line 2494 and `#modePill`),
reusing `.composer-icon-btn` (32×32, ≥28px ✓) + a new `.deck-key` modifier:
```html
<button class="composer-icon-btn deck-key" id="deckKey" onclick="toggleToolDeck()"
aria-label="Open the native tool deck" aria-expanded="false">◈</button>
```
(inline `onclick` → resolved by Suite 59; the rest of the deck is `addEventListener`-wired.)
- **Delete** the entire `<details class="sub-panel uplink-tray" …>` block (lines **2536–2660**),
removing `.tactical-dashboard`, `.d-pad`, `#macroTarget`, all seven `.macro-buttons`
(incl. `#consultBtn`, `#vatsCalcBtn`, `#termlinkBtn`).
- **Add the deck + scrim markup** as direct children of `.glass-frame` (after `.main-grid`
closes, ~line 2663) so the sheet overlays the whole screen and is anchored to a stable
`position:relative; overflow:hidden` container (see §7 "anchor" rationale). Mirror the mockup
markup (lines 477–519): `.deck-scrim[hidden]`, `.tool-deck[role=dialog][hidden]` with
`.deck-grip`, `.deck-head` (◈ TOOL DECK … + `.dx` ✕), `#deckTarget` input (16px),
six `.tool-row` buttons carrying `data-tool` + `.tool-chip` + description `<span>` +
optional `.uses-target`, then `.holster` (head + `.bind-key` + `.holster-rack` of four
`.socket[data-dir]` buttons + `.holster-hint[aria-live=polite]`). Socket/tool labels are
populated by `renderHolster()`; static gear names are NOT hardcoded.
### 6.2 `css/terminal.css`
- Port the mockup's deck/holster CSS: `.deck-key` (dashed + `.open`), `.deck-scrim`, `.tool-deck`
(+ `@keyframes deckRaise` as a plain **`animation:`** so the global `prefers-reduced-motion`
block at Suite 94 neutralizes it — Protocol UI-9, no bespoke carve-out), `.deck-grip`,
`.deck-head`/`.dx`, `.deck-target`, `.tool-row`/`.tool-chip`/`.uses-target`, `.holster`/
`.holster-head`/`.bind-key`(+`.armed`)/`.holster-rack`/`.socket`(+`.bound`)/`.socket-led`/
`.socket-gear`/`.holster.binding` (+`@keyframes socketPulse`)/`.holster-hint`.
- **Anchor rules:** `.tool-deck`/`.deck-scrim` `position:absolute` inside `.glass-frame`
(`position:relative; overflow:hidden` already at `css/terminal.css:597`). Colours use
`--bezel-wire` (amber) tokens per Protocol 38 — no game literal. Mobile = full-width sheet
(`left/right/bottom:6px`); a `@media (min-width:1000px)` rule may right-anchor it over the
Director column (polish, optional).
- **Remove** the now-dead `.tactical-dashboard`, `.d-pad`, `.d-pad button`, `.d-pad-row`,
`.macro-buttons button`, `.uplink-tray` rules (lines ~242, ~1033–1043, ~1661–1706) — or keep
`.macro-buttons` only if any survivor uses it (none will; verify).
- Sockets are ~76×46 (≥28px ✓); `#deckTarget` is 16px (no focus-zoom, Protocol 17).
### 6.3 `js/state.js`
- Default + migration for `padBindings` (§4).
### 6.4 `js/api.js`
- `sanitizeImportedContainer._sanitizeState`: add `padBindings` coercion (§4).
- `autoImportState`: documenting comment only — NOT mapped (§4/§5).
- `_routeNativeCommand()` (`api.js:1203`): add two regex intercepts **before** the `[WAIT:]`
block, mirroring it:
```js
const bindM = userText.match(/\[BIND:\s*(.+?)\s*,\s*(UP|DOWN|LEFT|RIGHT)\s*\]/i);
if (bindM) { _nativePadBind(bindM[1], bindM[2]); return true; }
const padM = userText.match(/\[PAD:\s*(UP|DOWN|LEFT|RIGHT)\s*\]/i);
if (padM) { _nativePadFire(padM[1]); return true; }
```
(BIND before PAD; both return true so the AI is never called.)
### 6.5 `js/ui-core.js`
- **`toggleToolDeck()` / `openToolDeck()` / `closeToolDeck()`** — bespoke in-glass sheet
controller (the deck is a screen-local bottom-sheet, structurally distinct from the centered
`#sysModal`; NOT a duplicate modal manager — Protocol 22/23). Handles `hidden` toggling on
`#toolDeck`/`.deck-scrim`, `.open`/`aria-expanded` on `#deckKey`, focus into `#deckTarget` on
open and back to `#deckKey` on close, Esc + scrim-tap close, and disarming bind mode on close.
`openToolDeck()` calls `renderHolster()`.
- **`_wireToolDeck()`** — boot function (addEventListener wiring for rows, sockets, BIND ▸, ✕,
scrim, Esc). Add its call alongside `_wireModeHint()` / `_wireComposerAutoGrow()`
(`ui-core.js:1698–1699`) inside that boot phase; register it in the phase list so Suite 132's
slim-composition contract stays honest.
- Tool-row dispatch (`data-tool` → existing handler, then `closeToolDeck()`):
`THREAT`→`macroCommand('[THREAT]')`, `VATS SIM`→`showVATSOverlay()`, `TRADE`→
`expandPanelForCategory('trade')` (+`switchTab('inv')`), `LOOT`→`renderLoot(deckTargetVal)`,
`CONSULT`→`macroCommand('[CONSULT]')`, `VATS CALC`→`showVATSOverlay()`.
- **Repoint `macroCommand`** to read the deck target: change
`document.getElementById('macroTarget')` → `'deckTarget'`, and drop the now-dead
`!actionStr.includes('PAD:')` branch (D-Pad no longer uses `macroCommand`). Keep the helper
(reused by the THREAT/CONSULT rows — Protocol 22).
- **`_nativePadBind(gear, dir)`**: normalize `dir`→lowercase, validate ∈ {up,down,left,right}
(invalid → sys hint, return); `gear=String(gear||'').trim()` (empty → hint "type gear first",
no clobber, return); `state.padBindings[dir]=gear`; `saveState()`; `renderHolster()`;
`appendToChat('> ▸ [BIND: '+gear.toUpperCase()+', '+dir.toUpperCase()+'] — VECTOR HOLSTERED','sys',true)`;
optionally `_logEvent('gear', …)` / `RobcoEvents.emit('gear.bind', …)` (aligns with the U8
auto-log expansion — flagged optional).
- **`_nativePadFire(dir)`**: normalize/validate; `gear=state.padBindings[dir]`; empty →
`appendToChat('> ▸ SOCKET EMPTY — HOLSTER GEAR FIRST (BIND ▸)','sys',true)`, **no AI**, return;
bound → `transmitMessage('Deploy '+gear)` (the additive override param — one clean user line,
reaches the Director). The socket UI tap calls `closeToolDeck()` then `_nativePadFire(dir)`.
- **Remove** the `uplinkCommandTray` desktop-open exception in `_wirePanelPersistence()`
(`ui-core.js:1545–1555`) — the tray no longer exists.
- Update `COMMAND_REGISTRY` entries (`ui-core.js:3404–3405`) to describe the native offline
Quick-Draw Holster (`[BIND: X, DIR]` / `[PAD: DIR]` — "Offline").
### 6.6 `js/ui-render.js`
- **`renderHolster()`**: for each of ▲(up)/◄(left)/►(right)/▼(down), read
`state.padBindings[dir]`; set the socket's `.socket-gear` text (uppercased gear name or
`EMPTY`), toggle `.bound` (LED), and set a literal `aria-label`
(`Fire gear vector up — Stimpak` / `Gear vector up — empty socket`). `escapeHtml` gear names
(XSS — a bound name can be arbitrary player text). Idempotent; safe to call when the deck is
closed (elements exist but hidden).
### 6.7 `tests/test.html` (Protocol 40)
- Add `padBindings` to the `KNOWN_KEYS` tripwire set; add a runtime assertion that a v8 container
with `padBindings` survives `sanitizeImportedContainer`→`migrateState` (4 keys, string|null),
and that a payload with a bogus `padBindings` shape normalizes to the empty default. No new
`section()` unless needed → `Suites: N` marker likely unchanged (verify).
---
## 7. Protocol-8 plan-audit — every entry path, state, edge
**Deck lifecycle**
- Closed at rest (default) → ◈ opens → renders holster from live state → focus `#deckTarget`.
- Close via ✕ / Esc / scrim-tap → focus returns to ◈, bind mode disarmed, `aria-expanded=false`.
- Re-open after a bind reflects the new socket (renderHolster on open). ✓
- **Anchor rationale:** deck is `position:absolute` inside `.glass-frame` (stable
`position:relative; overflow:hidden`). Rejected `.chat-panel` (has `overflow-y:auto` on desktop
AND mobile-uplink → an absolute `bottom:0` child scrolls with content / is mis-placed) and
`.col-right` (`overflow:hidden`/`clip` → clips the sheet). `.glass-frame` is inside the `.casing`
that ambient states filter/transform, but the deck is `absolute` (contained by glass-frame),
**not** `fixed`, so it does NOT degrade under idle/night/shutdown (the Suite 160 fixed-bezel
caveat does not apply here). ✓
**Each tool launch**
- THREAT/CONSULT with a `#deckTarget` value → resolved command → native modal; empty target →
the native tool's own no-arg behavior (unchanged). VATS/CALC ignore target (router already
ignores it). TRADE opens the barter panel (must also `switchTab('inv')`). LOOT pre-fills its
search with the target. Every launch closes the deck first. ✓
**Bind flow**
- Arm (BIND ▸) → sockets dashed-pulse, hint shows, focus `#deckTarget`.
- Pick direction (tap socket) with non-empty target → write + persist + re-render + log + disarm.
- Empty target → "type gear first" hint, **no clobber**, stay armed. ✓
- Re-bind an already-bound socket → overwrites (expected). ✓
- Deck closed mid-arm → disarm. ✓
- **Unbind:** the mockup has no unbind control → **v1 ships without unbind** (re-bind to replace).
Flagged in §12.
**Empty vs bound socket / deploying empty**
- Bound tap → deploy (resolved AI action) + close deck. Empty tap → hint, no AI, deck stays open
(so the player can BIND). ✓
**Typed command path vs native (no conflict/clobber)**
- `[BIND: gear, dir]` / `[PAD: dir]` in EITHER input mode → `_routeNativeCommand` regex → native
helpers; AI never sees them. `_nativePadBind` is the single writer. ✓
- Double-echo note: a *typed* `[PAD: UP]` shows `> [PAD: UP]` (outer echo) then `> Deploy <gear>`
(fire) — acceptable audit trail for the power-user path; the *socket* path (primary) is one
clean line. Flagged. ✓
**Mobile vs desktop**
- Mobile: ◈ only reachable on the UPLINK subsystem (the composer lives in `.col-right`, hidden on
other subsystems by `body:not([data-subsystem='uplink']) .col-right{display:none}`). Deck fills
the screen. Correct — the deck is an UPLINK-context feature; other subsystems show the
carrier-strip. Desktop: ◈ always reachable; deck overlays the full glass-frame. ✓
**Reduced-motion (Protocol UI-9)**
- `@keyframes deckRaise` + `socketPulse` are plain `animation:` → zeroed by the global
`prefers-reduced-motion` block → instant resting frame. No bespoke carve-out. ✓
**Migration / brand-new vs existing save**
- New campaign: default all-null (sockets EMPTY). Existing save w/o `padBindings`: migrateState
adds the default. Malformed padBindings (array/string/extra keys): normalized. ✓
**AI determinism / clobber**
- AI response can't touch `padBindings` (not in schema, not mapped). Cloud/file round-trip
preserved via sanitize. ✓
**PWA-intact**
- No SW/manifest structural change beyond `CACHE_NAME`. Deck is CSS+DOM chrome inside the app
shell; installable/offline/standalone unaffected. ✓
**Reused handlers unbroken**
- `macroCommand` retargeted to `#deckTarget`; `renderThreat`/`renderConsult`/`showVATSOverlay`/
`expandPanelForCategory`/`renderLoot` unchanged. `showTermlinkConsole` + `[TERMLINK]`/
`[CROSSROADS]` remain typable (only the on-screen `#termlinkBtn` is removed with the cluster). ✓
---
## 8. Tests (both runners at parity — Protocols 13, 14, 15, 2a)
### NEW **Suite 164** — "Command-cluster overhaul: Tool Deck + Quick-Draw Holster + padBindings"
State field (Protocol 4/14/34):
- `state.js` default `padBindings {up,down,left,right}` all null; `window._defaultState` includes it.
- `migrateState` adds the default when absent; normalizes array/string/extra-key inputs.
- `sanitizeImportedContainer` coerces `padBindings` (behavioral: rebuild 4 keys, string|null, drop extras).
- **Protocol 14:** `autoImportState` with a `padBindings` in the parsed response does **NOT** alter
`state.padBindings` (player-authority regression).
- **Round-trip (Protocol 34):** push (serialize) → sanitize → migrate → apply preserves bindings.
Native commands:
- `[BIND: gear, DIR]` intercepted by `_routeNativeCommand` → `_nativePadBind` writes state, no AI.
- `[PAD: DIR]` intercepted → `_nativePadFire`; empty socket → no transmit + hint; bound → resolved transmit.
- `_nativePadBind` validates direction + rejects empty gear (no clobber).
Markup/DOM:
- composer `#deckKey` (◈) present, `aria-label`, `.deck-key`, ≥28px.
- `#toolDeck`[role=dialog], `.deck-scrim`, `#deckTarget` (16px), six `.tool-row[data-tool]` with
`.tool-chip`, `.holster` with four `.socket[data-dir=UP/LEFT/RIGHT/DOWN]`, `.bind-key`, hint.
- deck rows map to existing handlers (assert dispatch table / onclick-free rows wired in `_wireToolDeck`).
- **Removed:** no `.tactical-dashboard`, `.d-pad`, `#macroTarget`, `uplinkCommandTray`,
`#termlinkBtn`, and no `macroCommand('[PAD: …]')` anywhere.
CSS/motion:
- `.tool-deck`/`.socket`/`.tool-chip`/`.holster` rules present; deck raise is an `animation`
(reduced-motion-safe); `--bezel-wire` used (Protocol 38, no `--robco-blue`).
Wiring:
- `_wireToolDeck`, `toggleToolDeck`, `_nativePadFire`, `_nativePadBind`, `renderHolster` defined;
`_wireToolDeck` called from `window.onload` phase list.
### UPDATE existing suites (same commit)
- **"Critical Feature Presence"** (`tests/robco-diagnostics.js:1377`, lines 1430–1439): replace the
four `macroCommand('[PAD: …]')` D-pad assertions + THREAT/VATS macro-button assertions with the
deck-row equivalents.
- **106.16** — `[TRADE]` → the deck row calls `expandPanelForCategory('trade')` (update selector).
- **108.14** — `#consultBtn`/`macroCommand('[CONSULT]')` → the deck CONSULT row.
- **111.11** — VISUAL UPLOAD wording lives in the composer already (verify still passes; the ◈ key
addition shouldn't affect it).
- **123.9** — drop the `#termlinkBtn` DOM requirement (button removed with the cluster); keep the
`[TERMLINK]` router + `COMMAND_REGISTRY` + console-CSS checks. TERMLINK stays typable.
- **113** — confirm `[PAD]`/`[BIND]` (regex, like `[WAIT:]`, not router-object tokens) don't break
the FEATURES/router consistency assertions; update the RETIRED-macro list if needed.
- **162.16a/16b/17/18a/18b** — remove/rewrite (uplink tray, `.tactical-dashboard`, `.d-pad`,
desktop-open exception, TERMLINK-CONSOLE-position all gone). **162.20** — extend to assert the ◈
`#deckKey` is present in the composer toolbar.
- **Suite 59** (inline-handler integrity) — the new `onclick="toggleToolDeck()"` on `#deckKey`
must resolve to a real definition (it will).
- **Protocol 2a count sync** across: `CLAUDE.md`, `RULES.md`, `README.md`, `ARCHITECTURE.md`,
`CHANGELOG.md` `[Unreleased]` header, both runners' per-suite comments, and `test.html`
`Suites:` marker (only if a section is added). Run the `Select-String` drift scan.
---
## 9. Protocol touchpoints
- **P1** CACHE_NAME bump (served files change). **P2/2a** docs + counts. **P4** new state field
(full checklist §4). **P10/17** render at 360/412/desktop, ≥28px targets, 16px inputs, no
overflow. **P13** regression tests for the fixed/changed behavior. **P14** AI-contract test
(padBindings not AI-writable). **P15** runner parity. **P19** batch → one push. **P20** static
guards for the new CSS/markup contracts. **P22** reuse existing tool handlers + `macroCommand`;
bespoke deck is a distinct surface, not a second modal manager. **P23** layering respected
(render renders, state owns state, api routes). **P24** AI never authoritative for bindings.
**P25** owner-approved redesign (signed-off mockup) — reframes the UPLINK cluster, same
subsystem, no tap-count increase for the tools (2 taps: ◈ + row), real labels ride along as
chips + descriptions. **P34** additive serialized-whole cloud path. **P38** `--bezel-wire` /
no game literal. **P39** UTF-8 (◈ ▲◄►▼ ✕ — use Edit tool / Node, never PowerShell writes).
**P40** test.html KNOWN_KEYS. **P41** cleanup sweep. **UI-1/UI-3/UI-5** buttons not spans,
literal aria-labels. **UI-6** the deck is an ephemeral overlay, not a persisted view-mode, so
no MetaStore key (documented). **UI-9** deck slide as a plain animation (reduced-motion-free).
---
## 10. Verification steps (Stage 2, before "done")
1. `npm run lint` + `npm run format` clean.
2. Both runners green + at parity + new total synced everywhere.
3. `tests/render-check.mjs` (Playwright 360/412) — no horizontal overflow, no focus-zoom.
4. Live render at **360 / 412 / ≥1000px** (Protocol 10): resting UPLINK = transcript only + ◈;
open deck; launch each of the 6 tools (confirm the right native surface opens + deck closes);
fire a bound socket (Director responds to `Deploy <gear>`); fire an empty socket (hint, no AI);
BIND flow (arm → type → tap → LED lights, name shows, persists across reload); typed
`[BIND: mentats, down]` then `[PAD: down]`. Confirm `scrollWidth === innerWidth` at 360.
5. Reduced-motion emulation → deck appears instantly.
6. Reload → migrated default holds; a bound socket survives reload (serialized-whole).
7. PWA smoke (installable/offline unaffected).
---
## 11. Docs / version (Protocol 2/2a)
- **APP_VERSION:** MINOR bump (new user-facing feature/panel) per Protocol 2 — auto, no ask.
- **CACHE_NAME:** bump `-rN` (served files change).
- **CHANGELOG `[Unreleased]`** (plain English, Protocol 21): Added — the Tool Deck launcher + the
Quick-Draw Holster (visible gear vectors); Changed — the command cluster no longer sits under
the chat; Under the Hood — bindings are now real saved state.
- **ARCHITECTURE.md / README.md** — update the UPLINK/command-cluster description, the state-field
list (`padBindings`), and the Native-Input-Path Audit table (padBindings now has a native setter).
---
## 12. Open questions (for the owner — NOT blocking Stage-1)
1. **Unbind:** the mockup ships no un-holster control (re-bind to replace). OK for v1, or add an
explicit clear (e.g. BIND ▸ + empty field + socket, or a long-press)? *Plan assumes no unbind.*
2. **Deploy verb:** `_nativePadFire` sends `Deploy <gear>` to the Director. Prefer a different
phrasing (`Use <gear>` / `Ready <gear>`)? *Plan uses "Deploy".*
3. **TERMLINK console redundancy:** the Tool Deck now overlaps `showTermlinkConsole()` (both launch
native subsystems). Plan keeps TERMLINK typable but removes its on-screen button. Retire the
console entirely in a later consolidation (Protocol 22)? *Out of scope here — flagged.*
4. **Bind validation:** the plan allows binding *any* typed gear name (player may bind gear not yet
held), matching the mockup. Restrict to inventory items instead? *Plan: no restriction.*
5. **Directive belt-and-suspenders:** add a one-line "bindings are native, don't compute" note to
`getSystemDirective()`? *Plan: omit (token budget; AI has no schema hook anyway).*
STAMP · generated for RELEASE v2.8.5 commit 06e5180 (06e51801b38a) · archive input-tree hash c07fbfbdd2e1ddeb · 754 files · no wall-clock timestamp (regenerates identically when nothing changed).