grilling-workbench 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,15 +18,21 @@ server or idle-chat wakeup service is included.
18
18
  From the project where you want to use the workbench:
19
19
 
20
20
  ```sh
21
- npx --yes grilling-workbench@0.2.0 install-skill
21
+ npx skills@latest add PavingLayer/grilling-workbench \
22
+ --skill grilling-workbench --agent codex
22
23
  ```
23
24
 
24
- The installer creates `.agents/skills/grilling-workbench` and refuses to overwrite
25
- an existing skill. Codex detects new skills automatically; restart it if the skill
25
+ The [Skills CLI](https://github.com/vercel-labs/skills) installs the skill from
26
+ GitHub into `.agents/skills/grilling-workbench` and records its source in
27
+ `skills-lock.json`. This command targets Codex; omit `--agent codex` to choose
28
+ another agent. Codex detects new skills automatically; restart it if the skill
26
29
  does not appear. Explicit `$grilling-workbench` invocation can verify installation;
27
- normal interview use should select it automatically. npm caches the application;
28
- the skill and interview sessions remain in your project. Use the same pinned
29
- version for setup and every command in a round.
30
+ normal interview use should select it automatically.
31
+
32
+ `@latest` selects the skill installer version. The installed skill pins application
33
+ commands to `grilling-workbench@0.3.0`, which npm fetches into its cache when run.
34
+ Keep that exact application version throughout a round. The bundled `install-skill`
35
+ command remains available for [exact-release and offline installation](docs/deployment.md#bundled-installer-for-an-exact-release).
30
36
 
31
37
  Source: [PavingLayer/grilling-workbench](https://github.com/PavingLayer/grilling-workbench).
32
38
  Package: [grilling-workbench on npm](https://www.npmjs.com/package/grilling-workbench).
@@ -39,15 +45,15 @@ explains skill discovery and project configuration alongside Matt Pocock's skill
39
45
  ## Agent workflow
40
46
 
41
47
  ```sh
42
- npx --yes grilling-workbench@0.2.0 init --session .workbench/topic-r01 --questions /absolute/path/round.json
43
- npx --yes grilling-workbench@0.2.0 serve --session .workbench/topic-r01
48
+ npx --yes grilling-workbench@0.3.0 init --session .workbench/topic-r01 --questions /absolute/path/round.json
49
+ npx --yes grilling-workbench@0.3.0 serve --session .workbench/topic-r01
44
50
  ```
45
51
 
46
52
  Keep the server process running. In a second persistent process, **before showing
47
53
  the URL returned by serve**:
48
54
 
49
55
  ```sh
50
- npx --yes grilling-workbench@0.2.0 wait --session .workbench/topic-r01
56
+ npx --yes grilling-workbench@0.3.0 wait --session .workbench/topic-r01
51
57
  ```
52
58
 
53
59
  Keep the agent turn waiting on that process. The listener blocks on TCP and exits
@@ -68,6 +74,23 @@ screens. The footer keeps navigation and submission actions visible. Each questi
68
74
  shows its full wording, options, benefits, and trade-offs. Recommendations are
69
75
  labels; every question starts unanswered.
70
76
 
77
+ The form supports arrow keys and Vim-style keyboard control. Use `↓` / `↑`
78
+ (or `j` / `k`) to focus options and buttons, `Enter` or `Space` to activate them,
79
+ and `←` / `→` (or `h` / `l`) to change questions. Moving focus does not select
80
+ an option. Use Tab or Shift+Tab to reach the answer field; arrows move the text
81
+ cursor normally while typing.
82
+ `1`–`9` chooses or toggles an option; `i` focuses your answer or notes and `Esc`
83
+ returns to navigation. `Ctrl+Enter` (or `Cmd+Enter`) submits the whole form,
84
+ including while typing. `q` opens the question picker, `H` opens submission
85
+ history, `b` toggles the sidebar, `d` moves on to answer later, and `x` clears the
86
+ current answer and notes. `gg` / `G` focuses the first / last control, and
87
+ `Ctrl+d` / `Ctrl+u` scrolls half a page. Dialogs keep navigation inside them.
88
+
89
+ The footer shows Normal or Insert mode. Open **Keys ?** (or press `?` outside a
90
+ text field) for the guide and an option to disable Vim shortcuts, remembered in
91
+ this browser. Arrow navigation, Tab, Shift+Tab, Enter, Space, Esc, and
92
+ Ctrl/Cmd+Enter remain available with Vim shortcuts turned off.
93
+
71
94
  Choices and notes save as drafts while you work. Drafts survive navigation,
72
95
  reloads, and question updates. If a question's meaning changes, the app retains
73
96
  your earlier answer and asks you to revisit the new wording. Save failures offer
@@ -76,7 +99,7 @@ retry and recovery options.
76
99
  **Submit form** sends the entire round in one click, including edits still being
77
100
  saved. Questions left blank are reported as `not_answered`. Each submission keeps
78
101
  the exact question versions and answers; retries reuse its identity to prevent
79
- duplicates. Drafts become submitted outcomes only when you click Submit.
102
+ duplicates. Drafts become submitted outcomes only when you explicitly submit the form.
80
103
 
81
104
  After saving, the server delivers the submission to the waiting agent over a
82
105
  socket. Unacknowledged forms replay after a reconnect, and the page shows when the
@@ -1,6 +1,6 @@
1
1
  # Local deployment
2
2
 
3
- Version 0.2.0 ships on public npm with a CLI, static browser files,
3
+ Version 0.3.0 ships on public npm with a CLI, static browser files,
4
4
  a demo questionnaire, operational docs, and the integration skill. It needs Node
5
5
  22+ and no production dependencies or build service. Linux with Node 22.22.2 was
6
6
  verified locally. CI is configured for Node 22 and 24 on Linux; other operating
@@ -11,14 +11,20 @@ systems have not been release-tested.
11
11
  Run this from any project, including a non-Node project:
12
12
 
13
13
  ```sh
14
- npx --yes grilling-workbench@0.2.0 install-skill
15
- npx --yes grilling-workbench@0.2.0 --version
14
+ npx skills@latest add PavingLayer/grilling-workbench \
15
+ --skill grilling-workbench --agent codex
16
+ npx --yes grilling-workbench@0.3.0 --version
16
17
  ```
17
18
 
18
- npm fetches the application into its execution cache. Only the installed skill
19
- and the sessions you create live in the consuming project; no project dependency
20
- or global installation is required. Keep `@0.2.0` on every command so the skill,
21
- server, and listener use the same release. npm needs registry access on first use;
19
+ The [Skills CLI](https://github.com/vercel-labs/skills) installs the skill from
20
+ GitHub into `.agents/skills/grilling-workbench` and records its source in
21
+ `skills-lock.json`. The command targets Codex; omit `--agent codex` to choose
22
+ another agent. No project dependency or global installation is required.
23
+
24
+ `@latest` selects the installer version; the skill pins the application to `0.3.0`.
25
+ The version check above fetches that application into npm's execution cache.
26
+ Keep `@0.3.0` on every application command so the server and listener use the
27
+ release required by the skill. Initial setup needs npm registry and GitHub access;
22
28
  use an explicit archive installation when reliable offline availability matters.
23
29
 
24
30
  Source and releases are maintained at
@@ -26,25 +32,35 @@ Source and releases are maintained at
26
32
  The public npm package is
27
33
  [grilling-workbench](https://www.npmjs.com/package/grilling-workbench).
28
34
 
35
+ ### Bundled installer for an exact release
36
+
37
+ To install the skill bundled with a specific application release, use:
38
+
39
+ ```sh
40
+ npx --yes grilling-workbench@0.3.0 install-skill
41
+ ```
42
+
43
+ Run this from each project that should discover it. The default target is
44
+ `.agents/skills/grilling-workbench`. `--target DIR` supports another host's skill
45
+ directory. This command copies the bundled skill; it does not edit AGENTS.md,
46
+ install upstream skills, or overwrite existing skills. This installer does not
47
+ create a Skills CLI lockfile.
48
+
29
49
  If you prefer a project dependency:
30
50
 
31
51
  ```sh
32
- npm install --save-dev --save-exact grilling-workbench@0.2.0
52
+ npm install --save-dev --save-exact grilling-workbench@0.3.0
33
53
  npx --no grilling-workbench install-skill
34
54
  ```
35
55
 
36
56
  Or explicitly install the executable globally:
37
57
 
38
58
  ```sh
39
- npm install --global grilling-workbench@0.2.0
59
+ npm install --global grilling-workbench@0.3.0
40
60
  grilling-workbench --version
61
+ grilling-workbench install-skill
41
62
  ```
42
63
 
43
- Run the setup command from each project that should discover it.
44
- The default target is `.agents/skills/grilling-workbench`. `--target DIR` supports
45
- another host's skill directory. This command copies the bundled skill; it does
46
- not edit AGENTS.md, install upstream skills, or overwrite existing skills.
47
-
48
64
  ### Offline archives and maintainer releases
49
65
 
50
66
  From a clean source checkout:
@@ -58,7 +74,7 @@ mkdir -p dist
58
74
  npm pack --pack-destination dist
59
75
  ```
60
76
 
61
- The result is `dist/grilling-workbench-0.2.0.tgz`. The explicit package allowlist
77
+ The result is `dist/grilling-workbench-0.3.0.tgz`. The explicit package allowlist
62
78
  excludes sessions, receipts, development dependencies, and tests. The package
63
79
  tests exercise both an offline project installation and `npx` execution from an
64
80
  isolated cache, including full form delivery, updates, shutdown, and restart.
@@ -67,7 +83,7 @@ Building the archive does not publish it.
67
83
  Offline consumers can install the archive as a project dependency or globally:
68
84
 
69
85
  ```sh
70
- npm install --offline --save-dev /absolute/path/grilling-workbench-0.2.0.tgz
86
+ npm install --offline --save-dev /absolute/path/grilling-workbench-0.3.0.tgz
71
87
  npx --no grilling-workbench install-skill
72
88
  ```
73
89
 
@@ -78,7 +94,7 @@ Maintainers publish the checked archive to npm and attach the same file to its
78
94
  GitHub release. After the source commit is pushed and GitHub checks pass:
79
95
 
80
96
  ```sh
81
- npm publish ./dist/grilling-workbench-0.2.0.tgz --access public
97
+ npm publish ./dist/grilling-workbench-0.3.0.tgz --access public
82
98
  ```
83
99
 
84
100
  Publication requires an authenticated npm account with publishing access. For
@@ -94,8 +110,8 @@ an absolute path when resuming from another directory. `init` refuses any existi
94
110
  directory, so it cannot reset a previous form accidentally.
95
111
 
96
112
  ```sh
97
- npx --yes grilling-workbench@0.2.0 init --session .workbench/topic-r01 --questions /absolute/path/round.json
98
- npx --yes grilling-workbench@0.2.0 serve --session .workbench/topic-r01
113
+ npx --yes grilling-workbench@0.3.0 init --session .workbench/topic-r01 --questions /absolute/path/round.json
114
+ npx --yes grilling-workbench@0.3.0 serve --session .workbench/topic-r01
99
115
  ```
100
116
 
101
117
  Both servers bind exclusively to `127.0.0.1`. By default the OS assigns free HTTP
@@ -154,9 +170,14 @@ new exact package version, compare its bundled skill with the installed copy, an
154
170
  restart `serve` at that version against the same directory. Update a local or
155
171
  global installation explicitly if you use one. To inspect a skill upgrade without overwriting
156
172
  customizations, use `install-skill --target /path/to/new-empty-directory` and merge
157
- changes deliberately. State schema 1 is retained in 0.2.0; unsupported state
173
+ changes deliberately. State schema 1 is retained in 0.3.0; unsupported state
158
174
  versions and corrupt files fail without resetting answers.
159
175
 
176
+ When upgrading a skill through the Skills CLI, review its changes and use the
177
+ exact application version required by the updated skill for subsequent rounds.
178
+ Finish active rounds with their original skill instructions and application
179
+ version before upgrading.
180
+
160
181
  Back up `config.json`, `questions.json`, `session.json`, and `chat-receipts.json`
161
182
  with the server and receipt writer stopped. Restore those files into a private
162
183
  session directory, then start serve. Do not restore `runtime.json` or lock files;
@@ -14,16 +14,22 @@ and agent must run on the same computer.
14
14
  From the project where you want to use it:
15
15
 
16
16
  ```sh
17
- npx --yes grilling-workbench@0.2.0 install-skill
17
+ npx skills@latest add PavingLayer/grilling-workbench \
18
+ --skill grilling-workbench --agent codex
18
19
  ```
19
20
 
20
- This fetches the application into npm's cache and installs the instructions that
21
- teach the agent when and how to use it. It creates `.agents/skills/grilling-workbench`
22
- and preserves any existing skill at that path. No `package.json`, project
23
- dependency, or global installation is required, including in non-Node projects.
24
- The skill uses the same pinned package version for subsequent commands.
21
+ The [Skills CLI](https://github.com/vercel-labs/skills) fetches the skill from GitHub
22
+ and installs the instructions that teach the agent when and how to use the
23
+ workbench. For Codex, it creates `.agents/skills/grilling-workbench` and records the
24
+ source in `skills-lock.json`. Omit `--agent codex` to choose another agent.
25
+ No `package.json`, project dependency, or global installation is required,
26
+ including in non-Node projects.
25
27
 
26
- For offline installation, source development, or upgrades, use the
28
+ `@latest` applies to the skill installer. The skill pins application commands to
29
+ `grilling-workbench@0.3.0`; npm downloads that application into its cache when the
30
+ agent first runs it. Keep the same exact application version throughout a round.
31
+
32
+ For exact-release or offline installation, source development, or upgrades, use the
27
33
  [deployment guide](deployment.md). Keep your existing interview skills installed;
28
34
  Matt Pocock's repository has its own [installation instructions](https://github.com/mattpocock/skills).
29
35
 
@@ -84,8 +90,8 @@ the workbench.
84
90
  First check whether `grilling-workbench` appears in the agent's available skills.
85
91
  In Codex, that list combines applicable project skills, user skills, administrator
86
92
  and system skills, and enabled plugin skills. It does not automatically include
87
- local skills from unrelated projects. Our installer adds the workbench at the
88
- current project's `.agents/skills/grilling-workbench` path.
93
+ local skills from unrelated projects. The setup command above adds the workbench
94
+ at the current project's `.agents/skills/grilling-workbench` path.
89
95
 
90
96
  Codex normally detects new skills automatically. If it is missing, restart Codex
91
97
  and check the installed path. If it appears more than once, inspect the source
@@ -105,7 +111,7 @@ rules. It is optional; the skill already defines the interview trigger.
105
111
  Whenever interviewing the user, use the grilling-workbench skill at
106
112
  `.agents/skills/grilling-workbench/SKILL.md`. Apply it alongside the current
107
113
  interview workflow, including grill-me, without requiring a request for a browser
108
- form. The command is `npx --yes grilling-workbench@0.2.0`.
114
+ form. The command is `npx --yes grilling-workbench@0.3.0`.
109
115
 
110
116
  Read the skill's operating protocol before presenting questions. Keep reasoning,
111
117
  clarification, and decision records in the existing workflow. Respect an explicit
@@ -135,11 +141,9 @@ explanations about an existing option should continue in chat.
135
141
  ## Share it with another project
136
142
 
137
143
  Share [PavingLayer/grilling-workbench](https://github.com/PavingLayer/grilling-workbench)
138
- and repeat the one-command setup above. npm includes the matching application and
139
- skill; browser and command-execution tools must be supplied by the agent host.
140
-
141
- The repository also exposes `skills/grilling-workbench/SKILL.md` in a layout
142
- recognized by the [Skills CLI](https://github.com/vercel-labs/skills#skill-discovery).
143
- The npm `install-skill` command is the primary setup path because it bundles the
144
- skill with the same exact application release. Offline users can instead share
145
- the release archive; see [distribution and installation](deployment.md#distribution-and-installation).
144
+ and repeat the Skills CLI setup above. The skill supplies the pinned application
145
+ command; browser and command-execution tools must be supplied by the agent host.
146
+
147
+ The bundled npm `install-skill` command remains an alternative when you want the
148
+ skill from an exact application release or need an offline archive. See
149
+ [distribution and installation](deployment.md#distribution-and-installation).
@@ -0,0 +1,28 @@
1
+ Forms can now be completed with arrow keys or Vim-style shortcuts, including
2
+ question navigation, answer selection, notes, submission, history, and recovery
3
+ actions.
4
+
5
+ - Use Up/Down to focus controls, Left/Right to change questions, and Enter or
6
+ Space to activate. Arrow navigation also works with Vim shortcuts disabled.
7
+ - Vim keys include j/k, h/l, gg/G, numbered choices, i to write, and Escape to
8
+ return to navigation. Ctrl/Cmd+Enter submits the whole form while typing.
9
+ - A visible keyboard guide and mode indicator explain the controls. The Vim
10
+ preference is remembered in the browser.
11
+ - Focus survives saved edits and question updates. Dialog navigation stays
12
+ inside the dialog; text fields retain normal cursor and selection keys.
13
+ - Installation guidance now uses the Skills CLI to install the agent skill
14
+ directly from GitHub. Bundled application commands are pinned to 0.3.0.
15
+
16
+ Requires Node.js 22 or later. No production dependencies were added. State schema
17
+ 1 and socket protocol 1 are unchanged; existing drafts and submissions remain
18
+ compatible. Finish active rounds on their original application version before
19
+ upgrading.
20
+
21
+ ```sh
22
+ npx --yes grilling-workbench@0.3.0 --version
23
+ ```
24
+
25
+ Validation includes 27 automated tests, offline-install and npx package smoke
26
+ tests, and embedded-browser checks. The validation guide records the remaining
27
+ browser and platform coverage limits. The attached archive is the npm package
28
+ artifact.
@@ -6,12 +6,24 @@ use the [deployment guide](deployment.md).
6
6
 
7
7
  ## Automated checks
8
8
 
9
- The 0.2.0 release checks passed locally on Linux with Node.js 22.22.2 on September
9
+ Version 0.3.0 adds arrow and Vim keyboard navigation, focus preservation, and a
10
+ shortcut guide. State schema 1 and socket protocol 1 are unchanged. Keyboard
11
+ checks and remaining browser-validation limits are recorded below.
12
+
13
+ The 0.3.0 release passed a clean `npm ci`, syntax validation, all 27 tests, and
14
+ both offline-install and npx package smoke tests locally on Linux with Node.js
15
+ 22.22.2 on September 6, 2026. GitHub Actions runs the release checks on Node 22
16
+ and 24 for the published source commit.
17
+
18
+ The 0.2.0 and 0.2.1 release checks passed locally on Linux with Node.js 22.22.2 on September
10
19
  6, 2026 (UTC): syntax validation, 18 automated tests, and both installed-package
11
20
  and `npx` smoke tests. CI runs the same checks on Node 22 and 24; see
12
21
  [GitHub Actions](https://github.com/PavingLayer/grilling-workbench/actions/workflows/verify.yml)
13
22
  for the hosted results associated with a release commit.
14
23
 
24
+ Version 0.2.1 updates the installation guides and matching package version pins.
25
+ The application code, state schema, and socket protocol are unchanged from 0.2.0.
26
+
15
27
  The automated tests cover:
16
28
 
17
29
  - Unanswered defaults, recommendation labels, and 1,000 generated action histories
@@ -28,14 +40,72 @@ The automated tests cover:
28
40
 
29
41
  The package smoke test packs the actual release and exercises it twice: installed
30
42
  offline in an unrelated project, and through `npx` from an isolated cache in a
31
- project without package files or dependencies. Both modes verify skill
43
+ project without package files or dependencies. Both modes verify bundled skill
32
44
  installation without overwriting an existing copy, two isolated sessions, served
33
45
  browser assets, whole-form delivery, acknowledgments, definition updates, restart,
34
46
  and the source-checkout demo entrypoint. The `npx` test uses the local archive,
35
47
  so registry publication and a fresh registry download are separate release checks.
36
48
 
49
+ The recommended Skills CLI installation was separately checked on September 6,
50
+ 2026 (UTC), with Skills CLI 1.5.23 and Node.js 22.22.2. In a clean temporary project,
51
+ `npx skills@latest add PavingLayer/grilling-workbench --skill grilling-workbench --agent codex`
52
+ was run with confirmation accepted through `--yes`. It installed the skill and
53
+ both reference files matching the repository, created `skills-lock.json`, and
54
+ added no `package.json`, `package-lock.json`, or `node_modules`. The installed
55
+ skill retained its `grilling-workbench@0.2.0` application pin. This check verifies
56
+ installation, not automatic selection by an agent.
57
+
37
58
  ## Embedded-browser checks
38
59
 
60
+ ### Vim keyboard controls
61
+
62
+ The keyboard-control change passed syntax validation, all 24 automated tests,
63
+ and both package smoke tests on September 6, 2026. Six keyboard tests exercise
64
+ an answer-and-submit workflow, dialog isolation, `gg` timing and focus resets,
65
+ held-key suppression for mutations, composition and native-key passthrough,
66
+ disabled shortcuts, and command mappings. The package tests verify that the
67
+ new keyboard module is included and served by the installed application.
68
+
69
+ An isolated demo session was checked in the embedded browser at the default
70
+ 1280×720 viewport and at 320×740, using the keyboard for:
71
+
72
+ - Moving focus without selecting, selecting a radio with Enter, and toggling
73
+ multiple choices with number keys and Space; focus survives saved edits.
74
+ - Entering multiline notes containing shortcut characters, leaving Insert mode,
75
+ and submitting all three question types directly from the text field.
76
+ - Navigating the question picker, opening submission history, expanding submitted
77
+ answers, and focusing and scrolling the submitted text. Question navigation
78
+ and submission shortcuts do not act behind an open dialog.
79
+ - Disabling Vim shortcuts, reloading to verify the remembered setting, then
80
+ using Tab, Enter, and Space to reopen the guide and enable them again.
81
+ - Opening the narrow-screen question picker, inspecting the shortcut guide
82
+ without horizontal overflow, and returning focus when dialogs close.
83
+ - Expanding an earlier answer and keeping it after a definition refresh;
84
+ clearing an answer and its notes; and moving on with Answer later.
85
+ - Reaching both recovery actions during a simulated save failure, then retrying
86
+ successfully after restoring the question definitions. The recovery download
87
+ control was activated, but the embedded runner did not report a download event;
88
+ recovery-file delivery was not verified in this keyboard pass.
89
+
90
+ The browser checks are manual integration evidence; the automated keyboard tests
91
+ cover the command interpreter rather than browser focus or layout. Screen-reader
92
+ and non-Chromium keyboard checks remain outstanding.
93
+
94
+ ### Earlier interface checks
95
+
96
+ The arrow-navigation follow-up passed syntax validation, all 27 tests, and both
97
+ package smoke tests on September 6, 2026. Three additional tests cover arrow
98
+ navigation with Vim enabled and disabled, native cursor/selection and modifier
99
+ behavior, and dialog isolation. Standard activation, Escape, and Ctrl/Cmd+Enter
100
+ also remain available when Vim shortcuts are disabled.
101
+
102
+ In a separate embedded-browser demo, Up/Down moved focus without changing radio
103
+ answers, Enter and Space selected radios and checkboxes, and Left/Right changed
104
+ questions. Tab reached multiline text; arrows and Shift+Arrow moved and selected
105
+ text without changing questions. With Vim disabled, arrow navigation, dialog
106
+ isolation, and submission from the text field were verified. The user's demo was
107
+ refreshed after its save indicator reported Saved.
108
+
39
109
  The implemented interface has been checked in the embedded browser for:
40
110
 
41
111
  - A 20-question form at 808×1139, with independently scrolling navigation and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grilling-workbench",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Local question forms with whole-form submission and event-driven agent delivery",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,7 +19,7 @@
19
19
  "scripts": {
20
20
  "dev": "node src/dev.js",
21
21
  "start": "node src/dev.js",
22
- "check": "node --check bin/grilling-workbench.js && node --check src/dev.js && node --check src/cli.js && node --check src/runtime.js && node --check src/core.js && node --check src/server.js && node --check src/storage.js && node --check src/delivery.js && node --check src/monitor.js && node --check src/submission-socket.js && node --check public/app.js",
22
+ "check": "node --check bin/grilling-workbench.js && node --check src/dev.js && node --check src/cli.js && node --check src/runtime.js && node --check src/core.js && node --check src/server.js && node --check src/storage.js && node --check src/delivery.js && node --check src/monitor.js && node --check src/submission-socket.js && node --check public/app.js && node --check public/keyboard.js",
23
23
  "test": "node --test test/*.test.js",
24
24
  "test:package": "node scripts/test-package.js && node scripts/test-package.js --npx"
25
25
  },
package/public/app.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { transition, draftFor, questionById, hasAnswer, isStale, statusFor, progress, formSubmitted, makeReview as makeSubmission, formatSubmission } from '/core.js';
2
+ import { createKeyboard } from '/keyboard.js';
2
3
 
3
4
  const app = document.querySelector('#app');
4
5
  const dialog = document.querySelector('#app-dialog');
@@ -6,6 +7,9 @@ const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&':
6
7
  let state, version, currentId, running = false, error = '', conflict = false, connectionWarning = '';
7
8
  let pending = [], shownSubmission = null, receivedIds = new Set();
8
9
  let toastTimer, modalMode = '', refreshing = false, sidebarCollapsed = false;
10
+ let dialogReturnFocus = null;
11
+ let vimEnabled = true;
12
+ try { vimEnabled = localStorage.getItem('workbench-vim') !== 'off'; } catch { /* Storage can be unavailable in embedded browsers. */ }
9
13
  const narrowViewport = matchMedia('(max-width: 640px)');
10
14
  const iconPaths = {
11
15
  check: '<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',
@@ -18,6 +22,35 @@ const iconPaths = {
18
22
  };
19
23
  const icon = name => `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${iconPaths[name]}</svg>`;
20
24
 
25
+ function rememberFocus(root) {
26
+ const element = document.activeElement;
27
+ if (!element || !root.contains(element)) return null;
28
+ const attribute = ['id', 'data-action', 'data-go', 'data-modal', 'data-submission'].find(name => element.hasAttribute(name));
29
+ if (!attribute) return null;
30
+ const region = element.closest('.footer-bar, #sidebar-questions');
31
+ const prefix = region ? (region.id ? `#${region.id} ` : '.footer-bar ') : '';
32
+ return {
33
+ selector: `${prefix}[${attribute}="${CSS.escape(element.getAttribute(attribute))}"]`,
34
+ selection: element.id === 'answer-text' ? [element.selectionStart, element.selectionEnd, element.selectionDirection] : null,
35
+ };
36
+ }
37
+
38
+ function restoreFocus(saved, root = document) {
39
+ const element = saved && root.querySelector(saved.selector);
40
+ if (!element || element.disabled || !element.getClientRects().length) return false;
41
+ element.focus({ preventScroll: true });
42
+ if (saved.selection) element.setSelectionRange(...saved.selection);
43
+ return true;
44
+ }
45
+
46
+ function replaceContent(element, html) {
47
+ if (element.innerHTML === html) return;
48
+ const focused = rememberFocus(element), scroll = element.scrollTop;
49
+ element.innerHTML = html;
50
+ element.scrollTop = scroll;
51
+ if (focused && !restoreFocus(focused)) focusControl(document.querySelector('#question-title'));
52
+ }
53
+
21
54
  function toast(message) {
22
55
  const notice = document.querySelector('#notice');
23
56
  notice.textContent = message;
@@ -59,7 +92,7 @@ function renderChrome() {
59
92
  document.querySelector('#compact-progress').textContent = `${stats.answered} of ${stats.total} answered · ${formSubmitted(state) ? 'Form submitted' : `${stats.total - stats.answered} not answered`}`;
60
93
  const sidebar = document.querySelector('#sidebar-questions');
61
94
  const list = questionItems();
62
- if (sidebar.innerHTML !== list) { const scroll = sidebar.scrollTop; sidebar.innerHTML = list; sidebar.scrollTop = scroll; }
95
+ replaceContent(sidebar, list);
63
96
  document.querySelector('#question-badges').innerHTML = badge(currentId);
64
97
  document.querySelector('[data-action="clear"]').disabled = !hasAnswer(draftFor(state, currentId));
65
98
  document.querySelector('#history-button').textContent = `Submissions (${state.submissions.length})`;
@@ -68,7 +101,7 @@ function renderChrome() {
68
101
  document.querySelector('#submit-form').disabled = conflict || pending.some(item => item.action.type === 'submit');
69
102
  const warning = document.querySelector('#save-warning');
70
103
  warning.hidden = !error && !connectionWarning;
71
- warning.innerHTML = warning.hidden ? '' : `<p>${escape(error || connectionWarning)} Your work in this tab is retained. Unsaved changes will be lost if you close or reload it.</p><button class="button small" data-action="${conflict ? 'load-saved' : 'retry'}">${conflict ? 'Load saved version…' : 'Retry saving'}</button><button class="button small" data-action="recover">Download recovery copy</button>`;
104
+ replaceContent(warning, warning.hidden ? '' : `<p>${escape(error || connectionWarning)} Your work in this tab is retained. Unsaved changes will be lost if you close or reload it.</p><button class="button small" data-action="${conflict ? 'load-saved' : 'retry'}">${conflict ? 'Load saved version…' : 'Retry saving'}</button><button class="button small" data-action="recover">Download recovery copy</button>`);
72
105
  }
73
106
 
74
107
  function oldAnswer(draft) {
@@ -77,8 +110,7 @@ function oldAnswer(draft) {
77
110
 
78
111
  function render() {
79
112
  if (!state) return;
80
- const focused = document.activeElement;
81
- const textFocus = focused?.id === 'answer-text' ? { start: focused.selectionStart, end: focused.selectionEnd } : null;
113
+ const focused = rememberFocus(app);
82
114
  const mainScroll = document.querySelector('.main')?.scrollTop || 0;
83
115
  const sidebarScroll = document.querySelector('#sidebar-questions')?.scrollTop || 0;
84
116
  currentId = questionById(state, currentId) ? currentId : state.questionnaire.questions[0].id;
@@ -92,15 +124,16 @@ function render() {
92
124
  <main class="main"><div class="form-content"><p class="compact-progress" id="compact-progress"></p>
93
125
  <div id="save-warning" class="alert" role="alert" hidden></div>
94
126
  <article class="question-sheet" id="question" aria-labelledby="question-title"><div class="question-meta"><span class="question-position">Question ${index + 1} of ${state.questionnaire.questions.length}</span><button class="button small question-selector" id="question-selector" data-action="questions" aria-haspopup="dialog" aria-controls="app-dialog">Question ${index + 1} of ${state.questionnaire.questions.length} <span aria-hidden="true">▾</span></button><span id="question-badges"></span></div><h1 id="question-title" tabindex="-1">${escape(q.title)}</h1><p class="question-context">${escape(q.context)}</p>
95
- ${stale ? `<div class="alert"><p><strong>This question was updated.</strong> Your earlier answer is kept below. ${canAdopt ? 'Review the new wording, then keep or edit your answer.' : 'An earlier choice is no longer available. Choose a new answer to continue.'}</p><details><summary>Earlier question and answer</summary><div class="old-answer">${escape(oldAnswer(draft))}</div></details>${canAdopt ? '<button class="button small" data-action="adopt">Keep my answer with this wording</button>' : ''}</div>` : ''}
96
- ${q.type !== 'text' ? `<p class="answer-hint">${q.type === 'single' ? 'Choose one, or write your own answer below.' : 'Choose any that apply, or write your own answer below.'}</p><fieldset class="options" aria-labelledby="question-title">${q.options.map(o => `<label class="option ${draft.optionIds.includes(o.id) ? 'selected' : ''}" id="option-${escape(q.id)}-${escape(o.id)}"><span class="option-top"><input type="${q.type === 'single' ? 'radio' : 'checkbox'}" name="answer-option" value="${escape(o.id)}" ${draft.optionIds.includes(o.id) ? 'checked' : ''} aria-labelledby="label-${escape(q.id)}-${escape(o.id)}"><span class="option-title" id="label-${escape(q.id)}-${escape(o.id)}">${escape(o.label)}</span>${o.recommended ? '<span class="badge recommended">Recommended</span>' : ''}</span><div class="option-body"><p class="option-description">${escape(o.description)}</p><div class="tradeoffs"><p><strong class="offers">${icon('check')} Offers</strong><span>${escape(o.benefit)}</span></p><p><strong class="tradeoff">${icon('balance')} Trade-off</strong><span>${escape(o.tradeoff)}</span></p></div></div></label>`).join('')}</fieldset>` : ''}
97
- <label for="answer-text" class="field-label">${q.type === 'text' ? 'Your answer' : 'Your answer <span class="optional">In your own words</span>'}</label><textarea id="answer-text" maxlength="20000" rows="${q.type === 'text' ? 7 : 3}">${escape(draft.text)}</textarea>
127
+ ${stale ? `<div class="alert"><p><strong>This question was updated.</strong> Your earlier answer is kept below. ${canAdopt ? 'Review the new wording, then keep or edit your answer.' : 'An earlier choice is no longer available. Choose a new answer to continue.'}</p><details><summary id="earlier-answer">Earlier question and answer</summary><div class="old-answer">${escape(oldAnswer(draft))}</div></details>${canAdopt ? '<button class="button small" data-action="adopt">Keep my answer with this wording</button>' : ''}</div>` : ''}
128
+ ${q.type !== 'text' ? `<p class="answer-hint">${q.type === 'single' ? 'Choose one, or write your own answer below.' : 'Choose any that apply, or write your own answer below.'}</p><fieldset class="options" aria-labelledby="question-title">${q.options.map((o, optionIndex) => `<label class="option ${draft.optionIds.includes(o.id) ? 'selected' : ''}" id="option-${escape(q.id)}-${escape(o.id)}"><span class="option-top"><input id="answer-option-${escape(q.id)}-${escape(o.id)}" type="${q.type === 'single' ? 'radio' : 'checkbox'}" name="answer-option" value="${escape(o.id)}" ${draft.optionIds.includes(o.id) ? 'checked' : ''} aria-labelledby="label-${escape(q.id)}-${escape(o.id)}"><span class="option-title" id="label-${escape(q.id)}-${escape(o.id)}">${escape(o.label)}</span>${optionIndex < 9 ? `<kbd class="option-key" aria-hidden="true">${optionIndex + 1}</kbd>` : ''}${o.recommended ? '<span class="badge recommended">Recommended</span>' : ''}</span><div class="option-body"><p class="option-description">${escape(o.description)}</p><div class="tradeoffs"><p><strong class="offers">${icon('check')} Offers</strong><span>${escape(o.benefit)}</span></p><p><strong class="tradeoff">${icon('balance')} Trade-off</strong><span>${escape(o.tradeoff)}</span></p></div></div></label>`).join('')}</fieldset>` : ''}
129
+ <label for="answer-text" class="field-label">${q.type === 'text' ? 'Your answer' : 'Your answer <span class="optional">In your own words</span>'}</label><textarea id="answer-text" aria-describedby="answer-keyboard-hint" maxlength="20000" rows="${q.type === 'text' ? 7 : 3}">${escape(draft.text)}</textarea><p class="field-hint" id="answer-keyboard-hint">Type normally. <kbd>Esc</kbd> returns to navigation.</p>
98
130
  <div class="question-actions"><button class="button" data-action="defer">${icon('clock')}Answer later</button><button class="button" data-action="clear" ${hasAnswer(draft) ? '' : 'disabled'}>${icon('clear')}Clear answer</button></div></article></div></main>
99
- </div><footer class="footer-bar"><nav class="footer-buttons ${index === state.questionnaire.questions.length - 1 ? 'last-question' : ''}" aria-label="Question navigation"><button class="button previous-question" ${index > 0 ? `data-go="${escape(state.questionnaire.questions[index - 1].id)}"` : 'disabled'} aria-label="Previous question">${icon('left')}<span>Previous</span></button><button class="button ${index === state.questionnaire.questions.length - 1 ? 'primary' : ''}" id="submit-form" data-action="submit">${icon('check')}Submit form</button>${index < state.questionnaire.questions.length - 1 ? `<button class="button primary" data-go="${escape(state.questionnaire.questions[index + 1].id)}">Next question ${icon('right')}</button>` : ''}</nav></footer>`;
131
+ </div><footer class="footer-bar"><div class="keyboard-bar"><span id="keyboard-mode" class="keyboard-mode" role="status"></span><span class="keyboard-hint"><kbd>↑</kbd> / <kbd>↓</kbd> move · <kbd>←</kbd> / <kbd>→</kbd> questions</span><button class="keyboard-help" data-action="keys" aria-haspopup="dialog" aria-controls="app-dialog">Keys <kbd>?</kbd></button></div><nav class="footer-buttons ${index === state.questionnaire.questions.length - 1 ? 'last-question' : ''}" aria-label="Question navigation"><button class="button previous-question" ${index > 0 ? `data-go="${escape(state.questionnaire.questions[index - 1].id)}"` : 'disabled'} aria-label="Previous question">${icon('left')}<span>Previous</span></button><button class="button ${index === state.questionnaire.questions.length - 1 ? 'primary' : ''}" id="submit-form" data-action="submit">${icon('check')}Submit form</button>${index < state.questionnaire.questions.length - 1 ? `<button class="button primary" data-go="${escape(state.questionnaire.questions[index + 1].id)}">Next question ${icon('right')}</button>` : ''}</nav></footer>`;
100
132
  renderChrome();
101
133
  document.querySelector('.main').scrollTop = mainScroll;
102
134
  document.querySelector('#sidebar-questions').scrollTop = sidebarScroll;
103
- if (textFocus) { const field = document.querySelector('#answer-text'); field.focus({ preventScroll: true }); field.setSelectionRange(textFocus.start, textFocus.end); }
135
+ if (focused && !restoreFocus(focused)) focusControl(document.querySelector('#question-title'));
136
+ updateKeyboardMode();
104
137
  }
105
138
 
106
139
  function stage(action, redraw = true) {
@@ -175,18 +208,21 @@ app.addEventListener('click', event => {
175
208
  if (action === 'submit') submitForm();
176
209
  if (action === 'questions') showQuestions();
177
210
  if (action === 'history') showHistory();
211
+ if (action === 'keys') showKeys();
178
212
  if (action === 'recover') downloadRecovery();
179
213
  if (action === 'retry') retry();
180
214
  if (action === 'load-saved') loadSaved();
181
215
  });
182
216
 
183
217
  function openDialog(html, mode) {
218
+ const focused = dialog.open && modalMode === mode ? rememberFocus(dialog) : null;
219
+ if (!dialog.open) dialogReturnFocus = rememberFocus(document);
184
220
  modalMode = mode;
185
221
  dialog.innerHTML = html;
186
222
  if (!dialog.open) dialog.showModal();
187
223
  const heading = dialog.querySelector('#dialog-title');
188
224
  heading.tabIndex = -1;
189
- heading.focus({ preventScroll: true });
225
+ if (!restoreFocus(focused, dialog)) heading.focus({ preventScroll: true });
190
226
  }
191
227
  const dialogHead = (title, description = '') => `<div class="dialog-head"><div><h2 id="dialog-title">${title}</h2>${description ? `<p>${description}</p>` : ''}</div><button class="close" data-modal="close" aria-label="Close dialog">×</button></div>`;
192
228
 
@@ -205,6 +241,104 @@ function questionItems() {
205
241
  }).join('');
206
242
  }
207
243
 
244
+ function showKeys() {
245
+ const shortcuts = [
246
+ ['↓ / ↑', 'Focus the next / previous option or button'],
247
+ ['← / →', 'Previous / next question'],
248
+ ['j / k', 'Focus the next / previous option or button'],
249
+ ['gg / G', 'Focus the first / last control'],
250
+ ['Enter / Space', 'Activate the focused control'],
251
+ ['h / l', 'Previous / next question'],
252
+ ['1–9', 'Choose or toggle an option'],
253
+ ['i', 'Write your answer or notes'],
254
+ ['Esc', 'Leave the text field or close a dialog'],
255
+ ['Ctrl / ⌘ + Enter', 'Submit the entire form, including while typing'],
256
+ ['Ctrl + d / u', 'Scroll down / up half a page'],
257
+ ['q', 'Open the question picker'],
258
+ ['H', 'Open submission history'],
259
+ ['b', 'Toggle the sidebar or open the question picker'],
260
+ ['d', 'Answer later'],
261
+ ['x', 'Clear this answer and its notes'],
262
+ ['?', 'Open this guide'],
263
+ ];
264
+ openDialog(`${dialogHead('Keyboard shortcuts', 'Arrow keys and Vim-style navigation. Typing in the answer field uses your normal text editing keys.')}<div class="dialog-body"><p>Use <kbd>↓</kbd> / <kbd>↑</kbd> or <kbd>j</kbd> / <kbd>k</kbd> to reach every form action, including recovery and updated answers. Movement focuses options; <kbd>Enter</kbd> or <kbd>Space</kbd> selects them. In dialogs, navigation stays inside the dialog. Use <kbd>Tab</kbd> / <kbd>Shift + Tab</kbd> to reach text fields and other controls.</p><dl class="shortcut-list">${shortcuts.map(([keys, description]) => `<div><dt><kbd>${keys}</kbd></dt><dd>${description}</dd></div>`).join('')}</dl><label class="shortcut-setting"><input id="vim-enabled" type="checkbox" ${vimEnabled ? 'checked' : ''}> Enable Vim shortcuts</label><p class="field-hint">Arrow navigation, Tab, Enter, Space, Esc, and Ctrl / ⌘ + Enter also work with Vim shortcuts turned off.</p></div><div class="dialog-foot"><button class="button primary" data-modal="close">Back to form</button></div>`, 'keys');
265
+ }
266
+
267
+ const isEditing = element => element?.matches('textarea, select, input:not([type="radio"]):not([type="checkbox"]):not([type="button"]):not([type="submit"])') || element?.isContentEditable;
268
+
269
+ function updateKeyboardMode() {
270
+ document.body.classList.toggle('vim-disabled', !vimEnabled);
271
+ const mode = document.querySelector('#keyboard-mode');
272
+ if (mode) mode.textContent = !vimEnabled ? 'Vim off' : isEditing(document.activeElement) ? 'Insert' : 'Normal';
273
+ }
274
+
275
+ function keyboardControls() {
276
+ const selector = 'button:not(:disabled), input[type="radio"], input[type="checkbox"], summary, a[href], [tabindex="0"]';
277
+ const controls = [...(dialog.open ? dialog : app).querySelectorAll(selector)].filter(element =>
278
+ !element.disabled && element.getClientRects().length && (dialog.open || !element.closest('.sidebar')));
279
+ // Start with dialog content; closing remains reachable at the end.
280
+ return dialog.open ? [...controls.filter(el => !el.matches('.close')), ...controls.filter(el => el.matches('.close'))] : controls;
281
+ }
282
+
283
+ function focusControl(element) {
284
+ element?.focus({ preventScroll: true });
285
+ element?.scrollIntoView({ block: 'nearest' });
286
+ }
287
+
288
+ function runKeyboardCommand(command, value) {
289
+ if (command === 'move' || command === 'edge') {
290
+ const controls = keyboardControls(), index = controls.indexOf(document.activeElement);
291
+ const next = command === 'edge' ? (value < 0 ? 0 : controls.length - 1)
292
+ : index < 0 ? (value > 0 ? 0 : controls.length - 1) : Math.max(0, Math.min(controls.length - 1, index + value));
293
+ return focusControl(controls[next]);
294
+ }
295
+ if (command === 'activate') {
296
+ const element = document.activeElement;
297
+ if (element?.matches('button:not(:disabled), input[type="radio"], input[type="checkbox"], summary, a[href]') && (!dialog.open || dialog.contains(element))) element.click();
298
+ return;
299
+ }
300
+ if (command === 'scroll') {
301
+ const focused = document.activeElement;
302
+ const candidates = dialog.open ? [focused?.closest('.export-text, .question-list'), dialog] : [document.querySelector('.main')];
303
+ const pane = candidates.find(element => element && element.scrollHeight > element.clientHeight);
304
+ pane?.scrollBy({ top: value * pane.clientHeight / 2, behavior: 'instant' });
305
+ return;
306
+ }
307
+ if (command === 'leave-edit') return focusControl(document.querySelector('#question-title'));
308
+ if (command === 'close') return dialog.close();
309
+ if (command === 'help') return showKeys();
310
+ if (!state) return;
311
+ if (command === 'navigate') {
312
+ const questions = state.questionnaire.questions, index = questions.findIndex(q => q.id === currentId);
313
+ if (questions[index + value]) navigate(questions[index + value].id);
314
+ }
315
+ if (command === 'choose') {
316
+ const option = document.querySelectorAll('input[name="answer-option"]')[value];
317
+ if (option) { focusControl(option); option.click(); }
318
+ }
319
+ if (command === 'edit') focusControl(document.querySelector('#answer-text'));
320
+ if (command === 'submit') document.querySelector('#submit-form:not(:disabled)')?.click();
321
+ if (command === 'questions') showQuestions();
322
+ if (command === 'history') showHistory();
323
+ if (command === 'sidebar') document.querySelector('#toggle-sidebar').click();
324
+ if (command === 'clear' || command === 'defer') document.querySelector(`[data-action="${command}"]:not(:disabled)`)?.click();
325
+ }
326
+
327
+ const keyboard = createKeyboard({
328
+ context: () => ({ enabled: vimEnabled, editing: isEditing(document.activeElement), modal: dialog.open }),
329
+ run: runKeyboardCommand,
330
+ });
331
+ document.addEventListener('keydown', keyboard.keydown);
332
+ document.addEventListener('focusin', () => { keyboard.reset(); updateKeyboardMode(); });
333
+ document.addEventListener('focusout', () => queueMicrotask(updateKeyboardMode));
334
+ window.addEventListener('blur', keyboard.reset);
335
+ dialog.addEventListener('change', event => {
336
+ if (event.target.id !== 'vim-enabled') return;
337
+ vimEnabled = event.target.checked;
338
+ try { localStorage.setItem('workbench-vim', vimEnabled ? 'on' : 'off'); } catch { /* The toggle still works for this tab. */ }
339
+ updateKeyboardMode();
340
+ });
341
+
208
342
  function updateSidebarToggle() {
209
343
  document.body.classList.toggle('sidebar-collapsed', sidebarCollapsed);
210
344
  const toggle = document.querySelector('#toggle-sidebar');
@@ -224,7 +358,7 @@ function submitForm() {
224
358
  if (conflict || pending.some(item => item.action.type === 'submit')) return;
225
359
  let snapshot;
226
360
  try {
227
- // Capture exactly what is in the form at the explicit click, including edits
361
+ // Capture exactly what is in the form at explicit submission, including edits
228
362
  // still saving. The existing queue persists those edits before this snapshot.
229
363
  snapshot = makeSubmission(state, crypto.randomUUID(), new Date().toISOString());
230
364
  } catch (failure) {
@@ -261,7 +395,7 @@ async function refreshReceipt() {
261
395
 
262
396
  function showSubmission(submission) {
263
397
  shownSubmission = submission;
264
- openDialog(`${dialogHead('Form submitted', 'Your entire form is saved. You can continue in chat without copying or pasting anything.')}<div class="dialog-body"><div class="success-symbol" aria-hidden="true">✓</div><p id="delivery-status" role="status"></p><details class="submission-details"><summary>View submitted form</summary><pre class="export-text">${escape(formatSubmission(submission))}</pre></details></div><div class="dialog-foot"><button class="button primary" data-modal="close">Back to form</button></div>`, 'submission');
398
+ openDialog(`${dialogHead('Form submitted', 'Your entire form is saved. You can continue in chat without copying or pasting anything.')}<div class="dialog-body"><div class="success-symbol" aria-hidden="true">✓</div><p id="delivery-status" role="status"></p><details class="submission-details"><summary id="submitted-form-details">View submitted form</summary><pre class="export-text" tabindex="0" aria-label="Submitted form text">${escape(formatSubmission(submission))}</pre></details></div><div class="dialog-foot"><button class="button primary" data-modal="close">Back to form</button></div>`, 'submission');
265
399
  render(); renderReceipt(); void refreshReceipt();
266
400
  }
267
401
 
@@ -272,22 +406,23 @@ function showHistory() {
272
406
 
273
407
  dialog.addEventListener('click', async event => {
274
408
  const go = event.target.closest('[data-go]');
275
- if (go) { dialog.close(); navigate(go.dataset.go); return; }
409
+ if (go) { dialogReturnFocus = { selector: '#question-title' }; dialog.close(); navigate(go.dataset.go); return; }
276
410
  const prior = event.target.closest('[data-submission]');
277
411
  if (prior) return showSubmission(state.submissions.find(s => s.id === prior.dataset.submission));
278
412
  const action = event.target.closest('[data-modal]')?.dataset.modal;
279
413
  if (action === 'close') dialog.close();
280
414
  if (action === 'history') showHistory();
415
+ if (action === 'keys') showKeys();
281
416
  if (action === 'recover') downloadRecovery();
282
417
  if (action === 'retry') { modalMode = 'saving'; retry(); }
283
418
 
284
419
  });
285
420
  dialog.addEventListener('close', () => {
286
- const wasQuestionPicker = modalMode === 'questions';
421
+ if (dialog.open) return;
287
422
  modalMode = '';
288
- const candidates = wasQuestionPicker ? ['#question-selector', '#toggle-sidebar'] : ['#submit-form:not(:disabled)', '#history-button:not([hidden])', '#toggle-sidebar'];
289
- const returnTarget = candidates.map(selector => document.querySelector(selector)).find(element => element?.getClientRects().length);
290
- returnTarget?.focus({ preventScroll: true });
423
+ if (!restoreFocus(dialogReturnFocus)) document.querySelector('#question-title')?.focus({ preventScroll: true });
424
+ dialogReturnFocus = null;
425
+ updateKeyboardMode();
291
426
  });
292
427
 
293
428
  function downloadRecovery() {
@@ -0,0 +1,50 @@
1
+ // Keep command interpretation separate from DOM focus and form mutations.
2
+ export function createKeyboard({ context, run, now = Date.now }) {
3
+ let firstG = null;
4
+ const reset = () => { firstG = null; };
5
+ function keydown(event) {
6
+ const { enabled, editing, modal } = context();
7
+ if (event.defaultPrevented || event.isComposing || event.keyCode === 229 || event.altKey) return reset();
8
+ const key = event.key;
9
+ let command, value;
10
+ if (event.ctrlKey || event.metaKey) {
11
+ reset();
12
+ if (key === 'Enter' && !event.shiftKey && !modal) command = 'submit';
13
+ else if (enabled && event.ctrlKey && !event.metaKey && !event.shiftKey && !editing && ['d', 'u'].includes(key)) {
14
+ command = 'scroll'; value = key === 'd' ? 1 : -1;
15
+ } else return;
16
+ } else if (editing) {
17
+ reset();
18
+ if (key !== 'Escape') return;
19
+ command = 'leave-edit';
20
+ } else {
21
+ const previousG = firstG;
22
+ reset();
23
+ if (!event.shiftKey && (key === 'ArrowDown' || key === 'ArrowUp')) { command = 'move'; value = key === 'ArrowDown' ? 1 : -1; }
24
+ else if (!event.shiftKey && (key === 'ArrowLeft' || key === 'ArrowRight')) {
25
+ if (modal) return;
26
+ command = 'navigate'; value = key === 'ArrowRight' ? 1 : -1;
27
+ }
28
+ else if (key === 'Enter' && !event.shiftKey) command = 'activate';
29
+ else if (key === 'Escape') command = modal ? 'close' : 'leave-edit';
30
+ else if (!enabled) return;
31
+ else if (key === 'g') {
32
+ if (event.repeat) { event.preventDefault(); return; }
33
+ if (previousG !== null && now() - previousG < 1000) { command = 'edge'; value = -1; }
34
+ else { firstG = now(); event.preventDefault(); return; }
35
+ } else if (key === 'G') { command = 'edge'; value = 1; }
36
+ else if (key === 'j' || key === 'k') { command = 'move'; value = key === 'j' ? 1 : -1; }
37
+ else if (!modal) {
38
+ if (key === 'h' || key === 'l') { command = 'navigate'; value = key === 'l' ? 1 : -1; }
39
+ else if (/^[1-9]$/.test(key)) { command = 'choose'; value = Number(key) - 1; }
40
+ else command = { i: 'edit', q: 'questions', H: 'history', b: 'sidebar', d: 'defer', x: 'clear', '?': 'help' }[key];
41
+ }
42
+ }
43
+ if (!command) return;
44
+ event.preventDefault();
45
+ // Holding a key may move the cursor, but must not toggle, clear or submit twice.
46
+ if (event.repeat && !['move', 'navigate', 'scroll'].includes(command)) return;
47
+ run(command, value);
48
+ }
49
+ return { keydown, reset };
50
+ }
package/public/styles.css CHANGED
@@ -24,7 +24,7 @@ button, a, input, textarea { touch-action: manipulation; }
24
24
  button { cursor: pointer; border: 0; background: none; color: inherit; }
25
25
  button:disabled { cursor: not-allowed; opacity: .42; }
26
26
  a { color: inherit; text-decoration: none; }
27
- button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, summary:focus-visible {
27
+ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, summary:focus-visible, [tabindex="0"]:focus-visible {
28
28
  outline: 3px solid #57b3bf; outline-offset: 3px;
29
29
  }
30
30
  p { line-height: 1.55; margin: 0 0 1rem; }
@@ -90,6 +90,7 @@ h3 { font-size: 1rem; }
90
90
  .option:last-child { border-bottom: 0; }
91
91
  .option:hover { background: #f8fbfb; }
92
92
  .option.selected { background: #f0f9f9; box-shadow: inset 3px 0 var(--blue); }
93
+ .option:has(input:focus-visible) { outline: 3px solid #57b3bf; outline-offset: -3px; }
93
94
  .option-top { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
94
95
  .option input { accent-color: var(--blue); height: 22px; width: 22px; flex: 0 0 22px; margin: 1px 0 0; }
95
96
  .option-title { font-size: 1.0625rem; line-height: 1.4; font-weight: 600; flex: 1; min-width: 120px; }
@@ -113,6 +114,19 @@ textarea::placeholder { color: #7c878c; }
113
114
  .button.small { padding: 6px 10px; min-height: 34px; }
114
115
  .question-actions .button { min-width: 145px; }
115
116
  .footer-bar { background: white; border-top: 1px solid #cbd3d7; padding: 19px 22px max(19px, env(safe-area-inset-bottom)); z-index: 5; }
117
+ .keyboard-bar { display: flex; align-items: center; gap: 12px; margin: -8px 0 10px; font-size: .75rem; color: var(--muted); }
118
+ .keyboard-mode { font-size: .6875rem; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; color: var(--blue); }
119
+ .keyboard-help { margin-left: auto; padding: 4px 6px; border-radius: 3px; white-space: nowrap; }
120
+ .keyboard-help:hover { background: var(--blue-soft); }
121
+ kbd { font: inherit; font-family: ui-monospace, monospace; border: 1px solid var(--line); border-radius: 3px; padding: 1px 4px; white-space: nowrap; }
122
+ .option-key { color: var(--muted); font-size: .75rem; margin-top: 3px; }
123
+ .field-hint { font-size: .75rem; color: var(--muted); margin: 8px 0 0; }
124
+ .shortcut-list { margin: 20px 0; }
125
+ .shortcut-list > div { display: grid; grid-template-columns: 155px minmax(0, 1fr); gap: 14px; padding: 10px 0; border-bottom: 1px solid var(--line); font-size: .875rem; line-height: 1.5; }
126
+ .shortcut-list dd { margin: 0; }
127
+ .shortcut-setting { display: flex; align-items: center; gap: 10px; font-size: .875rem; }
128
+ .shortcut-setting input { width: 20px; height: 20px; accent-color: var(--blue); }
129
+ .vim-disabled .option-key { display: none; }
116
130
  .footer-buttons { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 30px; }
117
131
  .footer-buttons.last-question { grid-template-columns: repeat(2, minmax(0, 1fr)); }
118
132
  .footer-buttons .button { min-height: 46px; }
@@ -196,6 +210,8 @@ dialog::backdrop { background: #111c2470; }
196
210
  .toast { bottom: 90px; }
197
211
  }
198
212
  @media (max-width: 420px) {
213
+ .keyboard-hint { display: none; }
214
+ .shortcut-list > div { grid-template-columns: 1fr; gap: 7px; }
199
215
  .topbar { grid-template-columns: 146px minmax(0, 1fr); }
200
216
  .brand-area { padding: 0 8px; gap: 2px; }
201
217
  .brand { font-size: .9375rem; }
@@ -19,10 +19,10 @@ skill on its own.
19
19
  Before presenting a round, read [the agent protocol](references/agent-protocol.md).
20
20
  When authoring or changing questions, read [the question format](references/questions.md).
21
21
 
22
- Run `npx --yes grilling-workbench@0.2.0` for every CLI command. This uses the
22
+ Run `npx --yes grilling-workbench@0.3.0` for every CLI command. This uses the
23
23
  public npm package maintained at `https://github.com/PavingLayer/grilling-workbench`
24
24
  without adding a project dependency. Check `--version`; this skill ships with
25
- package 0.2.0 and socket protocol 1. Keep the exact version throughout a round;
25
+ package 0.3.0 and socket protocol 1. Keep the exact version throughout a round;
26
26
  do not use `@latest` or an unversioned command. An explicitly configured local or
27
27
  global installation is also supported when its version matches. If installation
28
28
  or registry access fails, report it rather than substituting manual copy/paste.
@@ -1,6 +1,6 @@
1
1
  # Agent protocol
2
2
 
3
- Use `npx --yes grilling-workbench@0.2.0` throughout the round. An explicitly
3
+ Use `npx --yes grilling-workbench@0.3.0` throughout the round. An explicitly
4
4
  configured local or global executable at the same version is also supported.
5
5
  Commands emit JSON to stdout, diagnostics/readiness to stderr, and exit nonzero on
6
6
  failure. No command makes model calls or writes to the chat or issue tracker.
@@ -11,9 +11,9 @@ failure. No command makes model calls or writes to the chat or issue tracker.
11
11
  `.workbench/checkout-design-r01`. This names one chat's round, not a global inbox.
12
12
 
13
13
  ```sh
14
- npx --yes grilling-workbench@0.2.0 validate --questions /absolute/path/round.json
15
- npx --yes grilling-workbench@0.2.0 init --session .workbench/checkout-design-r01 --questions /absolute/path/round.json
16
- npx --yes grilling-workbench@0.2.0 serve --session .workbench/checkout-design-r01
14
+ npx --yes grilling-workbench@0.3.0 validate --questions /absolute/path/round.json
15
+ npx --yes grilling-workbench@0.3.0 init --session .workbench/checkout-design-r01 --questions /absolute/path/round.json
16
+ npx --yes grilling-workbench@0.3.0 serve --session .workbench/checkout-design-r01
17
17
  ```
18
18
 
19
19
  Keep `serve` running in a persistent process session. Wait for its `ready` JSON;
@@ -24,7 +24,7 @@ failure. No command makes model calls or writes to the chat or issue tracker.
24
24
  2. Start a second persistent process before showing the form:
25
25
 
26
26
  ```sh
27
- npx --yes grilling-workbench@0.2.0 wait --session .workbench/checkout-design-r01
27
+ npx --yes grilling-workbench@0.3.0 wait --session .workbench/checkout-design-r01
28
28
  ```
29
29
 
30
30
  The command authenticates to this session's loopback TCP socket. The stderr
@@ -52,7 +52,7 @@ failure. No command makes model calls or writes to the chat or issue tracker.
52
52
  5. Once the snapshot is in the current agent's context, record receipt:
53
53
 
54
54
  ```sh
55
- npx --yes grilling-workbench@0.2.0 ack SUBMISSION_ID --session .workbench/checkout-design-r01
55
+ npx --yes grilling-workbench@0.3.0 ack SUBMISSION_ID --session .workbench/checkout-design-r01
56
56
  ```
57
57
 
58
58
  Use the actual returned ID. Never auto-ack inside the listener or before the
@@ -70,7 +70,7 @@ failure. No command makes model calls or writes to the chat or issue tracker.
70
70
  `init` copies the source definitions into the session. To revise the live form:
71
71
 
72
72
  ```sh
73
- npx --yes grilling-workbench@0.2.0 update --session .workbench/checkout-design-r01 --questions /absolute/path/revised-round.json
73
+ npx --yes grilling-workbench@0.3.0 update --session .workbench/checkout-design-r01 --questions /absolute/path/revised-round.json
74
74
  ```
75
75
 
76
76
  Keep the questionnaire ID for the same round. The command validates and atomically
package/src/server.js CHANGED
@@ -8,7 +8,7 @@ import { readReceipts } from './delivery.js';
8
8
  export { atomicWrite } from './storage.js';
9
9
 
10
10
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
11
- const publicFiles = { '/': ['public/index.html', 'text/html'], '/app.js': ['public/app.js', 'text/javascript'], '/styles.css': ['public/styles.css', 'text/css'], '/core.js': ['src/core.js', 'text/javascript'] };
11
+ const publicFiles = { '/': ['public/index.html', 'text/html'], '/app.js': ['public/app.js', 'text/javascript'], '/keyboard.js': ['public/keyboard.js', 'text/javascript'], '/styles.css': ['public/styles.css', 'text/css'], '/core.js': ['src/core.js', 'text/javascript'] };
12
12
 
13
13
  export function createWorkbenchServer({ dataDir = join(root, '.workbench'), questionsPath = join(root, 'data/questions.json'), write = atomicWrite } = {}) {
14
14
  const statePath = join(dataDir, 'session.json');