diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..18aff7744
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,241 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Overview
+
+keybr.com is a touch typing learning application built with React, TypeScript, and Node.js. It uses a monorepo structure with multiple packages managed by npm workspaces. The application consists of a Node.js server backend and a webpack-bundled browser frontend.
+
+## Commands
+
+### Setup and Installation
+```bash
+npm install # Install all dependencies
+cp .env.example /etc/keybr/env # Create config file (recommended global location)
+./packages/devenv/lib/initdb.ts # Initialize database and create example users
+```
+
+### Development
+```bash
+npm start # Start dev server at http://localhost:3000
+npm run watch # Auto-rebuild on file changes (run alongside npm start)
+npm run compile # Compile TypeScript for all packages
+npm run build-dev # Build webpack bundles in development mode
+npm run build # Build webpack bundles in production mode
+```
+
+### Testing
+```bash
+env DATABASE_CLIENT=sqlite npm test # Run all tests
+npm test --workspace=@keybr/lesson # Run tests for specific package
+```
+
+### Linting and Formatting
+```bash
+npm run lint # Lint JavaScript/TypeScript files
+npm run lint-fix # Auto-fix linting issues
+npm run stylelint # Lint CSS/LESS files
+npm run stylelint-fix # Auto-fix style issues
+npm run format # Format code with Prettier
+```
+
+### Package Management
+```bash
+npm run clean # Clean all build artifacts
+lage compile --no-cache # Compile using lage build system
+lage test --no-cache # Test using lage build system
+```
+
+### Language and Content Generation
+```bash
+npm --workspace packages/keybr-generators run generate-languages # Generate phonetic models
+npm run translate # Translation utilities
+```
+
+## Architecture
+
+### Monorepo Structure
+
+The codebase is organized as an npm workspace monorepo with 60+ packages in `packages/`:
+
+**Core Domain Packages:**
+- `keybr-lesson` - Lesson generation logic and learning algorithms
+- `keybr-keyboard` - Keyboard layouts and language definitions
+- `keybr-phonetic-model` - Statistical language models for word generation
+- `keybr-result` - Typing test results and statistics
+- `keybr-settings` - User settings and preferences
+- `keybr-textinput` - Text input handling and validation
+
+**UI Packages:**
+- `keybr-widget` - Shared UI components
+- `keybr-lesson-ui` - Lesson-related UI components
+- `keybr-keyboard-ui` - Keyboard visualization components
+- `keybr-textinput-ui` - Text input UI components
+- `keybr-chart` - Charting and data visualization
+
+**Page Packages:**
+- `page-practice` - Main practice page
+- `page-profile` - User profile page
+- `page-account` - Account management
+- `page-typing-test` - Typing test page
+- `page-multiplayer` - Multiplayer mode
+- `page-highscores` - Leaderboards
+- `page-layouts` - Keyboard layout browser
+- `page-help` - Help and documentation
+
+**Infrastructure:**
+- `server` - Main Node.js server application
+- `server-cli` - CLI tools
+- `keybr-database` - Database models and migrations (Knex + Objection.js)
+- `keybr-pages-browser` - Browser entry point and routing
+- `keybr-pages-server` - Server-side rendering
+- `keybr-oauth` - OAuth authentication
+- `test-env-*` - Test environment setup packages
+
+### Application Entry Points
+
+**Server:** `packages/server/lib/main.ts`
+- Uses Node.js cluster module for multi-process architecture
+- Forks 4 HTTP worker processes (port 3000 by default)
+- Forks 1 WebSocket worker process for multiplayer (port 3001)
+- Each worker automatically restarts on failure
+
+**Browser:** `packages/keybr-pages-browser/lib/entry.ts`
+- Main React application entry point
+- Uses React Router for client-side routing
+- Lazy-loads page components
+
+### Web Framework
+
+The server uses a custom framework called `@fastr/*`:
+- `@fastr/core` - Core application framework
+- `@fastr/invert` - Dependency injection container
+- `@fastr/middleware-*` - Express-like middleware
+- Middleware are composed in `packages/server/lib/app/module.ts`
+
+### Database
+
+- **ORM:** Knex query builder + Objection.js models
+- **Supported databases:** SQLite (dev), MySQL (production), better-sqlite3
+- **Schema:** Defined in `packages/keybr-database/lib/model.ts`
+- **Main tables:** User, UserExternalId, Order, UserLoginRequest
+- **Data loaders:** Packages like `keybr-result-loader`, `keybr-settings-loader` handle data persistence
+
+### Build System
+
+**Webpack Configuration:** `webpack.config.js`
+- Two separate builds: "server" (Node.js target) and "browser" (web target)
+- Server build outputs to `root/lib/`
+- Browser build outputs to `root/public/assets/`
+- Uses content hashing for production builds
+- Shared chunks: vendor libraries, widgets, keyboard components
+- CSS extraction with MiniCssExtractPlugin
+- LESS preprocessing with CSS Modules (camelCase, hashed class names)
+- TypeScript compiled with ts-loader
+- Compression (gzip + brotli) in production
+
+**Task Runner:** Uses `lage` for orchestrating package builds and tests across the monorepo.
+
+**TypeScript:** Each package has its own `tsconfig.json` extending template configs from `packages/tsconfig-template*.json`
+
+### Internationalization
+
+- Uses `react-intl` for i18n
+- Custom transformer `@keybr/scripts/intl-transformer.js` extracts and compiles messages
+- Translation workflow via POEditor (see `scripts/poeditor-pull.js`)
+- Messages defined inline with `formatMessage` and enforced by ESLint rules
+
+### Code Generation and Grammars
+
+The `keybr-code` package includes:
+- Programming language syntax parsers and grammars
+- Code generation for practice lessons
+- Grammar files in `packages/keybr-code/lib/syntax/`
+
+### Lesson Generation Algorithm
+
+The core typing lesson algorithm:
+1. **Phonetic model** generates realistic words based on language statistics
+2. **Key progression** system adds letters incrementally based on user performance
+3. **Target speed** tracking adapts difficulty to user's skill level
+4. **Statistics collection** tracks per-key speed and accuracy
+5. **Learning rate** calculations predict progress
+
+Key packages: `keybr-lesson`, `keybr-phonetic-model`, `keybr-learningrate`
+
+### State Management
+
+- React Context API for global state (no Redux)
+- Settings stored in database and loaded via data loaders
+- Real-time updates via WebSocket for multiplayer mode
+
+## Package Dependencies
+
+Packages use `*` for internal dependencies in package.json, resolved via workspace protocol. External dependencies are pinned versions. When adding a new package dependency:
+- Use `@keybr/*` namespace for internal packages
+- Add to `dependencies` in package.json
+- Import with `.js` extension (required by ESLint)
+
+## Testing
+
+- Test runner: Custom `tstest` command (defined in package scripts)
+- Test environment packages provide setup: `test-env-browser`, `test-env-bundler`, `test-env-server`
+- Testing libraries: `@testing-library/react`, `@testing-library/dom`, `@testing-library/user-event`
+- Tests located alongside source: `lib/**/*.test.{ts,tsx}`
+- SQLite required for tests: `env DATABASE_CLIENT=sqlite npm test`
+
+## Code Style
+
+**ESLint config:** `eslint.config.js` (Flat config format)
+- Enforces type imports with `@typescript-eslint/consistent-type-imports`
+- Requires `.js` file extensions in imports (`n/file-extension-in-import`)
+- Import sorting with `eslint-plugin-simple-import-sort`
+- React hooks rules enforced in browser code
+- formatjs rules for i18n message validation
+
+**TypeScript conventions:**
+- Prefer `type` over `interface` (enforced)
+- Use inline type imports: `import { type Foo } from "./foo.js"`
+- Decorators enabled for dependency injection
+
+**React conventions:**
+- JSX runtime (no React import needed)
+- Fragments use `<>` syntax
+- Boolean props always explicit: ``
+- Handler names follow convention (enforced by `react/jsx-handler-names`)
+
+## Adding New Features
+
+### Adding a Language
+See `docs/custom_language.md`:
+1. Define language in `packages/keybr-keyboard/lib/language.ts`
+2. Add dictionary file: `packages/keybr-generators/dictionaries/dictionary-.csv.gz`
+3. Run `npm --workspace packages/keybr-generators run generate-languages`
+4. Commit generated model and word list files
+
+### Adding a Keyboard Layout
+See `docs/custom_keyboard.md` for the process.
+
+### Adding a Page
+1. Create package in `packages/page-/`
+2. Export main component from `lib/index.ts`
+3. Add route in `packages/keybr-pages-browser/lib/pages/`
+4. Import and register in routing configuration
+
+## Configuration
+
+**Environment variables:** Configured via `.env` or `/etc/keybr/env`
+- `APP_URL` - Application URL
+- `DATABASE_CLIENT` - Database type (sqlite/mysql)
+- `DATABASE_FILENAME` - SQLite database path
+- `DATA_DIR` - Data directory location
+- `COOKIE_DOMAIN`, `COOKIE_SECURE` - Cookie settings
+- `MAIL_*` - Email configuration
+
+## Important Notes
+
+- **Node version:** Requires Node.js v24+
+- **File extensions:** All imports must include `.js` extension (enforced)
+- **Module type:** Package is ESM (`"type": "module"`)
+- **Port 80/443:** Requires capability: `sudo setcap cap_net_bind_service=+ep $(which node)`
+- **Character encoding:** Extensive Unicode support via `@keybr/unicode` package
diff --git a/package-lock.json b/package-lock.json
index 03e2d8ff6..071376979 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -176,6 +176,7 @@
"integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
@@ -285,6 +286,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -326,6 +328,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1929,6 +1932,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -2128,6 +2132,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2138,6 +2143,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2214,6 +2220,7 @@
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.1",
"@typescript-eslint/types": "8.48.1",
@@ -2684,6 +2691,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2741,6 +2749,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -3199,6 +3208,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4686,6 +4696,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7201,6 +7212,7 @@
"integrity": "sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"copy-anything": "^2.0.1",
"parse-node-version": "^1.0.1",
@@ -7913,6 +7925,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -8577,6 +8590,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -9107,6 +9121,7 @@
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -9385,6 +9400,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9394,6 +9410,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -10425,6 +10442,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-syntax-patches-for-csstree": "^1.0.19",
@@ -10973,6 +10991,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -11253,6 +11272,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11435,6 +11455,7 @@
"integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
diff --git a/packages/keybr-keyboard-ui/lib/Key.tsx b/packages/keybr-keyboard-ui/lib/Key.tsx
index 56858b795..d44f8008c 100644
--- a/packages/keybr-keyboard-ui/lib/Key.tsx
+++ b/packages/keybr-keyboard-ui/lib/Key.tsx
@@ -1,5 +1,6 @@
import {
type DeadCharacter,
+ type Keyboard,
KeyCharacters,
type KeyShape,
type LabelShape,
@@ -22,9 +23,14 @@ export type KeyProps = {
export function makeKeyComponent(
{ letterName }: Language,
shape: KeyShape,
+ keyboard?: Keyboard,
): FunctionComponent {
const { isCodePoint, isDead, isLigature } = KeyCharacters;
const { id, a, b, c, d } = shape;
+
+ // Get extended characters for chord layouts (e.g., 月配列2-263)
+ const keyChars = keyboard?.characters.get(id);
+ const extendedChars = keyChars?.characters;
const x = shape.x * keySize;
const y = shape.y * keySize;
const w = shape.w * keySize - keyGap;
@@ -93,6 +99,28 @@ export function makeKeyComponent(
if (isLigature(d)) {
children.push(makeLigatureLabel(d, 25, 12, styles.secondarySymbol));
}
+
+ // Display extended characters for chord layouts (layers 4, 5, 6)
+ if (extendedChars && extendedChars.length > 4) {
+ // Layer 4: ★ (KeyD chord) - display on right side, top
+ const e = extendedChars[4];
+ if (isCodePoint(e)) {
+ children.push(makeCodePointLabel(e, 33, 10, styles.secondarySymbol));
+ }
+
+ // Layer 5: ☆ (KeyK chord) - display on right side, middle
+ const f = extendedChars[5];
+ if (isCodePoint(f)) {
+ children.push(makeCodePointLabel(f, 33, 20, styles.secondarySymbol));
+ }
+
+ // Layer 6: ※ (KeyF chord) - display on right side, bottom
+ const g = extendedChars[6];
+ if (isCodePoint(g)) {
+ children.push(makeCodePointLabel(g, 33, 30, styles.secondarySymbol));
+ }
+ }
+
const zoneClassName = zoneClassNameOf(shape);
function KeyComponent({
depressed,
diff --git a/packages/keybr-keyboard-ui/lib/KeyLayer.tsx b/packages/keybr-keyboard-ui/lib/KeyLayer.tsx
index 68bf41063..882489775 100644
--- a/packages/keybr-keyboard-ui/lib/KeyLayer.tsx
+++ b/packages/keybr-keyboard-ui/lib/KeyLayer.tsx
@@ -92,7 +92,7 @@ class MemoizedKeyElements {
readonly keyboard: Keyboard,
readonly shape: KeyShape,
) {
- const Component = makeKeyComponent(keyboard.layout.language, shape);
+ const Component = makeKeyComponent(keyboard.layout.language, shape, keyboard);
this.component = Component;
this.state0 = (
0 (base layer)
+ *
+ * // KeyK (☆) pressed
+ * resolveChordLayer(['KeyK', 'KeyQ'], metadata) // => 5 (☆ layer)
+ *
+ * // Multiple chord keys pressed - prioritize KeyD > KeyK > KeyF
+ * resolveChordLayer(['KeyD', 'KeyK'], metadata) // => 4 (★ layer has priority)
+ * ```
+ */
+export function resolveChordLayer(
+ depressedKeys: readonly KeyId[],
+ chordMetadata: ChordLayoutMetadata,
+): number {
+ const { chordModifiers } = chordMetadata;
+
+ // Priority order: KeyD (★) > KeyK (☆) > KeyF (※)
+ // Check in priority order and return the first match
+ const priorityOrder: KeyId[] = ["KeyD", "KeyK", "KeyF"];
+
+ for (const priorityKey of priorityOrder) {
+ if (
+ chordModifiers[priorityKey] !== undefined &&
+ depressedKeys.includes(priorityKey)
+ ) {
+ return chordModifiers[priorityKey];
+ }
+ }
+
+ // No chord modifier keys pressed - return base layer
+ return 0;
+}
diff --git a/packages/keybr-keyboard/lib/keyboard.ts b/packages/keybr-keyboard/lib/keyboard.ts
index b8fc6b403..91b669e53 100644
--- a/packages/keybr-keyboard/lib/keyboard.ts
+++ b/packages/keybr-keyboard/lib/keyboard.ts
@@ -6,6 +6,7 @@ import { KeyModifier } from "./keymodifier.ts";
import { KeyShape } from "./keyshape.ts";
import { getExampleLetters, getExampleText } from "./language.ts";
import { type Layout } from "./layout.ts";
+import { TSUKI_2_263_LEARNING_ORDER } from "./layout/ja_tsuki_2_263.ts";
import {
type CharacterDict,
type DeadCharacter,
@@ -33,14 +34,32 @@ export class Keyboard {
const shapes = new Map();
const zones = new Map();
- for (const [id, [a = null, b = null, c = null, d = null]] of Object.entries(
- characterDict,
- )) {
- characters.set(id, new KeyCharacters(id, a, b, c, d));
+ for (const [id, chars] of Object.entries(characterDict)) {
+ const [a = null, b = null, c = null, d = null] = chars;
+ // For chord layouts (length > 4), pass the full character array
+ const keyChars =
+ chars.length > 4
+ ? new KeyCharacters(id, a, b, c, d, chars)
+ : new KeyCharacters(id, a, b, c, d);
+ characters.set(id, keyChars);
}
- for (const { id, a, b, c, d } of characters.values()) {
- if (KeyCharacters.isCodePoint(a)) {
+ // Get chord modifier keys (D, K, F) that should not be registered as standalone keys
+ const chordModifierKeys = new Set();
+ if (layout.chordMetadata?.chordModifiers) {
+ for (const keyId of Object.keys(layout.chordMetadata.chordModifiers)) {
+ chordModifierKeys.add(keyId);
+ }
+ }
+
+ for (const keyChars of characters.values()) {
+ const { id, a, b, c, d } = keyChars;
+
+ // Skip base character (index 0) for chord modifier keys
+ // They should only work in combination with other keys
+ const isChordModifier = chordModifierKeys.has(id);
+
+ if (KeyCharacters.isCodePoint(a) && !isChordModifier) {
addCombo(combos, a, id, KeyModifier.None);
}
if (KeyCharacters.isCodePoint(b)) {
@@ -52,6 +71,17 @@ export class Keyboard {
if (KeyCharacters.isCodePoint(d)) {
addCombo(combos, d, id, KeyModifier.ShiftAlt);
}
+
+ // For chord layouts, add combos for extended character array
+ // This includes post-modifier results (dakuten, handakuten) and chord layers
+ if (keyChars.characters && keyChars.characters.length > 4) {
+ for (let i = 1; i < keyChars.characters.length; i++) {
+ const char = keyChars.characters[i];
+ if (KeyCharacters.isCodePoint(char)) {
+ addCombo(combos, char, id, KeyModifier.None);
+ }
+ }
+ }
}
for (const { id, a, b, c, d } of characters.values()) {
@@ -111,6 +141,10 @@ export class Keyboard {
}: Partial = {}): WeightedCodePointSet {
const list: CodePoint[] = [];
const weights = new Map();
+
+ // Check if this is 月配列2-263 layout - use custom learning order
+ const useTsukiLearningOrder = this.layout.id === "ja-tsuki-2-263";
+
for (const combo of this.combos.values()) {
const shape = this.getShape(combo.id);
if (
@@ -120,16 +154,54 @@ export class Keyboard {
(zones == null || shape?.inAnyZone(zones))
) {
list.push(combo.codePoint);
- switch (shape?.row) {
- case "home":
- weights.set(combo.codePoint, 1);
- break;
- case "top":
- weights.set(combo.codePoint, 2);
- break;
+
+ if (useTsukiLearningOrder) {
+ // Use custom learning order for 月配列2-263
+ const customWeight = TSUKI_2_263_LEARNING_ORDER.get(combo.codePoint);
+ if (customWeight != null) {
+ weights.set(combo.codePoint, customWeight);
+ }
+ } else {
+ // Default behavior for other layouts
+ switch (shape?.row) {
+ case "home":
+ weights.set(combo.codePoint, 1);
+ break;
+ case "top":
+ weights.set(combo.codePoint, 2);
+ break;
+ }
}
}
}
+
+ // For chord layouts, also include extended character array characters (slots 4-6)
+ for (const keyChars of this.characters.values()) {
+ if (keyChars.characters && keyChars.characters.length > 4) {
+ const shape = this.getShape(keyChars.id);
+ if (zones == null || shape?.inAnyZone(zones)) {
+ // Process chord layer characters (slots 4, 5, 6)
+ for (let i = 4; i < keyChars.characters.length; i++) {
+ const char = keyChars.characters[i];
+ if (KeyCharacters.isCodePoint(char)) {
+ list.push(char);
+
+ if (useTsukiLearningOrder) {
+ // Use custom learning order for chord characters
+ const customWeight = TSUKI_2_263_LEARNING_ORDER.get(char);
+ if (customWeight != null) {
+ weights.set(char, customWeight);
+ }
+ } else {
+ // Default weight for chord characters
+ weights.set(char, 3);
+ }
+ }
+ }
+ }
+ }
+ }
+
const codePoints = new Set(list.sort((a, b) => a - b));
return new (class implements WeightedCodePointSet {
[Symbol.iterator](): IterableIterator {
diff --git a/packages/keybr-keyboard/lib/keycharacters.ts b/packages/keybr-keyboard/lib/keycharacters.ts
index 5acf6b772..e32296bdc 100644
--- a/packages/keybr-keyboard/lib/keycharacters.ts
+++ b/packages/keybr-keyboard/lib/keycharacters.ts
@@ -31,6 +31,8 @@ export class KeyCharacters {
readonly b: Character | null;
readonly c: Character | null;
readonly d: Character | null;
+ /** Extended character array for chord layouts (optional, length 7+) */
+ readonly characters?: readonly (Character | null)[];
constructor(
id: KeyId,
@@ -38,12 +40,14 @@ export class KeyCharacters {
b: Character | null,
c: Character | null,
d: Character | null,
+ characters?: readonly (Character | null)[],
) {
this.id = id;
this.a = a || null;
this.b = b || null;
this.c = c || null;
this.d = d || null;
+ this.characters = characters;
}
getCodePoint(modifier: KeyModifier): CodePoint | null {
@@ -61,6 +65,40 @@ export class KeyCharacters {
}
}
+ /**
+ * Gets the code point for a chord layout key press.
+ * Supports extended character arrays with multiple chord layers.
+ *
+ * @param chordLayer The chord layer index (0 = base, 4 = ★, 5 = ☆, 6 = ※)
+ * @param modifier The key modifier (None, Shift, Alt, ShiftAlt)
+ * @returns The code point for the key+layer+modifier combination, or null
+ *
+ * Character array slots for chord layouts (length 7):
+ * [0] = None, [1] = Shift, [2] = Alt, [3] = ShiftAlt,
+ * [4] = ★ (KeyD), [5] = ☆ (KeyK), [6] = ※ (KeyF)
+ */
+ getCodePointForChord(
+ chordLayer: number,
+ modifier: KeyModifier,
+ ): CodePoint | null {
+ // If no extended characters or base layer, use standard method
+ if (!this.characters || this.characters.length <= 4 || chordLayer === 0) {
+ return this.getCodePoint(modifier);
+ }
+
+ // For chord layers, the layer index IS the base index
+ // Standard modifiers don't apply within chord layers for this layout
+ // (each chord layer only has one character per key)
+ const char = this.characters[chordLayer];
+
+ // Fallback: try base layer with modifier if chord layer is empty
+ if (char == null) {
+ return this.getCodePoint(modifier);
+ }
+
+ return select(char);
+ }
+
get valid() {
return Boolean(this.a || this.b || this.c || this.d);
}
diff --git a/packages/keybr-keyboard/lib/language.ts b/packages/keybr-keyboard/lib/language.ts
index ce7337b32..068c7e097 100644
--- a/packages/keybr-keyboard/lib/language.ts
+++ b/packages/keybr-keyboard/lib/language.ts
@@ -200,7 +200,7 @@ export class Language implements EnumItem {
Language.HR,
Language.HU,
Language.IT,
- // Language.JA,
+ Language.JA,
Language.LT,
Language.LV,
Language.NB,
diff --git a/packages/keybr-keyboard/lib/layout.ts b/packages/keybr-keyboard/lib/layout.ts
index 0b7b4befc..80c113093 100755
--- a/packages/keybr-keyboard/lib/layout.ts
+++ b/packages/keybr-keyboard/lib/layout.ts
@@ -1,7 +1,9 @@
import { Enum, XEnum, type XEnumItem } from "@keybr/lang";
import { Geometry } from "./geometry.ts";
import { Language } from "./language.ts";
+import { TSUKI_2_263_CHORD_METADATA } from "./layout/ja_tsuki_2_263.ts";
import { angleMod, angleWideMod, type Mod, nullMod } from "./mod.ts";
+import { type ChordLayoutMetadata } from "./types.ts";
export class Layout implements XEnumItem {
static custom(language: Language) {
@@ -1321,6 +1323,23 @@ export class Layout implements XEnumItem {
/* emulate= */ false,
/* geometries= */ new Enum(Geometry.ANSI_101),
);
+ static readonly JA_TSUKI_2_263 = new Layout(
+ /* id= */ "ja-tsuki-2-263",
+ /* xid= */ 0xba,
+ /* name= */ "月配列2-263",
+ /* family= */ "ja-tsuki",
+ /* language= */ Language.JA,
+ /* emulate= */ false,
+ /* geometries= */ new Enum(
+ Geometry.ANSI_101,
+ Geometry.ANSI_101_FULL,
+ Geometry.ISO_102,
+ Geometry.ISO_102_FULL,
+ Geometry.MATRIX,
+ ),
+ /* mod= */ nullMod,
+ /* chordMetadata= */ TSUKI_2_263_CHORD_METADATA,
+ );
static readonly ALL = new XEnum(
Layout.EN_US,
@@ -1384,6 +1403,7 @@ export class Layout implements XEnumItem {
Layout.HU_HU,
Layout.IT_IT,
// Layout.JA_JP,
+ Layout.JA_TSUKI_2_263,
Layout.LT_LT,
Layout.LV_LV,
Layout.NB_NO,
@@ -1472,6 +1492,7 @@ export class Layout implements XEnumItem {
readonly emulate: boolean,
readonly geometries: Enum,
readonly mod: Mod = nullMod,
+ readonly chordMetadata?: ChordLayoutMetadata,
) {
Object.freeze(this);
}
diff --git a/packages/keybr-keyboard/lib/layout/ja_tsuki_2_263.ts b/packages/keybr-keyboard/lib/layout/ja_tsuki_2_263.ts
new file mode 100644
index 000000000..c10e929c3
--- /dev/null
+++ b/packages/keybr-keyboard/lib/layout/ja_tsuki_2_263.ts
@@ -0,0 +1,232 @@
+// Generated file for 月配列2-263 (Tsuki-hairetsu 2-263) keyboard layout
+// This layout uses chord-based input where KeyD and KeyK act as prefix shift modifier keys
+
+import { type CharacterDict, type ChordLayoutMetadata } from "../types.ts";
+import { type CodePoint } from "@keybr/unicode";
+
+// Character array slots: [None, Shift, Alt, ShiftAlt, ★(KeyD), ☆(KeyK)]
+
+// prettier-ignore
+export const LAYOUT_JA_TSUKI_2_263: CharacterDict = {
+ // Top row
+ Backquote: [null, null, null, null, null, null],
+ Digit1: [null, null, null, null, null, null],
+ Digit2: [null, null, null, null, null, null],
+ Digit3: [null, null, null, null, null, null],
+ Digit4: [null, null, null, null, null, null],
+ Digit5: [null, null, null, null, null, null],
+ Digit6: [null, null, null, null, null, null],
+ Digit7: [null, null, null, null, null, null],
+ Digit8: [null, null, null, null, null, null],
+ Digit9: [null, null, null, null, null, null],
+ Digit0: [null, null, null, null, null, null],
+ Minus: [null, null, null, null, null, null],
+ Equal: [null, null, null, null, null, null],
+
+ // QWERTY row
+ KeyQ: [/* そ */ 0x305d, null, null, null, null, /* ぁ */ 0x3041],
+ KeyW: [/* こ */ 0x3053, null, null, null, null, /* ひ */ 0x3072],
+ KeyE: [/* し */ 0x3057, null, null, null, null, /* ほ */ 0x307b],
+ KeyR: [/* て */ 0x3066, null, null, null, null, /* ふ */ 0x3075],
+ KeyT: [/* ょ */ 0x3087, null, null, null, null, /* め */ 0x3081],
+ KeyY: [/* つ */ 0x3064, null, null, null, /* ぬ */ 0x306c, null],
+ KeyU: [/* ん */ 0x3093, null, null, null, /* え */ 0x3048, null],
+ KeyI: [/* い */ 0x3044, null, null, null, /* み */ 0x307f, null],
+ KeyO: [/* の */ 0x306e, null, null, null, /* や */ 0x3084, null],
+ KeyP: [/* り */ 0x308a, null, null, null, /* ぇ */ 0x3047, null],
+ BracketLeft: [/* ち */ 0x3061, null, null, null, null, null],
+ BracketRight: [null, null, null, null, null, null],
+ Backslash: [/* ・ */ 0x30fb, null, null, null, null, null],
+
+ // ASDF row (home row)
+ KeyA: [/* は */ 0x306f, /* ば */ 0x3070, /* ぱ */ 0x3071, null, null, /* ぃ */ 0x3043],
+ KeyS: [/* か */ 0x304b, /* が */ 0x304c, null, null, null, /* を */ 0x3092],
+ KeyD: [/* ★ */ 0x3057, /* じ */ 0x3058, null, null, null, /* ら */ 0x3089], // ★ modifier key (also types し when used alone)
+ KeyF: [/* と */ 0x3068, /* ど */ 0x3069, null, null, null, /* あ */ 0x3042], // Normal key (k+f = あ)
+ KeyG: [/* た */ 0x305f, /* だ */ 0x3060, null, null, null, /* よ */ 0x3088],
+ KeyH: [/* く */ 0x304f, /* ぐ */ 0x3050, null, null, /* ま */ 0x307e, null],
+ KeyJ: [/* う */ 0x3046, null, null, null, /* お */ 0x304a, null],
+ KeyK: [/* の */ 0x306e, null, null, null, /* も */ 0x3082, null], // ☆ modifier key
+ KeyL: [/* ゛ */ 0x309b, /* ゛ */ 0x309b, null, null, /* わ */ 0x308f, null], // Also dakuten post-modifier
+ Semicolon: [/* き */ 0x304d, /* ぎ */ 0x304e, null, null, /* ゆ */ 0x3086, null],
+ Quote: [/* れ */ 0x308c, null, null, null, null, null],
+
+ // ZXCV row
+ KeyZ: [/* す */ 0x3059, /* ず */ 0x305a, null, null, null, /* ぅ */ 0x3045],
+ KeyX: [/* け */ 0x3051, /* げ */ 0x3052, null, null, null, /* へ */ 0x3078],
+ KeyC: [/* に */ 0x306b, null, null, null, null, /* せ */ 0x305b],
+ KeyV: [/* な */ 0x306a, null, null, null, null, /* ゅ */ 0x3085],
+ KeyB: [/* さ */ 0x3055, /* ざ */ 0x3056, null, null, null, /* ゃ */ 0x3083],
+ KeyN: [/* っ */ 0x3063, null, null, null, /* む */ 0x3080, null],
+ KeyM: [/* る */ 0x308b, null, null, null, /* ろ */ 0x308d, null],
+ Comma: [/* 、 */ 0x3001, null, null, null, /* ね */ 0x306d, null],
+ Period: [/* 。 */ 0x3002, null, null, null, null, null],
+ Slash: [/* ゜ */ 0x309c, /* ゜ */ 0x309c, null, null, /* ぉ */ 0x3049, null], // Also handakuten post-modifier
+
+ // Space
+ Space: [/* SPACE */ 0x0020, /* SPACE */ 0x0020, null, null, null, null],
+};
+
+// Dakuten (゛) conversion map: か→が, き→ぎ, etc.
+const dakutenMap = new Map([
+ // か行 → が行
+ [0x304b, 0x304c], // か → が
+ [0x304d, 0x304e], // き → ぎ
+ [0x304f, 0x3050], // く → ぐ
+ [0x3051, 0x3052], // け → げ
+ [0x3053, 0x3054], // こ → ご
+ // さ行 → ざ行
+ [0x3055, 0x3056], // さ → ざ
+ [0x3057, 0x3058], // し → じ
+ [0x3059, 0x305a], // す → ず
+ [0x305b, 0x305c], // せ → ぜ
+ [0x305d, 0x305e], // そ → ぞ
+ // た行 → だ行
+ [0x305f, 0x3060], // た → だ
+ [0x3061, 0x3062], // ち → ぢ
+ [0x3064, 0x3065], // つ → づ
+ [0x3066, 0x3067], // て → で
+ [0x3068, 0x3069], // と → ど
+ // は行 → ば行
+ [0x306f, 0x3070], // は → ば
+ [0x3072, 0x3073], // ひ → び
+ [0x3075, 0x3076], // ふ → ぶ
+ [0x3078, 0x3079], // へ → べ
+ [0x307b, 0x307c], // ほ → ぼ
+]);
+
+// Handakuten (゜) conversion map: は→ぱ, ひ→ぴ, etc.
+const handakutenMap = new Map([
+ [0x306f, 0x3071], // は → ぱ
+ [0x3072, 0x3074], // ひ → ぴ
+ [0x3075, 0x3077], // ふ → ぷ
+ [0x3078, 0x307a], // へ → ぺ
+ [0x307b, 0x307d], // ほ → ぽ
+]);
+
+/**
+ * Applies dakuten (゛) to a hiragana character.
+ * @param baseChar The base character to modify
+ * @returns The modified character with dakuten, or null if not applicable
+ */
+export function applyDakuten(baseChar: CodePoint): CodePoint | null {
+ return dakutenMap.get(baseChar) ?? null;
+}
+
+/**
+ * Applies handakuten (゜) to a hiragana character.
+ * @param baseChar The base character to modify
+ * @returns The modified character with handakuten, or null if not applicable
+ */
+export function applyHandakuten(baseChar: CodePoint): CodePoint | null {
+ return handakutenMap.get(baseChar) ?? null;
+}
+
+/**
+ * Metadata for the 月配列2-263 chord layout.
+ * Defines which keys act as chord modifiers and post-modifiers.
+ */
+export const TSUKI_2_263_CHORD_METADATA: ChordLayoutMetadata = {
+ chordModifiers: {
+ KeyD: 4, // ★ layer
+ KeyK: 5, // ☆ layer
+ },
+ postModifiers: {
+ KeyL: applyDakuten,
+ Slash: applyHandakuten,
+ },
+ layerCount: 6,
+};
+
+/**
+ * Custom learning order for 月配列2-263.
+ * Order: は、か、う、き、ば、が、ど、ぎスタート + progression
+ */
+export const TSUKI_2_263_LEARNING_ORDER = new Map([
+ [0x306f, 1], // は
+ [0x304b, 2], // か
+ [0x3046, 3], // う
+ [0x304d, 4], // き
+ [0x3070, 5], // ば
+ [0x304c, 6], // が
+ [0x3069, 7], // ど
+ [0x304e, 8], // ぎ
+ [0x306e, 9], // の
+ [0x306b, 10], // に
+ [0x305f, 11], // た
+ [0x3044, 12], // い
+ [0x3092, 13], // を
+ [0x3068, 14], // と
+ [0x308b, 15], // る
+ [0x3057, 16], // し
+ [0x3067, 17], // で
+ [0x3066, 18], // て
+ [0x306a, 19], // な
+ [0x3063, 20], // っ
+ [0x308c, 21], // れ
+ [0x3089, 22], // ら
+ [0x3082, 23], // も
+ [0x3059, 24], // す
+ [0x308a, 25], // り
+ [0x3053, 26], // こ
+ [0x3060, 27], // だ
+ [0x307e, 28], // ま
+ [0x3055, 29], // さ
+ [0x3081, 30], // め
+ [0x304f, 31], // く
+ [0x3042, 32], // あ
+ [0x3051, 33], // け
+ [0x3093, 34], // ん
+ [0x3048, 35], // え
+ [0x3088, 36], // よ
+ [0x3064, 37], // つ
+ [0x3084, 38], // や
+ [0x305d, 39], // そ
+ [0x308f, 40], // わ
+ [0x3061, 41], // ち
+ [0x307f, 42], // み
+ [0x305b, 43], // せ
+ [0x308d, 44], // ろ
+ [0x304a, 45], // お
+ [0x3058, 46], // じ
+ [0x3079, 47], // べ
+ [0x305a, 48], // ず
+ [0x3052, 49], // げ
+ [0x307b, 50], // ほ
+ [0x3078, 51], // へ
+ [0x3073, 52], // び
+ [0x3080, 53], // む
+ [0x3054, 54], // ご
+ [0x306d, 55], // ね
+ [0x3076, 56], // ぶ
+ [0x3050, 57], // ぐ
+ [0x3072, 58], // ひ
+ [0x3087, 59], // ょ
+ [0x3065, 60], // づ
+ [0x307c, 61], // ぼ
+ [0x3056, 62], // ざ
+ [0x3075, 63], // ふ
+ [0x3083, 64], // ゃ
+ [0x305e, 65], // ぞ
+ [0x3086, 66], // ゆ
+ [0x305c, 67], // ぜ
+ [0x306c, 68], // ぬ
+ [0x3071, 69], // ぱ
+ [0x3085, 70], // ゅ
+ [0x3074, 71], // ぴ
+ [0x307d, 72], // ぽ
+ [0x3077, 73], // ぷ
+ [0x307a, 74], // ぺ
+ [0x3041, 75], // ぁ
+ [0x3047, 76], // ぇ
+ [0x3062, 77], // ぢ
+ // Additional characters
+ [0x3043, 78], // ぃ (A+☆)
+ [0x3045, 79], // ぅ (Z+☆)
+ [0x3049, 80], // ぉ (/+☆)
+ [0x309b, 81], // ゛ (L)
+ [0x309c, 82], // ゜ (/)
+ [0x3001, 83], // 、 (,)
+ [0x3002, 84], // 。 (.)
+ [0x30fb, 85], // ・ (\)
+]);
diff --git a/packages/keybr-keyboard/lib/load.ts b/packages/keybr-keyboard/lib/load.ts
index a6439572c..f52201863 100755
--- a/packages/keybr-keyboard/lib/load.ts
+++ b/packages/keybr-keyboard/lib/load.ts
@@ -83,6 +83,7 @@ import { LAYOUT_HU_HU } from "./layout/hu_hu.ts";
import { LAYOUT_IT_IT } from "./layout/it_it.ts";
import { LAYOUT_JA_JP } from "./layout/ja_jp.ts";
import { LAYOUT_JA_JP_JIS } from "./layout/ja_jp_jis.ts";
+import { LAYOUT_JA_TSUKI_2_263 } from "./layout/ja_tsuki_2_263.ts";
import { LAYOUT_LT_LT } from "./layout/lt_lt.ts";
import { LAYOUT_LV_LV } from "./layout/lv_lv.ts";
import { LAYOUT_NB_DVORAK } from "./layout/nb_dvorak.ts";
@@ -184,6 +185,7 @@ const layouts = new Map([
[Layout.HU_HU, LAYOUT_HU_HU],
[Layout.IT_IT, LAYOUT_IT_IT],
[Layout.JA_JP, LAYOUT_JA_JP],
+ [Layout.JA_TSUKI_2_263, LAYOUT_JA_TSUKI_2_263],
[Layout.LT_LT, LAYOUT_LT_LT],
[Layout.LV_LV, LAYOUT_LV_LV],
[Layout.NB_DVORAK, LAYOUT_NB_DVORAK],
diff --git a/packages/keybr-keyboard/lib/types.ts b/packages/keybr-keyboard/lib/types.ts
index 6c360c246..6a5ecc88d 100644
--- a/packages/keybr-keyboard/lib/types.ts
+++ b/packages/keybr-keyboard/lib/types.ts
@@ -41,6 +41,33 @@ export type CharacterDict = {
readonly [id: KeyId]: readonly (Character | null)[];
};
+/**
+ * Metadata for chord-based keyboard layouts where certain keys act as modifiers.
+ * Used by layouts like 月配列 (Tsuki) that use chord/combo keys.
+ */
+export type ChordLayoutMetadata = {
+ /**
+ * Maps chord modifier keys to their layer index.
+ * Example: { KeyD: 4, KeyK: 5, KeyF: 6 }
+ * When KeyD is held, characters are looked up at index 4.
+ */
+ readonly chordModifiers: { readonly [id: KeyId]: number };
+ /**
+ * Maps post-modifier keys to their character transformation functions.
+ * Example: { KeyL: applyDakuten, Slash: applyHandakuten }
+ * When pressed after a character, transforms the previous character.
+ */
+ readonly postModifiers: {
+ readonly [id: KeyId]: (char: CodePoint) => CodePoint | null;
+ };
+ /**
+ * Total number of layers in the character arrays.
+ * Standard layouts: 4 (none, shift, alt, shift+alt)
+ * Chord layouts: 7+ (standard + chord layers)
+ */
+ readonly layerCount: number;
+};
+
export type GeometryDict = {
readonly [id: KeyId]: {
readonly x: number;
diff --git a/packages/keybr-lesson/lib/guided.ts b/packages/keybr-lesson/lib/guided.ts
index c32472267..5b402441d 100644
--- a/packages/keybr-lesson/lib/guided.ts
+++ b/packages/keybr-lesson/lib/guided.ts
@@ -131,7 +131,19 @@ export class GuidedLesson extends Lesson {
#getLetters() {
const { letters } = this.model;
const { codePoints } = this;
- if (this.settings.get(lessonProps.guided.keyboardOrder)) {
+ // For 月配列2-263, always use keyboard order (custom learning progression)
+ const useTsukiOrder = this.keyboard.layout.id === "ja-tsuki-2-263";
+
+ // Debug logging (remove after verification)
+ if (useTsukiOrder) {
+ console.log("月配列2-263: カスタム学習順序を使用");
+ const sampleWeights = letters.slice(0, 10).map(l =>
+ `${String.fromCodePoint(l.codePoint)}:${codePoints.weight(l.codePoint)}`
+ );
+ console.log("サンプル文字のweight:", sampleWeights.join(", "));
+ }
+
+ if (this.settings.get(lessonProps.guided.keyboardOrder) || useTsukiOrder) {
return Letter.weightedFrequencyOrder(letters, ({ codePoint }) =>
codePoints.weight(codePoint),
);
diff --git a/packages/keybr-textinput-events/lib/chord-emulation.ts b/packages/keybr-textinput-events/lib/chord-emulation.ts
new file mode 100644
index 000000000..b24f44403
--- /dev/null
+++ b/packages/keybr-textinput-events/lib/chord-emulation.ts
@@ -0,0 +1,199 @@
+import { type Keyboard } from "@keybr/keyboard";
+import { type KeyId } from "@keybr/keyboard/lib/types.ts";
+import { toKeyModifier } from "./emulation.ts";
+import { PostModifierBuffer } from "./post-modifier.ts";
+import { type IInputEvent, type IKeyboardEvent, type InputListener } from "./types.ts";
+
+/**
+ * Creates a chord-aware input listener for layouts like 月配列2-263.
+ *
+ * Handles:
+ * - Pre-modifier keys (d, k, f) that modify the NEXT character (prefix shift)
+ * - Post-modifier keys (l, /) that modify the previous character
+ * - Extended character arrays with 7+ slots
+ *
+ * NOTE: This implementation uses PREFIX SHIFT, not simultaneous chord detection.
+ * Pre-modifiers are pressed sequentially (d then k), not simultaneously.
+ *
+ * @param keyboard The keyboard with chord layout metadata
+ * @param target The target input listener to forward events to
+ * @returns A wrapped input listener with chord support
+ */
+export function chordEmulation(
+ keyboard: Keyboard,
+ target: InputListener,
+): InputListener {
+ const postModBuffer = new PostModifierBuffer();
+ const chordMeta = keyboard.layout.chordMetadata;
+
+ if (!chordMeta) {
+ throw new Error("chordEmulation called on non-chord layout");
+ }
+
+ // State for pre-modifier (prefix shift) handling
+ // Support multiple pre-modifiers (e.g., K then D for combined layers)
+ const pendingPreModifiers: Map = new Map();
+ const PRE_MODIFIER_TIMEOUT = 500; // ms - time window for sequential pre-modifier input
+ let preModifierTimeout: NodeJS.Timeout | null = null;
+
+ // Check if a key is a pre-modifier (chord modifier key)
+ const isPreModifier = (code: KeyId): boolean => {
+ return chordMeta.chordModifiers[code] !== undefined;
+ };
+
+ // Clear pending pre-modifiers
+ const clearPendingPreModifiers = () => {
+ console.log('[TIMEOUT] Clearing pending pre-modifiers');
+ pendingPreModifiers.clear();
+ if (preModifierTimeout) {
+ clearTimeout(preModifierTimeout);
+ preModifierTimeout = null;
+ }
+ };
+
+ // Calculate combined chord layer from all pending pre-modifiers
+ const getCombinedChordLayer = (): number => {
+ if (pendingPreModifiers.size === 0) return 0;
+
+ // Sum all pre-modifier layers
+ let combinedLayer = 0;
+ for (const [code, _] of pendingPreModifiers) {
+ const layer = chordMeta.chordModifiers[code] ?? 0;
+ combinedLayer += layer;
+ }
+
+ console.log('[LAYER] Combined layer from', Array.from(pendingPreModifiers.keys()), '=', combinedLayer);
+ return combinedLayer;
+ };
+
+ return {
+ onKeyDown: (event: IKeyboardEvent): void => {
+ // Check if this is a post-modifier key
+ const postModFn = chordMeta.postModifiers[event.code];
+ if (postModFn) {
+ // Clear pending pre-modifiers before post-modification
+ clearPendingPreModifiers();
+
+ const result = postModBuffer.tryModify(postModFn, event.timeStamp);
+ console.log('[POST-MOD] tryModify result:', result);
+ if (result.success && result.newChar !== null && result.oldChar !== null) {
+ console.log('[POST-MOD] Transforming', String.fromCodePoint(result.oldChar), '→', String.fromCodePoint(result.newChar));
+ // Calculate the time from when the original character was typed to now
+ const timeToType = event.timeStamp - result.timeStamp;
+ console.log('[POST-MOD] Calculated timeToType:', timeToType, 'ms');
+
+ // Post-modification successful - emit clearChar to remove the old char from stats, then append new char
+ const clearEvent: IInputEvent = {
+ type: "input",
+ timeStamp: event.timeStamp,
+ inputType: "clearChar",
+ codePoint: result.oldChar,
+ timeToType: timeToType,
+ };
+ console.log('[POST-MOD] Sending clearEvent for', String.fromCodePoint(result.oldChar), 'with timeToType:', timeToType);
+ target.onInput(clearEvent);
+
+ const appendEvent: IInputEvent = {
+ type: "input",
+ timeStamp: event.timeStamp,
+ inputType: "appendChar",
+ codePoint: result.newChar,
+ timeToType: timeToType,
+ };
+ console.log('[POST-MOD] Sending appendEvent for', String.fromCodePoint(result.newChar), 'with timeToType:', timeToType);
+ target.onInput(appendEvent);
+ } else {
+ console.log('[POST-MOD] Modification failed or invalid result');
+ }
+ // Post-modification succeeded or failed - don't output the modifier character itself
+ // Pass through the keydown event and return
+ target.onKeyDown(event);
+ return;
+ }
+
+ // Check if this key is a pre-modifier
+ if (isPreModifier(event.code)) {
+ // If there are already pending pre-modifiers, treat this key as a regular character key
+ // This allows K -> D to output "ら" (D at layer 5)
+ if (pendingPreModifiers.size > 0) {
+ console.log('[PRE-MOD] Pre-modifier', event.code, 'pressed with pending modifiers - treating as regular key');
+ // Don't add to pending, fall through to regular key handling
+ } else {
+ // Add this pre-modifier to the pending set
+ pendingPreModifiers.set(event.code, {
+ timeStamp: event.timeStamp,
+ event,
+ });
+ console.log('[PRE-MOD] Added pre-modifier:', event.code, '-> layer', chordMeta.chordModifiers[event.code], '| Total pending:', pendingPreModifiers.size);
+
+ // Reset timeout - extend the window for sequential input
+ if (preModifierTimeout) {
+ clearTimeout(preModifierTimeout);
+ }
+ preModifierTimeout = setTimeout(clearPendingPreModifiers, PRE_MODIFIER_TIMEOUT);
+
+ // Pass through the keydown event
+ target.onKeyDown(event);
+ return;
+ }
+ }
+
+ // This is a regular character key (or pre-modifier treated as regular) - check if we have pending pre-modifiers
+ const chordLayer = getCombinedChordLayer();
+ if (chordLayer > 0) {
+ console.log('[CHORD] Using combined chord layer', chordLayer, 'for key', event.code);
+ // Clear pending pre-modifiers (they've been consumed)
+ clearPendingPreModifiers();
+ }
+
+ // Get the character for this key + chord layer + modifiers
+ const characters = keyboard.getCharacters(event.code);
+ if (characters) {
+ const modifier = toKeyModifier(event.modifiers);
+ const codePoint = characters.getCodePointForChord(chordLayer, modifier);
+ console.log('[LOOKUP] Key:', event.code, 'Layer:', chordLayer, 'Modifier:', modifier, '-> CodePoint:', codePoint ? `${String.fromCodePoint(codePoint)} (U+${codePoint.toString(16).toUpperCase()})` : 'null');
+
+ if (codePoint !== null) {
+ // Record the character in the post-modifier buffer
+ postModBuffer.recordChar(codePoint, event.timeStamp);
+
+ // Emit input event
+ const inputEvent: IInputEvent = {
+ type: "input",
+ timeStamp: event.timeStamp,
+ inputType: "appendChar",
+ codePoint,
+ timeToType: 0,
+ };
+ target.onInput(inputEvent);
+ }
+ }
+
+ // Pass through the keydown event
+ target.onKeyDown(event);
+ },
+
+ onKeyUp: (event: IKeyboardEvent): void => {
+ // Pre-modifiers are released but kept in pending state
+ // They will be cleared by timeout or consumed by the next regular key
+ if (isPreModifier(event.code)) {
+ console.log('[KEYUP] Pre-modifier released:', event.code, '| Pending:', Array.from(pendingPreModifiers.keys()));
+ }
+
+ target.onKeyUp(event);
+ },
+
+ onInput: (event: IInputEvent): void => {
+ // Handle non-appendChar input events (like clearWord, etc.)
+ if (event.inputType !== "appendChar") {
+ if (event.inputType === "clearChar" || event.inputType === "clearWord") {
+ // Clear both buffers
+ postModBuffer.clear();
+ clearPendingPreModifiers();
+ }
+ target.onInput(event);
+ }
+ // appendChar events are generated by onKeyDown, so we skip them here
+ },
+ };
+}
diff --git a/packages/keybr-textinput-events/lib/emulation.ts b/packages/keybr-textinput-events/lib/emulation.ts
index 6b5333f27..24eb72773 100644
--- a/packages/keybr-textinput-events/lib/emulation.ts
+++ b/packages/keybr-textinput-events/lib/emulation.ts
@@ -4,8 +4,10 @@ import {
keyboardProps,
KeyModifier,
} from "@keybr/keyboard";
+import { type KeyId } from "@keybr/keyboard/lib/types.ts";
import { type Settings } from "@keybr/settings";
import { type CodePoint } from "@keybr/unicode";
+import { chordEmulation } from "./chord-emulation.ts";
import { isTextInput } from "./modifiers.ts";
import { TimeToType } from "./timetotype.ts";
import {
@@ -18,7 +20,14 @@ export function emulateLayout(
settings: Settings,
keyboard: Keyboard,
target: InputListener,
+ getDepressedKeys?: () => readonly KeyId[],
): InputListener {
+ // Check for chord layout first (uses prefix shift, not simultaneous detection)
+ if (keyboard.layout.chordMetadata) {
+ return chordEmulationWithTiming(keyboard, target);
+ }
+
+ // Standard emulation for non-chord layouts
if (keyboard.layout.emulate) {
switch (settings.get(keyboardProps.emulation)) {
case Emulation.Forward:
@@ -30,6 +39,52 @@ export function emulateLayout(
return target;
}
+/**
+ * Wraps chord emulation with timing measurement.
+ */
+function chordEmulationWithTiming(
+ keyboard: Keyboard,
+ target: InputListener,
+): InputListener {
+ const timeToType = new TimeToType();
+ const chordListener = chordEmulation(keyboard, {
+ onKeyDown: (event) => {
+ target.onKeyDown(event);
+ },
+ onKeyUp: (event) => {
+ target.onKeyUp(event);
+ },
+ onInput: (event) => {
+ // Add timing measurement to input events from chord emulation
+ if (event.inputType === "appendChar" || event.inputType === "clearChar") {
+ // If timeToType > 0, it's a post-modifier transformation with already calculated timing
+ // Otherwise, measure timing from keyboard events
+ const measuredTime = event.timeToType > 0 ? event.timeToType : timeToType.measure(event);
+ target.onInput({
+ ...event,
+ timeToType: measuredTime,
+ });
+ } else {
+ target.onInput(event);
+ }
+ },
+ });
+
+ return {
+ onKeyDown: (event) => {
+ timeToType.add(event);
+ chordListener.onKeyDown(event);
+ },
+ onKeyUp: (event) => {
+ timeToType.add(event);
+ chordListener.onKeyUp(event);
+ },
+ onInput: (event) => {
+ chordListener.onInput(event);
+ },
+ };
+}
+
/**
* Expects the `code` property to be correct, changes the `key` property.
*
@@ -137,7 +192,7 @@ function fixCode(
return { type, timeStamp, code, key, modifiers };
}
-function toKeyModifier(modifiers: readonly ModifierId[]): KeyModifier {
+export function toKeyModifier(modifiers: readonly ModifierId[]): KeyModifier {
return KeyModifier.from(
modifiers.includes("Shift"),
modifiers.includes("AltGraph"),
diff --git a/packages/keybr-textinput-events/lib/post-modifier.ts b/packages/keybr-textinput-events/lib/post-modifier.ts
new file mode 100644
index 000000000..8defac42e
--- /dev/null
+++ b/packages/keybr-textinput-events/lib/post-modifier.ts
@@ -0,0 +1,100 @@
+import { type CodePoint } from "@keybr/unicode";
+
+/**
+ * Result of attempting to apply a post-modifier transformation.
+ */
+export type PostModifyResult = {
+ /** Whether the modification was successfully applied */
+ readonly success: boolean;
+ /** The new modified character (if successful) or null */
+ readonly newChar: CodePoint | null;
+ /** The old character that was replaced (if successful) or null */
+ readonly oldChar: CodePoint | null;
+ /** The timestamp when the old character was originally typed */
+ readonly timeStamp: number;
+};
+
+/**
+ * Buffer that tracks the last emitted character to allow post-modification.
+ *
+ * Used by chord layouts like 月配列2-263 to support dakuten (゛) and handakuten (゜)
+ * which modify the previously typed character rather than producing a new character.
+ *
+ * @example
+ * ```typescript
+ * const buffer = new PostModifierBuffer();
+ *
+ * // Type か (ka)
+ * buffer.recordChar(0x304b);
+ *
+ * // Press dakuten key
+ * const result = buffer.tryModify(applyDakuten);
+ * // result.success === true
+ * // result.newChar === 0x304c (が)
+ *
+ * // Try dakuten on non-applicable character
+ * buffer.recordChar(0x3042); // あ (a)
+ * const result2 = buffer.tryModify(applyDakuten);
+ * // result2.success === false
+ * // result2.newChar === null
+ * ```
+ */
+export class PostModifierBuffer {
+ private lastChar: CodePoint | null = null;
+ private lastTimeStamp: number = 0;
+
+ /**
+ * Records a character that was just emitted.
+ * This character can be modified by subsequent post-modifier key presses.
+ *
+ * @param char The character code point to record
+ * @param timeStamp The timestamp when the character was typed
+ */
+ recordChar(char: CodePoint, timeStamp: number): void {
+ this.lastChar = char;
+ this.lastTimeStamp = timeStamp;
+ }
+
+ /**
+ * Attempts to apply a post-modification function to the last recorded character.
+ *
+ * @param modifierFn Function that transforms a character (e.g., adds dakuten)
+ * @param currentTimeStamp The timestamp when the modifier key was pressed
+ * @returns Result object with success flag, modified character, and original timestamp
+ */
+ tryModify(modifierFn: (char: CodePoint) => CodePoint | null, currentTimeStamp: number): PostModifyResult {
+ // No previous character to modify
+ if (this.lastChar === null) {
+ return { success: false, newChar: null, oldChar: null, timeStamp: 0 };
+ }
+
+ // Try to apply the modification
+ const oldChar = this.lastChar;
+ const timeStamp = this.lastTimeStamp;
+ const modified = modifierFn(oldChar);
+
+ if (modified !== null) {
+ // Modification successful - update the buffer with the new character
+ this.lastChar = modified;
+ this.lastTimeStamp = currentTimeStamp;
+ return { success: true, newChar: modified, oldChar, timeStamp };
+ }
+
+ // Modification not applicable to this character
+ return { success: false, newChar: null, oldChar: null, timeStamp: 0 };
+ }
+
+ /**
+ * Clears the buffer. Called when starting a new word or resetting input.
+ */
+ clear(): void {
+ this.lastChar = null;
+ }
+
+ /**
+ * Returns the last recorded character (for debugging/testing).
+ */
+ getLastChar(): CodePoint | null {
+ return this.lastChar;
+ }
+}
diff --git a/packages/keybr-textinput/lib/textinput.test.ts b/packages/keybr-textinput/lib/textinput.test.ts
index f4a517e93..ca3b0e87a 100644
--- a/packages/keybr-textinput/lib/textinput.test.ts
+++ b/packages/keybr-textinput/lib/textinput.test.ts
@@ -121,14 +121,14 @@ test("accumulate and delete garbage", () => {
equal(textInput.pos, 0);
isFalse(textInput.completed);
- equal(textInput.clearChar(), Feedback.Succeeded);
+ equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded);
equal(showSteps(textInput), "");
equal(showChars(textInput), "*x|[a]|b|c");
equal(textInput.length, 3);
equal(textInput.pos, 0);
isFalse(textInput.completed);
- equal(textInput.clearChar(), Feedback.Succeeded);
+ equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded);
equal(showSteps(textInput), "");
equal(showChars(textInput), "[a]|b|c");
equal(textInput.length, 3);
@@ -175,14 +175,14 @@ test("handle backspace at the start of a word", () => {
equal(textInput.pos, 0);
isFalse(textInput.completed);
- equal(textInput.clearChar(), Feedback.Succeeded);
+ equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded);
equal(showSteps(textInput), "");
equal(showChars(textInput), "[a]|b|c");
equal(textInput.length, 3);
equal(textInput.pos, 0);
isFalse(textInput.completed);
- equal(textInput.clearChar(), Feedback.Succeeded);
+ equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded);
equal(showSteps(textInput), "");
equal(showChars(textInput), "[a]|b|c");
equal(textInput.length, 3);
@@ -218,14 +218,14 @@ test("handle backspace in the middle of a word", () => {
equal(textInput.pos, 1);
isFalse(textInput.completed);
- equal(textInput.clearChar(), Feedback.Succeeded);
+ equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded);
equal(showSteps(textInput), "a,100,101");
equal(showChars(textInput), "a|[b]|c");
equal(textInput.length, 3);
equal(textInput.pos, 1);
isFalse(textInput.completed);
- equal(textInput.clearChar(), Feedback.Succeeded);
+ equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded);
equal(showSteps(textInput), "a,100,101");
equal(showChars(textInput), "a|[b]|c");
equal(textInput.length, 3);
@@ -392,14 +392,14 @@ test("space in garbage", () => {
equal(textInput.pos, 0);
isFalse(textInput.completed);
- equal(textInput.clearChar(), Feedback.Succeeded);
+ equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded);
equal(showSteps(textInput), "");
equal(showChars(textInput), "*x|[a]|b|c");
equal(textInput.length, 3);
equal(textInput.pos, 0);
isFalse(textInput.completed);
- equal(textInput.clearChar(), Feedback.Succeeded);
+ equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded);
equal(showSteps(textInput), "");
equal(showChars(textInput), "[a]|b|c");
equal(textInput.length, 3);
diff --git a/packages/keybr-textinput/lib/textinput.ts b/packages/keybr-textinput/lib/textinput.ts
index 8ba893766..98e37d5d7 100644
--- a/packages/keybr-textinput/lib/textinput.ts
+++ b/packages/keybr-textinput/lib/textinput.ts
@@ -116,15 +116,79 @@ export class TextInput {
case "appendLineBreak":
return this.appendChar(timeStamp, 0x0020, timeToType);
case "clearChar":
- return this.clearChar();
+ return this.clearChar(timeStamp, codePoint, timeToType);
case "clearWord":
return this.clearWord();
}
}
- clearChar(): Feedback {
- this.#garbage.pop();
- this.#typo = true;
+ clearChar(
+ timeStamp: number,
+ codePoint: CodePoint,
+ timeToType: number,
+ ): Feedback {
+ const garbageItem = this.#garbage.pop();
+ console.log('[TEXTINPUT clearChar] Removed from garbage:', garbageItem, 'timeToType:', timeToType);
+
+ // For post-modifier transformations (dakuten/handakuten):
+ // There are two cases:
+ // 1. The base character was correctly typed (in steps)
+ // 2. The base character was a typo (in garbage)
+
+ if (timeToType > 0) {
+ // This is a post-modifier transformation (timeToType > 0 is the indicator)
+ console.log('[TEXTINPUT clearChar] Post-modifier transformation detected');
+
+ if (garbageItem === undefined && this.#steps.length > 0) {
+ // Case 1: Base character was in steps (correctly typed)
+ const lastStep = this.#steps[this.#steps.length - 1];
+ console.log('[TEXTINPUT clearChar] Base character from steps:', String.fromCodePoint(lastStep.codePoint));
+
+ // Update the timing to the actual measured time
+ const updatedStep = {
+ ...lastStep,
+ timeToType,
+ };
+
+ // Remove the step from the list (it will be replaced by the transformed character)
+ this.#steps.pop();
+
+ // Record the timing for statistics
+ console.log('[TEXTINPUT clearChar] Recording timing for base character:', timeToType, 'ms');
+ this.onStep(updatedStep);
+
+ // Don't set typo flag - this is a valid transformation
+ this.#typo = false;
+ } else if (garbageItem !== undefined) {
+ // Case 2: Base character was in garbage (typed as error)
+ console.log('[TEXTINPUT clearChar] Base character from garbage:', String.fromCodePoint(garbageItem.codePoint));
+
+ // Create a step with the actual timing for statistics
+ const step: Step = {
+ timeStamp: garbageItem.timeStamp,
+ codePoint: garbageItem.codePoint,
+ timeToType,
+ typo: false, // It's being transformed, so not counted as a typo
+ };
+
+ // Record the timing for statistics
+ console.log('[TEXTINPUT clearChar] Recording timing for garbage base character:', timeToType, 'ms');
+ this.onStep(step);
+
+ // Don't set typo flag - this is a valid transformation
+ this.#typo = false;
+ }
+ } else if (this.#garbage.length === 0 && garbageItem !== undefined) {
+ // Normal case: garbage had exactly one item which we just removed
+ console.log('[TEXTINPUT clearChar] Cleared one garbage item (normal backspace)');
+ this.#typo = false;
+ } else if (this.#steps.length > 0) {
+ // Normal backspace - remove step and set typo flag
+ const removed = this.#steps.pop();
+ console.log('[TEXTINPUT clearChar] Normal backspace - removed step:', removed);
+ this.#typo = true;
+ }
+ console.log('[TEXTINPUT clearChar] typo flag:', this.#typo, 'garbage length:', this.#garbage.length, 'pos:', this.pos);
return this.#return(Feedback.Succeeded);
}
diff --git a/packages/page-practice/lib/practice/Controller.tsx b/packages/page-practice/lib/practice/Controller.tsx
index 2c1ac0660..c0d8bc171 100644
--- a/packages/page-practice/lib/practice/Controller.tsx
+++ b/packages/page-practice/lib/practice/Controller.tsx
@@ -114,6 +114,7 @@ function useLessonState(
timeout.schedule(handleResetLesson, 10000);
},
},
+ () => state.depressedKeys,
);
return {
state,