diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b2a62ac..5c405c18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,8 @@ concurrency: jobs: build: - runs-on: ubuntu-latest + # Match the OS supported by the pinned Playwright version. + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -49,9 +50,18 @@ jobs: - name: Type Check run: pnpm run check:types + - name: Keyboard contract tests + run: pnpm test:keyboards + - name: Build run: pnpm run build + - name: Install keyboard test browser + run: pnpm exec playwright install --with-deps chromium + + - name: Keyboard browser tests + run: pnpm exec playwright test tests/keyboards --project=chromium + - name: Deploy to Cloudflare Pages if: github.ref == 'refs/heads/develop' && github.repository == 'connectbot/connectbot.github.io' && github.event_name == 'push' uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 diff --git a/README.md b/README.md index 352518a6..7a23321c 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Web page for [ConnectBot](https://connectbot.org/), the first open source SSH cl ## Contributor documentation +- [Terminal keyboard editor](docs/keyboard-editor.md): format compatibility, previews, tests, and community layout contributions. + - [Development](docs/development.md): project structure, Git hooks, LAN access, checks, and deployment. - [Parameterized commands in MDX](docs/parameterized-commands.md): shared inputs, templates, and validation. - [Authoring feature guides](docs/guide-authoring.md): scenarios, translations, capture, narration, and publication. diff --git a/docs/keyboard-editor.md b/docs/keyboard-editor.md new file mode 100644 index 00000000..3fc9aa53 --- /dev/null +++ b/docs/keyboard-editor.md @@ -0,0 +1,67 @@ +# Terminal keyboard editor + +The keyboard library at `/keyboards/` and editor at `/keyboards/editor/` run on +this site's existing static export. Imported files, drafts, and simulated actions +stay in the browser. Community JSON is read from `public/keyboards/` at build time; +see the README there for the pull request workflow. + +## Compatibility + +The baseline is ConnectBot commit `a754a104`, schema 17. The reference is +`docs/terminal-keyboard.md`, `KeyboardDefaults`, `KeyboardPresets`, +`KeyboardLayers`, `KeyboardRegions`, `KeyboardGrid`, `MacroFormat`, and +`SchemaBasedExporter` in the Android repository. The PNGs under +`app/build/reports/keyboard-presets/` establish preset key order and appearance. + +`src/keyboards/model.ts` defines normalized browser types, JSON5 parsing, semantic +validation, and configuration serialization. Wire files use camelCase properties +and integer booleans. Macro documents remain strings, with their independent +version 1 protocol. Unknown row fields and unsupported macro documents survive +import/export. Unsupported keys remain visible but are disabled in test mode. +Malformed structure does not replace the current document; invalid geometry can +be corrected in the inspector or JSON editor and blocks testing/export. + +Local drafts and the JSON editor retain unassigned library macros. A downloaded +export contains the selected layout and the closure of its referenced macros. +It never includes host/profile data or keyboard settings. New-copy exports remap +all identities, preserving shared references. Identical repeated downloads reuse +the copy identities until the document or export options change. Preserve UUIDs +is intended for gallery maintenance: ConnectBot skips existing UUIDs, rather than +overwriting them. + +## Preview behavior + +The shared renderer in `src/components/keyboards/` uses pure geometry calculations +from `src/keyboards/geometry.ts`. All layers and hidden keys reserve geometry. +Pinned regions use independent coordinates; fitted mode appends their columns. +Keys retain row spans when edited, unlike the current Android editor's flattening +behavior. Labels use locally hosted Roboto with its license in `public/fonts/`. + +Phone (390×844), tablet (800×1280), and desktop (1440×900) presets describe logical +app and input area dimensions. Orientation sorts/swaps the short and long sides. Custom +sizes range from 240 to 3000 dp. One dp equals one logical CSS pixel before the +whole preview is scaled to fit. The keyboard occupies at most half the console +height remaining above the input method. Phone and tablet previews include a +simulated Google Keyboard (Gboard), with a tablet number row and a shorter +landscape keyboard. The Show Google Keyboard checkbox and test-mode IME toggle +show or hide it. Desktop and custom previews omit it. These preview controls do +not change exported JSON. The simulated keys are decorative; actual Android IME +geometry, device decorations, dynamic colors, and font rendering may differ. + +Test mode simulates layers, one-shot/locked modifiers, IME/compose toggles, and +hold-to-repeat. Other app controls are logged. Semantic output is an action log, +not terminal escape encoding or a live shell. Haptic preferences are exported +without vibrating the browser device. + +## Checks + +- `pnpm check:keyboards`: validate contributed files and built-in templates. +- `pnpm test:keyboards`: Node tests for formats, identity, macros, and geometry. +- `pnpm test:keyboards:browser --project=chromium`: editor/gallery behavior against + the production export. First build with `pnpm build` and install browsers using + `PLAYWRIGHT_BROWSERS_PATH=local/playwright pnpm exec playwright install chromium`. +- `pnpm check:types`, `pnpm lint`, and `pnpm build`: standard repository checks. + +The production prebuild validates contributions. CI also runs contract tests and +Chromium browser tests. The Playwright configuration retains all three browser +projects; install their binaries to run the keyboard suite across all engines. diff --git a/eslint.config.mjs b/eslint.config.mjs index b2cce9d0..596f1ffb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -34,6 +34,10 @@ export default antfu( ], ...playwright.configs['flat/recommended'], }, + { + files: ['tests/keyboards/*.test.ts'], + rules: { 'test/no-import-node-test': 'off' }, + }, // --- Custom Rule Overrides --- { rules: { diff --git a/package.json b/package.json index 3a99b70b..94b7c8cd 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,14 @@ "prune:guides": "python3 scripts/guides/prune.py", "check:guides": "UV_CACHE_DIR=local/uv-cache uv run --no-project --with-requirements scripts/guides/requirements.lock python scripts/guides/cli.py check", "test:guides": "UV_CACHE_DIR=local/uv-cache uv run --no-project --with-requirements scripts/guides/requirements.lock python -m unittest discover -s scripts/guides/tests -v", - "test:guides:browser": "UV_CACHE_DIR=local/uv-cache uv run --no-project --with-requirements scripts/guides/requirements.lock python scripts/guides/browser_fixtures.py && PLAYWRIGHT_BROWSERS_PATH=local/playwright playwright test" + "test:guides:browser": "UV_CACHE_DIR=local/uv-cache uv run --no-project --with-requirements scripts/guides/requirements.lock python scripts/guides/browser_fixtures.py && PLAYWRIGHT_BROWSERS_PATH=local/playwright playwright test tests/guides", + "check:keyboards": "node --import tsx scripts/keyboards/check.ts", + "test:keyboards": "node --import tsx tests/keyboards/model.test.ts", + "test:keyboards:browser": "PLAYWRIGHT_BROWSERS_PATH=local/playwright playwright test tests/keyboards", + "prebuild": "pnpm check:keyboards" }, "dependencies": { + "json5": "^2.2.3", "next": "^16.2.3", "next-themes": "^0.4.6", "nextra": "^4.6.1", @@ -51,6 +56,7 @@ "lefthook": "^2.1.5", "next-sitemap": "^4.2.3", "pagefind": "^1.5.2", + "tsx": "^4.23.15", "typescript": "^5.9.3", "wrangler": "^4.83.0" } diff --git a/playwright.config.ts b/playwright.config.ts index e1c53d24..ed8c2949 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,10 +1,13 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ - testDir: './tests/guides', + testDir: './tests', + testMatch: '**/*.spec.ts', fullyParallel: true, use: { baseURL: 'http://127.0.0.1:3012', contextOptions: { reducedMotion: 'reduce' } }, webServer: { + stdout: 'ignore', + stderr: 'ignore', command: 'python3 -m http.server 3012 --bind 127.0.0.1 --directory out', url: 'http://127.0.0.1:3012/guides/', reuseExistingServer: process.env.CI !== 'true', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de9dd005..ab3f127e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + json5: + specifier: ^2.2.3 + version: 2.2.3 next: specifier: ^16.2.3 version: 16.2.3(@babel/core@7.28.5)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -93,6 +96,9 @@ importers: pagefind: specifier: ^1.5.2 version: 1.5.2 + tsx: + specifier: ^4.23.15 + version: 4.23.15 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -441,6 +447,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} @@ -453,6 +465,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} @@ -465,6 +483,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} @@ -477,6 +501,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} @@ -489,6 +519,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} @@ -501,6 +537,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} @@ -513,6 +555,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} @@ -525,6 +573,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} @@ -537,6 +591,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} @@ -549,6 +609,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} @@ -561,6 +627,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} @@ -573,6 +645,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} @@ -585,6 +663,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} @@ -597,6 +681,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} @@ -609,6 +699,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} @@ -621,6 +717,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} @@ -633,6 +735,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} @@ -645,6 +753,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} @@ -657,6 +771,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} @@ -669,6 +789,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} @@ -681,6 +807,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} @@ -693,6 +825,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} @@ -705,6 +843,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} @@ -717,6 +861,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} @@ -729,6 +879,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} @@ -741,6 +897,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-plugin-eslint-comments@4.6.0': resolution: {integrity: sha512-2EX2bBQq1ez++xz2o9tEeEQkyvfieWgUFMH4rtJJri2q0Azvhja3hZGXsjPXs31R4fQkZDtWzNDDK2zQn5UE5g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2940,6 +3102,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -5081,6 +5248,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + twoslash-protocol@0.3.6: resolution: {integrity: sha512-FHGsJ9Q+EsNr5bEbgG3hnbkvEBdW5STgPU824AHUjB4kw0Dn4p8tABT7Ncg1Ie6V0+mDg3Qpy41VafZXcQhWMA==} @@ -5885,156 +6057,234 @@ snapshots: '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.27.3': optional: true '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.27.3': optional: true '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.27.3': optional: true '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.27.3': optional: true '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.27.3': optional: true '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.27.3': optional: true '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.27.3': optional: true '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.27.3': optional: true '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.27.3': optional: true '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.27.3': optional: true '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.27.3': optional: true '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.27.3': optional: true '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.27.3': optional: true '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.27.3': optional: true '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.27.3': optional: true '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.27.3': optional: true '@esbuild/linux-x64@0.27.7': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.27.3': optional: true '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.27.3': optional: true '@esbuild/netbsd-x64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.27.3': optional: true '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.27.3': optional: true '@esbuild/openbsd-x64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.27.3': optional: true '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.27.3': optional: true '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.27.3': optional: true '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.27.3': optional: true '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.27.3': optional: true '@esbuild/win32-x64@0.27.7': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@eslint-community/eslint-plugin-eslint-comments@4.6.0(eslint@9.39.2(jiti@2.6.1))': dependencies: escape-string-regexp: 4.0.0 @@ -7322,13 +7572,13 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitest/browser-playwright@4.0.16(playwright@1.57.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))(vitest@4.0.16)': + '@vitest/browser-playwright@4.0.16(playwright@1.57.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)(yaml@2.8.3))(vitest@4.0.16)': dependencies: - '@vitest/browser': 4.0.16(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))(vitest@4.0.16) - '@vitest/mocker': 4.0.16(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) + '@vitest/browser': 4.0.16(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)(yaml@2.8.3))(vitest@4.0.16) + '@vitest/mocker': 4.0.16(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)(yaml@2.8.3)) playwright: 1.57.0 tinyrainbow: 3.1.0 - vitest: 4.0.16(@types/node@25.6.0)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) + vitest: 4.0.16(@types/node@25.6.0)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)(yaml@2.8.3) transitivePeerDependencies: - bufferutil - msw @@ -7336,16 +7586,16 @@ snapshots: - vite optional: true - '@vitest/browser@4.0.16(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))(vitest@4.0.16)': + '@vitest/browser@4.0.16(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)(yaml@2.8.3))(vitest@4.0.16)': dependencies: - '@vitest/mocker': 4.0.16(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) + '@vitest/mocker': 4.0.16(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)(yaml@2.8.3)) '@vitest/utils': 4.0.16 magic-string: 0.30.21 pixelmatch: 7.1.0 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.0.16(@types/node@25.6.0)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) + vitest: 4.0.16(@types/node@25.6.0)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)(yaml@2.8.3) ws: 8.20.0 transitivePeerDependencies: - bufferutil @@ -7361,7 +7611,7 @@ snapshots: eslint: 9.39.2(jiti@2.6.1) optionalDependencies: typescript: 5.9.3 - vitest: 4.0.16(@types/node@25.6.0)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) + vitest: 4.0.16(@types/node@25.6.0)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -7375,13 +7625,13 @@ snapshots: tinyrainbow: 3.1.0 optional: true - '@vitest/mocker@4.0.16(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))': + '@vitest/mocker@4.0.16(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.0.16 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)(yaml@2.8.3) optional: true '@vitest/pretty-format@4.0.16': @@ -8220,6 +8470,35 @@ snapshots: '@esbuild/win32-x64': 0.27.7 optional: true + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escape-string-regexp@1.0.5: {} @@ -11156,6 +11435,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + twoslash-protocol@0.3.6: {} twoslash@0.3.6(typescript@5.9.3): @@ -11334,7 +11619,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3): + vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)(yaml@2.8.3): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -11347,13 +11632,14 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 + tsx: 4.23.15 yaml: 2.8.3 optional: true - vitest@4.0.16(@types/node@25.6.0)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3): + vitest@4.0.16(@types/node@25.6.0)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)(yaml@2.8.3): dependencies: '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) + '@vitest/mocker': 4.0.16(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)(yaml@2.8.3)) '@vitest/pretty-format': 4.0.16 '@vitest/runner': 4.0.16 '@vitest/snapshot': 4.0.16 @@ -11370,11 +11656,11 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.6.0 - '@vitest/browser-playwright': 4.0.16(playwright@1.57.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))(vitest@4.0.16) + '@vitest/browser-playwright': 4.0.16(playwright@1.57.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.23.15)(yaml@2.8.3))(vitest@4.0.16) transitivePeerDependencies: - jiti - less diff --git a/public/fonts/keyboard-roboto-LICENSE.txt b/public/fonts/keyboard-roboto-LICENSE.txt new file mode 100644 index 00000000..65a3057b --- /dev/null +++ b/public/fonts/keyboard-roboto-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/keyboard-roboto-medium.ttf b/public/fonts/keyboard-roboto-medium.ttf new file mode 100644 index 00000000..310c9e48 Binary files /dev/null and b/public/fonts/keyboard-roboto-medium.ttf differ diff --git a/public/keyboards/README.md b/public/keyboards/README.md new file mode 100644 index 00000000..f96cb596 --- /dev/null +++ b/public/keyboards/README.md @@ -0,0 +1,34 @@ +# Share a ConnectBot terminal keyboard + +Create or import your layout at https://connectbot.org/keyboards/editor/ and choose +**Share your layout**. Download the JSON, then use GitHub's **Add file → Upload +files** here. GitHub will guide you through forking the repository and opening a +pull request against `develop`. + +One file is all you need: `.json`. The gallery discovers +JSON files here automatically; no index or screenshot needs updating. The layout +name inside the JSON becomes its gallery title. Credit and discussion remain in +the pull request and the file's Git history, linked from the gallery. + +## Before opening the pull request + +- Include exactly one named layout and only its keys and referenced named macros. +- Export a **new copy** for a new contribution, so its UUIDs do not collide with + another layout. Preserve UUIDs when maintaining an existing contributed file. +- Use standard JSON, containing only `version`, `keyboard_layouts`, + `keyboard_macros`, and `keyboard_items`. Exported boolean fields must use integer + `0`/`1` values. Do not contribute host configurations. +- Test the layout at phone and tablet sizes in portrait and landscape. Check layer + navigation, shortcuts, hidden keys, and pinned controls. +- Import the downloaded file into a current ConnectBot build via **Import Hosts**, + then select it under **Settings → Terminal keyboard**. +- Describe the intended workflow and any noteworthy macros in your pull request. + Use generic example text in macros. All contributed content is public. + +The website checks files against the current keyboard contract during CI and the +production build. Run `pnpm check:keyboards` locally to see diagnostics. Compatibility +is based on ConnectBot schema 17, commit `a754a104`. + +For existing layouts, ConnectBot skips records whose UUIDs already exist. Updating +a gallery file does not overwrite an installed copy; users can customize and +export it as a new copy when they want to import the updated version. diff --git a/scripts/keyboards/check.ts b/scripts/keyboards/check.ts new file mode 100644 index 00000000..061445ad --- /dev/null +++ b/scripts/keyboards/check.ts @@ -0,0 +1,4 @@ +import { readGallery } from '../../src/keyboards/gallery'; + +const entries = await readGallery(); +console.log(`Validated ${entries.filter(e => !e.template).length} contributed keyboards and ${entries.filter(e => e.template).length} templates.`); diff --git a/src/app/styles.css b/src/app/styles.css index b9df4f4e..7ed0e795 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -598,10 +598,10 @@ html:has(.hero-brand):not([data-brand-docked='true']) a:has(> .navbar-brand) { align-items: center; justify-content: center; min-height: 44px; - padding: 0 0.5rem; + padding: 0 0.3rem; border-radius: 0.5rem; color: inherit; - font-size: 0.9375rem; + font-size: 0.875rem; font-weight: 600; text-decoration: none; } diff --git a/src/components/keyboards/GoogleKeyboardPreview.tsx b/src/components/keyboards/GoogleKeyboardPreview.tsx new file mode 100644 index 00000000..56933f21 --- /dev/null +++ b/src/components/keyboards/GoogleKeyboardPreview.tsx @@ -0,0 +1,39 @@ +const letterRows = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm']; + +/** A visual stand-in for the Android input method; never part of the exported layout. */ +export function GoogleKeyboardPreview({ height, tablet }: Readonly<{ height: number; tablet: boolean }>) { + return ( +
+ +
+ ); +} diff --git a/src/components/keyboards/KeyboardCanvas.tsx b/src/components/keyboards/KeyboardCanvas.tsx new file mode 100644 index 00000000..51dc5bb7 --- /dev/null +++ b/src/components/keyboards/KeyboardCanvas.tsx @@ -0,0 +1,255 @@ +'use client'; + +import type { CSSProperties, PointerEvent } from 'react'; +import type { Rect, Region } from '@/keyboards/geometry'; +import type { Item, Layout, Macro } from '@/keyboards/model'; +import { useEffect, useRef } from 'react'; +import { geometry } from '@/keyboards/geometry'; +import { accessibleLabel, colorCss, label, populated, supported } from '@/keyboards/model'; +import './keyboard.css'; + +export type TestState = { modifiers: Record; ime: boolean; compose: boolean }; +function KeyIcon({ target, active }: Readonly<{ target: string; active: boolean }>) { + const paths: Record = { + arrow_up: 'm7 14 5-5 5 5', + arrow_down: 'm7 10 5 5 5-5', + arrow_left: 'm14 7-5 5 5 5', + arrow_right: 'm10 7 5 5-5 5', + paste: 'M9 5H5v16h14V5h-4M9 3h6v4H9z', + text_input: 'm4 16 12-12 4 4L8 20H4zm10-10 4 4', + toggle_ime: active ? 'M3 4h18v13H3zM7 8h1m3 0h1m3 0h1M7 11h1m3 0h1m3 0h1m-8 9 3 3 3-3' : 'M3 5h18v14H3zM6 9h1m3 0h1m3 0h1m3 0h1M6 12h1m3 0h1m3 0h1m3 0h1M8 16h8', + }; + const path = paths[target]; + return path !== undefined ? : null; +} +function faceOpacity(item: Item, active: number, enabled: boolean): number { + if (active > 0) { + return 1; + } + const opacity = item.backgroundOpacity ?? 0.85; + return enabled && item.visible ? opacity : opacity * 0.35 / 0.85; +} +function KeyFace({ item, macros, active, enabled }: Readonly<{ item: Item; macros: Macro[]; active: number; enabled: boolean }>) { + const text = useRef(null); + useEffect(() => { + const span = text.current; + if (!span?.parentElement) { + return; + } + const fit = () => { + const width = Math.max(0, span.parentElement!.clientWidth - 4); + const height = span.parentElement!.clientHeight; + span.style.transform = `scale(${Math.min(1, width / Math.max(1, span.scrollWidth))}, ${Math.min(1, height / Math.max(1, span.scrollHeight))})`; + }; + const observer = new ResizeObserver(fit); + observer.observe(span.parentElement); + + fit(); + + document.fonts.ready.then(fit).catch(() => {}); + return () => observer.disconnect(); + }, [item, macros]); + const style: CSSProperties = { + '--kb-background': item.backgroundColor === null ? undefined : colorCss(item.backgroundColor), + 'color': item.textColor === null ? undefined : `${colorCss(item.textColor)}${((item.textColor >>> 24) & 255).toString(16).padStart(2, '0')}`, + 'borderColor': item.outlineColor === null ? undefined : `${colorCss(item.outlineColor)}${((item.outlineColor >>> 24) & 255).toString(16).padStart(2, '0')}`, + 'borderWidth': item.outlineWidth ?? 1, + '--kb-opacity': faceOpacity(item, active, enabled), + } as CSSProperties; + const icon = item.kind !== 'macro' && ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'paste', 'text_input', 'toggle_ime'].includes(item.target); + return ( + + {icon ? : {label(item, macros)}} + + ); +} +type CanvasProps = Readonly<{ + layout: Layout; + items: Item[]; + macros: Macro[]; + width: number; + maxHeight: number; + layer: number; + editing?: boolean; + selectedId?: string; + testState?: TestState; + onPress?: (item: Item) => void; + onMove?: (id: string, row: number, column: number) => void; +}>; +function activeLevel(item: Item, items: Item[], layer: number, state?: TestState): number { + const initial = populated(items)[0] ?? 0; + const selectedLayer = item.target === String(layer) || (item.target === 'next' && layer !== initial); + if (item.kind === 'layer' && selectedLayer) { + return 2; + } + if (item.target === 'toggle_ime') { + return state?.ime ? 1 : 0; + } + if (item.target === 'toggle_compose') { + return state?.compose ? 2 : 0; + } + return state?.modifiers[item.target] ?? 0; +} +function keyDescription(item: Item, macros: Macro[], editing: boolean): string { + const name = accessibleLabel(item, macros); + if (!editing) { + return name; + } + const position = `, row ${item.row}, column ${item.column}`; + const visibility = item.visible ? '' : ', hidden'; + return name + position + visibility; +} +function keyTitle(item: Item, macros: Macro[], editing: boolean, active: number): string { + const action = `${label(item, macros)} · ${item.kind}: ${item.target || item.macroId}`; + if (editing || item.kind !== 'modifier') { + return action; + } + const state = ['off', 'one-shot', 'locked'][active]; + return `${action} · ${state}`; +} +type Gesture = { x: number; y: number; moved: boolean; timer?: ReturnType; interval?: ReturnType }; +function clearGesture(gesture: Gesture | null): void { + if (gesture === null) { + return; + } + clearTimeout(gesture.timer); + clearInterval(gesture.interval); +} +function startRepeating(gesture: Gesture, press: () => void): void { + gesture.timer = setTimeout(() => { + press(); + gesture.timer = setTimeout(() => { + press(); + gesture.interval = setInterval(press, 50); + }, 400); + }, 100); +} +function KeyboardButton({ item, rect, region, items, macros, layer, editing, selectedId, testState, onPress, onMove }: Readonly<{ + item: Item; + rect: Rect; + region: Region; + items: Item[]; + macros: Macro[]; + layer: number; + editing: boolean; + selectedId?: string; + testState?: TestState; + onPress?: (item: Item) => void; + onMove?: (id: string, row: number, column: number) => void; +}>) { + const gesture = useRef(null); + const suppressClick = useRef(false); + const cleanup = () => { + clearGesture(gesture.current); + gesture.current = null; + }; + useEffect(() => () => clearGesture(gesture.current), []); + const enabled = supported(item, items, macros); + const active = activeLevel(item, items, layer, testState); + const defaultRepeat = item.kind === 'key' && item.target.startsWith('arrow_'); + const repeating = !editing && ['key', 'macro'].includes(item.kind) && (item.repeatOnHold ?? defaultRepeat); + const pointerDown = (event: PointerEvent) => { + if (event.button !== 0 || (!onMove && !repeating)) { + return; + } + cleanup(); + suppressClick.current = false; + const current: Gesture = { x: event.clientX, y: event.clientY, moved: false }; + gesture.current = current; + if (editing) { + event.currentTarget.setPointerCapture(event.pointerId); + } + if (repeating) { + startRepeating(current, () => { + onPress?.(item); + suppressClick.current = true; + }); + } + }; + const pointerMove = (event: PointerEvent) => { + const current = gesture.current; + if (!current || !editing || !onMove) { + return; + } + if (Math.hypot(event.clientX - current.x, event.clientY - current.y) > 6) { + current.moved = true; + } + }; + const pointerUp = (event: PointerEvent) => { + const current = gesture.current; + if (current?.moved && editing && onMove) { + const scale = event.currentTarget.getBoundingClientRect().width / Math.max(1, rect.width); + const dx = (event.clientX - current.x) / scale; + const dy = (event.clientY - current.y) / scale; + const row = Math.max(0, Math.round(item.row + dy / Math.max(1, region.cellHeight))); + const column = Math.max(0, Math.round(item.column + dx / Math.max(1, region.width / Math.max(1, region.columns)))); + onMove(item.id, row, column); + suppressClick.current = true; + } + cleanup(); + }; + const classes = ['kb-key']; + if (editing) { + classes.push('kb-editable'); + } + if (selectedId === item.id) { + classes.push('kb-selected'); + } + if (!item.visible) { + classes.push('kb-hidden-key'); + } + return ( + + ); +} +export function KeyboardCanvas({ layout, items, macros, width, maxHeight, layer, editing = false, selectedId, testState, onPress, onMove }: CanvasProps) { + const regions = geometry(layout, items, width, maxHeight); + const height = regions.length ? Math.max(...regions.map(r => r.viewportHeight)) : 30; + return ( +
+ {regions.map(region => ( +
+
+ {region.rects.filter(r => r.item.layer === layer && (editing || r.item.visible)).map(rect => ( + i.id === rect.item.id)!} rect={rect} region={region} items={items} macros={macros} layer={layer} editing={editing} selectedId={selectedId} testState={testState} onPress={onPress} onMove={onMove} /> + ))} +
+
+ ))} + {regions.filter(r => r.pinned && r.x > 0).map(r =>
+ ); +} diff --git a/src/components/keyboards/KeyboardEditor.tsx b/src/components/keyboards/KeyboardEditor.tsx new file mode 100644 index 00000000..3a46df6b --- /dev/null +++ b/src/components/keyboards/KeyboardEditor.tsx @@ -0,0 +1,921 @@ +'use client'; + +/* eslint-disable react/no-array-index-key -- Diagnostic and action-log entries are display-only, with no component state. */ + +import type { ChangeEvent } from 'react'; +import type { TestState } from './KeyboardCanvas'; +import type { Action, Configuration, Item, Layout, Macro } from '@/keyboards/model'; +import Link from 'next/link'; +import { useEffect, useRef, useState } from 'react'; +import { appActions, colorCss, colorValue, decodeMacro, destination, keys, label, MAX_BYTES, modifiers, newId, newItem, overlaps, parseConfiguration, populated, serialize, slugify, supported, validate, validateContribution, warnings } from '@/keyboards/model'; +import { createPreset, templates } from '@/keyboards/presets'; +import { downloadJson } from './download'; +import { GoogleKeyboardPreview } from './GoogleKeyboardPreview'; +import { KeyboardCanvas } from './KeyboardCanvas'; +import { MacroLibrary } from './MacroLibrary'; +import './keyboard.css'; + +const devices = { phone: [390, 844], tablet: [800, 1280], desktop: [1440, 900], custom: [390, 844] } as const; +const storageKey = 'connectbot-keyboard-draft-v1'; +type History = { past: Configuration[]; present: Configuration; future: Configuration[] }; +type Simulation = TestState & { log: string[] }; +const initialSimulation = (ime = true): Simulation => ({ modifiers: { ctrl: 0, alt: 0, shift: 0 }, ime, compose: false, log: [] }); + +function previewImeHeight(device: keyof typeof devices, height: number, visible: boolean): number { + if (!visible || (device !== 'phone' && device !== 'tablet')) { + return 0; + } + const preferredHeight = device === 'tablet' ? 360 : 280; + return Math.min(preferredHeight, Math.round(height * 0.42)); +} + +const actionTargets = new Map([['key', 'escape'], ['modifier', 'ctrl'], ['app', 'toggle_ime'], ['layer', 'next']]); +const actionOptions = new Map([['key', keys], ['modifier', modifiers], ['app', appActions]]); +const appearanceLabels = { backgroundColor: 'Background', textColor: 'Text color', outlineColor: 'Outline color' }; +function formatAction(action: Action): string { + if (action.type === 'text') { + return `text ${JSON.stringify(action.text)}`; + } + if (action.type === 'bytes') { + return `bytes ${action.hex}`; + } + return `key ${[...action.modifiers, action.key ?? JSON.stringify(action.character)].join('+')}`; +} +function draftStatus(ready: boolean, recovery: string | null, storageError: string): string { + if (!ready) { + return 'Loading editor…'; + } + if (recovery === null && storageError === '') { + return 'Draft saved locally'; + } + return 'Local workspace'; +} +async function initialConfiguration(search: string): Promise { + const query = new URLSearchParams(search); + const template = query.get('template'); + if (template !== null && [...templates.map(t => t.key), 'blank'].includes(template)) { + return createPreset(template); + } + const slug = query.get('layout'); + if (slug === null || !/^[a-z0-9][a-z0-9-]*$/.test(slug)) { + return undefined; + } + const response = await fetch(`/keyboards/${slug}.json`); + if (!response.ok) { + throw new Error('Could not load this gallery layout.'); + } + return parseConfiguration(await response.text()); +} +function Numeric({ title, value, onChange, min = 0, max = 1000, step = 1 }: Readonly<{ title: string; value: number; onChange: (value: number) => void; min?: number; max?: number; step?: number }>) { + return ( + + ); +} +function TriState({ title, value, onChange }: Readonly<{ title: string; value: boolean | null; onChange: (value: boolean | null) => void }>) { + return ( + + ); +} +function Inspector({ item, config, onChange, onDuplicate, onDelete }: Readonly<{ item: Item; config: Configuration; onChange: (item: Item) => void; onDuplicate: () => void; onDelete: () => void }>) { + const update = (patch: Partial) => onChange({ ...item, ...patch }); + const options = actionOptions.get(item.kind) ?? ['next', '0', '1', '2', '3', '4']; + return ( +
+
+

{label(item, config.macros)}

+ Selected key +
+ + {item.kind !== 'macro' && ( + + )} + {['key', 'macro'].includes(item.kind) && ( + + )} +

For a custom label or key combination, create a named macro and assign it here.

+
+ Placement +
+ update({ row })} /> + update({ column })} /> + update({ rowSpan })} /> + update({ columnSpan })} /> + update({ layer })} /> +
+
+ + +
+

Pinned keys have their own coordinates. Fitted layouts append them to the main grid.

+
+
+ Appearance + {(['backgroundColor', 'textColor', 'outlineColor'] as const).map(field => ( +
+ + +
+ ))} + update({ backgroundOpacity })} /> + update({ outlineWidth })} /> + +
+
+ Behavior + update({ repeatOnHold })} /> + update({ hapticFeedback })} /> +

By default arrows repeat. Haptics are saved for ConnectBot; the preview does not vibrate.

+
+
+ + +
+
+ ); +} +export function KeyboardEditor() { + const [history, setHistory] = useState(() => ({ past: [], present: createPreset('citrus'), future: [] })); + const config = history.present; + const [layoutId, setLayoutId] = useState(config.layouts[0]!.id); + const layout = config.layouts.find(l => l.id === layoutId) ?? config.layouts[0]!; + const items = config.items.filter(i => i.layoutId === layout.id); + const [selectedId, setSelectedId] = useState(); + const selected = items.find(i => i.id === selectedId); + const [layer, setLayer] = useState(0); + const [mode, setMode] = useState<'edit' | 'test'>('edit'); + const [device, setDevice] = useState('phone'); + const [orientation, setOrientation] = useState<'portrait' | 'landscape'>('portrait'); + const [custom, setCustom] = useState<[number, number]>([390, 844]); + const [theme, setTheme] = useState('light'); + const [simulation, setSimulation] = useState(() => initialSimulation()); + const [notice, setNotice] = useState(''); + const [jsonDraft, setJsonDraft] = useState(null); + const [preserve, setPreserve] = useState(false); + const [share, setShare] = useState(false); + const [ready, setReady] = useState(false); + const [recovery, setRecovery] = useState(null); + const [storageError, setStorageError] = useState(''); + const stage = useRef(null); + const [availableWidth, setAvailableWidth] = useState(700); + const exported = useRef<{ key: string; source: string } | null>(null); + const source = serialize(config); + const errors = validate(config, layout.id); + const warn = warnings(config, layout.id); + const contributionErrors = share ? validateContribution(parseConfiguration(serialize(config, layout.id))) : []; + const dimensions = device === 'custom' ? custom : devices[device]; + const short = Math.min(...dimensions); + const long = Math.max(...dimensions); + const width = orientation === 'portrait' ? short : long; + const height = orientation === 'portrait' ? long : short; + const scale = Math.min(1, Math.max(100, availableWidth - 32) / width, 650 / height); + const commit = (next: Configuration) => setHistory(h => ({ past: [...h.past.slice(-49), h.present], present: next, future: [] })); + const mobileDevice = device === 'phone' || device === 'tablet'; + const imeHeight = previewImeHeight(device, height, simulation.ime); + const consoleHeight = height - imeHeight; + const reset = (nextDevice = device) => setSimulation(initialSimulation(nextDevice === 'phone' || nextDevice === 'tablet')); + const load = (next: Configuration) => { + commit(next); + setLayoutId(next.layouts[0]!.id); + setLayer(populated(next.items.filter(i => i.layoutId === next.layouts[0]!.id))[0] ?? 0); + setSelectedId(undefined); + reset(); + setJsonDraft(null); + }; + useEffect(() => { + const element = stage.current; + if (!element) { + return; + } + const observer = new ResizeObserver(entries => setAvailableWidth(entries[0]?.contentRect.width ?? 700)); + observer.observe(element); + return () => observer.disconnect(); + }, []); + useEffect(() => { + let cancelled = false; + const initialize = async () => { + try { + setRecovery(localStorage.getItem(storageKey)); + } catch { + setStorageError('Browser storage is unavailable. Download your layout to keep your work.'); + } + try { + const next = await initialConfiguration(window.location.search); + if (next !== undefined && !cancelled) { + setHistory({ past: [], present: next, future: [] }); + setLayoutId(next.layouts[0]!.id); + setLayer(populated(next.items.filter(i => i.layoutId === next.layouts[0]!.id))[0] ?? 0); + } + } catch (e) { + if (!cancelled) { + setNotice(e instanceof Error ? e.message : String(e)); + } + } + if (!cancelled) { + setReady(true); + } + }; + void initialize(); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { + if (!ready || recovery !== null) { + return; + } + const timer = setTimeout(() => { + try { + localStorage.setItem(storageKey, source); + } catch { + setStorageError('Draft could not be saved in this browser. Download your work.'); + } + }, 300); + return () => clearTimeout(timer); + }, [source, ready, recovery]); + const updateLayout = (patch: Partial) => commit({ ...config, layouts: config.layouts.map(l => l.id === layout.id ? { ...l, ...patch } : l) }); + const updateItem = (next: Item, moving = false) => { + if (moving && items.some(i => i.id !== next.id && overlaps(i, next))) { + setNotice('That cell is occupied. Move the key to an empty area.'); + return; + } + if (moving) { + const candidate = { ...config, items: config.items.map(i => i.id === next.id ? next : i) }; + const geometryError = validate(candidate, layout.id).filter(e => !e.includes('route back')); + if (geometryError.length) { + setNotice(geometryError[0]!); + return; + } + } + commit({ ...config, items: config.items.map(i => i.id === next.id ? next : i) }); + setNotice(''); + }; + const addKey = (copy?: Item) => { + if (items.length >= 256) { + setNotice('A layout supports at most 256 keys.'); + return; + } + let item = newItem(layout.id, copy ? { ...copy, id: newId(), layer } : { layer }); + let found = false; + const columns = Math.max(1, ...items.map(i => i.column + i.columnSpan)); + for (let row = 0; row < Math.floor(3000 / layout.buttonHeight) && !found; row++) { + for (let column = 0; column < columns && !found; column++) { + const candidate = { ...item, row, column }; + if ((column + item.columnSpan) * layout.buttonWidth <= 3000 && (row + item.rowSpan) * layout.buttonHeight <= 3000 && !items.some(i => overlaps(i, candidate))) { + item = candidate; + found = true; + } + } + } + if (!found) { + setNotice('No free cells remain within ConnectBot’s grid limits.'); + return; + } + commit({ ...config, items: [...config.items, item] }); + setSelectedId(item.id); + setNotice(''); + }; + const press = (item: Item) => { + if (mode === 'edit') { + setSelectedId(item.id); + return; + } + if (!supported(item, items, config.macros)) { + return; + } + if (item.kind === 'layer') { + const next = destination(items, layer, item.target); + if (next !== undefined) { + setLayer(next); + } + return; + } + setSimulation((previous) => { + const next = { ...previous, modifiers: { ...previous.modifiers } }; + let event = ''; + if (item.kind === 'modifier') { + next.modifiers[item.target] = ((previous.modifiers[item.target] ?? 0) + 1) % 3; + } else if (item.kind === 'app') { + if (item.target === 'toggle_ime') { + next.ime = !next.ime; + } + if (item.target === 'toggle_compose') { + next.compose = !next.compose; + } + event = `App: ${label(item, config.macros)}`; + } else { + const macro = config.macros.find(m => m.id === item.macroId); + if (macro) { + event = decodeMacro(macro.document).map(formatAction).join(' → '); + } else { + event = `key ${[...modifiers.filter(m => (next.modifiers[m] ?? 0) > 0), item.target].join('+')}`; + } + modifiers.forEach((m) => { + if (next.modifiers[m] === 1) { + next.modifiers[m] = 0; + } + }); + } + if (event) { + next.log = [...previous.log.slice(-99), event]; + } + return next; + }); + }; + const download = () => { + if (errors.length) { + setNotice('Resolve the layout errors before downloading.'); + return; + } + const key = `${source}:${layout.id}:${preserve}`; + if (exported.current?.key !== key) { + exported.current = { key, source: serialize(config, layout.id, !preserve) }; + } + downloadJson(exported.current.source, `${slugify(layout.name)}.json`); + setNotice('Downloaded. In ConnectBot, use Import Hosts, then choose the layout in Settings → Terminal keyboard.'); + }; + const importFile = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + try { + if (file.size > MAX_BYTES) { + throw new Error('Files must be 8 MiB or smaller.'); + } + load(parseConfiguration(await file.text())); + setNotice('Loaded keyboard data locally. Other configuration tables are excluded from downloads.'); + } catch (e) { + setNotice(e instanceof Error ? e.message : String(e)); + } + event.target.value = ''; + }; + const assignMacro = (macro: Macro) => { + if (!selected) { + return; + } + const actions = decodeMacro(macro.document); + const shortcut = actions.length === 1 && actions[0]?.type === 'key' ? actions[0] : undefined; + updateItem({ ...selected, kind: shortcut?.key !== undefined ? 'key' : 'macro', target: shortcut?.key ?? '', macroId: macro.id }); + }; + return ( +
+
+
+
+ ← Keyboard library +

Make room for your workflow.

+

Build a terminal keyboard. Preview it on any screen. Take it into ConnectBot.

+
+ + + {' '} + All editing stays in your browser + +
+ {recovery !== null && ( +
+ A previous keyboard draft is available. + + +
+ )} + {storageError && {storageError}} +
+ + + + + + + + +
+ {notice || `${items.length} keys · ${populated(items).length} populated layers · ${draftStatus(ready, recovery, storageError)}`} + {share && ( +
+

Give someone a better keyboard.

+ {contributionErrors.length > 0 && ( +
+ Before contributing +
    {contributionErrors.map(message =>
  • {message}
  • )}
+
+ )} +

Download your layout, then contribute the JSON file to the ConnectBot website repository. One file is all it takes.

+
    +
  1. Give the layout a clear name and resolve any errors.
  2. +
  3. + +
  4. +
  5. + Upload the JSON on GitHub ↗ + , then follow GitHub’s prompts to fork and propose a pull request against + {' '} + develop + . +
  6. +
+

+ The gallery updates after your pull request is merged. + {' '} + Contribution guide ↗ +

+
+ )} + {!!errors.length && ( +
+ Resolve these before testing or exporting +
    {errors.slice(0, 10).map(error =>
  • {error}
  • )}
+ {errors.length > 10 && ( +

+ And + {errors.length - 10} + {' '} + more errors. +

+ )} +
+ )} + {jsonDraft !== null && ( +
+

Configuration JSON

+

JSON5 is accepted. Apply validates the structure; layout errors remain visible for correction. Downloads contain the selected layout and its referenced macros.

+