Skip to content

Add keyboard layout selector (QWERTY/Colemak/Colemak-DH/Dvorak/Workman)#87

Open
Olson3R wants to merge 17 commits intorianadon:mainfrom
Olson3R:phase-1-keyboard-layouts
Open

Add keyboard layout selector (QWERTY/Colemak/Colemak-DH/Dvorak/Workman)#87
Olson3R wants to merge 17 commits intorianadon:mainfrom
Olson3R:phase-1-keyboard-layouts

Conversation

@Olson3R
Copy link
Copy Markdown
Contributor

@Olson3R Olson3R commented Apr 28, 2026

Summary

Cosmos has been QWERTY-only: letterForKeycap() baked QWERTY into every alpha key as it was generated, and FLIPPED_KEY mirrored those letters for the left half. This PR adds layout selection — QWERTY, Colemak, Colemak-DH, Dvorak, Workman — and threads it through the alpha-letter generation path so swapping the layout updates both keycap legends and ZMK/QMK firmware output.

This is Phase 1 of a planned three-phase rollout — letter-swap layouts only. Layers, mod-tap/layer-tap, Miryoku, and a general-purpose layer editor are scoped for follow-up PRs. See docs/LAYOUT_PHASES.md for the full plan.

Design

Layout is not stored on the config — it's a pure function of the alpha-key labels, derived the same way as clusterAngle / clusterSeparation / row curvature. detectLayout(kbd) inspects every finger cluster and returns LAYOUT.QWERTY / LAYOUT.COLEMAK / etc. when the alpha block matches one-for-one, or LAYOUT.CUSTOM when it doesn't. The dropdown reads this on every render; picking a named option mutates the keys via applyLayoutToKeys. No kbd.layout field, no state to keep in sync, no proto field.

This means custom edits in expert mode and per-key letter edits via the 3D viewer's Letter input both auto-flip the dropdown to Custom — without any reactive state-management.

What's included

Layout registry — src/lib/layouts/

Const-keyed registry exposing LAYOUT.QWERTY, LAYOUT.COLEMAK, LAYOUT.COLEMAK_DH, LAYOUT.DVORAK, LAYOUT.WORKMAN, plus LAYOUT.CUSTOM. Each named layout carries right-row letters for rows 2/3/4 and a flipMap for split-half mirroring. flipLetter, rightSideLetter, getLayout, isLayoutId, isNamedLayoutId are the public helpers.

Layout-aware letter generation

letterForKeycap, cosmosFingers, keycapInfo, flippedKey, and mirrorCluster all accept an optional layout (default: QWERTY for back-compat). Editor callers thread the keyboard's detected layout through. Number row, F-row, and outer-punctuation keys stay layout-independent — only rows 2/3/4 of the alpha block move.

Editor UI

  • Layout dropdownSelectThingy placed after Keycaps in VisualEditor2.svelte. Each option's hover popup includes a one-line description and a small ortholinear keyboard preview rendered with the layout's letters. Custom is text-only.
  • Auto-detect — the dropdown's value is detectLayout($protoConfig) on every render. Editing a single alpha key, deleting a column, or shrinking the cluster size flips it to Custom automatically.
  • Mirror-form letter editing — when the user clicks a virtual left-half key, the Letter input flips through the layout's flipMap so typing q writes p to the underlying right-cluster key.
  • Expert→basic transitionsetMode runs detectLayout on the freshly-derived cosmos config so the basic-mode dropdown reflects the post-edit layout (instead of always showing QWERTY).

Alert system

New popover-based alert framework (src/lib/store.ts + src/lib/presentation/Alert*.svelte):

  • Anchored to the right of the field that triggered the alert, with a tail/arrow pointing back at the anchor. Falls back to below-anchor on narrow viewports.
  • Single pink style, "Dismiss" text link, 10s auto-dismiss with a progress bar at the bottom; pause-on-hover stops both the timer and the bar animation.
  • Used in two places by this PR:
    1. Dropdown into Custom — info hint explaining how to edit a single key's legend.
    2. Dropdown out of Custom blocked — when the keyboard is missing alpha keys the target layout needs, the alert lists every missing letter (across both halves of split keyboards) and refuses the switch until the user adds them back.

Auto-flip safety: missingKeysFor enumerates both halves

For mirrored splits (only the right cluster stored), the synthesized left side is checked too. Deleting the visible-left q on a mirrored split surfaces both p and q as missing, not just the right-cluster's p. Non-mirror splits check each stored cluster independently.

Files touched

Area File
Layout registry src/lib/layouts/index.ts
Layout detection src/lib/worker/config.cosmos.ts (detectLayout, missingKeysFor, alphaColumns, mirrorCluster)
Letter generation src/lib/worker/config.ts, src/lib/geometry/keycaps.ts
Editor UI src/routes/beta/lib/editor/VisualEditor2.svelte, visualEditorHelpers.ts
Alert system src/lib/store.ts, src/lib/presentation/Alert.svelte, src/lib/presentation/AlertPopover.svelte
Mirror callers src/routes/beta/lib/editor/toCode.ts, src/routes/beta/lib/dialogs/HandFitView.svelte
Firmware (export) src/routes/beta/lib/firmware/zmk.ts, src/routes/beta/lib/firmware/qmk.ts (export keycode for tests)
Plan doc docs/LAYOUT_PHASES.md

Test plan

  • src/lib/layouts/layouts.test.ts — all 5 layouts × rightSideLetter, flipLetter bidirectionality, registry helpers.
  • src/lib/layouts/layoutEndToEnd.test.tsapplyLayoutToKeys updates alpha rows / preserves number rows; ZMK/QMK keycode mapping per layout; end-to-end home-row contract.
  • src/lib/layouts/missingKeys.test.tsmissingKeysFor covers shrunk-cluster, single-key-deleted, mirror-form-both-halves, non-mirror-split-left-only, expert-mode duplicate-letter, and round-trip flip-to-Custom scenarios.
  • Full suite: 58/58 pass (bun test).
  • npm run check — 0 errors.
  • Manual: open editor → switch Layout dropdown → verify 3D legends update for both halves.
  • Manual: export ZMK and QMK keymaps under each layout → verify keycodes match.
  • Manual: load an existing shared URL → verify it still decodes with QWERTY legends.
  • Manual: delete a single alpha key → dropdown silently flips to Custom; pick a layout that needs that key → blocked alert lists both halves' letters.

Notes

  • Per-key letter overrides are supported by the data model (the stored keycap.letter wins) but no UI yet — that lands with the layer editor in Phase 3.
  • An earlier draft persisted layout as proto field 33; that was removed once the design became "layout is derived from labels," so existing shared URLs are unaffected by definition (no new field, no parser change).

🤖 Generated with Claude Code

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cosmos-keyboards Ready Ready Preview May 7, 2026 6:15am

@rianadon
Copy link
Copy Markdown
Owner

rianadon commented May 1, 2026

Thanks for the PR! Took a quick look at the code and no major problems stood out.

Briefly played with these and writing up UI notes so far:

  • Layout should get its own icon (Its own icon would be great, but my best idea is reuse the Letter icon) image
  • The dropdown menu for layout should use the fancier dropdown keycaps/switches use. When you hover over a layout the popup should give an explanation on the layout and a rendering of the layout on a split ortholinear keyboard. I don't expect many people to know what all these layouts are or which is best for them.
  • There's a bug when switching from qwerty to colemak on the default model. The bottom keys ([ and ]) change to duplicates of the two keys above them.
  • Add a "Custom" option. We should detect when a user implements their own layout (i.e. changes any of the alpha keys) and change layout to "Custom". If someone manually switches the layout from qwerty to custom, show a dialog that shows them how to manually edit key legends (ideally show an image or video of how to do it, but linking to here is good enough for now).
    • Things to should switch to custom:
      • Changing an alpha key
      • Deleting a column with alpha keys
    • Things that should not switch to custom:
      • Adding an alpha key anywhere outside the ones the qwerty/colemak/etc layout uses
      • Adding a new key anywhere
  • The layout needs to be correctly computed when going from expert mode to basic mode. Right now it's always qwerty. Instead, if it one for one matches a layout (use homing key assignments to get your bearings), use that layout. Otherwise, "custom".
  • If you deleted a column of alpha keys, you're now on Custom layout. Unless you add back those keys, the site should prevent you from switching layouts. Probably a dialog that appears when you try to switch that tells you which keys you're missing.

Olson3R and others added 8 commits May 1, 2026 15:03
Cosmos previously baked QWERTY into letterForKeycap() and FLIPPED_KEY,
so every keycap legend and ZMK/QMK keycode came out QWERTY. This adds a
layout dimension to the cosmos config (proto field 33, persisted in the
URL) and wires it through the alpha-letter generation path so swapping
the layout updates both the on-keycap legends and the firmware export.

- src/lib/layouts/ exposes 5 layouts as a const-keyed registry with
  per-layout right-row letters and split-half flip maps. DEFAULT_LAYOUT
  preserves QWERTY back-compat for older shared configs.
- letterForKeycap, cosmosFingers, keycapInfo, and flippedKey accept an
  optional layout (default: QWERTY); mirrorCluster threads the keyboard's
  layout in via its callers (config.cosmos, toCode, HandFitView,
  VisualEditor2).
- Editor adds a Layout field after Keycaps. Switching layout calls
  applyLayoutToKeys, which uses alphaColumns() to update only the alpha
  block; number/F/punctuation rows stay layout-independent.
- Firmware exporters need no changes: ZMK/QMK already key off
  keycap.letter, which is now layout-aware end-to-end.

Tests cover all five layouts plus flip behavior. CI svelte-check
(bun src/scripts/check.ts) clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lock in the contract that the layout dimension flows from cosmos config
through key letters to firmware keycodes:

- Round-trip every LAYOUT_IDS value through encode → serialize → decode
  and verify the layout survives.
- Verify a legacy URL (encoded before this feature) still decodes as
  QWERTY for back-compat.
- Build a default Cosmos config and confirm applyLayoutToKeys updates
  alpha-block letters per layout while leaving the number row untouched.
- Confirm ZMK and QMK keycode() emit per-letter codes, and that the
  rightmost-of-center home-row position emits a different keycode under
  each layout (h/h/m/d/y across QWERTY/Colemak/Colemak-DH/Dvorak/Workman).

The keycode() helpers in zmk.ts and qmk.ts are now exported so the
end-to-end tests can verify the firmware contract directly without
needing a full FullGeometry stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tsconfig-paths' matchPath returns the absolute path verbatim, so a
specifier like \`\$lib/layouts\` (a folder with index.ts) resolved to
\`src/lib/layouts.js\` and 404'd in Vercel's Node-only build path. Bun
handles this natively, so it only surfaces under the Node loader.

Stat the resolved path; if it's a directory, append \`/index\` before the
extension so ts-node finds index.ts (or index.js).

Verified by importing \`\$lib/layouts\` through register_loader.js
(previously: Cannot find module 'src/lib/layouts.js'; now: resolves and
exports DEFAULT_LAYOUT, LAYOUT, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per PR review, the keycap icon was a placeholder; the Letter icon (mdiAlphaA)
is more semantically aligned with a layout selector.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR-review bug: switching from QWERTY to Colemak (or any layout swap)
duplicated the bottom-row outer-punctuation keys (e.g. [, ]) into the
row above them.

Cause: applyLayoutToKeys gated the swap on k.profile.row, but
keycapInfo() collapses row 5 → 4 for MT3 keycap-profile reasons. So a
row-5 key with letter `[` arrives in the swap loop as profile.row === 4
and its letter is replaced with the target layout's row-4 letter at the
same alpha-column index.

Fix: gate by whether the original letter is one a layout actually
manages — add isAlphaLetter() that recognizes any legend appearing in
any registered layout's row 2/3/4 (right side or, after flipping, left
side). Row-5 outer punctuation (`{`, `}`, `[`, `]`, `\`) isn't in any
alpha row, so it stays untouched across all layouts.

Tests in layouts.test.ts cover the helper directly. The end-to-end test
file was already broken pre-PR due to bun-test not resolving SvelteKit
$assets aliases (separate infra issue).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three of the PR-review items, sharing a layout-matcher and a CUSTOM enum
entry:

#5 (detect layout on expert→basic): toFullCosmosConfig used to leave
state.options.layout at QWERTY regardless of what the user actually
typed in expert mode. Run the new detectLayout(kbd) right after
toFullCosmosConfig in App.setMode so the dropdown reflects the keymap
the user has — including switching to "Custom" when the keymap doesn't
match any registered layout.

#4 (Custom layout + auto-detect): add LAYOUT.CUSTOM to the registry as
a sentinel with no letter mapping. The dropdown lists it explicitly
(LAYOUT_NAMES is the new label source). Picking Custom from a named
layout opens a small helper dialog explaining how to edit individual
key legends. A reactive `$:` block in VisualEditor2 watches protoConfig
and auto-flips to Custom whenever detectLayout no longer matches the
stored named layout — covers per-key letter edits, alpha-column
deletes, etc.

rianadon#6 (block named-layout switch from Custom when keys are missing): when
the user is on Custom and picks a named layout, missingKeysFor() checks
that the keyboard has the canonical 5-col alpha block. If not, the
dropdown reverts and a dialog lists the letters they'd need to add
back (e.g. "p ; / '").

detectLayout / missingKeysFor live in visualEditorHelpers.ts (alongside
applyLayoutToKeys — they need CosmosKeyboard awareness). The matcher
allows extra alpha columns past the canonical block (per the
maintainer: "adding a key outside the layout's range" shouldn't
auto-flip), and treats anything below 5 alpha cols as Custom.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eview

Per PR review, replace the basic <Select> with the SelectThingy used
elsewhere (keycaps, switches, microcontroller). On hover each option
shows:

  - The layout's name and a one-sentence description (added to the
    KeyboardLayout schema).
  - A small split-ortholinear render of its alpha block — left and
    right halves side-by-side, home row highlighted. Custom shows '?'
    placeholders.

The dropdown change also flips updateLayout from a DOM Event to a
SelectThingy CustomEvent. The Custom-out-of-Custom revert no longer
needs to mutate target.value — leaving $protoConfig.layout alone is
enough because SelectThingy is bound to the store value and re-syncs
on the next reactive tick.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Most cosmos configs store only the right finger cluster and synthesize
the left at render time via mirrorCluster + flipLetter (now layout-
aware after PR rianadon#87 added the layouts feature). The Letter editor in
Viewer3D was bound to the raw stored letter, so clicking a visually-
left key in 3D — say Colemak's "p" — showed the right-side
source-of-truth letter "l" in the input. Same key on screen, two
different letters in two places.

Track which visual half the user clicked in a new clickedVisualSide
store (set by KeyboardKey + KeyboardKeyInstance, both of which already
know their cluster's side). When a left-side click resolves to a
right-cluster key (i.e. mirror form is active), flip the displayed
value through flipLetter on read AND on write. Edits still go to the
right cluster's storage so the mirror behavior — edit one half,
both update — is preserved.

Type fix in the same area: ALPHA_ROWS in SelectLayoutInner is now
typed as `(2 | 3 | 4)[]` so the rightRows record can be indexed
without TS noise about a generic number index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without the protoConfig.set(next) the layout dropdown and 3D view
keep showing whatever was loaded before the expert-mode edits, even
though state.options is updated to the post-detect cosmos config.
The dropdown binds to \$protoConfig.layout, so the store has to be
the channel.

Also recompute config = fromCosmosConfig(next) so the 3D view
re-materializes from the new cosmos state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Olson3R
Copy link
Copy Markdown
Contributor Author

Olson3R commented May 1, 2026

@rianadon I think this one is good again.

Comment on lines +290 to +296
$: if ($protoConfig && ($protoConfig.layout ?? DEFAULT_LAYOUT) !== LAYOUT.CUSTOM) {
const detected = detectLayout($protoConfig)
if (detected === LAYOUT.CUSTOM) {
protoConfig.update((proto) => ({ ...proto, layout: LAYOUT.CUSTOM }))
}
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've noticed that on the editor I'm having trouble using the back button to undo. This logic looks very suspicious to me. The only things that should trigger a protoconfig update are UI interactions, not protoconfig updating. The cleaner way would be to extract this into a function, and call it in Viewer3D. The two places you should need to do it are when a key/column is deleted (removeKey) and a legend is changed (setLetter).

@rianadon
Copy link
Copy Markdown
Owner

rianadon commented May 2, 2026

More comments:

  • Sorry, the A icon was not a good choice. It looks too small compared to the other options. I'll make my own.
  • Awesome job on the dropdown menu and previews. My only comment is make the home row pink a slightly lighter pink so that there isn't as much contrast between rows.
  • Custom doesn't need a key layout preview. Just text is fine
  • There should be an alert when there's not enough keys to switch to a layout and you're stuck on Custom. I've been meaning to add an alerts system to Cosmos, and now's a good time to do it. You should be able to build a popover very similar to the ones I've already implemented for the dropdown menus that will show up next to the layout field. It'll have a dismiss button and also dismiss itself after, say, 10 seconds. There will also be a bar at the bottom that progresses across the alert as those 10 seconds go by.
    image
  • Speaking of alerts, I think it's better to move the dialog telling you that you've selected custom mode to an alert.

@rianadon
Copy link
Copy Markdown
Owner

rianadon commented May 2, 2026

The logic for changing into custom when the layout changes is very solid. I think it would also make sense to do the same for the other layouts as well. For example, in QWERTY if you change the Q to a Z then back to Q, the layout should switch from QWERTY->Custom->QWERTY. In this case, there's no point having a separate field in the config for layout. It's purely a function of the current keyboard labels. That should also simplify a ton of logic, and you can ignore my comment in VisualEditor2. Since layout isn't part of the config, you won't need to worry about state management.

The layout dropdown would work very similarly to row curvature / column curvature. Those are also fields that aren't tied to a specific config option but instead have their own logic for calculating themselves and updating the config when changed.

Olson3R and others added 3 commits May 2, 2026 23:52
Per maintainer review: layout no longer lives as a stored field on
CosmosKeyboard. It's a pure function of the alpha-key labels — like
clusterAngle/clusterSeparation already are. The dropdown calls
detectLayout(\$protoConfig) at render time and applyLayoutToKeys when
the user picks a new option. Custom is the absence of a named match.

This subsumes the maintainer's other code-review concern (the
suspicious reactive `$:` block in VisualEditor2 that broke the
browser back button) — there's nothing to auto-flip because nothing
holds stale state. The dropdown re-derives on every reactivity
trigger.

Schema changes:
- src/proto/cosmos.proto: \`reserved 33;\` for the old layout field
  number. Old URLs with \`layout=N\` are silently dropped on decode;
  the layout is re-derived from the keys' letters.
- CosmosKeyboard interface drops the \`layout\` field; KEYBOARD_DEFAULTS
  drops \`layout\`; encode/decode drops \`encodeLayout\`/\`decodeLayout\`.

Helper relocation: detectLayout, missingKeysFor, alphaColumns,
STANDARD_ALPHA_COLS move from visualEditorHelpers.ts (editor module)
into config.cosmos.ts (the data-model module) so mirrorCluster can
call detectLayout internally without circular imports.

mirrorCluster signature: takes \`kbd: CosmosKeyboard | undefined\`
instead of \`layout: LayoutId\`. Internal callers pass the kbd in
scope; the module-level cluster cache passes \`undefined\` and gets
QWERTY semantics (labels are erased anyway). All 6 internal sites +
3 external sites (HandFitView, toCode, visualEditorHelpers) updated.

Read sites converted to detectLayout(kbd):
- setClusterSize: detect BEFORE the resize so the seed letters reflect
  the user's pre-resize layout. CUSTOM falls back to QWERTY for the
  seed (CUSTOM has no canonical letters).
- Viewer3D Letter input flip-on-read/write: detectLayout(\$protoConfig)
  is the layout for the flipMap.

Write sites removed: the dropdown handler, App.setMode, and the
endToEnd test fixture all stop writing \`kbd.layout\`. The first two
just call applyLayoutToKeys; the test asserts via detectLayout instead.

Tests rewritten to round-trip via the keys (the layout survives
because applyLayoutToKeys writes Colemak/etc. letters into
\`profile.letter\`; serialize/deserialize preserves those; detectLayout
re-derives the named layout from them).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A small popover-alert system that the layout dropdown can use for
"missing keys" warnings and "switched to Custom" hints without
blocking the page with a modal Dialog. Maintainer asked for this in
PR rianadon#87 review ("now's a good time").

Public API in src/lib/store.ts:

  pushAlert({ message, anchor, variant?, durationMs? }): symbol
  dismissAlert(id)
  alerts: Writable<AlertItem[]>

\`Alert.svelte\` mounts once in App.svelte and iterates the alerts
store. Each alert renders via \`AlertPopover.svelte\`, which positions
itself just below + left-aligned with its anchor element using
\`getBoundingClientRect\` (no extra dep needed; the existing
melt-ui/svelte-easy-popover libs are heavier than necessary for this).
A 10s setTimeout drives auto-dismiss; a CSS keyframe animates a
matching progress bar at the bottom. Both pause on pointer-enter and
resume on pointer-leave so users can read longer messages. A
MutationObserver watches the anchor — if it leaves the DOM the alert
self-dismisses.

Variants: info (pink, default), warn (amber), error (red).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Custom-info dialog → an info alert anchored to the Layout field.
- "Missing keys for X" dialog → a warn alert with the missing letters.
  Both rely on the new pushAlert/Alert framework and dismiss the user
  out of a modal context (the dropdown stays usable).
- Drop the now-unused base path import and the dialog templates.
- Lighten the home-row pink in the dropdown's alpha-block preview
  per maintainer feedback (less contrast between rows; still visibly
  distinguished as the home row).
- Drop the '?' grid preview for Custom — text-only is enough.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Olson3R and others added 2 commits May 3, 2026 07:32
…tion, stricter detection

- AlertPopover: pause-on-hover timer math now subtracts active-time-before-pause (was subtracting the pause duration from total, so alerts dismissed early after a hover). Viewport clamping flips above the anchor when there's no room below. Dropped the MutationObserver — it dismissed alerts the moment the 3D viewer churned the DOM. `remaining` is initialized from the prop directly so the initial setTimeout doesn't fire on the next tick because the reactive `duration` was still undefined.
- SelectThingy: dropped createSync's writeback to local `value`, and re-sync the popover from the prop in a microtask after dispatch. Previously the user's pick was committed to local state regardless of whether the parent's change handler accepted it, so updateLayout's rejection path couldn't actually revert the dropdown.
- detectLayout now iterates ALL finger clusters, not just the right. After a left-only expert-mode edit the kbd is in non-mirror form with two explicit clusters; checking only the right would miss a duplicate or wrong-letter on the left. Each cluster also has a per-cluster duplicate-letter check so an inner/outer-col edit (e.g., stamping 'a' onto an inner pinky) is no longer ignored just because the standard 5-col alpha block still matches a named layout.
- missingKeysFor: also reports keys missing because individual keys were deleted from an alpha column (not just because the column count is short). Without this, deleting one alpha key flipped the kbd to Custom but the dropdown happily applied a named layout, which then re-detected as Custom because applyLayoutToKeys can't fill in a key that doesn't exist.
- VisualEditor2: auto-flip alert is now a `warn` (deletions can be intentional), and the wording prompts users to re-add removed keys before re-picking. Missing-keys alert wording updated to match the new failure mode.
- Icon: new `layout` icon (3×5 grid of mini-keys) used by the Layout field instead of the small mdiAlphaA.
- visualEditorHelpers: applyLayoutToKeys gate skips just row-5 punctuation (`{}[]\\`), not all non-alpha letters, so digits the user typed get overwritten when they pick a layout.
- Test: regression covering shrunk-column, single-key-deletion, expert-mode round-trip, inner-col duplicate, and left-cluster-only edit failure modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…youts

# Conflicts:
#	src/model_gen/loader.js
@Olson3R
Copy link
Copy Markdown
Contributor Author

Olson3R commented May 3, 2026

I believe it is ready to review again.

@rianadon
Copy link
Copy Markdown
Owner

rianadon commented May 4, 2026

Unless there's any design critiques you have (in which case I'd be more than happy to hear them), it would be great if you could tightly match the design in the screenshot, where the alert is to the right of the menus. That way it doesn't cover anything like it's currently doing:
image
Also please match the colors, which means this alert won't need variants or icons.

It's not necessary to spawn an alert when the key layout changes from anything -> custom. I'd imagine that if you are deleting a key, you understand you are changing the layout. So there's no need to point it out.

However, the wording of the alerts when you switch into custom using the dropdown or try to switch from custom->something else but there are too few keys is good.

My only minor suggestion is you should compute the missing keys for both halves of the split keyboard, taking into account that for a keyboard with mirrored splits, you should show two different keys. Right now if I delete q on a mirrored split then try to switch back, the error message only includes the p key.

PR rianadon#87 review feedback:
- AlertPopover anchors to the right of its field with a tail/arrow pointing
  back at the anchor; falls back to below on narrow viewports. Single pink
  style, no icon, "Dismiss" text link. Drops the variant field from
  AlertItem since the alert no longer needs info/warn/error variants.
- VisualEditor2 no longer alerts when the layout auto-flips to Custom on
  key edits or column deletions. The two manual-action alerts (dropdown
  into Custom, dropdown out blocked by missing keys) are kept.
- missingKeysFor now enumerates every visible half of the keyboard,
  synthesizing the left side via mirrorCluster in mirror form so deleting
  one alpha key on a mirrored split surfaces both halves' letters
  (e.g. deleting q reports {p, q} instead of just {p}).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rianadon
Copy link
Copy Markdown
Owner

rianadon commented May 6, 2026

Oh man, the decision to take out the custom field for layout has consequences.

Now if you change the layout to colemak then delete a key, the whole left side changes from colemak keys to qwerty keys. I think we are going to have to bring back a setting for the keyboard layout.

However, I think the best way to go about this is to have a hidden state. Which is dangerous, since there will be no direct way to observe or change the setting. What I'm thinking is that we bring back a global "layout" option for the keyboard that's qwerty, colemak, etc (but never custom), but call it "leftSideLayout". This option is only set for a keyboard when edit jointly is turned on for the board. For keyboards with both a left and a right side cluster in their config, it's null.

Backwards compatibility note: let's consider qwerty the null state for this option, since anything imported pre-this change will have qwerty layout.

The logic for the dropdown will still look the exact same. There will still be all the same options plus "custom", and they'll still operate by checking the entire keyboard layout against the predefined layouts. Hopefully that makes sense?

This means that for keyboards with "edit jointly" turned off (i.e. both a right and left cluster in their cosmosconfig), this new leftSideLayout option will have no effect and be ignored, so the logic will be the exact same as it is now. Then leftSideLayout becomes qwerty just to save some extra space in the url.
For keyboards with it turned on, you can end up in a few odd modes:

  • leftSideLayout = a keyboard layout (e.g. colemak), but the dropdown says custom. This is the solution to the original problem where you don't want the layout to change when deleting a key. Basically, this is saying for the keys that are left, use colemak mappings to determine the left side of the keyboard. I think this is fine, and the UI probably won't be in this state for long since if you're trying to put together a minimal keyboard for chording, you'll probably take control over the legends anyways by going into edit separately mode.
  • leftSideLayout = a keyboard layout, but the dropdown says a different keyboard layout. Will only happen when you have edit jointly turned off. Which is fine, because leftSideLayout doesn't affect anything in this case. This never happens for edit jointly because leftSideLayout determines the left half keys, which are checked for determining the keyboard layout.

Please let me know if you have any input on this.

@Olson3R
Copy link
Copy Markdown
Contributor Author

Olson3R commented May 7, 2026

I like the idea of making it best effort and detecting the closest layout. Maybe have an indicator if it. is complete or show missing letters.
Select the layout that most closely matches. Apply the letters to the keys that exist if they select a new layout.
Custom is an explicit choice.
Do we need Cosmos to be a fully featured layout editor? I feel like they are mainly there to help get a feel for things.

The strict per-key matcher collapsed to CUSTOM as soon as one alpha key
disagreed with the layout — including the maintainer's reported case
where deleting a key on a joint-edit Colemak board reverted the
synthesized left half to QWERTY (mirrorCluster's detectLayout fall-
through hit DEFAULT_LAYOUT). Replace it with a best-fit scorer: pick
the named layout with the most matching alpha positions across every
finger cluster, ties broken by registry order. A single off-letter
keeps the kbd on its closest layout (e.g., QWERTY with one mismatch)
and surfaces in the indicator's popover instead of dropping the layout
entirely. Sub-5-col clusters try both inner-deleted and outer-deleted
anchorings so cosmosFingers-shrunk and manually-trimmed boards both
detect cleanly.

CUSTOM is no longer reachable via auto-detection (best-fit always
returns a named layout) — drop it from the dropdown options, drop the
two Custom-related alerts (helper info + missing-keys-block), and drop
the now-dead reactive auto-flip plumbing. Replace missingKeysFor with
layoutDiff(kbd, target) → { missing, mismatched }, which picks the
anchoring that minimizes the diff per cluster and synthesizes the left
half in mirror form.

VisualEditor2 grows a status icon next to the dropdown — ✓ teal when
every canonical alpha position matches the displayed layout, ! amber
with a hover popover listing missing + mismatched letters when partial.

Tests rewritten to reflect best-fit semantics: deletion preserves the
detected layout, alien letter / duplicate / left-cluster off-key all
stay on the closest named layout with mismatches surfaced via
layoutDiff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants