Add keyboard layout selector (QWERTY/Colemak/Colemak-DH/Dvorak/Workman)#87
Add keyboard layout selector (QWERTY/Colemak/Colemak-DH/Dvorak/Workman)#87Olson3R wants to merge 17 commits intorianadon:mainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
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:
|
46e9bfa to
14ae4d6
Compare
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>
463dec1 to
f9af0ca
Compare
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>
|
@rianadon I think this one is good again. |
| $: if ($protoConfig && ($protoConfig.layout ?? DEFAULT_LAYOUT) !== LAYOUT.CUSTOM) { | ||
| const detected = detectLayout($protoConfig) | ||
| if (detected === LAYOUT.CUSTOM) { | ||
| protoConfig.update((proto) => ({ ...proto, layout: LAYOUT.CUSTOM })) | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
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).
|
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 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. |
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>
…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
|
I believe it is ready to review again. |
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>
|
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.
Please let me know if you have any input on this. |
|
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. |
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>


Summary
Cosmos has been QWERTY-only:
letterForKeycap()baked QWERTY into every alpha key as it was generated, andFLIPPED_KEYmirrored 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.mdfor 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 returnsLAYOUT.QWERTY/LAYOUT.COLEMAK/ etc. when the alpha block matches one-for-one, orLAYOUT.CUSTOMwhen it doesn't. The dropdown reads this on every render; picking a named option mutates the keys viaapplyLayoutToKeys. Nokbd.layoutfield, 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, plusLAYOUT.CUSTOM. Each named layout carries right-row letters for rows 2/3/4 and aflipMapfor split-half mirroring.flipLetter,rightSideLetter,getLayout,isLayoutId,isNamedLayoutIdare the public helpers.Layout-aware letter generation
letterForKeycap,cosmosFingers,keycapInfo,flippedKey, andmirrorClusterall 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
SelectThingyplaced after Keycaps inVisualEditor2.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.detectLayout($protoConfig)on every render. Editing a single alpha key, deleting a column, or shrinking the cluster size flips it to Custom automatically.flipMapso typingqwritespto the underlying right-cluster key.setModerunsdetectLayouton 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):Auto-flip safety:
missingKeysForenumerates both halvesFor mirrored splits (only the right cluster stored), the synthesized left side is checked too. Deleting the visible-left
qon a mirrored split surfaces bothpandqas missing, not just the right-cluster'sp. Non-mirror splits check each stored cluster independently.Files touched
src/lib/layouts/index.tssrc/lib/worker/config.cosmos.ts(detectLayout,missingKeysFor,alphaColumns,mirrorCluster)src/lib/worker/config.ts,src/lib/geometry/keycaps.tssrc/routes/beta/lib/editor/VisualEditor2.svelte,visualEditorHelpers.tssrc/lib/store.ts,src/lib/presentation/Alert.svelte,src/lib/presentation/AlertPopover.sveltesrc/routes/beta/lib/editor/toCode.ts,src/routes/beta/lib/dialogs/HandFitView.sveltesrc/routes/beta/lib/firmware/zmk.ts,src/routes/beta/lib/firmware/qmk.ts(exportkeycodefor tests)docs/LAYOUT_PHASES.mdTest plan
src/lib/layouts/layouts.test.ts— all 5 layouts ×rightSideLetter,flipLetterbidirectionality, registry helpers.src/lib/layouts/layoutEndToEnd.test.ts—applyLayoutToKeysupdates alpha rows / preserves number rows; ZMK/QMK keycode mapping per layout; end-to-end home-row contract.src/lib/layouts/missingKeys.test.ts—missingKeysForcovers 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.bun test).npm run check— 0 errors.Notes
keycap.letterwins) but no UI yet — that lands with the layer editor in Phase 3.🤖 Generated with Claude Code