From d0ce3ca406d119332b4e7f78bbdc13e2e994e2ec Mon Sep 17 00:00:00 2001
From: coldsofttech <161084795+coldsofttech@users.noreply.github.com>
Date: Fri, 30 Oct 2026 22:01:44 +0000
Subject: [PATCH 01/13] feat: add saccade-keyboards package and remove template
scaffolding
Add @coldsofttech/saccade-keyboards with a language-picked Keyboard, a /core
entry (Keyboard, useKeyboard, applyKey, validateLayout, layout helpers),
light/dark theming and a layout registry. Document the layout format.
Remove the example package, demo app, init workflow and repo settings script,
and stop tracking CLAUDE.md and .claude/.
---
.claude/settings.json | 6 -
.github/workflows/ci.yml | 2 -
.github/workflows/init.yml | 51 -----
.github/workflows/release.yml | 2 +-
.gitignore | 4 +
.size-limit.json | 13 +-
CHANGELOG.md | 5 +
CLAUDE.md | 67 ------
README.md | 44 ++--
docs/README.md | 5 +-
docs/architecture.md | 20 +-
docs/development.md | 11 +-
docs/layout-format.md | 55 +++++
docs/repo-setup.md | 25 +-
examples/demo/index.html | 12 -
examples/demo/package.json | 18 --
examples/demo/src/main.jsx | 13 --
examples/demo/vite.config.js | 4 -
package-lock.json | 30 +--
package.json | 5 +-
packages/example-react-package/README.md | 17 --
.../src/ExampleComponent.jsx | 24 --
.../src/ExampleComponent.module.css | 5 -
.../src/ExampleComponent.test.jsx | 24 --
packages/example-react-package/src/index.js | 2 -
.../example-react-package/src/useGreeting.js | 11 -
.../src/useGreeting.test.jsx | 14 --
packages/saccade-keyboards/README.md | 67 ++++++
.../package.json | 16 +-
packages/saccade-keyboards/src/Keyboard.jsx | 49 ++++
.../saccade-keyboards/src/Keyboard.test.jsx | 46 ++++
.../saccade-keyboards/src/core/Keyboard.jsx | 97 ++++++++
.../src/core/Keyboard.module.css | 56 +++++
.../src/core/Keyboard.test.jsx | 74 ++++++
.../saccade-keyboards/src/core/applyKey.js | 39 ++++
.../src/core/applyKey.test.js | 55 +++++
packages/saccade-keyboards/src/core/index.js | 13 ++
packages/saccade-keyboards/src/core/layout.js | 215 ++++++++++++++++++
.../saccade-keyboards/src/core/layout.test.js | 70 ++++++
.../saccade-keyboards/src/core/useKeyboard.js | 111 +++++++++
.../src/core/useKeyboard.test.jsx | 85 +++++++
.../src/css-modules.d.ts | 0
packages/saccade-keyboards/src/index.js | 2 +
packages/saccade-keyboards/src/registry.js | 54 +++++
.../tsconfig.build.json | 0
.../vite.config.js | 2 +-
scripts/README.md | 25 +-
scripts/apply-repo-settings.js | 115 ----------
48 files changed, 1182 insertions(+), 498 deletions(-)
delete mode 100644 .claude/settings.json
delete mode 100644 .github/workflows/init.yml
delete mode 100644 CLAUDE.md
create mode 100644 docs/layout-format.md
delete mode 100644 examples/demo/index.html
delete mode 100644 examples/demo/package.json
delete mode 100644 examples/demo/src/main.jsx
delete mode 100644 examples/demo/vite.config.js
delete mode 100644 packages/example-react-package/README.md
delete mode 100644 packages/example-react-package/src/ExampleComponent.jsx
delete mode 100644 packages/example-react-package/src/ExampleComponent.module.css
delete mode 100644 packages/example-react-package/src/ExampleComponent.test.jsx
delete mode 100644 packages/example-react-package/src/index.js
delete mode 100644 packages/example-react-package/src/useGreeting.js
delete mode 100644 packages/example-react-package/src/useGreeting.test.jsx
create mode 100644 packages/saccade-keyboards/README.md
rename packages/{example-react-package => saccade-keyboards}/package.json (64%)
create mode 100644 packages/saccade-keyboards/src/Keyboard.jsx
create mode 100644 packages/saccade-keyboards/src/Keyboard.test.jsx
create mode 100644 packages/saccade-keyboards/src/core/Keyboard.jsx
create mode 100644 packages/saccade-keyboards/src/core/Keyboard.module.css
create mode 100644 packages/saccade-keyboards/src/core/Keyboard.test.jsx
create mode 100644 packages/saccade-keyboards/src/core/applyKey.js
create mode 100644 packages/saccade-keyboards/src/core/applyKey.test.js
create mode 100644 packages/saccade-keyboards/src/core/index.js
create mode 100644 packages/saccade-keyboards/src/core/layout.js
create mode 100644 packages/saccade-keyboards/src/core/layout.test.js
create mode 100644 packages/saccade-keyboards/src/core/useKeyboard.js
create mode 100644 packages/saccade-keyboards/src/core/useKeyboard.test.jsx
rename packages/{example-react-package => saccade-keyboards}/src/css-modules.d.ts (100%)
create mode 100644 packages/saccade-keyboards/src/index.js
create mode 100644 packages/saccade-keyboards/src/registry.js
rename packages/{example-react-package => saccade-keyboards}/tsconfig.build.json (100%)
rename packages/{example-react-package => saccade-keyboards}/vite.config.js (89%)
delete mode 100644 scripts/apply-repo-settings.js
diff --git a/.claude/settings.json b/.claude/settings.json
deleted file mode 100644
index 0f77587..0000000
--- a/.claude/settings.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "permissions": {
- "allow": ["Bash(git status:*)", "Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)"],
- "deny": ["Read(./.env)", "Read(./.env.*)", "Read(**/*.pem)", "Read(**/*.key)"]
- }
-}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9b97a65..4c8cd1c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -71,8 +71,6 @@ jobs:
- run: npm ci
- run: npm run build
- run: npm run check:packages
- - name: Build demo against the built packages
- run: npm run build --workspace examples/demo
- name: Bundle size
run: npm run size
diff --git a/.github/workflows/init.yml b/.github/workflows/init.yml
deleted file mode 100644
index ba8a38d..0000000
--- a/.github/workflows/init.yml
+++ /dev/null
@@ -1,51 +0,0 @@
-name: Init
-
-# Run once from the Actions tab in a repository created from the template.
-# Replaces the template's owner and repository name, then opens a pull request.
-on:
- workflow_dispatch:
-
-permissions:
- contents: write
- pull-requests: write
-
-jobs:
- init:
- if: github.repository != 'coldsofttech/js-public-repo-template'
- runs-on: ubuntu-latest
- timeout-minutes: 10
- steps:
- - uses: actions/checkout@v7
-
- - name: Replace placeholders
- env:
- REPO: ${{ github.repository }}
- OWNER: ${{ github.repository_owner }}
- NAME: ${{ github.event.repository.name }}
- run: |
- git ls-files -z -- . \
- ':(exclude).github/workflows' ':(exclude)LICENSE' ':(exclude).pre-commit-config.yaml' \
- | xargs -0 grep -lZ -e coldsofttech -e public-repo-template \
- | xargs -0 -r sed -i \
- -e "s|coldsofttech/public-repo-template|$REPO|g" \
- -e "s|public-repo-template|$NAME|g" \
- -e "s|coldsofttech|$OWNER|g"
-
- - name: Open pull request
- env:
- GH_TOKEN: ${{ github.token }}
- BASE: ${{ github.event.repository.default_branch }}
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git switch -c chore/init-template
- git add -A
- if git diff --cached --quiet; then
- echo "Nothing to change"
- exit 0
- fi
- git commit -m "chore: initialize repository from template"
- git push origin chore/init-template
- gh pr create --base "$BASE" --head chore/init-template \
- --title "chore: initialize repository from template" \
- --body "Replaces the template owner and repository name. Review, merge, then delete \`.github/workflows/init.yml\`."
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b514757..87ea8b9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,7 +1,7 @@
name: Release
# Publishes to npm when a GitHub release is published, using trusted publishing (no stored tokens).
-# Tag the release as @, for example @scope/example-react-package@1.0.0.
+# Tag the release as @, for example @coldsofttech/saccade-keyboards@1.0.0.
on:
release:
types: [published]
diff --git a/.gitignore b/.gitignore
index 0da6047..a278127 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,7 @@ desktop.ini
node_modules/
dist/
coverage/
+
+.temp/
+CLAUDE.md
+.claude/
diff --git a/.size-limit.json b/.size-limit.json
index 033d1d4..2a3f433 100644
--- a/.size-limit.json
+++ b/.size-limit.json
@@ -1,9 +1,16 @@
[
{
- "name": "@scope/example-react-package",
- "path": "packages/example-react-package/dist/index.js",
+ "name": "@coldsofttech/saccade-keyboards",
+ "path": "packages/saccade-keyboards/dist/index.js",
"import": "*",
- "limit": "2 kB",
+ "limit": "4 kB",
+ "ignore": ["react", "react-dom"]
+ },
+ {
+ "name": "@coldsofttech/saccade-keyboards/core",
+ "path": "packages/saccade-keyboards/dist/core/index.js",
+ "import": "*",
+ "limit": "4 kB",
"ignore": ["react", "react-dom"]
}
]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 761a1a5..1ccd438 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,3 +3,8 @@
All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
+
+### Added
+
+- On-screen keyboard for React with a built-in light and dark theme, picked by language or built from your own layout.
+- Headless hook, a helper to apply key presses to text, and a layout checker for building custom keyboards.
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index 127baef..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,67 +0,0 @@
-# Working rules for this repository
-
-These override default behavior. Follow them exactly.
-
-1. **Ask before coding**: if anything is ambiguous or you have a design/scope question before
- writing code, use `AskUserQuestion` to ask first — don't guess, implement, and explain the
- guess afterward.
-
-2. **Running tests/lint/commit/push**: don't run `npm test`, `npm run lint`, `git commit`, or
- `git push` unless the user explicitly asks for it, or it's genuinely necessary to verify the
- specific change you just made (e.g. confirming a bug fix actually fixes the bug). Implementing
- or fixing code does not by itself imply "and then test/lint/commit/push it."
- - When a test run is genuinely necessary, scope it narrowly — the specific test file or
- workspace package the change touches (e.g. `npx vitest run packages/`) — never a full-suite run, unless the user explicitly asked for one.
-
-3. **Investigation budget [IMPORTANT]**: if a task needs investigation (reading code, searching, tracing a
- bug), budget roughly 1,000 tokens of exploration for it. If you hit that limit and need more,
- stop and ask the user for permission before spending more.
-
-4. **Claude session reference**: While creating commit and raising PR for GitHub, do not
- include Generated by Claude Code and Claude session references within the description.
-
-5. **Commit Monitoring**: While raising commit, do not keep running the Claude, instead use monitoring to
- avoid spending tokens with a hook to identify when it is done and act on it.
-
-6. **CHANGELOG.md entry style**: keep new `[Unreleased]` entries short — one or two lines,
- what changed from a reader's point of view, no file paths/function names/implementation
- rationale. That level of detail belongs in the commit message or PR description, not the
- changelog. This applies going forward only — don't rewrite past released versions' entries
- to match.
-
-7. **README.md**: add/update the README.md from a user perspective rather than technical. For technical
- please create documents within docs/ folder.
-
-## React conventions
-
-Apply these when writing or changing package code. They follow `docs/development.md`.
-
-8. **Peer dependencies**: `react` and `react-dom` are `peerDependencies` (`^18 || ^19`), never
- bundled. Don't import `react/cjs/*` or add React to `dependencies`. After changing build config,
- run `npm run check:packages` for the affected package.
-
-9. **Components and hooks**:
- - Named exports only, no default exports.
- - Function components with `.jsx` for JSX; hooks are `use*` in `.js` and have their own tests.
- - Document props with a JSDoc `@typedef` and export it. No PropTypes, no TypeScript source.
- - Data comes in through props or callbacks. Never read global state or import from an app.
-
-10. **Hooks rules**: follow the Rules of Hooks (`eslint-plugin-react-hooks`). Keep effect and memo
- dependency arrays complete, clean up subscriptions and timers, and don't use effects for values
- that can be derived during render.
-
-11. **Styles**: CSS modules, shipped as one `style.css` that consumers import. No global CSS, no
- CSS-in-JS.
-
-12. **Accessibility**: use semantic elements first, label every control, and keep `jsx-a11y`
- clean. Don't silence a11y lint rules without a comment saying why.
-
-13. **Tests**: Vitest, jsdom, Testing Library and `vitest-axe`. Query by role and label, not test
- IDs or class names. Use `userEvent` for interactions. Every component gets an `axe` check,
- and tests must pass under StrictMode (the default via `vitest.setup.js`).
-
-14. **Client-only by default**: don't use Server Component features. If an entry needs
- `"use client"`, make sure the build keeps the directive.
-
-15. **Public API changes**: adding or removing an export, or changing a peer range, needs a
- short `[Unreleased]` changelog entry and an update to the package README.
diff --git a/README.md b/README.md
index 4c6664e..2535eba 100644
--- a/README.md
+++ b/README.md
@@ -1,42 +1,38 @@
-# react-SaccadeKeyboards
+# Saccade Keyboards
-A ready-to-use starting point for public React component and hook libraries (React 18 and 19). It comes with community files, issue and pull request templates, automated dependency updates, security checks, testing, and publishing to npm already in place.
+On-screen keyboards for React apps (React 18 and 19). Show a keyboard for a language next to any text field, with light and dark themes and full screen reader and keyboard support.
## What you get
-- One or more npm packages in a single repository, each built and tested on its own
-- An example React package (component, hook, styles, tests) and a small demo app to copy from
-- Checks that React is never bundled, plus bundle-size limits
-- Automatic checks on every pull request across Node 22, 24 and 26
-- Publishing to npm when you create a GitHub release, with no stored tokens
-- Contribution, conduct, support and security-reporting guidelines
-- Issue forms and a pull request template
-- Automated dependency updates, dependency review and weekly vulnerability checks
-- Secret scanning before every commit
+- A keyboard you pick by language, or build from your own layout
+- Light and dark themes, or your own colours
+- Works with any input: the keyboard reports key presses and you decide where the text goes
+- Keys that screen readers can read, and focus that stays in your text field
+- Right-to-left and complex scripts, with a dotted circle shown for signs that sit on a letter
+- Small bundle: React is never included, and each language is its own package
-## Getting started
+## Install
-1. Click **Use this template** on GitHub and create your new repository.
-2. Run the **Init** workflow from the Actions tab and merge the pull request it opens. This fills in your owner and repository name. Then delete `.github/workflows/init.yml`.
-3. Rename `packages/example-react-package`, set your npm scope (replace `@scope`) and fill in the details in its `package.json`. Replace the example component, and update `.size-limit.json` and the demo imports.
-4. Add your contact details in `CODE_OF_CONDUCT.md` and replace this description with your own.
-5. Apply the repository protections and set up npm publishing by following the [setup guide](docs/repo-setup.md).
-6. Install the checks that run on each commit: `npm ci && pip install pre-commit && pre-commit install --hook-type pre-commit --hook-type post-commit`.
+```bash
+npm install @coldsofttech/saccade-keyboards
+```
+
+React and `react-dom` 18 or 19 must be installed in your app.
-## Using a package
+## Use it
```jsx
-import { ExampleComponent } from '@scope/example-react-package';
-import '@scope/example-react-package/style.css';
+import { Keyboard } from '@coldsofttech/saccade-keyboards';
+import '@coldsofttech/saccade-keyboards/style.css';
-;
+ console.log(key)} theme="dark" />;
```
-React and `react-dom` 18 or 19 must be installed in your app.
+Each language is installed separately, for example `@coldsofttech/saccade-en`. If one is missing, the keyboard tells you which package to install. See the [package guide](packages/saccade-keyboards/README.md) for all options, typing into a field, and building your own keyboard.
## Contributing
-See [CONTRIBUTING.md](CONTRIBUTING.md). Please report security issues privately as described in [SECURITY.md](SECURITY.md).
+See [CONTRIBUTING.md](CONTRIBUTING.md). Please report security issues privately as described in [SECURITY.md](SECURITY.md). Technical documentation is in [docs](docs/README.md).
## License
diff --git a/docs/README.md b/docs/README.md
index 97d821c..e240cb6 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -2,7 +2,8 @@
Technical documentation for this project lives in this folder. The top-level [README](../README.md) is for users.
-- [Architecture](architecture.md): how checks, workflows and releases fit together.
+- [Architecture](architecture.md): how the keyboard package, checks, workflows and releases fit together.
+- [Layout format](layout-format.md): the data that describes a keyboard, and how to check one.
- [Security](security.md): security layers, scanners and safe browser coding.
- [Development](development.md): Node setup, scripts, workspaces, CI and the release flow.
-- [Repository setup](repo-setup.md): protections, ruleset and manual steps for new repos.
+- [Repository setup](repo-setup.md): protections, ruleset and manual steps for the repository.
diff --git a/docs/architecture.md b/docs/architecture.md
index a1e0c53..2a7c0db 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -30,7 +30,6 @@ flowchart LR
```text
packages// one publishable package: src/, tests next to source, own vite.config.js
-examples/demo/ small Vite app that consumes the packages
scripts/ repository helper scripts (see scripts/README.md)
vitest.config.js runs every package as a Vitest project, sets coverage thresholds
eslint.config.js shared lint rules
@@ -49,13 +48,12 @@ Each package owns its version in its `package.json`, builds on its own with Vite
| `audit.yml` | Weekly, manual, and pull requests that change `package-lock.json` | Runs `npm audit` on production dependencies to catch newly published vulnerabilities. |
| `security.yml` | Pull request, push to `main`, weekly, manual | Runs Semgrep and uploads SARIF to the Security tab. Not part of the required `ci` check. |
| `release.yml` | GitHub release published | Checks the tag matches the package version, builds, then publishes that package to npm. |
-| `init.yml` | Manual | One-time placeholder replacement in a repo created from the template. Skipped in the template itself. |
CodeQL runs through GitHub's default setup rather than a workflow file.
## Branch governance
-The `main` ruleset in `.github/rulesets/main.json` is applied by `scripts/apply-repo-settings.js`. It requires:
+The `main` ruleset in `.github/rulesets/main.json` is applied by hand (see [repository setup](repo-setup.md)). It requires:
- a pull request with a code owner review and resolved conversations, squash merge only
- the `ci` and `dependency-review` checks, with the branch up to date
@@ -67,9 +65,19 @@ Repository admins can bypass through a pull request so a solo maintainer is not
## Release flow
1. Bump `version` in the package's `package.json` in a pull request.
-2. After merge, publish a GitHub release tagged `@`, for example `@scope/example-react-package@1.0.0`.
+2. After merge, publish a GitHub release tagged `@`, for example `@coldsofttech/saccade-keyboards@1.0.0`.
3. `release.yml` builds and packs that package in one job, then a second job in the `npm` environment publishes it using OpenID Connect. No token is stored, and only the publish job gets the `id-token` permission.
-## Template lifecycle
+## Keyboard package
-GitHub copies files, not settings. A new repo therefore needs three steps: run **Init** to replace the template owner and name, rename the example package and set your npm scope, and run `npm run repo:settings` to apply protections and the ruleset.
+`packages/saccade-keyboards` is the one published package. It has two entry points, in the same way as FoveaViewers' `fovea-viewers`:
+
+| Entry | What it is |
+| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| `@coldsofttech/saccade-keyboards` | `Keyboard` picked by `language`, with a registry that loads each layout when first needed. |
+| `@coldsofttech/saccade-keyboards/core` | The engine: `Keyboard` for a layout object, `useKeyboard`, `applyKey`, `validateLayout` and the layout helpers. |
+| `@coldsofttech/saccade-keyboards/style.css` | The styles, imported once by the app. |
+
+Each language will be its own package, `@coldsofttech/saccade-` (for example `saccade-en`), holding a layout and depending on `saccade-keyboards/core`. The layout format is described in [layout format](layout-format.md).
+
+`Keyboard` never edits a field. It reports key presses through `onKey`, and the app applies them, with `applyKey` for a plain input or textarea. Keys sit left to right for every language, because they follow the physical keys, so the keyboard is always `dir="ltr"`.
diff --git a/docs/development.md b/docs/development.md
index 917442a..ed0e863 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -3,7 +3,7 @@
## Setup
- Node 22 or newer. `.nvmrc` pins 24, the version used for linting, building and releasing.
-- `npm ci` installs every workspace (`packages/*`, `examples/*`).
+- `npm ci` installs every workspace (`packages/*`).
- `pip install pre-commit && pre-commit install --hook-type pre-commit --hook-type post-commit`.
## Scripts
@@ -12,7 +12,6 @@ All scripts run from the repository root.
| Script | Purpose |
| --------------------------------- | ---------------------------------------------------------------- |
-| `npm run dev` | Start the demo app (build packages first with `npm run build`) |
| `npm run build` | Build every package (Vite library mode, then `.d.ts` from JSDoc) |
| `npm run lint` | ESLint across the repository |
| `npm run format` / `format:check` | Prettier write / check, including Markdown |
@@ -24,7 +23,7 @@ All scripts run from the repository root.
- Plain JavaScript (`.jsx` for components) with ES modules. Types are written as JSDoc and checked through `jsconfig.json`.
- Each package declares `exports`, `types`, `files` and `sideEffects: false`, and builds and tests on its own. Its test environment is set in its `vite.config.js`.
- React and `react-dom` are `peerDependencies` (`^18 || ^19`), never bundled. `npm run check:packages` fails if a build contains React.
-- Components and hooks are named exports and document props with JSDoc typedefs. Styles are CSS modules, shipped as one `style.css` that consumers import (`@scope/pkg/style.css`).
+- Components and hooks are named exports and document props with JSDoc typedefs. Styles are CSS modules, shipped as one `style.css` that consumers import (`@coldsofttech/saccade-keyboards/style.css`).
- Packages are client-only unless stated otherwise. If an entry needs `"use client"`, make sure the build keeps the directive.
- Tests use Vitest, jsdom, Testing Library and `vitest-axe`; `vitest.setup.js` renders everything in StrictMode. Query by role and label.
- Packages never import from an application.
@@ -35,17 +34,17 @@ All scripts run from the repository root.
This repo is created from `js-SaccadeKeyboards` and receives no automatic updates. When a fix lands there:
1. Compare the changed files with this repo and apply the fix by hand.
-2. Re-check the files this template extends: `eslint.config.js`, `vitest.config.js`, `jsconfig.json`, `.github/workflows/ci.yml`, the example package and `examples/demo`.
+2. Re-check the files this template extends: `eslint.config.js`, `vitest.config.js`, `jsconfig.json`, and `.github/workflows/ci.yml`.
3. Note the change in `CHANGELOG.md`.
## CI
-`ci.yml` runs `lint`, `test` (Node 22, 24, 26 on Ubuntu; Node 24 on Windows and macOS, all with React 19; plus Node 24 with React 18) and `build`. `build` also builds the demo, checks tarball contents and reports gzipped bundle size against `.size-limit.json`. The final `ci` job is the single required check for the `main` ruleset. `audit.yml` runs `npm audit` weekly and when `package-lock.json` changes.
+`ci.yml` runs `lint`, `test` (Node 22, 24, 26 on Ubuntu; Node 24 on Windows and macOS, all with React 19; plus Node 24 with React 18) and `build`. `build` also checks tarball contents and reports gzipped bundle size against `.size-limit.json`. The final `ci` job is the single required check for the `main` ruleset. `audit.yml` runs `npm audit` weekly and when `package-lock.json` changes.
## Release
1. Set the new `version` in the package's `package.json` and merge it.
-2. Publish a GitHub release tagged `@`, for example `@scope/example-react-package@1.0.0`.
+2. Publish a GitHub release tagged `@`, for example `@coldsofttech/saccade-keyboards@1.0.0`.
3. `release.yml` checks the tag against the package version, builds, and publishes to npm with provenance through the `npm` environment. See [repo-setup.md](repo-setup.md) for the one-time npm setup.
## Cleanup
diff --git a/docs/layout-format.md b/docs/layout-format.md
new file mode 100644
index 0000000..55ff498
--- /dev/null
+++ b/docs/layout-format.md
@@ -0,0 +1,55 @@
+# Layout format
+
+A keyboard layout is plain data. The keyboard draws it and handles the key presses, so adding a language never needs a change to the engine. A layout has no rendering code.
+
+```js
+export const layout = {
+ id: 'hi',
+ name: 'Hindi',
+ dir: 'ltr',
+ fonts: ['Noto Sans Devanagari'],
+ layers: { letters: [/* rows */], symbols: [/* rows */] },
+ names: { '।': 'danda' },
+};
+```
+
+| Field | Required | Meaning |
+| -------- | -------- | ----------------------------------------------------------------------------------------------------------- |
+| `id` | Yes | Language code. It becomes the `lang` of the keyboard and the package name, `@coldsofttech/saccade-`. |
+| `name` | Yes | Name of the language, used for the default label "`` on-screen keyboard". |
+| `dir` | Yes | Reading direction, `ltr` or `rtl`. The keys themselves always sit left to right, like the physical keys. |
+| `fonts` | No | Font families the keys need. Fonts are never bundled; the app loads them and the layout package lists them. |
+| `layers` | Yes | Layer name to rows of keys. The first layer is shown first. |
+| `names` | No | What a screen reader says for a character, merged over the built-in punctuation names. |
+
+## Keys
+
+A row is a list of keys. `width` is the share of the row a key takes; 1 is an ordinary key.
+
+| Key | What it does |
+| -------------------------------------------- | ---------------------------------------------- |
+| `{ type: 'text', value, shift? }` | Types `value`, or `shift` while shift is on. |
+| `{ type: 'shift' }` | Shift: off, then the next key, then caps lock. |
+| `{ type: 'space' }` | Types a space. |
+| `{ type: 'backspace' }`, `{ type: 'enter' }` | Reported through `onKey` for the app to apply. |
+| `{ type: 'close' }` | Calls `onClose`. |
+| `{ type: 'layer', to, label, name? }` | Shows another layer and turns shift off. |
+
+A sign that only sits on a letter, such as a vowel sign or a virama, is shown on a dotted circle automatically.
+
+## Helpers
+
+`@coldsofttech/saccade-keyboards/core` exports helpers to write layouts briefly: `keys`, `cased`, `bottomRow`, `symbolsLayer`, `shiftKey`, `backspaceKey` and `PUNCTUATION_NAMES`.
+
+## Checking a layout
+
+`validateLayout(layout)` returns a list of problems, empty when the layout is valid. Every layout package should assert this in its tests. The `language` prop of the meta `Keyboard` runs it as well and shows the problems instead of a broken keyboard.
+
+## Adding a language
+
+1. Create a package `@coldsofttech/saccade-` that exports the layout as `layout` and depends on `@coldsofttech/saccade-keyboards` as a peer.
+2. Add a test that runs `validateLayout`.
+3. List its font (if any) in `fonts` and document how to load it.
+4. Register it in the meta package so `language=""` finds it.
+
+Composition rules (conjuncts, vowel signs joining a consonant) are not part of the format yet. A key types exactly its `value`.
diff --git a/docs/repo-setup.md b/docs/repo-setup.md
index 06bd633..15fd629 100644
--- a/docs/repo-setup.md
+++ b/docs/repo-setup.md
@@ -1,25 +1,16 @@
# Repository setup
-Template repositories copy files only, not settings. After creating a repo from this template, apply the settings below.
-
-## Automated
-
-```bash
-npm run repo:settings # current repo
-npm run repo:settings -- owner/repo # a specific repo
-```
-
-This configures squash-only merging, delete branch on merge, the `skip-changelog` label, secret scanning with push protection, Dependabot alerts and security updates, CodeQL default setup, and the `main` ruleset from `.github/rulesets/main.json`. Requires the `gh` CLI with admin rights.
+The repository settings below are applied by hand in the GitHub UI, or with the `gh` CLI. The `main` ruleset is stored in `.github/rulesets/main.json` and can be imported from Settings > Rules.
## Protections
| Setting | Value | How |
| ------------------------------- | ------------------------ | --------------------------------------------- |
| Dependency graph | On | Default for public repos |
-| Dependabot alerts | On | Script |
-| Dependabot security updates | On | Script |
-| Secret protection | On, with push protection | Script |
-| CodeQL | Default setup | Script |
+| Dependabot alerts | On | Settings > Advanced Security |
+| Dependabot security updates | On | Settings > Advanced Security |
+| Secret protection | On, with push protection | Settings > Advanced Security |
+| CodeQL | Default setup | Settings > Advanced Security |
| Malware alerts | On | Manual: Settings > Advanced Security |
| Copilot Autofix | Off | Manual: Settings > Advanced Security > CodeQL |
| Private vulnerability reporting | On | Manual: Settings > Advanced Security |
@@ -37,9 +28,9 @@ Repository admins can bypass the ruleset through a pull request. This lets a sol
## Manual steps
-- Tick **Template repository** on this repo (Settings > General).
-- Run the **Init** workflow (Actions tab) to replace the template owner and repository name, merge its pull request, then delete `.github/workflows/init.yml`. It requires "Allow GitHub Actions to create and approve pull requests", which the settings script turns on. If the owner is an organization, change `.github/CODEOWNERS` to a team.
+- Allow squash merging only and delete head branches after merge (Settings > General). Create the `skip-changelog` label used by `.github/release.yml`.
+- If the owner is an organization, change `.github/CODEOWNERS` to a team.
- Fill in the contact method in `CODE_OF_CONDUCT.md`.
- Run `pre-commit install --hook-type pre-commit --hook-type post-commit` locally (the post-commit type is needed for Sweepito cleanup).
- Publish to npm with trusted publishing (no token): create a GitHub environment named `npm` (Settings > Environments), then on npmjs.com open each package's Settings > Trusted publisher and register this repository, the workflow `release.yml` and the `npm` environment. A package must exist on npm before it can be configured, so publish the first version manually with `npm publish --access public`, then register the trusted publisher.
-- CodeQL default setup only works once the repo contains a supported language, so run the script after adding code.
+- CodeQL default setup only works once the repo contains a supported language, so enable it after adding code.
diff --git a/examples/demo/index.html b/examples/demo/index.html
deleted file mode 100644
index 5321f20..0000000
--- a/examples/demo/index.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
- Demo
-
-
-
-
-
-
diff --git a/examples/demo/package.json b/examples/demo/package.json
deleted file mode 100644
index 8b46076..0000000
--- a/examples/demo/package.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "name": "demo",
- "private": true,
- "version": "0.0.0",
- "type": "module",
- "scripts": {
- "dev": "vite",
- "build": "vite build"
- },
- "dependencies": {
- "@scope/example-react-package": "*",
- "react": "^19.1.0",
- "react-dom": "^19.1.0"
- },
- "devDependencies": {
- "@vitejs/plugin-react": "^6.1.2"
- }
-}
diff --git a/examples/demo/src/main.jsx b/examples/demo/src/main.jsx
deleted file mode 100644
index 2eb0ba6..0000000
--- a/examples/demo/src/main.jsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { StrictMode } from 'react';
-import { createRoot } from 'react-dom/client';
-import { ExampleComponent } from '@scope/example-react-package';
-import '@scope/example-react-package/style.css';
-
-const root = document.getElementById('root');
-if (root) {
- createRoot(root).render(
-
-
- ,
- );
-}
diff --git a/examples/demo/vite.config.js b/examples/demo/vite.config.js
deleted file mode 100644
index 56f7628..0000000
--- a/examples/demo/vite.config.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import { defineConfig } from 'vite';
-import react from '@vitejs/plugin-react';
-
-export default defineConfig({ plugins: [react()] });
diff --git a/package-lock.json b/package-lock.json
index 958b8f5..ad8f5ed 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,8 +6,7 @@
"": {
"name": "react-SaccadeKeyboards",
"workspaces": [
- "packages/*",
- "examples/*"
+ "packages/*"
],
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
@@ -42,17 +41,6 @@
"node": ">=22"
}
},
- "examples/demo": {
- "version": "0.0.0",
- "dependencies": {
- "@scope/example-react-package": "*",
- "react": "^19.1.0",
- "react-dom": "^19.1.0"
- },
- "devDependencies": {
- "@vitejs/plugin-react": "^6.1.2"
- }
- },
"node_modules/@adobe/css-tools": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
@@ -490,6 +478,10 @@
"keyv": "^5.6.0"
}
},
+ "node_modules/@coldsofttech/saccade-keyboards": {
+ "resolved": "packages/saccade-keyboards",
+ "link": true
+ },
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@@ -1243,10 +1235,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@scope/example-react-package": {
- "resolved": "packages/example-react-package",
- "link": true
- },
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@@ -2699,10 +2687,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/demo": {
- "resolved": "examples/demo",
- "link": true
- },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -6742,8 +6726,8 @@
"zod": "^3.25.0 || ^4.0.0"
}
},
- "packages/example-react-package": {
- "name": "@scope/example-react-package",
+ "packages/saccade-keyboards": {
+ "name": "@coldsofttech/saccade-keyboards",
"version": "0.1.0",
"license": "MIT",
"engines": {
diff --git a/package.json b/package.json
index 1ca2248..77248eb 100644
--- a/package.json
+++ b/package.json
@@ -6,11 +6,9 @@
"node": ">=22"
},
"workspaces": [
- "packages/*",
- "examples/*"
+ "packages/*"
],
"scripts": {
- "dev": "npm run dev --workspace examples/demo",
"build": "npm run build --workspaces --if-present",
"lint": "eslint .",
"format": "prettier --write .",
@@ -18,7 +16,6 @@
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"check:packages": "npm run check:packages --workspaces --if-present",
- "repo:settings": "node scripts/apply-repo-settings.js",
"size": "size-limit"
},
"devDependencies": {
diff --git a/packages/example-react-package/README.md b/packages/example-react-package/README.md
deleted file mode 100644
index 480a630..0000000
--- a/packages/example-react-package/README.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# @scope/example-package
-
-A small example package. Replace this text with what your package does.
-
-## Install
-
-```bash
-npm install @scope/example-package
-```
-
-## Usage
-
-```js
-import { greet } from '@scope/example-package';
-
-greet('World'); // "Hello, World!"
-```
diff --git a/packages/example-react-package/src/ExampleComponent.jsx b/packages/example-react-package/src/ExampleComponent.jsx
deleted file mode 100644
index fcbfaaf..0000000
--- a/packages/example-react-package/src/ExampleComponent.jsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import styles from './ExampleComponent.module.css';
-
-/**
- * @typedef {object} ExampleComponentProps
- * @property {string} name Who to greet.
- * @property {(name: string) => void} [onGreet] Called with the name when the button is pressed.
- */
-
-/**
- * Shows a greeting and a button that reports the greeted name to the host.
- *
- * @param {ExampleComponentProps} props Component props.
- * @returns {import('react').JSX.Element} The rendered greeting.
- */
-export function ExampleComponent({ name, onGreet }) {
- return (
-
-
Hello, {name}!
-
-
- );
-}
diff --git a/packages/example-react-package/src/ExampleComponent.module.css b/packages/example-react-package/src/ExampleComponent.module.css
deleted file mode 100644
index dd30527..0000000
--- a/packages/example-react-package/src/ExampleComponent.module.css
+++ /dev/null
@@ -1,5 +0,0 @@
-.root {
- display: inline-flex;
- flex-direction: column;
- gap: 0.5rem;
-}
diff --git a/packages/example-react-package/src/ExampleComponent.test.jsx b/packages/example-react-package/src/ExampleComponent.test.jsx
deleted file mode 100644
index 1dc2f30..0000000
--- a/packages/example-react-package/src/ExampleComponent.test.jsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import { axe } from 'vitest-axe';
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { ExampleComponent } from './index.js';
-
-describe('ExampleComponent', () => {
- it('greets by name', () => {
- render();
- expect(screen.getByText('Hello, World!')).toBeInTheDocument();
- });
-
- it('reports the name when the button is pressed', async () => {
- const onGreet = vi.fn();
- render();
- await userEvent.click(screen.getByRole('button', { name: 'Greet' }));
- expect(onGreet).toHaveBeenCalledWith('World');
- });
-
- it('has no accessibility violations', async () => {
- const { container } = render();
- expect(await axe(container)).toHaveNoViolations();
- });
-});
diff --git a/packages/example-react-package/src/index.js b/packages/example-react-package/src/index.js
deleted file mode 100644
index f0cc572..0000000
--- a/packages/example-react-package/src/index.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export { ExampleComponent } from './ExampleComponent.jsx';
-export { useGreeting } from './useGreeting.js';
diff --git a/packages/example-react-package/src/useGreeting.js b/packages/example-react-package/src/useGreeting.js
deleted file mode 100644
index 7b2c28c..0000000
--- a/packages/example-react-package/src/useGreeting.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { useMemo } from 'react';
-
-/**
- * Builds a greeting for the given name.
- *
- * @param {string} name Who to greet.
- * @returns {string} The greeting.
- */
-export function useGreeting(name) {
- return useMemo(() => `Hello, ${name}!`, [name]);
-}
diff --git a/packages/example-react-package/src/useGreeting.test.jsx b/packages/example-react-package/src/useGreeting.test.jsx
deleted file mode 100644
index 06c0cc9..0000000
--- a/packages/example-react-package/src/useGreeting.test.jsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { renderHook } from '@testing-library/react';
-import { useGreeting } from './index.js';
-
-describe('useGreeting', () => {
- it('builds the greeting and updates with the name', () => {
- const { result, rerender } = renderHook(({ name }) => useGreeting(name), {
- initialProps: { name: 'World' },
- });
- expect(result.current).toBe('Hello, World!');
- rerender({ name: 'React' });
- expect(result.current).toBe('Hello, React!');
- });
-});
diff --git a/packages/saccade-keyboards/README.md b/packages/saccade-keyboards/README.md
new file mode 100644
index 0000000..f6dd9d8
--- /dev/null
+++ b/packages/saccade-keyboards/README.md
@@ -0,0 +1,67 @@
+# @coldsofttech/saccade-keyboards
+
+On-screen keyboards for React. Pick a language, or build your own layout from data. React and `react-dom` 18 or 19 must be installed in your app.
+
+```bash
+npm install @coldsofttech/saccade-keyboards
+```
+
+## Use it
+
+The keyboard never edits a field. It tells you which key was pressed, and you type into your own field. Pressing a key keeps the focus (and the caret) in that field.
+
+```jsx
+import { useRef } from 'react';
+import { Keyboard } from '@coldsofttech/saccade-keyboards';
+import { applyKey } from '@coldsofttech/saccade-keyboards/core';
+import '@coldsofttech/saccade-keyboards/style.css';
+
+function Field() {
+ const input = useRef(null);
+
+ const onKey = (key) => {
+ const { selectionStart, selectionEnd, value } = input.current;
+ const next = applyKey(value, { start: selectionStart, end: selectionEnd }, key);
+ input.current.value = next.text;
+ input.current.setSelectionRange(next.selection.start, next.selection.end);
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+}
+```
+
+`language` needs that language's package, for example `@coldsofttech/saccade-en`. If it is missing, the keyboard says which one to install.
+
+## Props
+
+| Prop | Meaning |
+| ---------- | ------------------------------------------------------------------------------------------------------------ |
+| `language` | The language code, such as `en`. Loaded the first time it is needed. |
+| `layout` | Your own layout instead of `language` (see the layout format in the repository's docs). |
+| `onKey` | Called with `{ type: 'text', value }`, `{ type: 'backspace' }` or `{ type: 'enter' }`. |
+| `onClose` | Called when the hide key is pressed. |
+| `theme` | `'light'` (default), `'dark'`, or colours: `background`, `key`, `keyText`, `border`, `accent`, `accentText`. |
+| `label` | The name of the keyboard for a screen reader. |
+| `fontSize` | The size of the letters on the keys, in pixels (16). |
+| `fallback` | What to show while a language loads. |
+
+## Build your own
+
+`@coldsofttech/saccade-keyboards/core` has what you need without the language loader:
+
+- `Keyboard`: the same keyboard for a `layout` object.
+- `useKeyboard`: the state and behaviour without markup, to draw the keys yourself.
+- `applyKey`: applies a key press to text and a selection.
+- `validateLayout`: lists what is wrong with a layout.
+- `keys`, `cased`, `bottomRow`, `symbolsLayer`, `shiftKey`, `backspaceKey`, `PUNCTUATION_NAMES`: helpers to write layouts.
+
+`registerLanguage(id, loader)`, `languages()` and `loadLayout(id)` from the main entry let you add a language by its code.
+
+## License
+
+MIT
diff --git a/packages/example-react-package/package.json b/packages/saccade-keyboards/package.json
similarity index 64%
rename from packages/example-react-package/package.json
rename to packages/saccade-keyboards/package.json
index 4e56029..6a08887 100644
--- a/packages/example-react-package/package.json
+++ b/packages/saccade-keyboards/package.json
@@ -1,7 +1,7 @@
{
- "name": "@scope/example-react-package",
+ "name": "@coldsofttech/saccade-keyboards",
"version": "0.1.0",
- "description": "Example React package. Replace this with your own description.",
+ "description": "On-screen keyboards for React. Pick a language, or build your own layout from data.",
"license": "MIT",
"type": "module",
"sideEffects": [
@@ -15,6 +15,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
+ "./core": {
+ "types": "./dist/core/index.d.ts",
+ "import": "./dist/core/index.js"
+ },
"./style.css": "./dist/style.css"
},
"files": [
@@ -29,12 +33,12 @@
},
"repository": {
"type": "git",
- "url": "git+https://github.com/coldsofttech/react-SaccadeKeyboards.git",
- "directory": "packages/example-react-package"
+ "url": "git+https://github.com/coldsofttech/SaccadeKeyboards.git",
+ "directory": "packages/saccade-keyboards"
},
- "homepage": "https://github.com/coldsofttech/react-SaccadeKeyboards#readme",
+ "homepage": "https://github.com/coldsofttech/SaccadeKeyboards#readme",
"bugs": {
- "url": "https://github.com/coldsofttech/react-SaccadeKeyboards/issues"
+ "url": "https://github.com/coldsofttech/SaccadeKeyboards/issues"
},
"scripts": {
"build": "vite build && npm run build:types",
diff --git a/packages/saccade-keyboards/src/Keyboard.jsx b/packages/saccade-keyboards/src/Keyboard.jsx
new file mode 100644
index 0000000..a39d93d
--- /dev/null
+++ b/packages/saccade-keyboards/src/Keyboard.jsx
@@ -0,0 +1,49 @@
+import { useEffect, useState } from 'react';
+import { Keyboard as CoreKeyboard } from './core/Keyboard.jsx';
+import { loadLayout } from './registry.js';
+
+/** @typedef {import('./core/layout.js').Layout} Layout */
+
+/**
+ * @typedef {Omit & {
+ * language?: string,
+ * layout?: import('./core/layout.js').Layout,
+ * fallback?: import('react').ReactNode,
+ * }} LanguageKeyboardProps
+ */
+
+/**
+ * An on-screen keyboard picked by language. The layout is loaded the first time it is needed, and
+ * a missing one says which package to install. Pass `layout` instead to use your own.
+ *
+ * @param {LanguageKeyboardProps} props Component props: `language` (a registered code), or
+ * `layout`; `fallback` shows while a layout loads; the rest go to the keyboard.
+ * @returns {import('react').ReactNode} The keyboard, the fallback, or the error.
+ */
+export function Keyboard({ language, layout, fallback = null, ...props }) {
+ const [result, setResult] = useState(
+ /** @type {{ language: string, layout: Layout | null, error: Error | null }} */ ({
+ language: '',
+ layout: null,
+ error: null,
+ }),
+ );
+
+ useEffect(() => {
+ if (layout || !language) return undefined;
+ let current = true;
+ loadLayout(language).then(
+ (loaded) => current && setResult({ language, layout: loaded, error: null }),
+ (error) => current && setResult({ language, layout: null, error }),
+ );
+ return () => {
+ current = false;
+ };
+ }, [language, layout]);
+
+ const loaded = result.language === language ? result : null;
+ const shown = layout ?? loaded?.layout;
+ if (shown) return ;
+ if (loaded?.error) return
+ );
+}
diff --git a/packages/saccade-keyboards/src/core/Keyboard.module.css b/packages/saccade-keyboards/src/core/Keyboard.module.css
new file mode 100644
index 0000000..7dc6a1a
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/Keyboard.module.css
@@ -0,0 +1,56 @@
+.root {
+ --saccade-background: #f0f0f0;
+ --saccade-key: #ffffff;
+ --saccade-key-text: #242424;
+ --saccade-border: #d1d1d1;
+ --saccade-accent: #0f6cbd;
+ --saccade-accent-text: #ffffff;
+
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 8px;
+ box-sizing: border-box;
+ user-select: none;
+ touch-action: manipulation;
+ background: var(--saccade-background);
+ border-radius: 4px;
+}
+
+.dark {
+ --saccade-background: #292929;
+ --saccade-key: #3d3d3d;
+ --saccade-key-text: #ffffff;
+ --saccade-border: #666666;
+ --saccade-accent: #479ef5;
+ --saccade-accent-text: #000000;
+}
+
+.row {
+ display: flex;
+ gap: 6px;
+}
+
+.key {
+ flex: 1 1 0;
+ min-width: 0;
+ min-height: 44px;
+ padding: 0;
+ font: inherit;
+ cursor: pointer;
+ color: var(--saccade-key-text);
+ background: var(--saccade-key);
+ border: 1px solid var(--saccade-border);
+ border-radius: 4px;
+}
+
+.key:focus-visible {
+ outline: 2px solid var(--saccade-accent);
+ outline-offset: 1px;
+}
+
+.active {
+ color: var(--saccade-accent-text);
+ background: var(--saccade-accent);
+ border-color: var(--saccade-accent);
+}
diff --git a/packages/saccade-keyboards/src/core/Keyboard.test.jsx b/packages/saccade-keyboards/src/core/Keyboard.test.jsx
new file mode 100644
index 0000000..d5bf8fe
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/Keyboard.test.jsx
@@ -0,0 +1,74 @@
+import { describe, expect, it, vi } from 'vitest';
+import { axe } from 'vitest-axe';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Keyboard } from './index.js';
+import { bottomRow, cased, shiftKey } from './layout.js';
+
+const layout = {
+ id: 'en',
+ name: 'Test',
+ dir: 'ltr',
+ layers: {
+ letters: [
+ [shiftKey, ...cased('ab')],
+ bottomRow({ type: 'layer', to: 'symbols', label: '?123', name: 'Numbers' }),
+ ],
+ symbols: [[{ type: 'text', value: '1' }]],
+ },
+};
+
+describe('Keyboard', () => {
+ it('reports the key that was pressed', async () => {
+ const onKey = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole('button', { name: 'a' }));
+ expect(onKey).toHaveBeenCalledWith({ type: 'text', value: 'a' });
+ });
+
+ it('types capitals after shift and shows that it is on', async () => {
+ const onKey = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole('button', { name: 'Shift' }));
+ expect(screen.getByRole('button', { name: 'Shift' })).toHaveAttribute('aria-pressed', 'true');
+ await userEvent.click(screen.getByRole('button', { name: 'A' }));
+ expect(onKey).toHaveBeenCalledWith({ type: 'text', value: 'A' });
+ });
+
+ it('shows another layer', async () => {
+ render();
+ await userEvent.click(screen.getByRole('button', { name: 'Numbers' }));
+ expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument();
+ });
+
+ it('keeps the focus in the field being typed into', async () => {
+ render(
+ <>
+
+
+ >,
+ );
+ await userEvent.click(screen.getByLabelText('Field'));
+ await userEvent.click(screen.getByRole('button', { name: 'a' }));
+ expect(screen.getByLabelText('Field')).toHaveFocus();
+ });
+
+ it('is named after the layout and takes its language', () => {
+ render();
+ expect(screen.getByRole('group', { name: 'Test on-screen keyboard' })).toHaveAttribute(
+ 'lang',
+ 'en',
+ );
+ });
+
+ it('takes colours from a theme object', () => {
+ render();
+ expect(screen.getByRole('group').style.getPropertyValue('--saccade-accent')).toBe('red');
+ expect(screen.getByRole('group').style.length).toBe(1);
+ });
+
+ it.each(['light', 'dark'])('has no accessibility violations in the %s theme', async (theme) => {
+ const { container } = render();
+ expect(await axe(container)).toHaveNoViolations();
+ });
+});
diff --git a/packages/saccade-keyboards/src/core/applyKey.js b/packages/saccade-keyboards/src/core/applyKey.js
new file mode 100644
index 0000000..aa2c73a
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/applyKey.js
@@ -0,0 +1,39 @@
+/**
+ * @typedef {object} Selection
+ * @property {number} start Where the selection (or caret) starts.
+ * @property {number} end Where it ends. Equal to `start` for a caret.
+ */
+
+/**
+ * @typedef {object} KeyPress
+ * @property {'text' | 'backspace' | 'enter'} type
+ * @property {string} [value] What a text key types.
+ */
+
+/**
+ * Applies a key press to the text of a field: a text key replaces the selection, backspace removes
+ * the selection or the character before the caret, and enter adds a line break.
+ *
+ * @param {string} text The current text.
+ * @param {Selection} selection The selection or caret in `text`.
+ * @param {KeyPress} key The key that was pressed.
+ * @returns {{ text: string, selection: Selection }} The new text, with the caret after the change.
+ */
+export function applyKey(text, selection, key) {
+ const start = Math.min(selection.start, selection.end);
+ const end = Math.max(selection.start, selection.end);
+ let from = start;
+ let insert = '';
+ if (key.type === 'text') insert = key.value ?? '';
+ else if (key.type === 'enter') insert = '\n';
+ else if (key.type === 'backspace' && start === end && start > 0) {
+ // Remove a whole code point, so a surrogate pair is never cut in half.
+ const before = text.slice(0, start);
+ from = start - ([...before].at(-1)?.length ?? 1);
+ }
+ const caret = from + insert.length;
+ return {
+ text: text.slice(0, from) + insert + text.slice(end),
+ selection: { start: caret, end: caret },
+ };
+}
diff --git a/packages/saccade-keyboards/src/core/applyKey.test.js b/packages/saccade-keyboards/src/core/applyKey.test.js
new file mode 100644
index 0000000..ee53d03
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/applyKey.test.js
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest';
+import { applyKey } from './applyKey.js';
+
+const caret = (at) => ({ start: at, end: at });
+
+describe('applyKey', () => {
+ it('types at the caret', () => {
+ expect(applyKey('ac', caret(1), { type: 'text', value: 'b' })).toEqual({
+ text: 'abc',
+ selection: caret(2),
+ });
+ });
+
+ it('replaces the selection', () => {
+ expect(applyKey('abc', { start: 0, end: 2 }, { type: 'text', value: 'x' })).toEqual({
+ text: 'xc',
+ selection: caret(1),
+ });
+ });
+
+ it('removes the character before the caret on backspace', () => {
+ expect(applyKey('abc', caret(2), { type: 'backspace' })).toEqual({
+ text: 'ac',
+ selection: caret(1),
+ });
+ });
+
+ it('removes the selection on backspace', () => {
+ expect(applyKey('abc', { start: 2, end: 0 }, { type: 'backspace' })).toEqual({
+ text: 'c',
+ selection: caret(0),
+ });
+ });
+
+ it('does nothing on backspace at the start', () => {
+ expect(applyKey('abc', caret(0), { type: 'backspace' })).toEqual({
+ text: 'abc',
+ selection: caret(0),
+ });
+ });
+
+ it('removes a whole code point on backspace', () => {
+ expect(applyKey('a😀', caret(3), { type: 'backspace' })).toEqual({
+ text: 'a',
+ selection: caret(1),
+ });
+ });
+
+ it('adds a line break on enter', () => {
+ expect(applyKey('ab', caret(1), { type: 'enter' })).toEqual({
+ text: 'a\nb',
+ selection: caret(2),
+ });
+ });
+});
diff --git a/packages/saccade-keyboards/src/core/index.js b/packages/saccade-keyboards/src/core/index.js
new file mode 100644
index 0000000..5f12178
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/index.js
@@ -0,0 +1,13 @@
+export { Keyboard } from './Keyboard.jsx';
+export { useKeyboard, typedValue } from './useKeyboard.js';
+export { applyKey } from './applyKey.js';
+export {
+ PUNCTUATION_NAMES,
+ backspaceKey,
+ bottomRow,
+ cased,
+ keys,
+ shiftKey,
+ symbolsLayer,
+ validateLayout,
+} from './layout.js';
diff --git a/packages/saccade-keyboards/src/core/layout.js b/packages/saccade-keyboards/src/core/layout.js
new file mode 100644
index 0000000..ade1110
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/layout.js
@@ -0,0 +1,215 @@
+// A keyboard layout is data. Its `layers` map a layer name to rows, the first layer being the one
+// shown first. A row is a list of keys; a key is one of
+// { type: 'text', value, shift? } types `value` (or `shift` while shift is on)
+// { type: 'shift' | 'backspace' | 'enter' | 'space' | 'close' }
+// { type: 'layer', to, label, name? } switches layer
+// `width` is the share of a row the key takes (1 is one ordinary key).
+
+/**
+ * @typedef {object} TextKey
+ * @property {'text'} type
+ * @property {string} value What the key types.
+ * @property {string} [shift] What it types while shift is on.
+ * @property {number} [width] Share of the row, 1 being an ordinary key.
+ */
+
+/**
+ * @typedef {object} ActionKey
+ * @property {'shift' | 'backspace' | 'enter' | 'space' | 'close'} type
+ * @property {number} [width] Share of the row, 1 being an ordinary key.
+ */
+
+/**
+ * @typedef {object} LayerKey
+ * @property {'layer'} type
+ * @property {string} to The layer to show.
+ * @property {string} label Text on the key.
+ * @property {string} [name] What a screen reader says (the label).
+ * @property {number} [width] Share of the row, 1 being an ordinary key.
+ */
+
+/** @typedef {TextKey | ActionKey | LayerKey} Key */
+
+/**
+ * @typedef {object} Layout
+ * @property {string} id Language code, such as `en`.
+ * @property {string} name Name of the language, such as `English`.
+ * @property {'ltr' | 'rtl'} dir Reading direction of the language. Keys always sit left to right.
+ * @property {string[]} [fonts] Font families the keys need, for the host to load.
+ * @property {Record} layers Layer name to rows of keys.
+ * @property {Record} [names] What a screen reader says for a character.
+ */
+
+const ACTIONS = ['shift', 'backspace', 'enter', 'space', 'close'];
+
+/**
+ * Keys that type `normal`, and `shifted` (one character each) while shift is on.
+ *
+ * @param {string} normal Characters, one per key.
+ * @param {string} [shifted] Their shifted characters, in the same order.
+ * @returns {TextKey[]} The keys.
+ */
+export function keys(normal, shifted = '') {
+ const upper = [...shifted];
+ return [...normal].map((value, i) =>
+ upper[i] ? { type: 'text', value, shift: upper[i] } : { type: 'text', value },
+ );
+}
+
+/**
+ * Keys for letters of a script with case: each types the letter, and its capital with shift.
+ *
+ * @param {string} letters Lower case letters, one per key.
+ * @returns {TextKey[]} The keys.
+ */
+export const cased = (letters) => keys(letters, letters.toUpperCase());
+
+/** @type {ActionKey} */
+export const backspaceKey = { type: 'backspace', width: 1.5 };
+/** @type {ActionKey} */
+export const shiftKey = { type: 'shift', width: 1.5 };
+
+/**
+ * The last row of every layer: a key to another layer (or several), `marks` (a comma and a full
+ * stop by default), space, enter and hide.
+ *
+ * @param {LayerKey | LayerKey[]} layerKey The key (or keys) to another layer.
+ * @param {string} [marks] Punctuation keys beside the space key.
+ * @returns {Key[]} The row.
+ */
+export function bottomRow(layerKey, marks = ',.') {
+ return [
+ ...[layerKey].flat().map((key) => ({ ...key, width: 1.5 })),
+ ...keys(marks),
+ { type: 'space', width: 5 },
+ { type: 'enter', width: 1.5 },
+ { type: 'close', width: 1.5 },
+ ];
+}
+
+/**
+ * The numbers and symbols layer every language can share, but for the row of digits (so a language
+ * can show its own) and the key back to its letters.
+ *
+ * @param {string} digits The row of digits, one per key.
+ * @param {LayerKey} backKey The key back to the letters.
+ * @returns {Key[][]} The rows.
+ */
+export function symbolsLayer(digits, backKey) {
+ return [
+ keys(digits),
+ keys('-/:;()$&@"'),
+ keys('[]{}#%^*+='),
+ [...keys("_\\|~<>?!'"), backspaceKey],
+ bottomRow(backKey),
+ ];
+}
+
+/** What a screen reader says for the punctuation of the shared symbols layer. */
+export const PUNCTUATION_NAMES = {
+ ',': 'comma',
+ '.': 'full stop',
+ '-': 'hyphen',
+ '/': 'slash',
+ ':': 'colon',
+ ';': 'semicolon',
+ '(': 'open parenthesis',
+ ')': 'close parenthesis',
+ $: 'dollar',
+ '&': 'ampersand',
+ '@': 'at sign',
+ '"': 'double quote',
+ '[': 'open bracket',
+ ']': 'close bracket',
+ '{': 'open brace',
+ '}': 'close brace',
+ '#': 'hash',
+ '%': 'percent',
+ '^': 'caret',
+ '*': 'asterisk',
+ '+': 'plus',
+ '=': 'equals',
+ _: 'underscore',
+ '\\': 'backslash',
+ '|': 'vertical bar',
+ '~': 'tilde',
+ '<': 'less than',
+ '>': 'greater than',
+ '?': 'question mark',
+ '!': 'exclamation mark',
+ "'": 'apostrophe',
+};
+
+/** @param {unknown} value */
+const isText = (value) => typeof value === 'string' && value !== '';
+
+/**
+ * @param {any} key
+ * @param {string} where
+ * @param {string[]} layerNames
+ * @returns {string[]}
+ */
+function keyProblems(key, where, layerNames) {
+ if (key === null || typeof key !== 'object') return [`${where}: a key must be an object`];
+ const problems = [];
+ if (key.width !== undefined && !(typeof key.width === 'number' && key.width > 0)) {
+ problems.push(`${where}: width must be a positive number`);
+ }
+ if (key.type === 'text') {
+ if (!isText(key.value)) problems.push(`${where}: a text key needs a value`);
+ if (key.shift !== undefined && !isText(key.shift)) {
+ problems.push(`${where}: shift must be a non-empty string`);
+ }
+ } else if (key.type === 'layer') {
+ if (!layerNames.includes(key.to)) problems.push(`${where}: no layer named "${key.to}"`);
+ if (!isText(key.label)) problems.push(`${where}: a layer key needs a label`);
+ } else if (!ACTIONS.includes(key.type)) {
+ problems.push(`${where}: unknown key type "${key.type}"`);
+ }
+ return problems;
+}
+
+/**
+ * Checks a layout against the documented format.
+ *
+ * @param {unknown} layout The layout to check.
+ * @returns {string[]} What is wrong with it; empty when it is valid.
+ */
+export function validateLayout(layout) {
+ if (layout === null || typeof layout !== 'object') return ['a layout must be an object'];
+ const { id, name, dir, fonts, layers, names } = /** @type {Record} */ (layout);
+ const problems = [];
+ if (!isText(id)) problems.push('id must be a non-empty string');
+ if (!isText(name)) problems.push('name must be a non-empty string');
+ if (dir !== 'ltr' && dir !== 'rtl') problems.push('dir must be "ltr" or "rtl"');
+ if (fonts !== undefined && !(Array.isArray(fonts) && fonts.every(isText))) {
+ problems.push('fonts must be a list of font family names');
+ }
+ if (names !== undefined && (names === null || typeof names !== 'object')) {
+ problems.push('names must be an object');
+ }
+ if (layers === null || typeof layers !== 'object' || Object.keys(layers).length === 0) {
+ problems.push('layers must be an object with at least one layer');
+ return problems;
+ }
+ const layerNames = Object.keys(layers);
+ layerNames.forEach((layer) => {
+ const rows = layers[layer];
+ if (!Array.isArray(rows) || rows.length === 0) {
+ problems.push(`layer "${layer}": must be a non-empty list of rows`);
+ return;
+ }
+ rows.forEach((row, r) => {
+ if (!Array.isArray(row) || row.length === 0) {
+ problems.push(`layer "${layer}", row ${r + 1}: must be a non-empty list of keys`);
+ return;
+ }
+ row.forEach((key, k) => {
+ problems.push(
+ ...keyProblems(key, `layer "${layer}", row ${r + 1}, key ${k + 1}`, layerNames),
+ );
+ });
+ });
+ });
+ return problems;
+}
diff --git a/packages/saccade-keyboards/src/core/layout.test.js b/packages/saccade-keyboards/src/core/layout.test.js
new file mode 100644
index 0000000..a26ef20
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/layout.test.js
@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest';
+import { bottomRow, cased, keys, symbolsLayer, validateLayout } from './layout.js';
+
+const toSymbols = { type: 'layer', to: 'symbols', label: '?123' };
+const toLetters = { type: 'layer', to: 'letters', label: 'abc' };
+
+const layout = {
+ id: 'xx',
+ name: 'Test',
+ dir: 'ltr',
+ fonts: ['Test Sans'],
+ layers: {
+ letters: [[...cased('ab')], bottomRow(toSymbols)],
+ symbols: symbolsLayer('123', toLetters),
+ },
+};
+
+describe('layout helpers', () => {
+ it('pairs each key with its shifted character', () => {
+ expect(keys('ab', 'A')).toEqual([
+ { type: 'text', value: 'a', shift: 'A' },
+ { type: 'text', value: 'b' },
+ ]);
+ });
+
+ it('types the capital of a cased letter with shift', () => {
+ expect(cased('a')).toEqual([{ type: 'text', value: 'a', shift: 'A' }]);
+ });
+});
+
+describe('validateLayout', () => {
+ it('accepts a well-formed layout', () => {
+ expect(validateLayout(layout)).toEqual([]);
+ });
+
+ it('rejects something that is not an object', () => {
+ expect(validateLayout(null)).toEqual(['a layout must be an object']);
+ });
+
+ it('reports a missing id, name and direction', () => {
+ const problems = validateLayout({ layers: layout.layers });
+ expect(problems).toEqual([
+ 'id must be a non-empty string',
+ 'name must be a non-empty string',
+ 'dir must be "ltr" or "rtl"',
+ ]);
+ });
+
+ it('reports a layer key that points nowhere', () => {
+ const problems = validateLayout({ ...layout, layers: { letters: [[toSymbols]] } });
+ expect(problems).toEqual(['layer "letters", row 1, key 1: no layer named "symbols"']);
+ });
+
+ it('reports an unknown key type and a bad width', () => {
+ const problems = validateLayout({
+ ...layout,
+ layers: { letters: [[{ type: 'tab' }, { type: 'space', width: 0 }]] },
+ });
+ expect(problems).toEqual([
+ 'layer "letters", row 1, key 1: unknown key type "tab"',
+ 'layer "letters", row 1, key 2: width must be a positive number',
+ ]);
+ });
+
+ it('reports a layout without layers', () => {
+ expect(validateLayout({ ...layout, layers: {} })).toEqual([
+ 'layers must be an object with at least one layer',
+ ]);
+ });
+});
diff --git a/packages/saccade-keyboards/src/core/useKeyboard.js b/packages/saccade-keyboards/src/core/useKeyboard.js
new file mode 100644
index 0000000..57d7efc
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/useKeyboard.js
@@ -0,0 +1,111 @@
+import { useState } from 'react';
+import { PUNCTUATION_NAMES } from './layout.js';
+
+/** @typedef {import('./layout.js').Key} Key */
+/** @typedef {import('./layout.js').Layout} Layout */
+/** @typedef {import('./applyKey.js').KeyPress} KeyPress */
+
+/**
+ * @typedef {object} UseKeyboardOptions
+ * @property {Layout} layout The layout to drive.
+ * @property {(key: KeyPress) => void} onKey A key was pressed (the space key types a space).
+ * @property {() => void} [onClose] The hide key was pressed.
+ */
+
+/**
+ * @typedef {object} KeyDescription
+ * @property {string} label Text to show on the key.
+ * @property {string} name What a screen reader says.
+ * @property {boolean} [pressed] Whether the key is on, for the shift key.
+ */
+
+/** @type {Record} */
+const FIXED = {
+ shift: { label: '⇧', name: 'Shift' },
+ backspace: { label: '⌫', name: 'Backspace' },
+ enter: { label: '⏎', name: 'Enter' },
+ space: { label: 'space', name: 'Space' },
+ close: { label: '⌄', name: 'Hide keyboard' },
+};
+
+/**
+ * What a text key types: its shifted character while shift is on, if it has one.
+ *
+ * @param {import('./layout.js').TextKey} key The key.
+ * @param {boolean} shifted Whether shift is on.
+ * @returns {string} The character.
+ */
+export function typedValue(key, shifted) {
+ return shifted && key.shift ? key.shift : key.value;
+}
+
+// A sign that only sits on a letter (a vowel sign, a virama) is shown on a dotted circle.
+/** @param {string} value */
+const shown = (value) => (/^\p{M}$/u.test(value) ? `◌${value}` : value);
+
+/**
+ * The state and behaviour of an on-screen keyboard, without any markup, so a custom UI can render
+ * the keys itself. Shift is `'off'`, `'once'` (the next key) or `'caps'`.
+ *
+ * @param {UseKeyboardOptions} options Layout and callbacks.
+ * @returns {{
+ * layer: string,
+ * shift: 'off' | 'once' | 'caps',
+ * rows: Key[][],
+ * press: (key: Key) => void,
+ * describe: (key: Key) => KeyDescription,
+ * }} What to render and how to react.
+ */
+export function useKeyboard({ layout, onKey, onClose }) {
+ const [chosen, setChosen] = useState('');
+ const [shift, setShift] = useState(/** @type {'off' | 'once' | 'caps'} */ ('off'));
+ // Fall back to the first layer when the layout changes to one without the chosen layer.
+ const layer = layout.layers[chosen] ? chosen : Object.keys(layout.layers)[0];
+ /** @type {Record} */
+ const names = { ...PUNCTUATION_NAMES, ...layout.names };
+
+ /** @param {Key} key */
+ const press = (key) => {
+ switch (key.type) {
+ case 'text':
+ onKey({ type: 'text', value: typedValue(key, shift !== 'off') });
+ if (shift === 'once') setShift('off');
+ break;
+ case 'space':
+ onKey({ type: 'text', value: ' ' });
+ if (shift === 'once') setShift('off');
+ break;
+ case 'shift':
+ setShift(shift === 'off' ? 'once' : shift === 'once' ? 'caps' : 'off');
+ break;
+ case 'layer':
+ setChosen(key.to);
+ setShift('off');
+ break;
+ case 'close':
+ onClose?.();
+ break;
+ default:
+ onKey({ type: key.type });
+ }
+ };
+
+ /** @param {Key} key @returns {KeyDescription} */
+ const describe = (key) => {
+ if (key.type === 'text') {
+ const value = typedValue(key, shift !== 'off');
+ return { label: shown(value), name: names[value] ?? value };
+ }
+ if (key.type === 'layer') return { label: key.label, name: key.name ?? key.label };
+ if (key.type === 'shift') {
+ return {
+ label: shift === 'caps' ? '⇪' : FIXED.shift.label,
+ name: shift === 'caps' ? 'Caps lock' : FIXED.shift.name,
+ pressed: shift !== 'off',
+ };
+ }
+ return FIXED[key.type];
+ };
+
+ return { layer, shift, rows: layout.layers[layer], press, describe };
+}
diff --git a/packages/saccade-keyboards/src/core/useKeyboard.test.jsx b/packages/saccade-keyboards/src/core/useKeyboard.test.jsx
new file mode 100644
index 0000000..ed137a8
--- /dev/null
+++ b/packages/saccade-keyboards/src/core/useKeyboard.test.jsx
@@ -0,0 +1,85 @@
+import { describe, expect, it, vi } from 'vitest';
+import { act, renderHook } from '@testing-library/react';
+import { useKeyboard } from './useKeyboard.js';
+import { bottomRow, cased, shiftKey } from './layout.js';
+
+const layout = {
+ id: 'xx',
+ name: 'Test',
+ dir: 'ltr',
+ layers: {
+ letters: [
+ [shiftKey, ...cased('ab')],
+ bottomRow({ type: 'layer', to: 'symbols', label: '?123' }),
+ ],
+ symbols: [[{ type: 'text', value: '1' }]],
+ },
+};
+const shift = layout.layers.letters[0][0];
+const a = layout.layers.letters[0][1];
+
+describe('useKeyboard', () => {
+ it('turns shift off after one key, and keeps it on with caps', () => {
+ const onKey = vi.fn();
+ const { result } = renderHook(() => useKeyboard({ layout, onKey }));
+
+ act(() => result.current.press(shift));
+ act(() => result.current.press(a));
+ act(() => result.current.press(a));
+ expect(onKey.mock.calls).toEqual([
+ [{ type: 'text', value: 'A' }],
+ [{ type: 'text', value: 'a' }],
+ ]);
+
+ act(() => result.current.press(shift));
+ act(() => result.current.press(shift));
+ expect(result.current.shift).toBe('caps');
+ act(() => result.current.press(a));
+ act(() => result.current.press(a));
+ expect(onKey).toHaveBeenLastCalledWith({ type: 'text', value: 'A' });
+ });
+
+ it('switches layer and clears shift', () => {
+ const { result } = renderHook(() => useKeyboard({ layout, onKey: vi.fn() }));
+ act(() => result.current.press(shift));
+ act(() => result.current.press({ type: 'layer', to: 'symbols', label: '?123' }));
+ expect(result.current.layer).toBe('symbols');
+ expect(result.current.shift).toBe('off');
+ });
+
+ it('types a space and reports backspace, enter and close', () => {
+ const onKey = vi.fn();
+ const onClose = vi.fn();
+ const { result } = renderHook(() => useKeyboard({ layout, onKey, onClose }));
+ act(() => result.current.press({ type: 'space' }));
+ act(() => result.current.press({ type: 'backspace' }));
+ act(() => result.current.press({ type: 'enter' }));
+ act(() => result.current.press({ type: 'close' }));
+ expect(onKey.mock.calls).toEqual([
+ [{ type: 'text', value: ' ' }],
+ [{ type: 'backspace' }],
+ [{ type: 'enter' }],
+ ]);
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it('falls back to the first layer when the layout changes', () => {
+ const other = { ...layout, layers: { only: layout.layers.symbols } };
+ const { result, rerender } = renderHook(
+ ({ current }) => useKeyboard({ layout: current, onKey: vi.fn() }),
+ {
+ initialProps: { current: layout },
+ },
+ );
+ act(() => result.current.press({ type: 'layer', to: 'symbols', label: '?123' }));
+ rerender({ current: other });
+ expect(result.current.layer).toBe('only');
+ });
+
+ it('describes keys for a screen reader', () => {
+ const { result } = renderHook(() => useKeyboard({ layout, onKey: vi.fn() }));
+ expect(result.current.describe({ type: 'text', value: ',' }).name).toBe('comma');
+ expect(result.current.describe({ type: 'text', value: '्' }).label).toBe('◌्');
+ expect(result.current.describe(shift)).toEqual({ label: '⇧', name: 'Shift', pressed: false });
+ });
+});
diff --git a/packages/example-react-package/src/css-modules.d.ts b/packages/saccade-keyboards/src/css-modules.d.ts
similarity index 100%
rename from packages/example-react-package/src/css-modules.d.ts
rename to packages/saccade-keyboards/src/css-modules.d.ts
diff --git a/packages/saccade-keyboards/src/index.js b/packages/saccade-keyboards/src/index.js
new file mode 100644
index 0000000..39e30aa
--- /dev/null
+++ b/packages/saccade-keyboards/src/index.js
@@ -0,0 +1,2 @@
+export { Keyboard } from './Keyboard.jsx';
+export { languages, loadLayout, registerLanguage } from './registry.js';
diff --git a/packages/saccade-keyboards/src/registry.js b/packages/saccade-keyboards/src/registry.js
new file mode 100644
index 0000000..fb63428
--- /dev/null
+++ b/packages/saccade-keyboards/src/registry.js
@@ -0,0 +1,54 @@
+import { validateLayout } from './core/layout.js';
+
+/** @typedef {import('./core/layout.js').Layout} Layout */
+
+/**
+ * Loads a layout: a function that imports it, such as `() => import('@coldsofttech/saccade-en')`.
+ * It resolves to the layout itself or to a module that exports it as `layout`.
+ *
+ * @typedef {() => Promise} LayoutLoader
+ */
+
+/** @type {Map} */
+const loaders = new Map();
+
+/**
+ * Makes a language available to `Keyboard` by its code.
+ *
+ * @param {string} id The language code, such as `en`.
+ * @param {LayoutLoader} loader Imports the layout when it is first needed.
+ */
+export function registerLanguage(id, loader) {
+ loaders.set(id, loader);
+}
+
+/**
+ * The language codes that can be loaded.
+ *
+ * @returns {string[]} The codes.
+ */
+export function languages() {
+ return [...loaders.keys()];
+}
+
+/**
+ * Loads and checks the layout of a language.
+ *
+ * @param {string} id The language code.
+ * @returns {Promise} The layout.
+ */
+export async function loadLayout(id) {
+ const loader = loaders.get(id);
+ if (!loader) {
+ throw new Error(
+ `No keyboard for "${id}". Install @coldsofttech/saccade-${id}, or add it with registerLanguage().`,
+ );
+ }
+ const loaded = await loader();
+ const layout = 'layout' in loaded ? loaded.layout : loaded;
+ const problems = validateLayout(layout);
+ if (problems.length > 0) {
+ throw new Error(`The keyboard for "${id}" is not a valid layout: ${problems.join('; ')}`);
+ }
+ return layout;
+}
diff --git a/packages/example-react-package/tsconfig.build.json b/packages/saccade-keyboards/tsconfig.build.json
similarity index 100%
rename from packages/example-react-package/tsconfig.build.json
rename to packages/saccade-keyboards/tsconfig.build.json
diff --git a/packages/example-react-package/vite.config.js b/packages/saccade-keyboards/vite.config.js
similarity index 89%
rename from packages/example-react-package/vite.config.js
rename to packages/saccade-keyboards/vite.config.js
index 47025d2..509d415 100644
--- a/packages/example-react-package/vite.config.js
+++ b/packages/saccade-keyboards/vite.config.js
@@ -5,7 +5,7 @@ export default defineConfig({
plugins: [react()],
build: {
lib: {
- entry: 'src/index.js',
+ entry: { index: 'src/index.js', 'core/index': 'src/core/index.js' },
formats: ['es'],
},
sourcemap: true,
diff --git a/scripts/README.md b/scripts/README.md
index 5426cc9..f870d67 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -1,26 +1,11 @@
# Scripts
-Helper scripts for maintaining the repository. They need Node 22 or newer and use no extra dependencies.
+Helper scripts for maintaining the repository. They need Node 22 or newer and use no extra dependencies. Packages run them from their `check:packages` script.
-## apply-repo-settings.js
+## check-no-react.js
-Applies the standard repository settings in one go: squash-only merging, delete branch on merge, secret scanning with push protection, Dependabot alerts and security updates, CodeQL default setup, the `skip-changelog` label and the `main` ruleset from `.github/rulesets/main.json`.
+Fails if a built package bundles React instead of treating it as a peer dependency.
-### Before you run it
+## check-pack.js
-- Install the [GitHub CLI](https://cli.github.com/) and sign in with `gh auth login`.
-- Use an account with admin rights on the repository.
-- CodeQL setup only works once the repository contains JavaScript, so run the script after adding code.
-
-### Run it
-
-From the repository root:
-
-```bash
-npm run repo:settings # the current repository
-npm run repo:settings -- owner/repo # a specific repository
-```
-
-Or call it directly: `node scripts/apply-repo-settings.js [owner/repo]`.
-
-It is safe to run again. If a ruleset named `main` already exists, it is left untouched. When it finishes, complete the manual steps in [docs/repo-setup.md](../docs/repo-setup.md).
+Fails if a package tarball contains anything other than build output and package metadata.
diff --git a/scripts/apply-repo-settings.js b/scripts/apply-repo-settings.js
deleted file mode 100644
index 5fdca5c..0000000
--- a/scripts/apply-repo-settings.js
+++ /dev/null
@@ -1,115 +0,0 @@
-#!/usr/bin/env node
-/**
- * Apply the standard security settings and the "main" ruleset to a repository.
- *
- * Usage: node scripts/apply-repo-settings.js [owner/repo] (defaults to the current repo)
- * Requires: gh CLI authenticated with admin rights on the repository.
- */
-import { spawnSync } from 'node:child_process';
-import { fileURLToPath } from 'node:url';
-
-const RULESET = fileURLToPath(new URL('../.github/rulesets/main.json', import.meta.url));
-
-/**
- * Runs `gh` and returns trimmed stdout. Exits the process on failure.
- *
- * @param {string[]} args Arguments for gh.
- * @param {unknown} [payload] JSON body sent on stdin.
- * @returns {string} Trimmed stdout.
- */
-function gh(args, payload) {
- const result = spawnSync('gh', args, {
- input: payload === undefined ? undefined : JSON.stringify(payload),
- encoding: 'utf8',
- });
- if (result.error || result.status !== 0) {
- const reason = result.error?.message ?? result.stderr.trim();
- console.error(`gh ${args.join(' ')} failed:\n${reason}`);
- process.exit(1);
- }
- return result.stdout.trim();
-}
-
-const repo =
- process.argv[2] ?? gh(['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']);
-console.log(`Configuring ${repo}`);
-
-// Merge settings: squash only, delete head branches after merge.
-gh([
- 'api',
- '-X',
- 'PATCH',
- `repos/${repo}`,
- '-F',
- 'allow_squash_merge=true',
- '-F',
- 'allow_merge_commit=false',
- '-F',
- 'allow_rebase_merge=false',
- '-F',
- 'delete_branch_on_merge=true',
-]);
-
-// Secret protection (secret scanning + push protection).
-gh(['api', '-X', 'PATCH', `repos/${repo}`, '--input', '-'], {
- security_and_analysis: {
- secret_scanning: { status: 'enabled' },
- secret_scanning_push_protection: { status: 'enabled' },
- },
-});
-
-// Dependency graph is on by default for public repos.
-// Dependabot alerts and security updates.
-gh(['api', '-X', 'PUT', `repos/${repo}/vulnerability-alerts`]);
-gh(['api', '-X', 'PUT', `repos/${repo}/automated-security-fixes`]);
-
-// CodeQL default setup.
-gh([
- 'api',
- '-X',
- 'PATCH',
- `repos/${repo}/code-scanning/default-setup`,
- '-f',
- 'state=configured',
- '-f',
- 'query_suite=default',
-]);
-
-// Let the Init workflow open its pull request.
-gh([
- 'api',
- '-X',
- 'PUT',
- `repos/${repo}/actions/permissions/workflow`,
- '-f',
- 'default_workflow_permissions=read',
- '-F',
- 'can_approve_pull_request_reviews=true',
-]);
-
-// Label referenced by .github/release.yml (ignored if it already exists).
-spawnSync(
- 'gh',
- [
- 'label',
- 'create',
- 'skip-changelog',
- '--repo',
- repo,
- '--description',
- 'Exclude from release notes',
- '--color',
- 'ededed',
- ],
- { encoding: 'utf8' },
-);
-
-// Ruleset (skips creation if one named "main" already exists).
-const names = gh(['api', `repos/${repo}/rulesets`, '-q', '.[].name']).split('\n');
-if (names.includes('main')) {
- console.log("Ruleset 'main' already exists; skipping");
-} else {
- gh(['api', '-X', 'POST', `repos/${repo}/rulesets`, '--input', RULESET]);
-}
-
-console.log('Done. Complete the manual steps in docs/repo-setup.md.');
From e22fcf8abf9aa7455ba4772587307c9f69d10ed9 Mon Sep 17 00:00:00 2001
From: coldsofttech <161084795+coldsofttech@users.noreply.github.com>
Date: Sat, 31 Oct 2026 07:49:38 +0000
Subject: [PATCH 02/13] feat: add saccade-en English keyboard, font prop and
demo app
Adds @coldsofttech/saccade-en with QWERTY and alphabetical letters plus a numbers and symbols layer, a font prop (native, sans, serif, mono) on Keyboard, an examples/demo app, the Layout type export, and docs and size-limit updates.
---
.size-limit.json | 7 ++
CHANGELOG.md | 2 +
README.md | 13 ++-
docs/development.md | 17 ++--
docs/layout-format.md | 4 +-
examples/demo/index.html | 12 +++
examples/demo/package.json | 19 ++++
examples/demo/src/main.jsx | 93 +++++++++++++++++++
examples/demo/vite.config.js | 4 +
package-lock.json | 44 +++++++--
package.json | 6 +-
packages/saccade-en/README.md | 28 ++++++
packages/saccade-en/package.json | 43 +++++++++
packages/saccade-en/src/index.js | 64 +++++++++++++
packages/saccade-en/src/index.test.js | 36 +++++++
packages/saccade-en/tsconfig.build.json | 13 +++
packages/saccade-en/vite.config.js | 21 +++++
packages/saccade-keyboards/README.md | 8 +-
.../saccade-keyboards/src/core/Keyboard.jsx | 30 +++++-
.../src/core/Keyboard.test.jsx | 16 +++-
packages/saccade-keyboards/src/core/index.js | 2 +
packages/saccade-keyboards/src/core/layout.js | 10 +-
.../saccade-keyboards/src/core/useKeyboard.js | 11 ++-
23 files changed, 466 insertions(+), 37 deletions(-)
create mode 100644 examples/demo/index.html
create mode 100644 examples/demo/package.json
create mode 100644 examples/demo/src/main.jsx
create mode 100644 examples/demo/vite.config.js
create mode 100644 packages/saccade-en/README.md
create mode 100644 packages/saccade-en/package.json
create mode 100644 packages/saccade-en/src/index.js
create mode 100644 packages/saccade-en/src/index.test.js
create mode 100644 packages/saccade-en/tsconfig.build.json
create mode 100644 packages/saccade-en/vite.config.js
diff --git a/.size-limit.json b/.size-limit.json
index 2a3f433..ec7ad1f 100644
--- a/.size-limit.json
+++ b/.size-limit.json
@@ -12,5 +12,12 @@
"import": "*",
"limit": "4 kB",
"ignore": ["react", "react-dom"]
+ },
+ {
+ "name": "@coldsofttech/saccade-en",
+ "path": "packages/saccade-en/dist/index.js",
+ "import": "*",
+ "limit": "1 kB",
+ "ignore": ["@coldsofttech/saccade-keyboards"]
}
]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ccd438..468006d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,3 +8,5 @@ All notable changes to this project are documented here. The format follows [Kee
- On-screen keyboard for React with a built-in light and dark theme, picked by language or built from your own layout.
- Headless hook, a helper to apply key presses to text, and a layout checker for building custom keyboards.
+- English keyboard (`@coldsofttech/saccade-en`) with QWERTY or alphabetical letters, plus numbers and symbols.
+- Choose the font of the keys: the device's own font (default), sans, serif, mono or any font you load.
diff --git a/README.md b/README.md
index 2535eba..c732e31 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,8 @@ On-screen keyboards for React apps (React 18 and 19). Show a keyboard for a lang
## What you get
- A keyboard you pick by language, or build from your own layout
-- Light and dark themes, or your own colours
+- Light and dark themes, or your own colours, and a choice of font: the device's own, sans, serif or mono
+- English out of the box, with letters in QWERTY or alphabetical order
- Works with any input: the keyboard reports key presses and you decide where the text goes
- Keys that screen readers can read, and focus that stays in your text field
- Right-to-left and complex scripts, with a dotted circle shown for signs that sit on a letter
@@ -14,7 +15,7 @@ On-screen keyboards for React apps (React 18 and 19). Show a keyboard for a lang
## Install
```bash
-npm install @coldsofttech/saccade-keyboards
+npm install @coldsofttech/saccade-keyboards @coldsofttech/saccade-en
```
React and `react-dom` 18 or 19 must be installed in your app.
@@ -22,13 +23,15 @@ React and `react-dom` 18 or 19 must be installed in your app.
## Use it
```jsx
-import { Keyboard } from '@coldsofttech/saccade-keyboards';
+import { Keyboard, registerLanguage } from '@coldsofttech/saccade-keyboards';
import '@coldsofttech/saccade-keyboards/style.css';
- console.log(key)} theme="dark" />;
+registerLanguage('en', () => import('@coldsofttech/saccade-en'));
+
+ console.log(key)} theme="dark" font="native" />;
```
-Each language is installed separately, for example `@coldsofttech/saccade-en`. If one is missing, the keyboard tells you which package to install. See the [package guide](packages/saccade-keyboards/README.md) for all options, typing into a field, and building your own keyboard.
+Each language is installed separately. If one is missing, the keyboard tells you which package to install. For English with the letters in alphabetical order, pass `layout={createLayout('alphabetical')}` from `@coldsofttech/saccade-en` instead of `language`. See the [package guide](packages/saccade-keyboards/README.md) for all options, typing into a field, and building your own keyboard.
## Contributing
diff --git a/docs/development.md b/docs/development.md
index ed0e863..b02f7a2 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -3,20 +3,21 @@
## Setup
- Node 22 or newer. `.nvmrc` pins 24, the version used for linting, building and releasing.
-- `npm ci` installs every workspace (`packages/*`).
+- `npm ci` installs every workspace (`packages/*` and `examples/*`).
- `pip install pre-commit && pre-commit install --hook-type pre-commit --hook-type post-commit`.
## Scripts
All scripts run from the repository root.
-| Script | Purpose |
-| --------------------------------- | ---------------------------------------------------------------- |
-| `npm run build` | Build every package (Vite library mode, then `.d.ts` from JSDoc) |
-| `npm run lint` | ESLint across the repository |
-| `npm run format` / `format:check` | Prettier write / check, including Markdown |
-| `npm test` / `test:coverage` | Vitest; coverage thresholds live in `vitest.config.js` |
-| `npm run check:packages` | `publint` and `@arethetypeswrong/cli` on each built package |
+| Script | Purpose |
+| --------------------------------- | ----------------------------------------------------------------------------------------- |
+| `npm run build` | Build every package (Vite library mode, then `.d.ts` from JSDoc); the engine builds first |
+| `npm run dev` | Start the demo in `examples/demo` (build packages first) |
+| `npm run lint` | ESLint across the repository |
+| `npm run format` / `format:check` | Prettier write / check, including Markdown |
+| `npm test` / `test:coverage` | Vitest; coverage thresholds live in `vitest.config.js` |
+| `npm run check:packages` | `publint` and `@arethetypeswrong/cli` on each built package |
## Conventions
diff --git a/docs/layout-format.md b/docs/layout-format.md
index 55ff498..1a15411 100644
--- a/docs/layout-format.md
+++ b/docs/layout-format.md
@@ -50,6 +50,8 @@ A sign that only sits on a letter, such as a vowel sign or a virama, is shown on
1. Create a package `@coldsofttech/saccade-` that exports the layout as `layout` and depends on `@coldsofttech/saccade-keyboards` as a peer.
2. Add a test that runs `validateLayout`.
3. List its font (if any) in `fonts` and document how to load it.
-4. Register it in the meta package so `language=""` finds it.
+4. Apps register it with `registerLanguage('', () => import('@coldsofttech/saccade-'))` so `language=""` finds it, or pass the layout through the `layout` prop.
+
+`@coldsofttech/saccade-en` is the reference: it exports `layout` (QWERTY) and `createLayout('qwerty' | 'alphabetical')`.
Composition rules (conjuncts, vowel signs joining a consonant) are not part of the format yet. A key types exactly its `value`.
diff --git a/examples/demo/index.html b/examples/demo/index.html
new file mode 100644
index 0000000..12fc63d
--- /dev/null
+++ b/examples/demo/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Saccade Keyboards demo
+
+
+
+
+
+
diff --git a/examples/demo/package.json b/examples/demo/package.json
new file mode 100644
index 0000000..e2cbb07
--- /dev/null
+++ b/examples/demo/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "demo",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build"
+ },
+ "dependencies": {
+ "@coldsofttech/saccade-en": "*",
+ "@coldsofttech/saccade-keyboards": "*",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^6.1.2"
+ }
+}
diff --git a/examples/demo/src/main.jsx b/examples/demo/src/main.jsx
new file mode 100644
index 0000000..f864402
--- /dev/null
+++ b/examples/demo/src/main.jsx
@@ -0,0 +1,93 @@
+import { StrictMode, useRef, useState } from 'react';
+import { createRoot } from 'react-dom/client';
+import { Keyboard } from '@coldsofttech/saccade-keyboards';
+import { applyKey } from '@coldsofttech/saccade-keyboards/core';
+import '@coldsofttech/saccade-keyboards/style.css';
+import { createLayout } from '@coldsofttech/saccade-en';
+
+const ARRANGEMENTS = ['qwerty', 'alphabetical'];
+const THEMES = ['light', 'dark'];
+const FONTS = ['native', 'sans', 'serif', 'mono'];
+
+function Select({ label, value, options, onChange }) {
+ return (
+
+ );
+}
+
+function Demo() {
+ const field = useRef(null);
+ const [arrangement, setArrangement] = useState('qwerty');
+ const [theme, setTheme] = useState('light');
+ const [font, setFont] = useState('native');
+ const [fontSize, setFontSize] = useState(20);
+ const [shown, setShown] = useState(true);
+
+ const onKey = (key) => {
+ const { value, selectionStart, selectionEnd } = field.current;
+ const next = applyKey(value, { start: selectionStart, end: selectionEnd }, key);
+ field.current.value = next.text;
+ field.current.setSelectionRange(next.selection.start, next.selection.end);
+ };
+
+ return (
+
+
English keyboard
+
Click in the field, then press keys on the on-screen keyboard.
+
+
+
+
+
+
+
+ );
+}
+
+createRoot(document.getElementById('root')).render(
+
+
+ ,
+);
diff --git a/examples/demo/vite.config.js b/examples/demo/vite.config.js
new file mode 100644
index 0000000..56f7628
--- /dev/null
+++ b/examples/demo/vite.config.js
@@ -0,0 +1,4 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({ plugins: [react()] });
diff --git a/package-lock.json b/package-lock.json
index ad8f5ed..efdff8f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,7 +6,8 @@
"": {
"name": "react-SaccadeKeyboards",
"workspaces": [
- "packages/*"
+ "packages/*",
+ "examples/*"
],
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
@@ -41,6 +42,18 @@
"node": ">=22"
}
},
+ "examples/demo": {
+ "version": "0.0.0",
+ "dependencies": {
+ "@coldsofttech/saccade-en": "*",
+ "@coldsofttech/saccade-keyboards": "*",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^6.1.2"
+ }
+ },
"node_modules/@adobe/css-tools": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
@@ -478,6 +491,10 @@
"keyv": "^5.6.0"
}
},
+ "node_modules/@coldsofttech/saccade-en": {
+ "resolved": "packages/saccade-en",
+ "link": true
+ },
"node_modules/@coldsofttech/saccade-keyboards": {
"resolved": "packages/saccade-keyboards",
"link": true
@@ -2687,6 +2704,10 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/demo": {
+ "resolved": "examples/demo",
+ "link": true
+ },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -2978,9 +2999,9 @@
}
},
"node_modules/eslint": {
- "version": "10.13.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz",
- "integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==",
+ "version": "10.14.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz",
+ "integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -2993,7 +3014,7 @@
"@eslint/config-helpers": "^0.7.0",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.3",
- "@humanfs/node": "^0.16.6",
+ "@humanfs/node": "^0.16.8",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
@@ -3014,7 +3035,7 @@
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
- "minimatch": "^10.2.5",
+ "minimatch": "^10.2.6",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -6726,6 +6747,17 @@
"zod": "^3.25.0 || ^4.0.0"
}
},
+ "packages/saccade-en": {
+ "name": "@coldsofttech/saccade-en",
+ "version": "0.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
+ },
"packages/saccade-keyboards": {
"name": "@coldsofttech/saccade-keyboards",
"version": "0.1.0",
diff --git a/package.json b/package.json
index 77248eb..0387ea8 100644
--- a/package.json
+++ b/package.json
@@ -6,10 +6,12 @@
"node": ">=22"
},
"workspaces": [
- "packages/*"
+ "packages/*",
+ "examples/*"
],
"scripts": {
- "build": "npm run build --workspaces --if-present",
+ "build": "npm run build -w @coldsofttech/saccade-keyboards && npm run build --workspaces --if-present",
+ "dev": "npm run dev --workspace examples/demo",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
diff --git a/packages/saccade-en/README.md b/packages/saccade-en/README.md
new file mode 100644
index 0000000..55f3796
--- /dev/null
+++ b/packages/saccade-en/README.md
@@ -0,0 +1,28 @@
+# @coldsofttech/saccade-en
+
+The English keyboard for [`@coldsofttech/saccade-keyboards`](../saccade-keyboards), with letters, numbers and symbols. Letters can be in QWERTY order (the default) or in alphabetical order.
+
+```bash
+npm install @coldsofttech/saccade-keyboards @coldsofttech/saccade-en
+```
+
+## Use it
+
+```jsx
+import { Keyboard, registerLanguage } from '@coldsofttech/saccade-keyboards';
+
+registerLanguage('en', () => import('@coldsofttech/saccade-en'));
+
+;
+```
+
+## Alphabetical order
+
+```jsx
+import { Keyboard } from '@coldsofttech/saccade-keyboards';
+import { createLayout } from '@coldsofttech/saccade-en';
+
+;
+```
+
+`createLayout(type)` takes `'qwerty'` (default) or `'alphabetical'`.
diff --git a/packages/saccade-en/package.json b/packages/saccade-en/package.json
new file mode 100644
index 0000000..6295269
--- /dev/null
+++ b/packages/saccade-en/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@coldsofttech/saccade-en",
+ "version": "0.1.0",
+ "description": "English keyboard layout for @coldsofttech/saccade-keyboards, in QWERTY or alphabetical order.",
+ "license": "MIT",
+ "type": "module",
+ "sideEffects": false,
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "engines": {
+ "node": ">=22"
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/coldsofttech/SaccadeKeyboards.git",
+ "directory": "packages/saccade-en"
+ },
+ "homepage": "https://github.com/coldsofttech/SaccadeKeyboards#readme",
+ "bugs": {
+ "url": "https://github.com/coldsofttech/SaccadeKeyboards/issues"
+ },
+ "scripts": {
+ "build": "vite build && npm run build:types",
+ "build:types": "tsc -p tsconfig.build.json"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
+}
diff --git a/packages/saccade-en/src/index.js b/packages/saccade-en/src/index.js
new file mode 100644
index 0000000..5b444e0
--- /dev/null
+++ b/packages/saccade-en/src/index.js
@@ -0,0 +1,64 @@
+import {
+ backspaceKey,
+ bottomRow,
+ cased,
+ shiftKey,
+ symbolsLayer,
+} from '@coldsofttech/saccade-keyboards/core';
+
+/** @typedef {import('@coldsofttech/saccade-keyboards/core').Layout} Layout */
+
+/**
+ * How the letters are arranged: `qwerty` like a physical keyboard, or `alphabetical` from a to z.
+ *
+ * @typedef {'qwerty' | 'alphabetical'} Arrangement
+ */
+
+/** The rows of letters of each arrangement, without the shift and backspace keys. */
+const ARRANGEMENTS = new Map([
+ ['qwerty', ['qwertyuiop', 'asdfghjkl', 'zxcvbnm']],
+ ['alphabetical', ['abcdefghij', 'klmnopqrs', 'tuvwxyz']],
+]);
+
+/** The English layout in the given arrangement.
+ *
+ * @param {Arrangement} [type] The arrangement of the letters.
+ * @returns {Layout} The layout.
+ */
+export function createLayout(type = 'qwerty') {
+ const arrangement = ARRANGEMENTS.get(type);
+ if (!arrangement) {
+ throw new Error(
+ `Unknown arrangement "${type}". Use ${[...ARRANGEMENTS.keys()]
+ .map((name) => `"${name}"`)
+ .join(' or ')}.`,
+ );
+ }
+ const [top, middle, bottom] = arrangement;
+ return {
+ id: 'en',
+ name: 'English',
+ dir: 'ltr',
+ layers: {
+ letters: [
+ cased(top),
+ cased(middle),
+ [shiftKey, ...cased(bottom), backspaceKey],
+ bottomRow({ type: 'layer', to: 'symbols', label: '123', name: 'numbers and symbols' }),
+ ],
+ symbols: symbolsLayer('1234567890', {
+ type: 'layer',
+ to: 'letters',
+ label: 'ABC',
+ name: 'letters',
+ }),
+ },
+ };
+}
+
+/**
+ * The English layout in QWERTY order, which is what `language="en"` uses.
+ *
+ * @type {Layout}
+ */
+export const layout = createLayout();
diff --git a/packages/saccade-en/src/index.test.js b/packages/saccade-en/src/index.test.js
new file mode 100644
index 0000000..a8a74f5
--- /dev/null
+++ b/packages/saccade-en/src/index.test.js
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+import { validateLayout } from '@coldsofttech/saccade-keyboards/core';
+import { createLayout, layout } from './index.js';
+
+const letters = (l) =>
+ l.layers.letters
+ .flat()
+ .filter((key) => key.type === 'text' && /^[a-z]$/.test(key.value))
+ .map((key) => key.value);
+
+describe('saccade-en', () => {
+ it('is a valid layout in QWERTY order by default', () => {
+ expect(validateLayout(layout)).toEqual([]);
+ expect(layout.id).toBe('en');
+ expect(letters(layout).slice(0, 10).join('')).toBe('qwertyuiop');
+ });
+
+ it.each(['qwerty', 'alphabetical'])('has all 26 letters once in %s', (type) => {
+ const built = createLayout(type);
+ expect(validateLayout(built)).toEqual([]);
+ expect([...letters(built)].sort().join('')).toBe('abcdefghijklmnopqrstuvwxyz');
+ });
+
+ it('puts the letters in order when alphabetical', () => {
+ expect(letters(createLayout('alphabetical')).join('')).toBe('abcdefghijklmnopqrstuvwxyz');
+ });
+
+ it('types capitals with shift', () => {
+ const key = layout.layers.letters[0][0];
+ expect(key).toMatchObject({ value: 'q', shift: 'Q' });
+ });
+
+ it('rejects an unknown arrangement', () => {
+ expect(() => createLayout('dvorak')).toThrow(/Unknown arrangement "dvorak"/);
+ });
+});
diff --git a/packages/saccade-en/tsconfig.build.json b/packages/saccade-en/tsconfig.build.json
new file mode 100644
index 0000000..c231669
--- /dev/null
+++ b/packages/saccade-en/tsconfig.build.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../jsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "noEmit": false,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src"],
+ "exclude": ["src/**/*.test.jsx", "src/**/*.test.js"]
+}
diff --git a/packages/saccade-en/vite.config.js b/packages/saccade-en/vite.config.js
new file mode 100644
index 0000000..98c65ff
--- /dev/null
+++ b/packages/saccade-en/vite.config.js
@@ -0,0 +1,21 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vite';
+
+const core = fileURLToPath(new URL('../saccade-keyboards/src/core/index.js', import.meta.url));
+
+export default defineConfig({
+ build: {
+ lib: { entry: { index: 'src/index.js' }, formats: ['es'] },
+ sourcemap: true,
+ rollupOptions: {
+ // The engine is a peer dependency and must never be bundled.
+ external: [/^@coldsofttech\/saccade-keyboards($|\/)/],
+ output: { entryFileNames: '[name].js' },
+ },
+ },
+ resolve: {
+ // Tests run against the engine source, so they need no build of it first.
+ alias: { '@coldsofttech/saccade-keyboards/core': core },
+ },
+ test: { environment: 'node' },
+});
diff --git a/packages/saccade-keyboards/README.md b/packages/saccade-keyboards/README.md
index f6dd9d8..5573525 100644
--- a/packages/saccade-keyboards/README.md
+++ b/packages/saccade-keyboards/README.md
@@ -12,10 +12,12 @@ The keyboard never edits a field. It tells you which key was pressed, and you ty
```jsx
import { useRef } from 'react';
-import { Keyboard } from '@coldsofttech/saccade-keyboards';
+import { Keyboard, registerLanguage } from '@coldsofttech/saccade-keyboards';
import { applyKey } from '@coldsofttech/saccade-keyboards/core';
import '@coldsofttech/saccade-keyboards/style.css';
+registerLanguage('en', () => import('@coldsofttech/saccade-en'));
+
function Field() {
const input = useRef(null);
@@ -35,7 +37,7 @@ function Field() {
}
```
-`language` needs that language's package, for example `@coldsofttech/saccade-en`. If it is missing, the keyboard says which one to install.
+`language` needs that language's package, for example `@coldsofttech/saccade-en` (QWERTY by default; use its `createLayout('alphabetical')` with the `layout` prop for alphabetical letters), and a `registerLanguage` call for it. If the package is missing, the keyboard says which one to install.
## Props
@@ -47,6 +49,7 @@ function Field() {
| `onClose` | Called when the hide key is pressed. |
| `theme` | `'light'` (default), `'dark'`, or colours: `background`, `key`, `keyText`, `border`, `accent`, `accentText`. |
| `label` | The name of the keyboard for a screen reader. |
+| `font` | The font of the keys: `'native'` (default, the device's own font), `'sans'`, `'serif'`, `'mono'`. |
| `fontSize` | The size of the letters on the keys, in pixels (16). |
| `fallback` | What to show while a language loads. |
@@ -58,6 +61,7 @@ function Field() {
- `useKeyboard`: the state and behaviour without markup, to draw the keys yourself.
- `applyKey`: applies a key press to text and a selection.
- `validateLayout`: lists what is wrong with a layout.
+- `Layout`: the type of a layout, for packages that provide one.
- `keys`, `cased`, `bottomRow`, `symbolsLayer`, `shiftKey`, `backspaceKey`, `PUNCTUATION_NAMES`: helpers to write layouts.
`registerLanguage(id, loader)`, `languages()` and `loadLayout(id)` from the main entry let you add a language by its code.
diff --git a/packages/saccade-keyboards/src/core/Keyboard.jsx b/packages/saccade-keyboards/src/core/Keyboard.jsx
index 42b05b0..776d793 100644
--- a/packages/saccade-keyboards/src/core/Keyboard.jsx
+++ b/packages/saccade-keyboards/src/core/Keyboard.jsx
@@ -23,6 +23,8 @@ import { useKeyboard } from './useKeyboard.js';
* @property {() => void} [onClose] The hide key was pressed.
* @property {'light' | 'dark' | ThemeColors} [theme] Colours of the keyboard ('light').
* @property {string} [label] The name of the keyboard for a screen reader.
+ * @property {string} [font] The font of the keys: `native` (the device's own font), `sans`, `serif`,
+ * `mono`, or any CSS font-family list, such as `"Atkinson Hyperlegible", sans-serif` ('native').
* @property {number} [fontSize] The size of the letters on the keys, in pixels (16).
*/
@@ -35,6 +37,14 @@ const TOKENS = new Map([
['accentText', '--saccade-accent-text'],
]);
+/** Fonts every device has, so nothing is bundled and no font licence is involved. */
+const FONTS = new Map([
+ ['native', 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'],
+ ['sans', 'ui-sans-serif, "Helvetica Neue", Arial, sans-serif'],
+ ['serif', 'ui-serif, Georgia, "Times New Roman", serif'],
+ ['mono', 'ui-monospace, "SF Mono", Consolas, "Liberation Mono", monospace'],
+]);
+
/** @param {KeyboardProps['theme']} theme */
function themeProps(theme) {
if (theme !== null && typeof theme === 'object') {
@@ -46,6 +56,14 @@ function themeProps(theme) {
return { className: theme === 'dark' ? `${styles.root} ${styles.dark}` : styles.root };
}
+/**
+ * @param {{ className: string, style?: Record }} props Theme props.
+ * @param {string} font The `font` prop.
+ */
+function withFont(props, font) {
+ return { ...props, style: { ...props.style, fontFamily: FONTS.get(font) ?? font } };
+}
+
/**
* An on-screen keyboard for a layout. It edits nothing: it says which key was pressed, and
* whoever uses it types into its own field (see `applyKey`). Pressing a key keeps the focus where
@@ -54,7 +72,15 @@ function themeProps(theme) {
* @param {KeyboardProps} props Component props.
* @returns {import('react').JSX.Element} The keyboard.
*/
-export function Keyboard({ layout, onKey, onClose, theme = 'light', label, fontSize = 16 }) {
+export function Keyboard({
+ layout,
+ onKey,
+ onClose,
+ theme = 'light',
+ label,
+ font = 'native',
+ fontSize = 16,
+}) {
const { rows, shift, press, describe } = useKeyboard({ layout, onKey, onClose });
return (
@@ -64,7 +90,7 @@ export function Keyboard({ layout, onKey, onClose, theme = 'light', label, fontS
aria-label={label ?? `${layout.name} on-screen keyboard`}
lang={layout.id}
dir="ltr"
- {...themeProps(theme)}
+ {...withFont(themeProps(theme), font)}
>
{rows.map((row, r) => (
diff --git a/packages/saccade-keyboards/src/core/Keyboard.test.jsx b/packages/saccade-keyboards/src/core/Keyboard.test.jsx
index d5bf8fe..4cee9f7 100644
--- a/packages/saccade-keyboards/src/core/Keyboard.test.jsx
+++ b/packages/saccade-keyboards/src/core/Keyboard.test.jsx
@@ -64,7 +64,21 @@ describe('Keyboard', () => {
it('takes colours from a theme object', () => {
render();
expect(screen.getByRole('group').style.getPropertyValue('--saccade-accent')).toBe('red');
- expect(screen.getByRole('group').style.length).toBe(1);
+ expect(screen.getByRole('group').style.getPropertyValue('--saccade-key')).toBe('');
+ });
+
+ it('uses the device font unless told otherwise', () => {
+ render();
+ expect(screen.getByRole('group').style.fontFamily).toContain('system-ui');
+ });
+
+ it.each([
+ ['mono', 'monospace'],
+ ['serif', 'serif'],
+ ['"Atkinson Hyperlegible", sans-serif', 'Atkinson Hyperlegible'],
+ ])('takes the %s font', (font, expected) => {
+ render();
+ expect(screen.getByRole('group').style.fontFamily).toContain(expected);
});
it.each(['light', 'dark'])('has no accessibility violations in the %s theme', async (theme) => {
diff --git a/packages/saccade-keyboards/src/core/index.js b/packages/saccade-keyboards/src/core/index.js
index 5f12178..d677437 100644
--- a/packages/saccade-keyboards/src/core/index.js
+++ b/packages/saccade-keyboards/src/core/index.js
@@ -11,3 +11,5 @@ export {
symbolsLayer,
validateLayout,
} from './layout.js';
+
+/** @typedef {import('./layout.js').Layout} Layout */
diff --git a/packages/saccade-keyboards/src/core/layout.js b/packages/saccade-keyboards/src/core/layout.js
index ade1110..838334f 100644
--- a/packages/saccade-keyboards/src/core/layout.js
+++ b/packages/saccade-keyboards/src/core/layout.js
@@ -51,9 +51,10 @@ const ACTIONS = ['shift', 'backspace', 'enter', 'space', 'close'];
*/
export function keys(normal, shifted = '') {
const upper = [...shifted];
- return [...normal].map((value, i) =>
- upper[i] ? { type: 'text', value, shift: upper[i] } : { type: 'text', value },
- );
+ return [...normal].map((value, i) => {
+ const capital = upper.at(i);
+ return capital ? { type: 'text', value, shift: capital } : { type: 'text', value };
+ });
}
/**
@@ -193,8 +194,7 @@ export function validateLayout(layout) {
return problems;
}
const layerNames = Object.keys(layers);
- layerNames.forEach((layer) => {
- const rows = layers[layer];
+ Object.entries(layers).forEach(([layer, rows]) => {
if (!Array.isArray(rows) || rows.length === 0) {
problems.push(`layer "${layer}": must be a non-empty list of rows`);
return;
diff --git a/packages/saccade-keyboards/src/core/useKeyboard.js b/packages/saccade-keyboards/src/core/useKeyboard.js
index 57d7efc..36b6e78 100644
--- a/packages/saccade-keyboards/src/core/useKeyboard.js
+++ b/packages/saccade-keyboards/src/core/useKeyboard.js
@@ -60,9 +60,10 @@ export function useKeyboard({ layout, onKey, onClose }) {
const [chosen, setChosen] = useState('');
const [shift, setShift] = useState(/** @type {'off' | 'once' | 'caps'} */ ('off'));
// Fall back to the first layer when the layout changes to one without the chosen layer.
- const layer = layout.layers[chosen] ? chosen : Object.keys(layout.layers)[0];
- /** @type {Record} */
- const names = { ...PUNCTUATION_NAMES, ...layout.names };
+ const layers = new Map(Object.entries(layout.layers));
+ const layer = layers.has(chosen) ? chosen : [...layers.keys()][0];
+ /** @type {Map} */
+ const names = new Map(Object.entries({ ...PUNCTUATION_NAMES, ...layout.names }));
/** @param {Key} key */
const press = (key) => {
@@ -94,7 +95,7 @@ export function useKeyboard({ layout, onKey, onClose }) {
const describe = (key) => {
if (key.type === 'text') {
const value = typedValue(key, shift !== 'off');
- return { label: shown(value), name: names[value] ?? value };
+ return { label: shown(value), name: names.get(value) ?? value };
}
if (key.type === 'layer') return { label: key.label, name: key.name ?? key.label };
if (key.type === 'shift') {
@@ -107,5 +108,5 @@ export function useKeyboard({ layout, onKey, onClose }) {
return FIXED[key.type];
};
- return { layer, shift, rows: layout.layers[layer], press, describe };
+ return { layer, shift, rows: /** @type {Key[][]} */ (layers.get(layer)), press, describe };
}
From 4dfb1b73108090a5637817018f960db91650093a Mon Sep 17 00:00:00 2001
From: coldsofttech <161084795+coldsofttech@users.noreply.github.com>
Date: Sat, 31 Oct 2026 08:00:39 +0000
Subject: [PATCH 03/13] feat: add saccade-es Spanish keyboard
Adds @coldsofttech/saccade-es with QWERTY and alphabetical letters including n with tilde, an accented vowels layer, and a symbols layer with inverted marks and euro. Adds a language selector to the demo and a size-limit entry.
---
.size-limit.json | 7 ++
CHANGELOG.md | 1 +
README.md | 2 +-
examples/demo/package.json | 1 +
examples/demo/src/main.jsx | 14 +++-
package-lock.json | 16 +++++
packages/saccade-es/README.md | 28 ++++++++
packages/saccade-es/package.json | 43 ++++++++++++
packages/saccade-es/src/index.js | 93 +++++++++++++++++++++++++
packages/saccade-es/src/index.test.js | 50 +++++++++++++
packages/saccade-es/tsconfig.build.json | 13 ++++
packages/saccade-es/vite.config.js | 21 ++++++
sweepito.toml | 4 +-
13 files changed, 288 insertions(+), 5 deletions(-)
create mode 100644 packages/saccade-es/README.md
create mode 100644 packages/saccade-es/package.json
create mode 100644 packages/saccade-es/src/index.js
create mode 100644 packages/saccade-es/src/index.test.js
create mode 100644 packages/saccade-es/tsconfig.build.json
create mode 100644 packages/saccade-es/vite.config.js
diff --git a/.size-limit.json b/.size-limit.json
index ec7ad1f..7d85945 100644
--- a/.size-limit.json
+++ b/.size-limit.json
@@ -19,5 +19,12 @@
"import": "*",
"limit": "1 kB",
"ignore": ["@coldsofttech/saccade-keyboards"]
+ },
+ {
+ "name": "@coldsofttech/saccade-es",
+ "path": "packages/saccade-es/dist/index.js",
+ "import": "*",
+ "limit": "1 kB",
+ "ignore": ["@coldsofttech/saccade-keyboards"]
}
]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 468006d..a4f1a6e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,5 +8,6 @@ All notable changes to this project are documented here. The format follows [Kee
- On-screen keyboard for React with a built-in light and dark theme, picked by language or built from your own layout.
- Headless hook, a helper to apply key presses to text, and a layout checker for building custom keyboards.
+- Spanish keyboard with ñ, accented vowels, ¿ ¡ and €, in QWERTY or alphabetical order.
- English keyboard (`@coldsofttech/saccade-en`) with QWERTY or alphabetical letters, plus numbers and symbols.
- Choose the font of the keys: the device's own font (default), sans, serif, mono or any font you load.
diff --git a/README.md b/README.md
index c732e31..3bb50d8 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ On-screen keyboards for React apps (React 18 and 19). Show a keyboard for a lang
- A keyboard you pick by language, or build from your own layout
- Light and dark themes, or your own colours, and a choice of font: the device's own, sans, serif or mono
-- English out of the box, with letters in QWERTY or alphabetical order
+- English and Spanish keyboards, with letters in QWERTY or alphabetical order
- Works with any input: the keyboard reports key presses and you decide where the text goes
- Keys that screen readers can read, and focus that stays in your text field
- Right-to-left and complex scripts, with a dotted circle shown for signs that sit on a letter
diff --git a/examples/demo/package.json b/examples/demo/package.json
index e2cbb07..7a92b28 100644
--- a/examples/demo/package.json
+++ b/examples/demo/package.json
@@ -9,6 +9,7 @@
},
"dependencies": {
"@coldsofttech/saccade-en": "*",
+ "@coldsofttech/saccade-es": "*",
"@coldsofttech/saccade-keyboards": "*",
"react": "^19.1.0",
"react-dom": "^19.1.0"
diff --git a/examples/demo/src/main.jsx b/examples/demo/src/main.jsx
index f864402..0264f38 100644
--- a/examples/demo/src/main.jsx
+++ b/examples/demo/src/main.jsx
@@ -3,8 +3,14 @@ import { createRoot } from 'react-dom/client';
import { Keyboard } from '@coldsofttech/saccade-keyboards';
import { applyKey } from '@coldsofttech/saccade-keyboards/core';
import '@coldsofttech/saccade-keyboards/style.css';
-import { createLayout } from '@coldsofttech/saccade-en';
+import { createLayout as createEnglish } from '@coldsofttech/saccade-en';
+import { createLayout as createSpanish } from '@coldsofttech/saccade-es';
+const LAYOUTS = new Map([
+ ['English', createEnglish],
+ ['Spanish', createSpanish],
+]);
+const LANGUAGES = [...LAYOUTS.keys()];
const ARRANGEMENTS = ['qwerty', 'alphabetical'];
const THEMES = ['light', 'dark'];
const FONTS = ['native', 'sans', 'serif', 'mono'];
@@ -24,6 +30,7 @@ function Select({ label, value, options, onChange }) {
function Demo() {
const field = useRef(null);
+ const [language, setLanguage] = useState('English');
const [arrangement, setArrangement] = useState('qwerty');
const [theme, setTheme] = useState('light');
const [font, setFont] = useState('native');
@@ -39,9 +46,10 @@ function Demo() {
return (
-
English keyboard
+
{language} keyboard
Click in the field, then press keys on the on-screen keyboard.
+
{shown ? (
setShown(false)}
theme={theme}
diff --git a/package-lock.json b/package-lock.json
index efdff8f..01c3e34 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -46,6 +46,7 @@
"version": "0.0.0",
"dependencies": {
"@coldsofttech/saccade-en": "*",
+ "@coldsofttech/saccade-es": "*",
"@coldsofttech/saccade-keyboards": "*",
"react": "^19.1.0",
"react-dom": "^19.1.0"
@@ -495,6 +496,10 @@
"resolved": "packages/saccade-en",
"link": true
},
+ "node_modules/@coldsofttech/saccade-es": {
+ "resolved": "packages/saccade-es",
+ "link": true
+ },
"node_modules/@coldsofttech/saccade-keyboards": {
"resolved": "packages/saccade-keyboards",
"link": true
@@ -6758,6 +6763,17 @@
"@coldsofttech/saccade-keyboards": "^0.1.0"
}
},
+ "packages/saccade-es": {
+ "name": "@coldsofttech/saccade-es",
+ "version": "0.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
+ },
"packages/saccade-keyboards": {
"name": "@coldsofttech/saccade-keyboards",
"version": "0.1.0",
diff --git a/packages/saccade-es/README.md b/packages/saccade-es/README.md
new file mode 100644
index 0000000..b953d6c
--- /dev/null
+++ b/packages/saccade-es/README.md
@@ -0,0 +1,28 @@
+# @coldsofttech/saccade-es
+
+The Spanish keyboard for [`@coldsofttech/saccade-keyboards`](../saccade-keyboards), with letters including ñ, a layer of accented vowels (á é í ó ú ü), and numbers and symbols including ¿ ¡ and €. Letters can be in QWERTY order (the default) or in alphabetical order.
+
+```bash
+npm install @coldsofttech/saccade-keyboards @coldsofttech/saccade-es
+```
+
+## Use it
+
+```jsx
+import { Keyboard, registerLanguage } from '@coldsofttech/saccade-keyboards';
+
+registerLanguage('es', () => import('@coldsofttech/saccade-es'));
+
+;
+```
+
+## Alphabetical order
+
+```jsx
+import { Keyboard } from '@coldsofttech/saccade-keyboards';
+import { createLayout } from '@coldsofttech/saccade-es';
+
+;
+```
+
+`createLayout(type)` takes `'qwerty'` (default) or `'alphabetical'`.
diff --git a/packages/saccade-es/package.json b/packages/saccade-es/package.json
new file mode 100644
index 0000000..5090106
--- /dev/null
+++ b/packages/saccade-es/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@coldsofttech/saccade-es",
+ "version": "0.1.0",
+ "description": "Spanish keyboard layout for @coldsofttech/saccade-keyboards, in QWERTY or alphabetical order.",
+ "license": "MIT",
+ "type": "module",
+ "sideEffects": false,
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "engines": {
+ "node": ">=22"
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/coldsofttech/SaccadeKeyboards.git",
+ "directory": "packages/saccade-es"
+ },
+ "homepage": "https://github.com/coldsofttech/SaccadeKeyboards#readme",
+ "bugs": {
+ "url": "https://github.com/coldsofttech/SaccadeKeyboards/issues"
+ },
+ "scripts": {
+ "build": "vite build && npm run build:types",
+ "build:types": "tsc -p tsconfig.build.json"
+ },
+ "peerDependencies": {
+ "@coldsofttech/saccade-keyboards": "^0.1.0"
+ }
+}
diff --git a/packages/saccade-es/src/index.js b/packages/saccade-es/src/index.js
new file mode 100644
index 0000000..30f1b3c
--- /dev/null
+++ b/packages/saccade-es/src/index.js
@@ -0,0 +1,93 @@
+import {
+ backspaceKey,
+ bottomRow,
+ cased,
+ keys,
+ shiftKey,
+ symbolsLayer,
+} from '@coldsofttech/saccade-keyboards/core';
+
+/** @typedef {import('@coldsofttech/saccade-keyboards/core').Layout} Layout */
+
+/**
+ * How the letters are arranged: `qwerty` like a Spanish physical keyboard, or `alphabetical` from a
+ * to z with the ñ after the n.
+ *
+ * @typedef {'qwerty' | 'alphabetical'} Arrangement
+ */
+
+/** The rows of letters of each arrangement, without the shift and backspace keys. */
+const ARRANGEMENTS = new Map([
+ ['qwerty', ['qwertyuiop', 'asdfghjklñ', 'zxcvbnm']],
+ ['alphabetical', ['abcdefghij', 'klmnñopqrs', 'tuvwxyz']],
+]);
+
+/** What a screen reader says for the characters Spanish adds. */
+const NAMES = {
+ '¿': 'signo de interrogación de apertura',
+ '¡': 'signo de exclamación de apertura',
+ '€': 'euro',
+ '«': 'comillas angulares de apertura',
+ '»': 'comillas angulares de cierre',
+};
+
+/**
+ * The Spanish layout in the given arrangement.
+ *
+ * @param {Arrangement} [type] The arrangement of the letters.
+ * @returns {Layout} The layout.
+ */
+export function createLayout(type = 'qwerty') {
+ const arrangement = ARRANGEMENTS.get(type);
+ if (!arrangement) {
+ throw new Error(
+ `Unknown arrangement "${type}". Use ${[...ARRANGEMENTS.keys()]
+ .map((name) => `"${name}"`)
+ .join(' or ')}.`,
+ );
+ }
+ const [top, middle, bottom] = arrangement;
+ const toNumbers = /** @type {const} */ ({
+ type: 'layer',
+ to: 'symbols',
+ label: '123',
+ name: 'números y símbolos',
+ });
+ const toLetters = /** @type {const} */ ({
+ type: 'layer',
+ to: 'letters',
+ label: 'ABC',
+ name: 'letras',
+ });
+ const toAccents = /** @type {const} */ ({
+ type: 'layer',
+ to: 'accents',
+ label: 'áé',
+ name: 'vocales con acento',
+ });
+ const symbols = symbolsLayer('1234567890', toLetters);
+ return {
+ id: 'es',
+ name: 'Spanish',
+ dir: 'ltr',
+ layers: {
+ letters: [
+ cased(top),
+ cased(middle),
+ [shiftKey, ...cased(bottom), backspaceKey],
+ bottomRow([toNumbers, toAccents]),
+ ],
+ accents: [[shiftKey, ...cased('áéíóúü'), backspaceKey], bottomRow([toLetters, toNumbers])],
+ // The inverted marks and the euro sit above the last row of the shared symbols layer.
+ symbols: [...symbols.slice(0, -1), keys('¿¡€«»'), ...symbols.slice(-1)],
+ },
+ names: NAMES,
+ };
+}
+
+/**
+ * The Spanish layout in QWERTY order, which is what `language="es"` uses.
+ *
+ * @type {Layout}
+ */
+export const layout = createLayout();
diff --git a/packages/saccade-es/src/index.test.js b/packages/saccade-es/src/index.test.js
new file mode 100644
index 0000000..f7ab237
--- /dev/null
+++ b/packages/saccade-es/src/index.test.js
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import { validateLayout } from '@coldsofttech/saccade-keyboards/core';
+import { createLayout, layout } from './index.js';
+
+const typed = (l, layer) =>
+ new Map(Object.entries(l.layers))
+ .get(layer)
+ .flat()
+ .filter((key) => key.type === 'text')
+ .map((key) => key.value);
+const lettersOf = (l) => typed(l, 'letters').filter((value) => /\p{L}/u.test(value));
+
+const ALPHABET = 'abcdefghijklmnñopqrstuvwxyz';
+
+describe('saccade-es', () => {
+ it('is a valid layout in QWERTY order by default', () => {
+ expect(validateLayout(layout)).toEqual([]);
+ expect(layout.id).toBe('es');
+ expect(typed(layout, 'letters').slice(0, 10).join('')).toBe('qwertyuiop');
+ });
+
+ it.each(['qwerty', 'alphabetical'])('has all 27 letters once in %s', (type) => {
+ const built = createLayout(type);
+ expect(validateLayout(built)).toEqual([]);
+ expect(lettersOf(built).sort().join('')).toBe([...ALPHABET].sort().join(''));
+ });
+
+ it('puts the letters in order, with ñ after n, when alphabetical', () => {
+ expect(lettersOf(createLayout('alphabetical')).join('')).toBe(ALPHABET);
+ });
+
+ it('puts ñ after l on the QWERTY layer', () => {
+ expect(lettersOf(layout).join('')).toContain('klñ');
+ });
+
+ it('has the accented vowels with capitals on their own layer', () => {
+ const accents = layout.layers.accents.flat().filter((key) => key.shift);
+ expect(accents.map((key) => key.value).join('')).toBe('áéíóúü');
+ expect(accents.map((key) => key.shift).join('')).toBe('ÁÉÍÓÚÜ');
+ });
+
+ it('has ¿ and ¡ on the symbols layer and names them', () => {
+ expect(typed(layout, 'symbols')).toEqual(expect.arrayContaining(['¿', '¡', '€']));
+ expect(layout.names['¿']).toBeTruthy();
+ });
+
+ it('rejects an unknown arrangement', () => {
+ expect(() => createLayout('dvorak')).toThrow(/Unknown arrangement "dvorak"/);
+ });
+});
diff --git a/packages/saccade-es/tsconfig.build.json b/packages/saccade-es/tsconfig.build.json
new file mode 100644
index 0000000..c231669
--- /dev/null
+++ b/packages/saccade-es/tsconfig.build.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../jsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "noEmit": false,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src"],
+ "exclude": ["src/**/*.test.jsx", "src/**/*.test.js"]
+}
diff --git a/packages/saccade-es/vite.config.js b/packages/saccade-es/vite.config.js
new file mode 100644
index 0000000..98c65ff
--- /dev/null
+++ b/packages/saccade-es/vite.config.js
@@ -0,0 +1,21 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vite';
+
+const core = fileURLToPath(new URL('../saccade-keyboards/src/core/index.js', import.meta.url));
+
+export default defineConfig({
+ build: {
+ lib: { entry: { index: 'src/index.js' }, formats: ['es'] },
+ sourcemap: true,
+ rollupOptions: {
+ // The engine is a peer dependency and must never be bundled.
+ external: [/^@coldsofttech\/saccade-keyboards($|\/)/],
+ output: { entryFileNames: '[name].js' },
+ },
+ },
+ resolve: {
+ // Tests run against the engine source, so they need no build of it first.
+ alias: { '@coldsofttech/saccade-keyboards/core': core },
+ },
+ test: { environment: 'node' },
+});
diff --git a/sweepito.toml b/sweepito.toml
index 55de16d..3ad1ef0 100644
--- a/sweepito.toml
+++ b/sweepito.toml
@@ -3,8 +3,10 @@ profiles = ["node"]
risk = "safe"
[clean]
+# Also remove build output and coverage reports, which are not in the safe set by default.
+enable = ["node.dist", "node.coverage"]
# Exclusions always win over cleanup rules. Keep installed dependencies between commits.
exclude = ["node_modules/"]
[hook]
-quiet = true
+quiet = true
\ No newline at end of file
From 6d2db97ae3b7c920ab33514c23b5ef9e159a6e3c Mon Sep 17 00:00:00 2001
From: coldsofttech <161084795+coldsofttech@users.noreply.github.com>
Date: Sat, 31 Oct 2026 08:55:07 +0000
Subject: [PATCH 04/13] feat: realistic keyboard layouts with AltGr, regions
and number pad
Lays out the English and Spanish keyboards like physical keyboards: number row with shifted symbols, tall Enter, Shift, Caps Lock and AltGr. Adds English regions (US, GB, AU, CA, IN) and Spanish regions (ES, MX, AR, CO, US, LATAM), row-spanning keys on a grid, control key icons and captions, an optional number pad with Num Lock, and the matching docs, tests, demo and size limits.
---
.size-limit.json | 4 +-
CHANGELOG.md | 5 +-
README.md | 3 +-
docs/layout-format.md | 42 +++--
examples/demo/src/main.jsx | 18 +-
packages/saccade-en/README.md | 10 +-
packages/saccade-en/src/index.js | 138 +++++++++++---
packages/saccade-en/src/index.test.js | 76 +++++++-
packages/saccade-es/README.md | 10 +-
packages/saccade-es/src/index.js | 138 +++++++++-----
packages/saccade-es/src/index.test.js | 101 ++++++++--
packages/saccade-keyboards/README.md | 25 +--
.../saccade-keyboards/src/core/Keyboard.jsx | 92 +++++++---
.../src/core/Keyboard.module.css | 60 +++++-
.../src/core/Keyboard.test.jsx | 70 ++++++-
packages/saccade-keyboards/src/core/grid.js | 76 ++++++++
.../saccade-keyboards/src/core/grid.test.js | 37 ++++
packages/saccade-keyboards/src/core/index.js | 4 +
packages/saccade-keyboards/src/core/layout.js | 173 +++++++++++++++++-
.../saccade-keyboards/src/core/layout.test.js | 61 +++++-
.../saccade-keyboards/src/core/useKeyboard.js | 85 ++++++---
.../src/core/useKeyboard.test.jsx | 92 +++++++++-
22 files changed, 1119 insertions(+), 201 deletions(-)
create mode 100644 packages/saccade-keyboards/src/core/grid.js
create mode 100644 packages/saccade-keyboards/src/core/grid.test.js
diff --git a/.size-limit.json b/.size-limit.json
index 7d85945..f36baab 100644
--- a/.size-limit.json
+++ b/.size-limit.json
@@ -17,14 +17,14 @@
"name": "@coldsofttech/saccade-en",
"path": "packages/saccade-en/dist/index.js",
"import": "*",
- "limit": "1 kB",
+ "limit": "2 kB",
"ignore": ["@coldsofttech/saccade-keyboards"]
},
{
"name": "@coldsofttech/saccade-es",
"path": "packages/saccade-es/dist/index.js",
"import": "*",
- "limit": "1 kB",
+ "limit": "2 kB",
"ignore": ["@coldsofttech/saccade-keyboards"]
}
]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a4f1a6e..b9d3414 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ All notable changes to this project are documented here. The format follows [Kee
- On-screen keyboard for React with a built-in light and dark theme, picked by language or built from your own layout.
- Headless hook, a helper to apply key presses to text, and a layout checker for building custom keyboards.
-- Spanish keyboard with ñ, accented vowels, ¿ ¡ and €, in QWERTY or alphabetical order.
-- English keyboard (`@coldsofttech/saccade-en`) with QWERTY or alphabetical letters, plus numbers and symbols.
+- English keyboards for the US, GB, AU, CA and IN, and Spanish keyboards for Spain, Mexico, Argentina, Colombia, the US and Latin America, with QWERTY or alphabetical letters.
+- Keyboards laid out like real ones: number row, tall Enter, Shift, Caps Lock and AltGr, with keys that show both characters (such as 1 and !) and control keys that show an icon and a name.
+- Optional number pad with Num Lock, and a decimal sign that follows the language.
- Choose the font of the keys: the device's own font (default), sans, serif, mono or any font you load.
diff --git a/README.md b/README.md
index 3bb50d8..df6e453 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,8 @@ On-screen keyboards for React apps (React 18 and 19). Show a keyboard for a lang
- A keyboard you pick by language, or build from your own layout
- Light and dark themes, or your own colours, and a choice of font: the device's own, sans, serif or mono
-- English and Spanish keyboards, with letters in QWERTY or alphabetical order
+- English and Spanish keyboards laid out like real ones for each country, with letters in QWERTY or alphabetical order
+- Shift, Caps Lock and AltGr keys, keys that show both characters (1 and !), and an optional number pad
- Works with any input: the keyboard reports key presses and you decide where the text goes
- Keys that screen readers can read, and focus that stays in your text field
- Right-to-left and complex scripts, with a dotted circle shown for signs that sit on a letter
diff --git a/docs/layout-format.md b/docs/layout-format.md
index 1a15411..337df19 100644
--- a/docs/layout-format.md
+++ b/docs/layout-format.md
@@ -13,33 +13,37 @@ export const layout = {
};
```
-| Field | Required | Meaning |
-| -------- | -------- | ----------------------------------------------------------------------------------------------------------- |
-| `id` | Yes | Language code. It becomes the `lang` of the keyboard and the package name, `@coldsofttech/saccade-`. |
-| `name` | Yes | Name of the language, used for the default label "`` on-screen keyboard". |
-| `dir` | Yes | Reading direction, `ltr` or `rtl`. The keys themselves always sit left to right, like the physical keys. |
-| `fonts` | No | Font families the keys need. Fonts are never bundled; the app loads them and the layout package lists them. |
-| `layers` | Yes | Layer name to rows of keys. The first layer is shown first. |
-| `names` | No | What a screen reader says for a character, merged over the built-in punctuation names. |
+| Field | Required | Meaning |
+| --------- | -------- | ----------------------------------------------------------------------------------------------------------- |
+| `id` | Yes | Language code. It becomes the `lang` of the keyboard and the package name, `@coldsofttech/saccade-`. |
+| `name` | Yes | Name of the language, used for the default label "`` on-screen keyboard". |
+| `dir` | Yes | Reading direction, `ltr` or `rtl`. The keys themselves always sit left to right, like the physical keys. |
+| `fonts` | No | Font families the keys need. Fonts are never bundled; the app loads them and the layout package lists them. |
+| `layers` | Yes | Layer name to rows of keys. The first layer is shown first. |
+| `decimal` | No | The sign of the decimal key of the number pad (`.`). Spain, for one, uses `,`. |
+| `names` | No | What a screen reader says for a character, merged over the built-in punctuation names. |
## Keys
-A row is a list of keys. `width` is the share of the row a key takes; 1 is an ordinary key.
+A row is a list of keys. `width` is the share of the row a key takes; 1 is an ordinary key. A row always fills the width of the keyboard. `rowSpan` makes a key taller: `{ type: 'enter', rowSpan: 2 }` at the end of a row also covers the row below, which then shares only the space that is left. Use it for a tall Enter at the start or the end of a row.
-| Key | What it does |
-| -------------------------------------------- | ---------------------------------------------- |
-| `{ type: 'text', value, shift? }` | Types `value`, or `shift` while shift is on. |
-| `{ type: 'shift' }` | Shift: off, then the next key, then caps lock. |
-| `{ type: 'space' }` | Types a space. |
-| `{ type: 'backspace' }`, `{ type: 'enter' }` | Reported through `onKey` for the app to apply. |
-| `{ type: 'close' }` | Calls `onClose`. |
-| `{ type: 'layer', to, label, name? }` | Shows another layer and turns shift off. |
+| Key | What it does |
+| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
+| `{ type: 'text', value, shift?, altgr? }` | Types `value`; `shift` while shift is on, or `altgr` after AltGr (capitalised with shift if it is a letter). |
+| `{ type: 'shift' }` | Shift: capitals for the next key only. |
+| `{ type: 'caps' }` | Caps lock: capitals until it is pressed again. |
+| `{ type: 'numlock' }` | Num Lock of the number pad: its `numpad` keys are off while it is off. |
+| `{ type: 'altgr' }` | AltGr: the next key types its `altgr` character. |
+| `{ type: 'space' }` | Types a space. |
+| `{ type: 'backspace' }`, `{ type: 'enter' }` | Reported through `onKey` for the app to apply. |
+| `{ type: 'close' }` | Calls `onClose`. |
+| `{ type: 'layer', to, label, name? }` | Shows another layer and turns shift off. |
A sign that only sits on a letter, such as a vowel sign or a virama, is shown on a dotted circle automatically.
## Helpers
-`@coldsofttech/saccade-keyboards/core` exports helpers to write layouts briefly: `keys`, `cased`, `bottomRow`, `symbolsLayer`, `shiftKey`, `backspaceKey` and `PUNCTUATION_NAMES`.
+`@coldsofttech/saccade-keyboards/core` exports helpers to write layouts briefly: `keys`, `cased`, `physicalRows`, `numpadRows`, `bottomRow`, `symbolsLayer`, `shiftKey`, `capsKey`, `altGrKey`, `backspaceKey` and `PUNCTUATION_NAMES`.
## Checking a layout
@@ -52,6 +56,6 @@ A sign that only sits on a letter, such as a vowel sign or a virama, is shown on
3. List its font (if any) in `fonts` and document how to load it.
4. Apps register it with `registerLanguage('', () => import('@coldsofttech/saccade-'))` so `language=""` finds it, or pass the layout through the `layout` prop.
-`@coldsofttech/saccade-en` is the reference: it exports `layout` (QWERTY) and `createLayout('qwerty' | 'alphabetical')`.
+`@coldsofttech/saccade-en` is the reference: it exports `layout` (QWERTY) and `createLayout(type, region)`.
Composition rules (conjuncts, vowel signs joining a consonant) are not part of the format yet. A key types exactly its `value`.
diff --git a/examples/demo/src/main.jsx b/examples/demo/src/main.jsx
index 0264f38..67ba7ec 100644
--- a/examples/demo/src/main.jsx
+++ b/examples/demo/src/main.jsx
@@ -11,6 +11,10 @@ const LAYOUTS = new Map([
['Spanish', createSpanish],
]);
const LANGUAGES = [...LAYOUTS.keys()];
+const REGIONS = new Map([
+ ['English', ['US', 'GB', 'AU', 'CA', 'IN']],
+ ['Spanish', ['ES', 'MX', 'AR', 'CO', 'US', 'LATAM']],
+]);
const ARRANGEMENTS = ['qwerty', 'alphabetical'];
const THEMES = ['light', 'dark'];
const FONTS = ['native', 'sans', 'serif', 'mono'];
@@ -31,11 +35,17 @@ function Select({ label, value, options, onChange }) {
function Demo() {
const field = useRef(null);
const [language, setLanguage] = useState('English');
+ const [chosenRegion, setRegion] = useState('US');
const [arrangement, setArrangement] = useState('qwerty');
const [theme, setTheme] = useState('light');
const [font, setFont] = useState('native');
const [fontSize, setFontSize] = useState(20);
const [shown, setShown] = useState(true);
+ const [numpad, setNumpad] = useState(false);
+
+ // A region from the other language falls back to that language's first one.
+ const regions = REGIONS.get(language);
+ const region = regions.includes(chosenRegion) ? chosenRegion : regions[0];
const onKey = (key) => {
const { value, selectionStart, selectionEnd } = field.current;
@@ -56,8 +66,13 @@ function Demo() {
options={ARRANGEMENTS}
onChange={setArrangement}
/>
+
+
+