Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-11
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
## Context

`AnswerInput.vue` holds a plain `<input>` that the app auto-focuses on mount
and that the on-screen `OnScreenKeyboard.vue` writes into (see
`openspec/specs/typing-practice/spec.md` — "On-screen keyboard for the
exercise's language"). On a touchscreen, focusing any `<input>` is the
browser's own trigger for the OS virtual keyboard; the app has never
suppressed it because the desktop/laptop case this was built for (see "The
exercise fits on one screen") has no virtual keyboard to suppress. There is no
device-classification, settings, or persistence code anywhere in the frontend
yet — this change introduces all three.

## Goals / Non-Goals

**Goals:**
- Keep the OS virtual keyboard off the answer field by default on phones.
- Leave it available by default everywhere else, since a connected physical
keyboard cannot be detected.
- Give the learner a persistent, explicit override in both directions.
- Keep the on-screen keyboard's own control reachable on a phone-sized
screen, and let the learner hide that board entirely.

**Non-Goals:**
- General responsive layout for small screens beyond the keyboard board
itself (the "fits on one screen" requirement is still laptop-scoped;
reflowing the exercise column or the sentence's type scale for narrow
viewports is separate work). The keyboard board's own overflow is
in scope below — it turned out to block the override control's own
reachability during e2e verification, not just a cosmetic gap.
- Detecting a physical keyboard directly — no browser API exposes this, which
is precisely why an explicit override exists.
- A general settings panel/infrastructure beyond what these two settings
need — no accounts, no server-side sync, no third setting speculatively
added.

## Decisions

### Suppress via `inputmode="none"`, not `readonly` or a hidden proxy input
Setting the field's `inputmode` attribute to `"none"` tells the browser not to
draw its virtual keyboard on focus, while the field stays focused, editable,
and still receives physical key events — because `inputmode` only hints at
which virtual keyboard to show, it does not disable input. `readonly` was
considered and rejected: it also blocks physical keyboard input and caret
placement, which would break the "physical keyboard also works" scenario
already in the spec. A hidden/offscreen proxy input was considered and
rejected as needless complexity — `v-model`, `@keyup.enter`, and the visible
caret all keep working unchanged with `inputmode` alone.

`inputmode` becomes a computed property on the input, driven by whichever of
the two settings states below is active.

### Two independent pieces of state: device classification and override
1. **Device classification** (`phone` vs. not) is computed, not stored — a
small composable reads it live from `matchMedia`, so it reacts if the
viewport or input mode changes (e.g. rotating a phone, or a tablet
docking a keyboard, changes `hover`/`pointer` on some platforms).
Classification rule: `(max-width: 640px)` — Tailwind's own `sm` breakpoint,
already the project's small-screen line — **and** `(hover: none) and
(pointer: coarse)`, i.e. touch-primary with no mouse/trackpad. Both must
hold; a narrow desktop window with a mouse is not a phone.
2. **Override** is a tri-state value — `auto` (default), `on`, `off` — held in
a small Pinia store and mirrored to `localStorage` under one key, read
once at store creation and written on every change. `auto` means "follow
classification"; `on`/`off` force the `inputmode` regardless of it.

Keeping these separate (rather than collapsing to one persisted boolean)
means a learner who never touches the setting keeps getting the
device-appropriate default even if they later switch devices with the same
browser profile — `localStorage` is per-origin-per-browser, not per-device,
so a forced boolean would follow them somewhere it no longer applies.

### Settings control placement: a header menu, not the keyboard's bottom bar
Tried first as a pill in `OnScreenKeyboard.vue`'s existing bottom bar
(alongside the layout switch and finger legend). Phone-width e2e
verification (Playwright, `hasTouch`/`isMobile` emulation) caught what a
static mockup didn't: at 390px the bar is already tight, and the pill was
genuinely unclickable — other elements physically overlapped it. Rather than
fight that bar for space, both settings moved to a small popover menu off a
gear icon in the app header (`SettingsMenu.vue`), positioned
absolutely so it costs the header row no height on any viewport (`App.vue`'s
title row must stay short — see "The exercise fits on one screen"). This
also gave the second setting (below) a natural home instead of a second
crowded pill.

### The on-screen keyboard scrolls sideways rather than shrinking
`OnScreenKeyboard.vue`'s panel was `w-[var(--board)]`, a fixed width; below
that width the whole page grew wider than the viewport instead of the panel
adapting. Shrinking every key to fit was rejected — a key below a tappable
size defeats the board's whole point, and there is no width where a
13-column row shrinks to fit a phone and stays tappable. Instead: the panel
is now `w-full max-w-[var(--board)]` (shrinks with the viewport, matching
`AnswerInput.vue`'s column), and inside it the rows + space bar sit in an
`overflow-x-auto` wrapper at their full, unshrunk width — so on any viewport
narrower than `--board` the keys stay true size and the learner scrolls
sideways to reach the rest, the way they would scroll any wide content on a
touch device. The bottom bar (legend + layout switch) sits outside that
scroll wrapper, so it never scrolls out of reach itself; the legend also
gained `flex-wrap` for narrow panels.

### A second, independent setting: hide the on-screen keyboard
Once a learner is typing on a physical keyboard they already know work, the
board is a lot of screen for a hint they are not using — a plain "hide it"
option, not tied to the virtual-keyboard override (that one is about the
*OS* keyboard; this one is about *this app's own* board). Held as a second
field, `onScreenKeyboardVisible`, on the same settings store, defaulting to
shown and persisted the same way. `SentenceView.vue` wraps
`<OnScreenKeyboard>` in `v-if`, not `v-show`: the board holds no state worth
preserving while hidden (its layer and scroll position reset the same way a
new exercise already resets them), so mounting fresh on re-show costs
nothing and is simpler than tracking what to reset by hand.

### Where the logic lives
- `src/frontend/src/lib/device.js`: the `matchMedia`-backed
phone-classification composable/helper, unit-testable by mocking
`matchMedia` the way the existing `lib/keyboard` tests mock their inputs.
- `src/frontend/src/stores/settings.js`: the Pinia store holding both
settings, their `localStorage` keys, and a computed `virtualKeyboardEnabled`
that combines the override + classification into the boolean
`AnswerInput.vue` needs.
- `AnswerInput.vue`: binds `:inputmode` to `store.virtualKeyboardEnabled ?
'text' : 'none'`.
- `SettingsMenu.vue` (new): the header's gear button and its popover, reading
and writing both settings.
- `OnScreenKeyboard.vue`: the `overflow-x-auto` scroll wrapper; no longer
holds any settings-control markup itself.
- `SentenceView.vue`: `v-if`s the board on `onScreenKeyboardVisible`.

## Risks / Trade-offs

- **Classification is a heuristic and will misjudge some devices** (e.g. a
large phone above 640px, or a small tablet at 640px in portrait) → this is
exactly what the override exists for; the proposal treats the override as a
first-class requirement rather than an escape hatch, and the design keeps
the default in the "safer to leave the keyboard available" direction for
anything not confidently phone-sized.
- **`inputmode="none"` support varies slightly across mobile browsers**
(notably some older WebKit versions honored it inconsistently) → the
override lets an affected learner switch it back on if suppression fails to
behave, and lets one switch it off if a browser shows the virtual keyboard
despite `inputmode="none"`; no feature detection is added since there is
nothing reliable to detect.
- **`localStorage` can be unavailable** (private browsing in some browsers,
storage disabled) → reads/writes are wrapped so a failure falls back to the
in-memory default (`auto`) for that session rather than throwing.

## Migration Plan

Additive, frontend-only change with no persisted server data and no schema.
Ships as a normal frontend deploy; nothing to migrate or roll back beyond
reverting the commit if the heuristic proves wrong in practice.
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
## Why

On a phone, focusing the answer field pops up the OS's virtual keyboard on top
of the app's own on-screen Kedmanee keyboard — two keyboards fighting for the
same screen, with the OS one usually covering the on-screen one entirely. A
phone has no physical keyboard, so the virtual keyboard adds nothing the
on-screen board doesn't already provide, and should stay down. A tablet is a
harder case: some are typed on with a connected physical keyboard, but many
are not, and the app cannot reliably detect a connected one — so it must not
assume a tablet is keyboard-less the way a phone can be assumed to be. Because
that assumption can be wrong in both directions, the learner needs a manual
override.

## What Changes

- Suppress the OS virtual keyboard on the answer field when the device is
classified as a phone (small viewport, touch-primary), since it has no
physical keyboard and the on-screen board already covers typing.
- Leave the virtual keyboard available by default on tablets and larger
touch devices, since a physical keyboard cannot be reliably detected and
many tablets are typed on without one.
- Add a settings menu (a gear icon in the header) where the learner can force
the virtual keyboard on or off, overriding the device-based default — for a
tablet with a physical keyboard attached (force off) or a phone-sized
device misclassified as a tablet, or vice versa (force either way). The
choice persists across visits.
- Fix the on-screen keyboard so it no longer forces the page wider than a
phone screen: it scrolls sideways below its full width instead of shrinking
keys past a tappable size. This surfaced during verification — the settings
control itself was unreachable on a real phone width until this was fixed.
- Add a second, independent setting in the same menu letting the learner hide
the app's own on-screen keyboard entirely, for anyone typing on a physical
keyboard they already know.

## Capabilities

### New Capabilities

(none)

### Modified Capabilities

- `typing-practice`: adds device-aware control over whether the OS virtual
keyboard is allowed to appear when the answer field is focused, a
learner-facing setting that overrides the device default, a requirement
that the on-screen keyboard stays usable (not shrunk below a tappable size)
on any viewport, and a setting to hide the on-screen keyboard entirely.

## Impact

- Affected code: `src/frontend/src/components/AnswerInput.vue` (the answer
field's `inputmode`), a new `SettingsMenu.vue` component (header gear
button + popover) replacing the answer field's earlier in-keyboard control,
`OnScreenKeyboard.vue` (the board's panel now scrolls instead of a fixed
width) and `SentenceView.vue` (conditionally renders the board), a settings
Pinia store backed by `localStorage`, and a small device-classification
helper under `src/frontend/src/lib/`.
- No backend, API, or data changes.
- No new dependencies expected — classification uses standard `matchMedia`
queries already available in the target browsers.
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
## ADDED Requirements

### Requirement: Virtual keyboard suppressed on phone-classified devices
The frontend SHALL classify the device as a phone when it has a small screen
and is primarily touch-operated (no hover-capable pointer such as a mouse or
trackpad), and SHALL keep the OS virtual keyboard from appearing when the
answer field is focused on such a device, unless the learner has overridden
this. A phone has no physical keyboard, and the app's own on-screen Kedmanee
keyboard already covers typing, so a second, OS-drawn keyboard only competes
for the same screen space.

#### Scenario: Focusing the field on a phone
- **WHEN** the learner taps or otherwise focuses the answer field on a
phone-classified device, and no override is set
- **THEN** the OS virtual keyboard does not appear, and the on-screen keyboard
remains the way to type

#### Scenario: A physical keyboard still works on a phone-classified device
- **WHEN** the learner types on a physical keyboard while the answer field is
focused on a phone-classified device
- **THEN** the typed characters reach the field exactly as they do on any
other device, because suppressing the virtual keyboard does not block
physical key input

### Requirement: Virtual keyboard available by default on tablets and larger devices
The frontend SHALL leave the OS virtual keyboard free to appear when the
answer field is focused on any device not classified as a phone, unless the
learner has overridden this. Whether such a device has a physical keyboard
attached cannot be reliably determined from the browser, and many of these
devices are typed on without one, so the default SHALL favor not removing a
learner's only way to type.

#### Scenario: Focusing the field on a tablet
- **WHEN** the learner taps or otherwise focuses the answer field on a device
not classified as a phone, and no override is set
- **THEN** the OS virtual keyboard is free to appear as it would on any other
input field

### Requirement: Learner can override virtual keyboard visibility
The frontend SHALL provide a setting the learner can use to force the OS
virtual keyboard on or off for the answer field, overriding the device-based
default in either direction, and SHALL persist that choice across visits.
Device classification is a heuristic and can be wrong in both directions — a
tablet with a physical keyboard attached, or a phone-sized device the
heuristic misreads — so the learner needs a way to correct it.

#### Scenario: Forcing the virtual keyboard off
- **WHEN** the learner turns the setting to "off" and then focuses the answer
field, regardless of device classification
- **THEN** the OS virtual keyboard does not appear

#### Scenario: Forcing the virtual keyboard on
- **WHEN** the learner turns the setting to "on" and then focuses the answer
field, regardless of device classification
- **THEN** the OS virtual keyboard is free to appear

#### Scenario: The override persists
- **WHEN** the learner sets the override and later reloads the application or
returns in a new visit, on the same device and browser
- **THEN** the same override is still in effect, without the learner setting
it again

#### Scenario: No override set
- **WHEN** the learner has never changed the setting
- **THEN** the device-classified default governs whether the virtual keyboard
appears, and the setting shows that this is the current, unforced state

### Requirement: The on-screen keyboard fits any viewport without shrinking below a tappable size
The on-screen keyboard's keys SHALL NOT be sized smaller than they are today
to fit a narrow viewport; where the layout's rows are wider than the
viewport, the frontend SHALL let the learner scroll the keyboard sideways to
reach the rest of it instead. A key shrunk to fit a phone stops being a
target a thumb can reliably hit, and stops being the size the finger-position
promise this keyboard makes is about.

#### Scenario: The page does not grow wider than the viewport
- **WHEN** the exercise is displayed on a viewport narrower than the
keyboard's full width
- **THEN** the page itself does not scroll or grow wider than the viewport —
only the keyboard's own rows do

#### Scenario: Every key stays reachable
- **WHEN** the keyboard is wider than the viewport showing it
- **THEN** every key the current layout can type, including ones initially
off-screen, can still be reached and pressed by scrolling the keyboard

### Requirement: Learner can hide the on-screen keyboard
The frontend SHALL provide a setting, independent of the virtual keyboard
setting, that lets the learner hide the on-screen keyboard entirely, and
SHALL persist that choice across visits. A learner typing on a physical
keyboard they already know needs no help finding the keys, and the board is
a lot of screen to keep spending on a hint they are not using.

#### Scenario: Hiding the on-screen keyboard
- **WHEN** the learner turns the on-screen keyboard setting to "hide"
- **THEN** the on-screen keyboard is no longer displayed

#### Scenario: Typing still works while it is hidden
- **WHEN** the on-screen keyboard is hidden
- **THEN** the learner can still type into the answer field, whether from a
physical keyboard or, where enabled, the OS virtual keyboard, and checking
the answer works exactly as it does with the board shown

#### Scenario: Showing it again
- **WHEN** the learner turns the on-screen keyboard setting back to "show"
- **THEN** the on-screen keyboard is displayed again, following the current
exercise's layout and next-key highlight as usual

#### Scenario: The choice persists
- **WHEN** the learner sets this independently of the virtual keyboard
setting and later reloads the application or returns in a new visit
- **THEN** the on-screen keyboard's shown/hidden state is unchanged, and the
virtual keyboard setting is unaffected by it
Loading
Loading