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
36 changes: 18 additions & 18 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
chart:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
# prerequisiteChecks off for lint alone: lint renders without a cluster and
# cannot be told what one has, so the checks would report a missing
# operator that the render steps below supply properly.
Expand Down Expand Up @@ -102,15 +102,15 @@ jobs:
IMAGE: ${{ needs.setup.outputs.backend_image }}
TAG: ${{ needs.setup.outputs.candidate_tag }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push the backend candidate (runtime — what would ship)
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ./src/backend
file: ./src/backend/Containerfile
Expand All @@ -124,7 +124,7 @@ jobs:
# that group — same Python, same code — so the suite still runs inside the
# artifact this run built rather than an environment assembled beside it.
- name: Build and push the backend test stage
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ./src/backend
file: ./src/backend/Containerfile
Expand All @@ -144,15 +144,15 @@ jobs:
IMAGE: ${{ needs.setup.outputs.frontend_image }}
TAG: ${{ needs.setup.outputs.candidate_tag }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push the frontend candidate (nginx, built bundle)
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ./src/frontend
file: ./src/frontend/Containerfile
Expand All @@ -166,7 +166,7 @@ jobs:
# pushed too and the two test jobs below run inside it — what is tested is
# then what was actually installed, not a separately assembled environment.
- name: Build and push the frontend test stage
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ./src/frontend
file: ./src/frontend/Containerfile
Expand Down Expand Up @@ -200,7 +200,7 @@ jobs:
--health-timeout 5s
--health-retries 5
steps:
- uses: docker/login-action@v3
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
Expand All @@ -226,7 +226,7 @@ jobs:
IMAGE: ${{ needs.setup.outputs.frontend_image }}
TAG: ${{ needs.setup.outputs.candidate_tag }}
steps:
- uses: docker/login-action@v3
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
Expand All @@ -244,7 +244,7 @@ jobs:
IMAGE: ${{ needs.setup.outputs.frontend_image }}
TAG: ${{ needs.setup.outputs.candidate_tag }}
steps:
- uses: docker/login-action@v3
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
Expand All @@ -269,8 +269,8 @@ jobs:
FRONTEND_IMAGE: ${{ needs.setup.outputs.frontend_image }}
TAG: ${{ needs.setup.outputs.candidate_tag }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
- uses: actions/checkout@v7
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
Expand Down Expand Up @@ -328,8 +328,8 @@ jobs:
FRONTEND_IMAGE: ${{ needs.setup.outputs.frontend_image }}
TAG: ${{ needs.setup.outputs.candidate_tag }}
steps:
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
Expand Down
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,132 @@
## Context

See proposal.md - Why. The board and finger data live in two files:

- `src/frontend/src/lib/keyboard/board.js` describes the physical ANSI board:
a `FINGERS` grid (one finger id per row position) and `FURNITURE` (Backspace,
Shift, AltGr, each with its own `finger`). Positions carry a finger id but
nothing marking any of them as a "home" position.
- `src/frontend/src/components/OnScreenKeyboard.vue` owns all rendering: the
`FINGER_TINT` map from finger id to Tailwind classes, the `LEGEND` array
shown under the board, and the `highlighted()`/`ACTIVE_CLASSES` logic for
the next-key ring.

The home row is `FINGERS` row index 2 (`ASDF GHJKL;` on a Latin board, 11
positions). Within it, column 3 is the left index finger's home key (F) and
column 6 is the right index finger's home key (J); columns 4 and 5 (G and H)
are also `l-index`/`r-index` but are the lateral reach, not the resting
position — a real keyboard puts no bump there either. This design marks
exactly columns 3 and 6 of row index 2.

## Goals / Non-Goals

**Goals:**
- Split `l-index`/`r-index` into two distinct colours in `FINGER_TINT` and
`LEGEND`, with no change to which finger is assigned to which position.
- Add a per-position "home" flag to the board data, and render it as a small,
static marker (a bar) on the two keys it applies to, visible independently
of hover/highlight state.
- Keep the next-key highlight (`ACTIVE_CLASSES`) legible when it lands on a
home-marked key, per the existing "highlight stays legible over the
colours" requirement extended to the home marker.

**Non-Goals:**
- Not touching `l-pinky`/`r-pinky`, `l-ring`/`r-ring`, or `l-middle`/`r-middle`
— they keep mirroring, per the user's chosen scope.
- Not marking a home position for every finger (pinky/ring/middle) — only the
two index fingers get the marker, matching the physical F/J bump.
- Not changing `kedmanee.js` or `layout.js` — no layout, layer, or key-to-
character mapping changes; this is purely a board-data + rendering change.
- Not changing keyboard behaviour (pressing, highlighting logic, layout
switching) beyond the visual additions.

## Decisions

**Where the home flag lives: `board.js`, not the component.**
The finger-guidance requirement already establishes "the finger is a property
of the physical board." A home position is the same kind of fact — it depends
on which key a finger rests on, not on the layout or layer displayed — so it
belongs next to `FINGERS` in `board.js` rather than being hardcoded as
row/column literals inside the Vue component. Concretely, `FINGERS` entries
become `{ finger, home: true }` for the two marked positions (F and J's board
positions) and a bare finger id — or `{ finger }` — elsewhere; `keyboardModel`
(in `keyboard/index.js`, which turns board+layout into rendered cells) carries
the `home` flag through to each cell alongside `finger`, the way it already
carries `finger` through untouched.

Alternative considered: keep `FINGERS` as a flat array of finger ids and add a
separate `HOME_POSITIONS` list of `[row, col]` pairs. Rejected because it
duplicates the row/column indexing `FINGERS` already does and separates two
facts about the same position, which is exactly the split the file's own
header comment (interleaving discouraged) warns against for finger data.

**Index-finger colours: one shade of blue each, not two different hues.**
`sky` (currently shared by both index fingers) stays with the left hand; the
right gets a different shade of the same blue family (`blue-600`, deeper than
`sky-500`) rather than an unrelated hue such as lime or indigo. A shared hue
reads as "the two index fingers, told apart by shade" — which is what they
are, a mirrored pair like the other three — where two unrelated hues would
have read as two unrelated fingers, undoing the mirroring the other three
fingers still rely on. Pinky/ring/middle/thumb keep their own hues
unchanged, so no colour collides with them.

Alternative considered: a fifth, unrelated hue (lime, indigo, cyan) for the
right index finger, maximizing hue distance from every other finger. Rejected
per explicit user preference — a hue that reads as "an unrelated finger"
undersells that the two index fingers are still one pair, just distinguished
by side rather than merged.

**Home marker rendering: a short bar, not a dot, a border, or a background
change.**
The finger colour already occupies the key's background, and the next-key
highlight already occupies the ring/border. A third indicator needs a channel
that doesn't collide with either — but it also can't be a round dot: Kedmanee
already draws a dotted circle (`◌`) under a combining mark's label so the mark
has somewhere to sit (see `decorate` in `kedmanee.js`), and `่`, the *right*
index finger's own home key, is one of those marks. A round marker at the
bottom of that exact key reads as a second occurrence of a glyph already on
it, not as an unrelated indicator. A short horizontal bar avoids that
collision by shape alone — it also happens to be closer to what a physical
keyboard's F/J bump actually looks like, a ridge rather than a dot — sized as
a fraction of the key's own width so it scales between a normal keycap and
the narrower legend swatch. It is positioned at the bottom-center of the
keycap, rendered whenever the cell's `home` flag is true, in a neutral colour
(current text colour at reduced opacity) so it reads on top of any finger
tint and
under the highlight ring.

**Legend additions are appended, not restructured.**
The existing `LEGEND` array (one entry per left-hand finger, relying on the
mirror) gains a second index-finger entry (so index appears twice, once per
colour) and one line explaining the home marker. The legend's existing
five-item layout grows to six items plus one marker note; no redesign of the
legend's layout beyond accommodating one more item.

## Risks / Trade-offs

- [Two shades of the same hue are closer together than two unrelated hues,
so the left/right index distinction could be harder to read at a glance,
especially for a colour-vision-deficient learner, than the lime/indigo
alternative would have been] → Mitigation: the shades are spaced further
apart than the tint/hover step already used elsewhere on the board
(`sky-500` vs `blue-600`, not two points on the same 500-scale), and the
aria-label and home marker carry the left/right distinction independently
of colour, so the colour is a reinforcement rather than the only signal.
- [A bar marker could be mistaken for a stray artifact or a third kind of
highlight] → Mitigation: the legend explicitly names it, per the new
"home marker is explained" scenario, and it renders identically regardless
of highlight/hover state so its meaning stays constant.
- [Marking only F/J and not the full home row could read as inconsistent once
a learner asks "why not my other fingers too?"] → Accepted trade-off per
the user's explicit choice; the proposal and spec scope this to the index
fingers only, matching the physical keyboard's own bumps.

## Migration Plan

Frontend-only, additive change to existing files; no data migration, no API
change, no backend involvement. Ship behind no flag — the change is purely
visual/additive and the existing Playwright/unit tests for the keyboard
(`__tests__/kedmanee.test.js`, `__tests__/layout.test.js`,
`e2e/practice.spec.js`) continue to exercise typing behaviour unchanged; new
tests cover the two colour and marker scenarios. Rollback is a plain revert
since no persisted state or schema is touched.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
## Why

The on-screen keyboard's finger guidance mirrors colours across hands: both index
fingers show the same blue, both pinkies the same rose, and so on. A learner
glancing at a highlighted key can tell which finger presses it but not which
hand — for the index fingers this matters most, since they anchor the whole home
row on a physical keyboard. The board also has no equivalent of the tactile
bumps a physical keyboard puts on F and J, so nothing on screen tells a learner
where their fingers should rest between keystrokes. Both gaps make the guidance
read as "here is a key" rather than "here is where your hand lives."

## What Changes

- Give the left and right index fingers their own distinct colours instead of
sharing one hue, so a highlighted index-finger key also says which hand
reaches it. Pinky, ring, and middle fingers keep mirroring across hands as
today.
- Mark the home-row key of each index finger — F and J on a Latin board, their
Kedmanee equivalents on the Thai board — with a visible resting-position
indicator, the on-screen equivalent of the tactile bump on a physical
keyboard.
- Extend the legend so the two index-finger colours and the home-position
marker are each named, not just the mirrored colours already explained.

## Capabilities

### New Capabilities

(none)

### Modified Capabilities

- `typing-practice`: the "Finger guidance on the keyboard" requirement changes
from a fully mirrored, unmarked colour scheme to one where the index fingers
carry distinct left/right colours and their home-row keys carry a resting-
position marker.

## Impact

- `src/frontend/src/lib/keyboard/board.js` — the physical board description
gains a way to mark a position as a finger's home key (currently only
position and finger are recorded).
- `src/frontend/src/components/OnScreenKeyboard.vue` — the finger-colour map
splits `l-index`/`r-index` into two colours, renders the home-position
marker, and extends the legend.
- No backend, API, or layout (`kedmanee.js`, `layout.js`) changes: the finger
assignments and key positions are unchanged, only how two of them are drawn.
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
## MODIFIED Requirements

### Requirement: Finger guidance on the keyboard
Key position alone does not tell a learner which finger to press a key with, so the on-screen keyboard SHALL show the touch-typing finger for every key, and SHALL explain what the indication means rather than relying on an unexplained visual code. The finger SHALL be a property of the physical board rather than of the layout printed on it, so every layout on that board guides the same finger to the same position. The two index fingers SHALL each carry their own colour, distinct from each other and from every other finger, so a highlighted index-finger key also says which hand reaches it; every other finger SHALL keep sharing one colour between its left and right instance. Each index finger's home-row key SHALL carry a visible resting-position marker, the on-screen equivalent of the tactile bump a physical keyboard puts on F and J, so a learner has an anchor to return to between keystrokes.

#### Scenario: Keys are grouped by finger
- **WHEN** the keyboard is displayed
- **THEN** each key carries the colour of the finger that presses it, so the columns belonging to one finger are distinguishable from its neighbours

#### Scenario: The index fingers are distinguishable by hand
- **WHEN** the keyboard is displayed
- **THEN** the left index finger's keys carry a different colour from the right index finger's keys, so the two are told apart without reading a label

#### Scenario: Other fingers still mirror across hands
- **WHEN** the keyboard is displayed
- **THEN** the pinky, ring, and middle fingers each keep one colour shared between their left and right instance, so the palette does not grow beyond what is needed to tell the index fingers apart

#### Scenario: The colours are explained
- **WHEN** the keyboard is displayed
- **THEN** a legend naming each finger accompanies it, including the two index-finger colours as distinct entries, so the colour code can be read without being guessed

#### Scenario: A key's finger does not depend on the layer
- **WHEN** the displayed layer changes to any other layer of the layout
- **THEN** the key in a given position keeps the same finger, because the finger follows the position rather than the character printed on it

#### Scenario: A key's finger does not depend on the layout
- **WHEN** two layouts on the same physical board are compared
- **THEN** a given position carries the same finger in both, because the finger belongs to the board

#### Scenario: The modifiers are covered too
- **WHEN** the keyboard is displayed
- **THEN** every modifier the layout declares, along with Backspace and the space bar, carries a finger indication on the same scheme as the character keys

#### Scenario: The next-key highlight stays legible over the colours
- **WHEN** a key is highlighted as the next expected character
- **THEN** the highlight remains distinguishable regardless of which finger colour that key carries

#### Scenario: An index finger's home key is marked
- **WHEN** the keyboard is displayed
- **THEN** the key each index finger rests on when no other key is being reached — F and J on a Latin board, their Kedmanee equivalents on the Thai board — carries a visible marker distinct from the highlight used for the next expected character

#### Scenario: The home marker does not depend on the layout or layer
- **WHEN** the displayed layout or layer changes
- **THEN** the home marker stays on the same position — the index fingers' home key on the physical board — because it marks a position, not a character

#### Scenario: The home marker survives the next-key highlight
- **WHEN** the key carrying the home marker is also the next expected character
- **THEN** both the home marker and the highlight are visible on that key, so neither indication is lost to the other

#### Scenario: The home marker is explained
- **WHEN** the keyboard is displayed
- **THEN** the legend states what the resting-position marker means, alongside the finger colours
Loading