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 +33 -10
- package/docs/deployment.md +41 -20
- package/docs/integration.md +22 -18
- package/docs/release-0.3.0.md +28 -0
- package/docs/validation.md +72 -2
- package/package.json +2 -2
- package/public/app.js +152 -17
- package/public/keyboard.js +50 -0
- package/public/styles.css +17 -1
- package/skills/grilling-workbench/SKILL.md +2 -2
- package/skills/grilling-workbench/references/agent-protocol.md +7 -7
- package/src/server.js +1 -1
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
|
|
21
|
+
npx skills@latest add PavingLayer/grilling-workbench \
|
|
22
|
+
--skill grilling-workbench --agent codex
|
|
22
23
|
```
|
|
23
24
|
|
|
24
|
-
The
|
|
25
|
-
|
|
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.
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
43
|
-
npx --yes grilling-workbench@0.
|
|
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.
|
|
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
|
|
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
|
package/docs/deployment.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Local deployment
|
|
2
2
|
|
|
3
|
-
Version 0.
|
|
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
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
98
|
-
npx --yes grilling-workbench@0.
|
|
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.
|
|
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;
|
package/docs/integration.md
CHANGED
|
@@ -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
|
|
17
|
+
npx skills@latest add PavingLayer/grilling-workbench \
|
|
18
|
+
--skill grilling-workbench --agent codex
|
|
18
19
|
```
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
teach the agent when and how to use
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
The
|
|
142
|
-
|
|
143
|
-
|
|
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.
|
package/docs/validation.md
CHANGED
|
@@ -6,12 +6,24 @@ use the [deployment guide](deployment.md).
|
|
|
6
6
|
|
|
7
7
|
## Automated checks
|
|
8
8
|
|
|
9
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 (
|
|
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
|
|
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
|
-
|
|
421
|
+
if (dialog.open) return;
|
|
287
422
|
modalMode = '';
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
15
|
-
npx --yes grilling-workbench@0.
|
|
16
|
-
npx --yes grilling-workbench@0.
|
|
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.
|
|
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.
|
|
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.
|
|
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');
|