|
|
|
|
@@ -0,0 +1,302 @@
|
|
|
|
|
---
|
|
|
|
|
phase: quick-261022-k9k
|
|
|
|
|
plan: 01
|
|
|
|
|
quick_id: 261022-k9k
|
|
|
|
|
slug: keyboard-shortcuts-in-the-admin-spa-ctrl
|
|
|
|
|
type: execute
|
|
|
|
|
mode: quick
|
|
|
|
|
wave: 1
|
|
|
|
|
depends_on: []
|
|
|
|
|
autonomous: true
|
|
|
|
|
requirements:
|
|
|
|
|
- K9K-01 # D1: ctrl/cmd+s = Save (update) / the create button (create) on FormView, settings forms, AccountView; browser "Save page" prevented
|
|
|
|
|
- K9K-02 # D1: ctrl/cmd+enter = Save and close on FormView (create and update, singleton)
|
|
|
|
|
- K9K-03 # D1: ctrl/cmd+shift+s + new "Create and new" footer button on create forms (backend::lang.form.create_and_new): save, then a blank create form of the same model
|
|
|
|
|
- K9K-04 # D1: shortcuts respect the buttons' disabled state (busy, loading, active uploads)
|
|
|
|
|
- K9K-05 # D2: "/" focuses the list search; Esc on record forms = Cancel through the dirty-form confirmation unless a dialog/popover/dropdown/link popover is open; "?" opens a help dialog of the current screen's shortcuts
|
|
|
|
|
- K9K-06 # D2: save buttons show their shortcut in a tooltip (Save (Ctrl+S), ⌘ on macOS)
|
|
|
|
|
- K9K-07 # D3: with a modal form open, form shortcuts reach only the innermost dialog's form
|
|
|
|
|
- K9K-08 # D4: modifier shortcuts fire in inputs, textareas, code fields and the rich editor; plain keys never fire in an editable control
|
|
|
|
|
- K9K-09 # constraints: no new npm dependency, lang strings through phrasebook YAML, docs updated with the feature, dist rebuilt and check-admin-dist.sh green
|
|
|
|
|
files_modified:
|
|
|
|
|
# Task 1: hotkey core, record form save keys, Create and new, strings, forms doc, tracer test
|
|
|
|
|
- admin/src/app/hotkeys.ts
|
|
|
|
|
- admin/src/components/ui/Button.vue
|
|
|
|
|
- admin/src/components/shell/AppShell.vue
|
|
|
|
|
- admin/src/App.vue
|
|
|
|
|
- admin/src/state/useRemount.ts
|
|
|
|
|
- admin/src/views/FormView.vue
|
|
|
|
|
- modules/phrasebook/backend/lang/en/lang.yaml
|
|
|
|
|
- modules/phrasebook/backend/lang/pl/lang.yaml
|
|
|
|
|
- admin/tests/fixtures/lang.json
|
|
|
|
|
- admin/tests/smoke/shortcuts.smoke.test.ts
|
|
|
|
|
- docs/backend/forms.md
|
|
|
|
|
# Task 2: remaining surfaces, help dialog, admin SPA doc
|
|
|
|
|
- admin/src/components/shell/ShortcutHelpDialog.vue
|
|
|
|
|
- admin/src/components/list/ListToolbar.vue
|
|
|
|
|
- admin/src/views/SettingsFormView.vue
|
|
|
|
|
- admin/src/views/AccountView.vue
|
|
|
|
|
- admin/src/components/relation/RelationChildModal.vue
|
|
|
|
|
- admin/src/components/relation/RelationPivotModal.vue
|
|
|
|
|
- admin/src/components/relation/RelationPickerModal.vue
|
|
|
|
|
- admin/src/components/form/fields/FileCaptionModal.vue
|
|
|
|
|
- admin/src/components/account/ApiTokenCreateDialog.vue
|
|
|
|
|
- docs/backend/admin-spa.md
|
|
|
|
|
# Task 3: tests, then the embedded build
|
|
|
|
|
- admin/tests/helpers.ts
|
|
|
|
|
- admin/tests/app/hotkeys.test.ts
|
|
|
|
|
- admin/tests/shell/ShortcutHelpDialog.test.ts
|
|
|
|
|
- modules/boardwalk/dist
|
|
|
|
|
|
|
|
|
|
estimate:
|
|
|
|
|
tokens: 150000
|
|
|
|
|
raw_tokens: 150000
|
|
|
|
|
tasks: 3
|
|
|
|
|
confidence: low # estimate-calibration: factor 1, sample_count 0
|
|
|
|
|
|
|
|
|
|
must_haves:
|
|
|
|
|
truths:
|
|
|
|
|
- "On an update record form (/acme/demo/widgets/1), keydown Ctrl+S or Cmd+S anywhere (body, a text input, a textarea, the rich editor) sends exactly one PUT to the record, the event is defaultPrevented (no browser Save page), and the form stays; Ctrl+Enter sends the PUT and then follows update.redirectClose (singleton: /settings)."
|
|
|
|
|
- "On a create form (/acme/demo/widgets/create), Ctrl+S POSTs and follows create.redirect, Ctrl+Enter POSTs and follows create.redirectClose, and the footer has a new outline button [data-action=\"save-new\"] labelled backend::lang.form.create_and_new; that button and Ctrl+Shift+S POST the record and then show an empty create form at the same URL, whose next save carries a different X-Session-Key."
|
|
|
|
|
- "No shortcut clicks a disabled button: while busy, loading, schema missing or an upload is active, Ctrl+S / Ctrl+Enter / Ctrl+Shift+S send nothing; auto-repeat (event.repeat) never triggers a second save."
|
|
|
|
|
- "With a modal form open (relation picker, child, pivot, file caption, API token create), Ctrl+S and Ctrl+Enter trigger only that modal's primary button; the record form underneath sends no PUT. With a ConfirmDialog or the shortcut help dialog innermost, Ctrl+S triggers nothing (but is still defaultPrevented)."
|
|
|
|
|
- "On a settings form Ctrl+S sends PUT /settings/{code}; on My account Ctrl+S sends PUT /account."
|
|
|
|
|
- "Esc on a record form with focus outside input/textarea/select/contenteditable and no Reka layer open acts as Cancel: a clean form navigates to its back path, a dirty form shows the existing discard confirmation. Esc in an input, with a dialog, popover, dropdown or the rich-editor link popover open, or after a component already handled it (defaultPrevented) does nothing to the form."
|
|
|
|
|
- "'/' on a list with search focuses input[data-list-search] without typing the slash; '?' opens a dialog [data-shortcut-help] that lists exactly the shortcuts present on the current screen plus '?'; neither fires while focus is in an editable control."
|
|
|
|
|
- "Save buttons carry title 'Zapisz (Ctrl+S)' style hints (macOS: '⌘S', '⌘↩', '⇧⌘S') and an aria-keyshortcuts attribute listing both Control and Meta forms."
|
|
|
|
|
- "modules/boardwalk/dist matches a fresh build of admin/ (scripts/check-admin-dist.sh exits 0); no npm dependency was added (admin/package.json and package-lock.json unchanged)."
|
|
|
|
|
artifacts:
|
|
|
|
|
- path: admin/src/app/hotkeys.ts
|
|
|
|
|
provides: "Dependency-free hotkey core: combo parsing, platform labels, innermost-modal root, one refcounted window listener pair, help-dialog state"
|
|
|
|
|
contains: "installHotkeys"
|
|
|
|
|
- path: admin/src/components/ui/Button.vue
|
|
|
|
|
provides: "hotkey prop rendering data-hotkey and aria-keyshortcuts on button and RouterLink"
|
|
|
|
|
contains: "data-hotkey"
|
|
|
|
|
- path: admin/src/state/useRemount.ts
|
|
|
|
|
provides: "remountView() + remountRevision used by App.vue's view key for Create and new"
|
|
|
|
|
contains: "remountView"
|
|
|
|
|
- path: admin/src/components/shell/ShortcutHelpDialog.vue
|
|
|
|
|
provides: "'?' help dialog listing the current screen's shortcuts"
|
|
|
|
|
contains: "data-shortcut-help"
|
|
|
|
|
- path: admin/tests/app/hotkeys.test.ts
|
|
|
|
|
provides: "unit tests of the hotkey core"
|
|
|
|
|
- path: modules/boardwalk/dist
|
|
|
|
|
provides: "embedded admin build with the shortcuts"
|
|
|
|
|
key_links:
|
|
|
|
|
- from: admin/src/components/shell/AppShell.vue
|
|
|
|
|
to: admin/src/app/hotkeys.ts
|
|
|
|
|
via: "installHotkeys() on mount, its uninstall on unmount; ShortcutHelpDialog mounted next to Toast"
|
|
|
|
|
- from: admin/src/app/hotkeys.ts
|
|
|
|
|
to: "buttons carrying data-hotkey (FormView, SettingsFormView, AccountView, modal primary buttons, ListToolbar search)"
|
|
|
|
|
via: "the listener resolves the innermost aria-modal dialog (else document), finds the first [data-hotkey~=combo] element there and clicks it (focuses it for an input) unless it is disabled"
|
|
|
|
|
- from: admin/src/views/FormView.vue
|
|
|
|
|
to: admin/src/App.vue
|
|
|
|
|
via: "onSaveAndNew -> remountView() bumps remountRevision, which is part of the RouterView component key, so the create form remounts blank"
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
<objective>
|
|
|
|
|
Keyboard shortcuts for the admin SPA, modelled on WinterCMS `data-hotkey` (the hotkey clicks the toolbar button that carries it): Ctrl/Cmd+S, Ctrl/Cmd+Enter and Ctrl/Cmd+Shift+S on every save surface, a new "Create and new" button on create forms, `/` for list search, Esc as Cancel on record forms, `?` for a help dialog, and shortcut hints on save buttons. Modal forms take the save shortcuts from the page underneath (D3). Plain keys stay out of editable controls; modifier keys work everywhere (D4).
|
|
|
|
|
|
|
|
|
|
Purpose: WinterCMS parity for administrators who save with the keyboard, plus the help and search keys the user asked for. All locked decisions in 261022-k9k-CONTEXT.md (D1 save keys, D2 extras, D3 modals, D4 editors) are implemented as written.
|
|
|
|
|
|
|
|
|
|
Output: a dependency-free hotkey module, wiring on FormView / SettingsFormView / AccountView / ListToolbar / five modals, a help dialog, a Create-and-new remount, new phrasebook strings (en, pl), docs in docs/backend/forms.md and docs/backend/admin-spa.md, vitest coverage, and a rebuilt modules/boardwalk/dist. SPA plus phrasebook YAML only: no API, pact or Go code change.
|
|
|
|
|
</objective>
|
|
|
|
|
|
|
|
|
|
<execution_context>
|
|
|
|
|
@~/.claude/msd-core/workflows/execute-plan.md
|
|
|
|
|
@~/.claude/msd-core/templates/summary.md
|
|
|
|
|
</execution_context>
|
|
|
|
|
|
|
|
|
|
<context>
|
|
|
|
|
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/CLAUDE.md
|
|
|
|
|
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/STATE.md
|
|
|
|
|
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261022-k9k-keyboard-shortcuts-in-the-admin-spa-ctrl/261022-k9k-CONTEXT.md
|
|
|
|
|
|
|
|
|
|
Source files the executor reads before editing:
|
|
|
|
|
@admin/src/views/FormView.vue
|
|
|
|
|
@admin/src/components/ui/Button.vue
|
|
|
|
|
@admin/src/components/shell/AppShell.vue
|
|
|
|
|
@admin/src/App.vue
|
|
|
|
|
@admin/src/app/i18n.ts
|
|
|
|
|
@admin/src/components/form/fields/FileCaptionModal.vue (smallest modal; markup to mirror for the help dialog)
|
|
|
|
|
@admin/tests/helpers.ts
|
|
|
|
|
@admin/tests/smoke/edit.smoke.test.ts
|
|
|
|
|
@modules/phrasebook/backend/lang/en/lang.yaml
|
|
|
|
|
@modules/phrasebook/backend/lang/pl/lang.yaml
|
|
|
|
|
@../examples/golem15-wintercms-starter/modules/backend/behaviors/formcontroller/partials/_toolbar.php
|
|
|
|
|
|
|
|
|
|
Facts established during planning (do not re-derive):
|
|
|
|
|
- Concurrency: at planning time another session (phase 19.6.1 plan 05, gap G-19.6.1-T2) had uncommitted edits in admin/src/views/FormView.vue, admin/src/components/relation/RelationChildModal.vue, admin/src/components/form/formContext.ts, admin/src/components/form/fields/RelationField.vue and admin/tests/form/FormView.test.ts. Do not start until they are committed (see the Task 1 precondition). Refer to FormView by function names (save, onSave, onSaveAndClose, onLeave, guard), never by line numbers.
|
|
|
|
|
- Reka (reka-ui 2.9.10) DismissableLayer, used by every Dialog, AlertDialog, Popover, DropdownMenu, Select and Combobox content, renders a data-dismissable-layer attribute and listens for Escape with a plain window keydown listener (via its bundled @vueuse onKeyStroke) and does NOT call preventDefault when it dismisses. So "a layer consumes Esc" cannot be detected through defaultPrevented; the Esc rule checks the DOM for any [data-dismissable-layer] element instead.
|
|
|
|
|
- Every admin modal sets aria-modal="true" on its Reka DialogContent (RelationChildModal, RelationPivotModal, RelationPickerModal, FileCaptionModal, ApiTokenCreateDialog). ConfirmDialog is a Reka AlertDialog (role alertdialog). Reka PopoverContent also has role dialog but no aria-modal (LinkPopover, date pickers): a popover is NOT a modal root. Portals append to body in open order, so the last matching modal in document order is the innermost.
|
|
|
|
|
- Non-Reka components that handle Escape already call preventDefault (SectionFlyout, PluginRail, RelationField when its list is open), and they handle it on the element, so a window listener in the bubble phase sees defaultPrevented.
|
|
|
|
|
- TipTap StarterKit (admin/src/components/form/richeditor/editor.ts) keeps HardBreak (Mod-Enter) and Strike (Mod-Shift-s) enabled and handles keydown on the editor element. To make D4 hold (modifier shortcuts fire inside the rich editor) without TipTap also inserting a hard break or toggling strike, the modifier listener runs in the window CAPTURE phase and calls preventDefault and stopPropagation whenever a matching hotkey element exists in the active root. When no element carries the combo (for example Ctrl+Shift+S on an update form), the event passes through untouched, so TipTap strike and hard break keep working there.
|
|
|
|
|
- AltGr on Windows/Linux arrives as ctrlKey plus altKey (Polish layout: AltGr+S types the s-acute letter). Modifier combos therefore require altKey false.
|
|
|
|
|
- App.vue keys the routed view by current.path, so pushing the same create path does not remount FormView. Create and new remounts through a revision counter appended to that key.
|
|
|
|
|
- Buttons in FormView's footer are the shared Button component; attrs such as title fall through to the rendered button or RouterLink. In FileCaptionModal, RelationPivotModal and RelationPickerModal the primary buttons are native button elements (data-action save-caption, save-pivot, confirm-link); RelationChildModal (save-child) and ApiTokenCreateDialog (create-token-submit, type submit, step 1 only) use Button.
|
|
|
|
|
- Strings: framework admin strings live in modules/phrasebook/backend/lang/{en,pl}/lang.yaml as backend::lang.group.key. modules/phrasebook TestPhase10SPAKeysResolve scans every .ts/.vue under admin/src with the regex backend::lang followed by dot-separated segments and requires each match to resolve to a leaf in both pl and en. Consequence: every backend::lang key literal written in admin/src (comments included) must be a full leaf key that exists in both YAML files; never write a group prefix or a wildcard after backend::lang in a comment. A YAML value that starts with a colon must be quoted.
|
|
|
|
|
- admin/tests/fixtures/lang.json is a hand-maintained Polish bundle (keys sorted alphabetically, each value an object with "other"); tests call setBundle(lang.data, 'pl'). New keys must be added there in Polish.
|
|
|
|
|
- Existing Polish copy: form.save Zapisz, form.save_and_close Zapisz i zamknij, form.cancel Anuluj, form.create Utwórz, form.close Zamknij. Create forms keep their current Save / Save and close labels (this plan does not relabel them; see the review note in Task 1).
|
|
|
|
|
- Smoke tests mount the whole App through mountApp(path, routes, { attach: true }) from admin/tests/helpers.ts; attach is required because the hotkey listener queries document. The widgets fixtures route GET /admin-test/api/v1/acme/demo/widgets/schema/form and GET .../widgets/1 (see edit.smoke.test.ts).
|
|
|
|
|
- vitest runs with cwd admin/ through npm --prefix admin; test filters are relative to admin/. Never run npm ci (shared node_modules); check-admin-dist.sh runs it only when admin/node_modules is missing.
|
|
|
|
|
- Docs checker: only pkg.Ident-shaped code spans are checked as identifiers; key names such as Ctrl+S and attribute names are safe. Relative links and anchors are checked.
|
|
|
|
|
</context>
|
|
|
|
|
|
|
|
|
|
<tasks>
|
|
|
|
|
|
|
|
|
|
<task type="tracer">
|
|
|
|
|
<name>Task 1: Hotkey core and record-form save keys end to end (Ctrl+S, Ctrl+Enter, Ctrl+Shift+S with Create and new)</name>
|
|
|
|
|
<precondition>git status --porcelain -- admin modules/boardwalk/dist modules/phrasebook docs prints nothing (the 19.6.1-05 session has committed its FormView, RelationChildModal, formContext, RelationField and FormView.test.ts edits); admin/node_modules exists.</precondition>
|
|
|
|
|
<files>admin/src/app/hotkeys.ts, admin/src/components/ui/Button.vue, admin/src/components/shell/AppShell.vue, admin/src/App.vue, admin/src/state/useRemount.ts, admin/src/views/FormView.vue, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/tests/fixtures/lang.json, admin/tests/smoke/shortcuts.smoke.test.ts, docs/backend/forms.md</files>
|
|
|
|
|
<action>
|
|
|
|
|
Implements D1 (save keys, disabled state, preventDefault, Create and new), the D3 root rule and the D4 editor rule in the core; Task 2 wires the remaining surfaces. No npm dependency (no hotkeys-js, no direct @vueuse import).
|
|
|
|
|
|
|
|
|
|
1. admin/src/app/hotkeys.ts (new, no imports beyond vue and ./i18n). Header comment in the project's style: the WinterCMS model (a data-hotkey attribute on the button, the hotkey clicks it), the supported combos, the modal root rule (D3), the editable rule (D4), why the modifier listener is in the capture phase (TipTap Mod-Enter / Mod-Shift-s, see context facts) and why the Esc check reads the DOM (Reka does not preventDefault). Exports:
|
|
|
|
|
- type HotkeyCombo, the union of the six tokens mod+s, mod+enter, mod+shift+s, escape, the slash and the question mark. Tokens in a data-hotkey attribute are space separated; mod means Ctrl or Cmd, both accepted on every platform (WinterCMS binds ctrl+s and cmd+s).
|
|
|
|
|
- SHORTCUTS: readonly ordered list of objects with combo and labelKey, in this order: mod+s with backend::lang.form.save, mod+enter with backend::lang.form.save_and_close, mod+shift+s with backend::lang.form.create_and_new, escape with backend::lang.shortcuts.leave, the slash with backend::lang.shortcuts.search, the question mark with backend::lang.shortcuts.help. Write each key as a full string literal.
|
|
|
|
|
- isMacPlatform(platform?: string): true when the given string (default: navigator.platform when navigator exists, else empty) matches mac, iphone, ipad or ipod case-insensitively. Do not use navigator.userAgentData: TypeScript 5.9's DOM lib does not declare it and navigator.platform is enough for a label.
|
|
|
|
|
- comboOf(event: KeyboardEvent): HotkeyCombo or null. Null when event.isComposing. With ctrlKey or metaKey and altKey false: the letter is event.key lowercased when it is a single a-z character, else the letter of event.code when it matches Key plus a capital letter (non-Latin layouts); s with shiftKey is mod+shift+s, s without is mod+s; event.key Enter without shift is mod+enter. Without ctrl, meta and alt: Escape is escape, a slash key is the slash, a question-mark key is the question mark (shift state ignored for these two, layouts differ). Anything else null.
|
|
|
|
|
- isEditableTarget(target: EventTarget or null): true when the target is an Element whose closest input, textarea, select or contenteditable element (contenteditable not equal to false) exists. Every input type counts (D4 says input).
|
|
|
|
|
- shortcutParts(combo, mac = isMacPlatform()): string array. Non-mac: Ctrl, Shift, S / Ctrl, Enter / Ctrl, S. Mac: the shift glyph before the command glyph (Apple order), the return glyph for Enter: for mod+shift+s the parts are the shift glyph, the command glyph and S; for mod+enter the command glyph and the return glyph. escape is Esc on both; the slash and the question mark are themselves.
|
|
|
|
|
- shortcutLabel(combo, mac?): parts joined with a plus sign on non-mac and with nothing on mac (Ctrl+Shift+S, and on mac the three glyphs run together).
|
|
|
|
|
- shortcutHint(label: string, combo, mac?): t('backend::lang.shortcuts.hint', { label, keys: shortcutLabel(combo, mac) }).
|
|
|
|
|
- ariaKeyshortcuts(hotkey: string): the aria-keyshortcuts value for a space-separated token list: each mod token yields a Control form and a Meta form (Control+S Meta+S, Control+Shift+S Meta+Shift+S, Control+Enter Meta+Enter), escape yields Escape, the slash and question mark themselves; joined by spaces.
|
|
|
|
|
- hotkeyRoot(doc: Document = document): ParentNode. All elements matching role dialog with aria-modal true, or role alertdialog, whose data-state is not closed; the last one in document order, else doc.
|
|
|
|
|
- hotkeyTarget(combo, root): the first element in root matching the attribute-contains-word selector for data-hotkey and the combo, or null.
|
|
|
|
|
- availableShortcuts(doc: Document = document): the SHORTCUTS entries whose combo has a hotkeyTarget in hotkeyRoot(doc), plus the question-mark entry always, in SHORTCUTS order.
|
|
|
|
|
- shortcutHelp: a readonly ref holding the SHORTCUTS entries shown by the help dialog, or null when closed; openShortcutHelp() sets it to availableShortcuts(); closeShortcutHelp() sets it to null. (Task 2 renders the dialog.)
|
|
|
|
|
- installHotkeys(): () => void. Reference counted: the first call adds the two window listeners, every call returns an idempotent uninstall, and the listeners are removed when the count returns to zero (tests that leak mounts never get duplicate listeners).
|
|
|
|
|
Listener behaviour. Capture-phase keydown (modifier combos only): resolve combo; if it is not a mod combo return; if it is mod+s call preventDefault always (no browser Save page in the shell). Find hotkeyTarget in hotkeyRoot(target's ownerDocument or document). No target: return (mod+enter and mod+shift+s pass through untouched). Target found: preventDefault and stopPropagation; then return without clicking when event.repeat is set or the target matches :disabled or has aria-disabled true; otherwise call target.click(). No editable check for modifier combos (D4). Bubble-phase keydown (plain keys only): return when the combo is not escape, slash or question mark, when event.defaultPrevented, when isEditableTarget(event.target), or when any element with data-dismissable-layer exists in the document (dialog, popover, dropdown, select, link popover: D2). Question mark: preventDefault and openShortcutHelp(). Slash and escape: hotkeyTarget in the document; none, disabled or aria-disabled: return; else preventDefault, then focus() plus select() when it is an input or textarea, otherwise click().
|
|
|
|
|
|
|
|
|
|
2. admin/src/components/ui/Button.vue: optional prop hotkey (string, default undefined). On both the RouterLink branch and the button branch render data-hotkey bound to the prop and aria-keyshortcuts bound to ariaKeyshortcuts(hotkey) when set, undefined otherwise. Extend the header comment by one sentence (a hotkey prop marks the button for the admin's keyboard shortcuts).
|
|
|
|
|
|
|
|
|
|
3. admin/src/state/useRemount.ts (new): a module-level ref revision exported read-only as remountRevision, and remountView() incrementing it. Header comment: lets a view ask App to remount the current route's component in place (Create and new).
|
|
|
|
|
|
|
|
|
|
4. admin/src/App.vue: the RouterView component key becomes current.path joined with remountRevision (for example path, a hash sign, revision). Update the comment above it: still keyed by path for the reasons given, plus the revision so a view can remount itself.
|
|
|
|
|
|
|
|
|
|
5. admin/src/components/shell/AppShell.vue: import installHotkeys and closeShortcutHelp; call installHotkeys() during setup and register an onBeforeUnmount that runs the returned uninstall and closeShortcutHelp(). Mention the shortcuts in the header comment (installed here so only signed-in shell screens answer them).
|
|
|
|
|
|
|
|
|
|
6. admin/src/views/FormView.vue (per D1, D2 tooltips):
|
|
|
|
|
- Import remountView and shortcutHint. Add async function onSaveAndNew with a doc comment (Utwórz i nowy, WinterCMS create_and_new: saves, then a blank create form of the same model in place; ignores both redirects): await save(); when it returned a record, call remountView().
|
|
|
|
|
- Footer, inside the right-hand group, between Cancel and Save and close: a Button shown only when mode is create, variant outline, data-action save-new, hotkey mod+shift+s, title shortcutHint(t('backend::lang.form.create_and_new'), 'mod+shift+s'), the same disabled expression as the save buttons (busy, loading, no schema, active uploads), click onSaveAndNew, text t('backend::lang.form.create_and_new').
|
|
|
|
|
- Save and close Button: add hotkey mod+enter and title shortcutHint(t('backend::lang.form.save_and_close'), 'mod+enter'). Save Button: add hotkey mod+s and title shortcutHint(t('backend::lang.form.save'), 'mod+s'). Disabled expressions unchanged, so D1's disabled rule holds through the button.
|
|
|
|
|
- Update the view's header comment (the footer now also holds Create and new on create forms; the save buttons answer the shortcuts).
|
|
|
|
|
|
|
|
|
|
7. Strings (all of them, Task 2 adds none). modules/phrasebook/backend/lang/en/lang.yaml: under form add create_and_new: Create and new. Add a new top-level group shortcuts after settings with title: Keyboard shortcuts, description: Shortcuts available on this screen., hint: quoted ":label (:keys)", leave: Cancel and go back, search: Go to search, help: Show keyboard shortcuts. modules/phrasebook/backend/lang/pl/lang.yaml: form create_and_new: Utwórz i nowy; shortcuts title: Skróty klawiaturowe, description: Skróty dostępne na tym ekranie., hint: quoted ":label (:keys)", leave: Anuluj i wróć, search: Przejdź do wyszukiwania, help: Pokaż skróty klawiaturowe. admin/tests/fixtures/lang.json: the same seven keys with the Polish texts, inserted in sorted position.
|
|
|
|
|
|
|
|
|
|
8. admin/tests/smoke/shortcuts.smoke.test.ts (new, tracer check): beforeEach/afterEach as in edit.smoke.test.ts (clearUser, setNavigation([]), clearToasts, setBundle(lang.data, 'pl'); clear document.body afterwards, unmount the wrapper). One describe "record form save shortcuts" with: (a) mount /acme/demo/widgets/1 with attach true and a PUT route for the record replying the record fixture; dispatch on document.body a bubbling, cancelable keydown with key s and ctrlKey true; expect the event defaultPrevented, flushPromises, exactly one PUT to the record; repeat with metaKey true and expect a second PUT. (b) the Save button has title Zapisz (Ctrl+S) when navigator.platform reads as Linux (stub the getter with vi.spyOn if happy-dom reports something else). Task 3 adds the remaining cases.
|
|
|
|
|
|
|
|
|
|
9. docs/backend/forms.md, same commit (CONTEXT: docs with the feature): after the paragraph that starts with modelClass in the config_form.yaml section, add a short paragraph in the page's plain style: Save follows redirect and Save and close follows redirectClose; the create form also has Create and new, which saves the record and opens an empty create form of the same controller, ignoring both redirects; the buttons answer Ctrl+S (Cmd+S on macOS), Ctrl+Enter and, on the create form, Ctrl+Shift+S. No cross-page anchor (Task 2 adds the shortcuts section).
|
|
|
|
|
|
|
|
|
|
Review note to keep visible in the SUMMARY (not an action): create forms keep the Zapisz / Zapisz i zamknij labels; WinterCMS labels them Create / Create and close. The plan did not relabel them because no decision asked for it.
|
|
|
|
|
|
|
|
|
|
Run go test -short ./modules/phrasebook before committing. Commit by path (never git add -A): feat(quick-261022-k9k): admin save shortcuts and Create and new on record forms. No co-author tag.
|
|
|
|
|
</action>
|
|
|
|
|
<verify>
|
|
|
|
|
<automated>npm --prefix admin run test -- tests/smoke/shortcuts.smoke.test.ts tests/smoke/edit.smoke.test.ts tests/smoke/form.smoke.test.ts tests/form/FormView.test.ts tests/views/App.test.ts && npm --prefix admin run typecheck && go test -short ./modules/phrasebook -count=1 && go run ./cmd/summer docs:build --check</automated>
|
|
|
|
|
</verify>
|
|
|
|
|
<done>Ctrl+S and Cmd+S on an update form send one PUT with the browser default prevented; the create form shows Utwórz i nowy; the listed suites, vue-tsc, the phrasebook key test and the docs checker pass; one feat commit holds the eleven files. If an existing FormView or smoke assertion breaks only because the create footer gained a button, adjust that assertion in this commit and name it in the SUMMARY.</done>
|
|
|
|
|
</task>
|
|
|
|
|
|
|
|
|
|
<task type="auto">
|
|
|
|
|
<name>Task 2: Remaining surfaces: settings, account, modal forms, list search, Esc, the '?' help dialog and the admin SPA doc</name>
|
|
|
|
|
<files>admin/src/components/shell/ShortcutHelpDialog.vue, admin/src/components/shell/AppShell.vue, admin/src/components/list/ListToolbar.vue, admin/src/views/FormView.vue, admin/src/views/SettingsFormView.vue, admin/src/views/AccountView.vue, admin/src/components/relation/RelationChildModal.vue, admin/src/components/relation/RelationPivotModal.vue, admin/src/components/relation/RelationPickerModal.vue, admin/src/components/form/fields/FileCaptionModal.vue, admin/src/components/account/ApiTokenCreateDialog.vue, docs/backend/admin-spa.md</files>
|
|
|
|
|
<action>
|
|
|
|
|
Implements the rest of D1 (settings forms and AccountView), D2 (slash, Esc, help dialog, hints), D3 (modal forms) on top of the Task 1 core. The core already has the listeners; this task only marks elements and adds the dialog.
|
|
|
|
|
|
|
|
|
|
1. admin/src/components/shell/ShortcutHelpDialog.vue (new). Mirror FileCaptionModal's Reka Dialog markup (DialogRoot, DialogPortal, DialogOverlay with the same classes, DialogContent with the same modal classes and max-w-[440px], DialogTitle, DialogDescription, DialogClose X button with aria-label t('backend::lang.form.close')). DialogRoot open is shortcutHelp !== null; on update:open false call closeShortcutHelp(). DialogContent carries data-shortcut-help and aria-modal true. Title t('backend::lang.shortcuts.title'), description t('backend::lang.shortcuts.description'). Body: a dl with one row per shortcutHelp entry, data-shortcut set to the combo: a dt holding one kbd element per shortcutParts(combo) item (small bordered chip classes in the admin's token style, for example rounded-pager border border-border bg-subtle px-1.5 font-mono text-[13px]) and a dd with t(entry.labelKey). Text interpolation only. Header comment: what it lists (the current screen's shortcuts, read from the page when '?' was pressed), that Esc and the close button close it.
|
|
|
|
|
|
|
|
|
|
2. admin/src/components/shell/AppShell.vue: import and render ShortcutHelpDialog right after Toast.
|
|
|
|
|
|
|
|
|
|
3. admin/src/views/FormView.vue: the footer Cancel Button gets hotkey escape (D2 Esc = Cancel through onLeave, which already asks for a dirty form). The back arrow keeps no hotkey (one target per combo).
|
|
|
|
|
|
|
|
|
|
4. admin/src/views/SettingsFormView.vue (D1 settings forms): the Save Button gets hotkey mod+s and title shortcutHint(t('backend::lang.form.save'), 'mod+s'). Add a "Save and close" Button (user decision at plan review, matching WinterCMS settings/update.php): variant outline, data-action="save-close", label backend::lang.form.save_and_close, same disabled state as Save, hotkey mod+enter, title shortcutHint(..., 'mod+enter'), placed before Save like FormView's footer; onSaveAndClose awaits the same save path as onSave and, only when it succeeded (no errors), navigates to /settings (the Cancel target). No Esc: the settings form has no dirty-form confirmation, so binding Esc to its Cancel link would discard edits silently, which contradicts D2's "via the existing dirty-form confirmation". Say so in one comment line next to the footer.
|
|
|
|
|
|
|
|
|
|
5. admin/src/views/AccountView.vue (D1 save only): the footer Save Button gets hotkey mod+s and title shortcutHint(t('backend::lang.form.save'), 'mod+s'). No Esc (no Cancel or dirty confirmation on this screen). The ApiTokensPanel's create dialog is covered by step 6.
|
|
|
|
|
|
|
|
|
|
6. Modal forms (D3: the innermost dialog's form takes the save keys; the core's root rule already ignores the page underneath). On each modal's primary submit, add data-hotkey "mod+s mod+enter" (its single submit already closes the modal, so both keys mean the same) and aria-keyshortcuts from ariaKeyshortcuts with the same value, plus a title from shortcutHint with the button's own label expression and mod+s. Use the hotkey prop where the element is the Button component (RelationChildModal save-child, ApiTokenCreateDialog create-token-submit which exists only on step 1) and plain attributes where it is a native button (FileCaptionModal save-caption, RelationPivotModal save-pivot, RelationPickerModal confirm-link). Do not change any disabled expression or handler; a disabled primary button makes the shortcut a no-op, as on the page. RelationChildModal's preview mode shows no submit, so it has no target and the keys do nothing there.
|
|
|
|
|
|
|
|
|
|
7. admin/src/components/list/ListToolbar.vue (D2 slash): the search input gets data-hotkey set to the slash and aria-keyshortcuts set to the slash. It only exists when showSearch is true, so lists without search have no target.
|
|
|
|
|
|
|
|
|
|
8. docs/backend/admin-spa.md, same commit: a new section titled "## Keyboard shortcuts" right before "## Types from OpenAPI", plain prose plus one table (key, where, what), modelled on WinterCMS's toolbar hotkeys: Ctrl+S (Cmd+S on macOS, both accepted everywhere) saves on record forms, settings forms and My account and creates on a create form; Ctrl+Enter saves and closes (creates and closes); Ctrl+Shift+S runs Create and new on a create form; Esc on a record form works like Cancel and asks first when there are unsaved changes; the slash focuses the list search; the question mark shows the shortcuts of the current screen. Then the rules: a shortcut does what its button does and nothing while the button is disabled (saving, loading, an upload in progress); the save keys also work while typing in a field, a code field or the rich editor, where on a record form Ctrl+Enter saves and closes instead of inserting a line break (Shift+Enter still does) and on a create form Ctrl+Shift+S creates instead of toggling strikethrough; Esc, the slash and the question mark do nothing while typing in a field or while a dialog, popover or menu is open, which closes on Esc itself; when a dialog with a form is open (a relation record, a pivot, a picker, a file caption, a new API token) Ctrl+S and Ctrl+Enter submit that dialog and never the form underneath; save buttons show their shortcut in their tooltip. Link to [Forms](forms.md) for Create and new. No anchors that do not exist.
|
|
|
|
|
|
|
|
|
|
Run the full admin suite before committing (the help dialog and the attributes must not break existing tests). Commit by path: feat(quick-261022-k9k): Esc, slash and help shortcuts, settings, account and modal save keys. No co-author tag.
|
|
|
|
|
</action>
|
|
|
|
|
<verify>
|
|
|
|
|
<automated>npm --prefix admin run test && npm --prefix admin run typecheck && go test -short ./modules/phrasebook -count=1 && go run ./cmd/summer docs:build --check && go test ./cmd/summer -count=1 -run '^TestDocsTree$'</automated>
|
|
|
|
|
</verify>
|
|
|
|
|
<done>Every listed element carries its data-hotkey; the help dialog renders from the shell; the whole admin suite, vue-tsc, the phrasebook test, the docs checker and TestDocsTree pass; one feat commit holds the twelve files.</done>
|
|
|
|
|
</task>
|
|
|
|
|
|
|
|
|
|
<task type="auto" tdd="true">
|
|
|
|
|
<name>Task 3: Vitest coverage for every shortcut rule, then rebuild the embedded admin dist</name>
|
|
|
|
|
<precondition>Tasks 1 and 2 are committed; git status --porcelain -- admin modules/boardwalk/dist prints nothing apart from this task's own edits (if another session has uncommitted admin or dist edits, stop and report instead of building over them); admin/node_modules exists (never run npm ci).</precondition>
|
|
|
|
|
<files>admin/tests/helpers.ts, admin/tests/app/hotkeys.test.ts, admin/tests/smoke/shortcuts.smoke.test.ts, admin/tests/shell/ShortcutHelpDialog.test.ts, modules/boardwalk/dist</files>
|
|
|
|
|
<behavior>
|
|
|
|
|
- Core (hotkeys.test.ts): comboOf maps ctrl+s, meta+s, ctrl+S with shift, ctrl+Enter, meta+Enter, Escape, slash, question mark; returns null for ctrl+alt+s (AltGr), alt+s, plain s, shift+Enter with ctrl, isComposing; uses event.code KeyS when event.key is a non-Latin letter. isMacPlatform true for MacIntel, iPhone, iPad, false for Linux x86_64 and Win32. shortcutLabel gives Ctrl+S, Ctrl+Enter, Ctrl+Shift+S, Esc on Linux and the glyph forms on mac; shortcutHint gives Zapisz (Ctrl+S). ariaKeyshortcuts('mod+s mod+enter') is Control+S Meta+S Control+Enter Meta+Enter. isEditableTarget true for input, textarea, select, a span inside contenteditable, false for a button, body and contenteditable false. hotkeyRoot returns the document without modals, the last aria-modal dialog when two are open, an alertdialog when it is last, and ignores a role dialog without aria-modal (popover) and one whose data-state is closed. installHotkeys twice then both uninstalls leaves no listener (a click spy on a data-hotkey button sees one click per keydown while installed twice and none after both uninstalls). A disabled target is consumed (defaultPrevented) but not clicked; event.repeat is consumed but not clicked; mod+enter with no target is not defaultPrevented; mod+s with no target is defaultPrevented.
|
|
|
|
|
- Record form (shortcuts.smoke.test.ts, mounted with attach true): Ctrl+S inside a textarea or text input saves; Ctrl+S while the record GET is still pending sends nothing; Ctrl+Enter on update PUTs then lands on the redirectClose path; on create Ctrl+S POSTs and follows create.redirect; the save-new button and Ctrl+Shift+S POST, keep the route on the create path, empty the field that was filled, and the following save sends a different X-Session-Key header; the create footer has the save-new button and the update footer does not; Esc on body with a clean update form navigates to the back path; Esc after an edit shows the discard ConfirmDialog and stays; Esc with focus in an input does nothing; Esc while a Reka layer (the ConfirmDialog) is open does not navigate; Esc whose event a component already defaultPrevented does nothing.
|
|
|
|
|
- Modal scope (D3), following the relation picker cases in tests/smoke/relation.smoke.test.ts: with the picker open and a candidate selected, Ctrl+S sends the link request and no PUT to the record; with the picker open and nothing selected (confirm-link disabled), Ctrl+S sends nothing at all.
|
|
|
|
|
- Settings and account: on a settings form the save-close button exists; Ctrl+Enter (and clicking it) sends PUT /settings/{code} then lands on /settings, and stays on the form when the PUT fails validation; on a settings form Ctrl+S sends PUT /settings/{code} (follow tests/views/SettingsFormView.test.ts or tests/smoke/settings.smoke.test.ts routes); on /account Ctrl+S sends PUT /account (follow tests/views/AccountView.test.ts); Esc on either screen does not navigate.
|
|
|
|
|
- List and help: on a list with search, the slash on body focuses input[data-list-search] and is defaultPrevented; the slash inside another input does nothing; a list schema without search has no target; the question mark opens [data-shortcut-help] whose rows ([data-shortcut]) are exactly mod+s, mod+enter, escape and the question mark on an update form, those plus mod+shift+s on a create form, and the slash plus the question mark on a list; the question mark inside an input does not open it; Esc closes the help dialog and does not leave the form underneath. ShortcutHelpDialog.test.ts covers the dialog alone: title Skróty klawiaturowe, kbd parts per row, the close button calls closeShortcutHelp.
|
|
|
|
|
</behavior>
|
|
|
|
|
<action>
|
|
|
|
|
Covers K9K-01 to K9K-08 (every locked decision has at least one test) and closes K9K-09 with the dist rebuild. Unit tests are the last code task per the project rule; fixes for bugs the tests find go into the source files in this task, as a separate fix commit before the test commit when they change behaviour.
|
|
|
|
|
|
|
|
|
|
1. admin/tests/helpers.ts: add press(target: Element, key: string, init?: KeyboardEventInit): KeyboardEvent, dispatching a bubbling, cancelable keydown with the key and init merged and returning it. Leave the existing keydown helper unchanged.
|
|
|
|
|
|
|
|
|
|
2. admin/tests/app/hotkeys.test.ts (new): behavior bullet 1. Build small DOM fixtures directly in document.body (buttons with data-hotkey, divs with role dialog and aria-modal) and call installHotkeys in each test, uninstalling in afterEach. Stub the platform through the mac parameter or vi.spyOn(navigator, 'platform', 'get').
|
|
|
|
|
|
|
|
|
|
3. admin/tests/smoke/shortcuts.smoke.test.ts: extend with behavior bullets 2 to 5 (mount with attach true everywhere; use press from helpers). For the session key check, read the X-Session-Key header of the two POST requests from the calls list.
|
|
|
|
|
|
|
|
|
|
4. admin/tests/shell/ShortcutHelpDialog.test.ts (new): bullet 5's component part, mounting the dialog with enableAutoUnmount and calling openShortcutHelp after placing data-hotkey buttons in the body.
|
|
|
|
|
|
|
|
|
|
5. Run the whole admin suite and vue-tsc. Commit the test files by path: test(quick-261022-k9k): keyboard shortcut coverage. No co-author tag.
|
|
|
|
|
|
|
|
|
|
6. Embedded build: npm --prefix admin run build; scripts/check-admin-dist.sh must report a match; go test -short ./modules/boardwalk. Stage only modules/boardwalk/dist by path (git add modules/boardwalk/dist also stages removed hashed assets) and commit: chore(quick-261022-k9k): rebuild embedded admin dist. No co-author tag. Do not push or tag.
|
|
|
|
|
</action>
|
|
|
|
|
<verify>
|
|
|
|
|
<automated>npm --prefix admin run test && npm --prefix admin run typecheck && scripts/check-admin-dist.sh && go test -short ./modules/boardwalk ./modules/phrasebook -count=1 && git diff --quiet HEAD -- admin/package.json admin/package-lock.json && test -z "$(git log --format=%H --grep='quick-261022-k9k' -- admin/package.json admin/package-lock.json)" && test -z "$(git status --porcelain -- admin modules/boardwalk/dist)"</automated>
|
|
|
|
|
</verify>
|
|
|
|
|
<done>Every behavior bullet has a passing test; the full admin suite and vue-tsc pass; check-admin-dist.sh exits 0; package.json and the lockfile are unchanged; the test commit and the dist commit exist and nothing is left uncommitted under admin/ or modules/boardwalk/dist.</done>
|
|
|
|
|
</task>
|
|
|
|
|
|
|
|
|
|
</tasks>
|
|
|
|
|
|
|
|
|
|
<threat_model>
|
|
|
|
|
## Trust Boundaries
|
|
|
|
|
|
|
|
|
|
| Boundary | Description |
|
|
|
|
|
|----------|-------------|
|
|
|
|
|
| keyboard -> SPA actions | A keystroke now triggers saves, navigation and modal submits that previously needed a click |
|
|
|
|
|
| page form <-> modal form | Two forms are on screen at once while a modal is open; a shortcut must reach exactly one |
|
|
|
|
|
|
|
|
|
|
## STRIDE Threat Register
|
|
|
|
|
|
|
|
|
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
|
|
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
|
|
|
| T-261022-k9k-01 | Tampering | hotkeys.ts root resolution | medium | mitigate | Ctrl+S / Ctrl+Enter resolve to the innermost aria-modal dialog or alertdialog only; with a modal open the page form is never searched, so a relation modal's shortcut cannot save the record form (or the reverse). Covered by the picker smoke test and the hotkeyRoot unit tests. |
|
|
|
|
|
| T-261022-k9k-02 | Tampering | Esc on forms | medium | mitigate | Esc clicks the Cancel button, which runs onLeave and its dirty-form confirmation; screens without that confirmation (settings forms, My account) get no Esc binding, so Esc never discards edits silently. Esc never fires in an editable control or with a Reka layer open. |
|
|
|
|
|
| T-261022-k9k-03 | Denial of service | repeated keydown | low | mitigate | event.repeat is consumed without clicking, and every target keeps its disabled expression (busy, loading, uploads), so holding Ctrl+S cannot queue duplicate saves. |
|
|
|
|
|
| T-261022-k9k-04 | Elevation of privilege | shortcut targets | low | accept | A shortcut only clicks a button already rendered for the administrator; the API authorises every request as before. No client-side permission logic is added. |
|
|
|
|
|
| T-261022-k9k-SC | Tampering | npm/pip/cargo installs | high | accept | No package is added or installed (no hotkeys-js, no direct @vueuse import); npm ci is not run; Task 3's verify proves package.json and the lockfile are unchanged. |
|
|
|
|
|
</threat_model>
|
|
|
|
|
|
|
|
|
|
<verification>
|
|
|
|
|
- npm --prefix admin run test (whole suite) and npm --prefix admin run typecheck pass.
|
|
|
|
|
- go test -short ./modules/phrasebook (TestPhase10SPAKeysResolve: every backend::lang literal in admin/src resolves in pl and en) and go test -short ./modules/boardwalk pass.
|
|
|
|
|
- go run ./cmd/summer docs:build --check and go test ./cmd/summer -run '^TestDocsTree$' pass.
|
|
|
|
|
- scripts/check-admin-dist.sh passes after the dist commit.
|
|
|
|
|
- Four or five commits in order: feat (Task 1), feat (Task 2), optional fix (Task 3, only if tests found a bug), test, chore (dist). git diff --stat over them lists only admin/, modules/phrasebook/backend/lang/, docs/backend/forms.md, docs/backend/admin-spa.md and modules/boardwalk/dist.
|
|
|
|
|
</verification>
|
|
|
|
|
|
|
|
|
|
<success_criteria>
|
|
|
|
|
- An administrator saves, saves and closes, and (on create) creates and starts a new record from the keyboard with Ctrl or Cmd, on record forms, settings forms and My account, and the browser's Save page dialog never opens in the shell.
|
|
|
|
|
- With a modal form open, the save keys act on the modal only.
|
|
|
|
|
- Esc leaves a record form through the usual unsaved-changes question; the slash jumps to list search; the question mark lists the current screen's shortcuts; none of the plain keys interfere with typing.
|
|
|
|
|
- Save buttons show their shortcut, rendered with macOS glyphs on a Mac.
|
|
|
|
|
- Tests cover every rule; the docs describe it; the embedded dist is current; no dependency was added.
|
|
|
|
|
</success_criteria>
|
|
|
|
|
|
|
|
|
|
<output>
|
|
|
|
|
Create `.planning/quick/261022-k9k-keyboard-shortcuts-in-the-admin-spa-ctrl/261022-k9k-SUMMARY.md` when done
|
|
|
|
|
</output>
|