22 Commits

Author SHA1 Message Date
9266702cbb feat: add sentence wpm stage 2025-01-16 20:41:00 +01:00
77e2d2b20e feat: sentence trainer idle timeout 2025-01-16 17:50:52 +01:00
7819f546a6 fix: package manager 2025-01-16 17:15:25 +01:00
e37b38085d feat: sentence trainer prototype
feat: layout learner prototype
2025-01-16 17:12:56 +01:00
a3bf9ac32b 2.2.3 2025-01-15 11:31:47 +01:00
David Villafaña
5bd3245084 fix: typographical error (#156)
Co-authored-by: David Rog Desktop <dvillafanaiv@proton.me>
2025-01-15 11:30:46 +01:00
1cd2ec318a 2.2.2 2025-01-14 13:35:53 +01:00
6c8bfa0272 fix: ota update 2025-01-14 13:31:22 +01:00
f69be14b5e 2.2.1 2025-01-06 19:34:28 +01:00
dce554fc66 fix: set pnpm version in github actions correctly 2025-01-06 19:32:43 +01:00
f152dbdcf5 fix: set node/pnpm versions correctly 2025-01-06 19:31:04 +01:00
6a29e6a2fc 2.2.0 2025-01-06 19:25:45 +01:00
9bf3801fef Mark factory flash as wip 2025-01-06 19:25:27 +01:00
d2accfb838 Squash merge fix-vocabulary-export into master 2024-12-09 18:41:26 +01:00
b8a376b93b feat: update m4g 2024-12-09 18:35:05 +01:00
588719df91 feat: support factory flashing 2024-11-23 19:02:35 +01:00
6a0dad9dad feat: android support 2024-11-23 15:07:35 +01:00
f3704e4051 2.1.0 2024-11-20 22:26:59 +01:00
3e6298717e feat: m4gr 2024-11-19 22:25:01 +01:00
aced0bbbb7 feat: m4g support 2024-11-19 17:48:50 +01:00
Raymond Li
36874c59e3 Temporarily make chat available 2024-11-19 06:08:37 +00:00
9dc61a3482 fix: exclude pre-rendered ccos update pages 2024-11-08 16:04:50 +01:00
32 changed files with 1474 additions and 342 deletions

View File

@@ -20,7 +20,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with: with:
version: 8 version: 9
- name: 🐉 Use Node.js 22.4.x - name: 🐉 Use Node.js 22.4.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:

View File

@@ -14,7 +14,13 @@
flake-utils.lib.eachDefaultSystem ( flake-utils.lib.eachDefaultSystem (
system: system:
let let
overlays = [ (import rust-overlay) ]; overlays = [
(import rust-overlay)
(final: prev: {
nodejs = prev.nodejs_22;
corepack = prev.corepack_22;
})
];
pkgs = import nixpkgs { inherit system overlays; }; pkgs = import nixpkgs { inherit system overlays; };
rust-bin = pkgs.rust-bin.stable.latest.default.override { rust-bin = pkgs.rust-bin.stable.latest.default.override {
extensions = [ extensions = [
@@ -46,8 +52,8 @@
]; ];
packages = packages =
(with pkgs; [ (with pkgs; [
nodejs_22 nodejs
nodePackages.pnpm pnpm
rust-bin rust-bin
fontMin fontMin
]) ])

View File

@@ -110,6 +110,9 @@ const config = {
"experiment", "experiment",
"code", "code",
"dictionary", "dictionary",
"developer_board",
"developer_board_off",
"memory",
], ],
codePoints: { codePoints: {
speed: "e9e4", speed: "e9e4",

View File

@@ -1,11 +1,11 @@
{ {
"name": "charachorder-device-manager", "name": "charachorder-device-manager",
"version": "2.0.2", "version": "2.2.3",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18.16", "node": ">=22.4",
"pnpm": ">=8.6" "pnpm": ">=9.4"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -62,6 +62,7 @@
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"cypress": "^13.13.2", "cypress": "^13.13.2",
"d3": "^7.9.0", "d3": "^7.9.0",
"esptool-js": "^0.4.7",
"flexsearch": "^0.7.43", "flexsearch": "^0.7.43",
"fontkit": "^2.0.4", "fontkit": "^2.0.4",
"glob": "^11.0.0", "glob": "^11.0.0",
@@ -89,6 +90,7 @@
"vite-plugin-mkcert": "^1.17.6", "vite-plugin-mkcert": "^1.17.6",
"vite-plugin-pwa": "^0.20.5", "vite-plugin-pwa": "^0.20.5",
"vitest": "^2.1.4", "vitest": "^2.1.4",
"web-serial-polyfill": "^1.0.15",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"
}, },
"type": "module" "type": "module"

30
pnpm-lock.yaml generated
View File

@@ -92,6 +92,9 @@ importers:
d3: d3:
specifier: ^7.9.0 specifier: ^7.9.0
version: 7.9.0 version: 7.9.0
esptool-js:
specifier: ^0.4.7
version: 0.4.7
flexsearch: flexsearch:
specifier: ^0.7.43 specifier: ^0.7.43
version: 0.7.43 version: 0.7.43
@@ -173,6 +176,9 @@ importers:
vitest: vitest:
specifier: ^2.1.4 specifier: ^2.1.4
version: 2.1.4(@types/node@20.14.10)(jsdom@25.0.1)(sass@1.80.6)(terser@5.31.1) version: 2.1.4(@types/node@20.14.10)(jsdom@25.0.1)(sass@1.80.6)(terser@5.31.1)
web-serial-polyfill:
specifier: ^1.0.15
version: 1.0.15
workbox-window: workbox-window:
specifier: ^7.3.0 specifier: ^7.3.0
version: 7.3.0 version: 7.3.0
@@ -1645,6 +1651,9 @@ packages:
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
engines: {node: '>= 4.0.0'} engines: {node: '>= 4.0.0'}
atob-lite@2.0.0:
resolution: {integrity: sha512-LEeSAWeh2Gfa2FtlQE1shxQ8zi5F9GHarrGKz08TMdODD5T4eH6BMsvtnhbWZ+XQn+Gb6om/917ucvRu7l7ukw==}
autoprefixer@10.4.20: autoprefixer@10.4.20:
resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -2250,6 +2259,9 @@ packages:
esm-env@1.0.0: esm-env@1.0.0:
resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==} resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==}
esptool-js@0.4.7:
resolution: {integrity: sha512-xVwtSVDRsvjXSEvNFrorgJfB71RFFkZkL+hs7O7gW5hgPrKGywZxo2U5LJddzkJ6eE31QinNVyywc0OaSntZCw==}
esrap@1.2.2: esrap@1.2.2:
resolution: {integrity: sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==} resolution: {integrity: sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==}
@@ -3084,6 +3096,9 @@ packages:
pako@0.2.9: pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
parent-module@1.0.1: parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -4060,6 +4075,9 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'} engines: {node: '>=18'}
web-serial-polyfill@1.0.15:
resolution: {integrity: sha512-usZN7kGRkEWr8DzRWxW+og55L1fHo4hNIwxCSCfWKpM+i0L+2AwzupMvkDFxnJNqUFOhLaD3PlgAOJxUOUrAoA==}
webidl-conversions@4.0.2: webidl-conversions@4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
@@ -5796,6 +5814,8 @@ snapshots:
at-least-node@1.0.0: {} at-least-node@1.0.0: {}
atob-lite@2.0.0: {}
autoprefixer@10.4.20(postcss@8.4.39): autoprefixer@10.4.20(postcss@8.4.39):
dependencies: dependencies:
browserslist: 4.24.2 browserslist: 4.24.2
@@ -6519,6 +6539,12 @@ snapshots:
esm-env@1.0.0: {} esm-env@1.0.0: {}
esptool-js@0.4.7:
dependencies:
atob-lite: 2.0.0
pako: 2.1.0
tslib: 2.6.3
esrap@1.2.2: esrap@1.2.2:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
@@ -7336,6 +7362,8 @@ snapshots:
pako@0.2.9: {} pako@0.2.9: {}
pako@2.1.0: {}
parent-module@1.0.1: parent-module@1.0.1:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0
@@ -8336,6 +8364,8 @@ snapshots:
dependencies: dependencies:
xml-name-validator: 5.0.0 xml-name-validator: 5.0.0
web-serial-polyfill@1.0.15: {}
webidl-conversions@4.0.2: {} webidl-conversions@4.0.2: {}
webidl-conversions@7.0.0: {} webidl-conversions@7.0.0: {}

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "app" name = "app"
version = "2.0.2" version = "2.2.3"
description = "A Tauri App" description = "A Tauri App"
authors = ["Thea Schöbl <dev@theaninova.de>"] authors = ["Thea Schöbl <dev@theaninova.de>"]
license = "AGPL-3" license = "AGPL-3"

View File

@@ -6,7 +6,7 @@
"devPath": "http://localhost:5173", "devPath": "http://localhost:5173",
"distDir": "../build" "distDir": "../build"
}, },
"package": { "productName": "amacc1ng", "version": "2.0.2" }, "package": { "productName": "amacc1ng", "version": "2.2.3" },
"tauri": { "tauri": {
"allowlist": { "all": false }, "allowlist": { "all": false },
"bundle": { "bundle": {

View File

@@ -3,35 +3,35 @@ col:
# Ring / Middle # Ring / Middle
- offset: [2, 0] - offset: [2, 0]
row: row:
- switch: { d: 25, e: 26, n: 27, w: 28, s: 29 } - switch: { e: 26, n: 27, w: 28, s: 29 }
- switch: { d: 20, e: 21, n: 22, w: 23, s: 24 } - switch: { e: 21, n: 22, w: 23, s: 24 }
- offset: [4, 0] - offset: [4, 0]
switch: { d: 65, w: 66, n: 67, e: 68, s: 69 } switch: { w: 66, n: 67, e: 68, s: 69 }
- switch: { d: 70, w: 71, n: 72, e: 73, s: 74 } - switch: { w: 71, n: 72, e: 73, s: 74 }
- offset: [2, 0] - offset: [2, 0]
row: row:
- switch: { d: 40, e: 41, n: 42, w: 43, s: 44 } - switch: { e: 41, n: 42, w: 43, s: 44 }
- switch: { d: 35, e: 36, n: 37, w: 38, s: 39 } - switch: { e: 36, n: 37, w: 38, s: 39 }
- offset: [4, 0] - offset: [4, 0]
switch: { d: 80, w: 81, n: 82, e: 83, s: 84 } switch: { w: 81, n: 82, e: 83, s: 84 }
- switch: { d: 85, w: 86, n: 87, e: 88, s: 89 } - switch: { w: 86, n: 87, e: 88, s: 89 }
# Pinkie / Index # Pinkie / Index
- offset: [0, -3] - offset: [0, -3]
row: row:
- switch: { d: 30, e: 31, n: 32, w: 33, s: 34 } - switch: { e: 31, n: 32, w: 33, s: 34 }
- offset: [4, 0] - offset: [4, 0]
switch: { d: 15, e: 16, n: 17, w: 18, s: 19 } switch: { e: 16, n: 17, w: 18, s: 19 }
- switch: { d: 60, w: 61, n: 62, e: 63, s: 64 } - switch: { w: 61, n: 62, e: 63, s: 64 }
- offset: [4, 0] - offset: [4, 0]
switch: { d: 75, w: 76, n: 77, e: 78, s: 79 } switch: { w: 76, n: 77, e: 78, s: 79 }
# Thumbs # Thumbs
- row: - row:
- offset: [5.5, 0.5] - offset: [5.5, 0.5]
switch: { d: 10, e: 11, n: 12, w: 13, s: 14 } switch: { e: 11, n: 12, w: 13, s: 14 }
- offset: [1, 0.5] - offset: [1, 0.5]
switch: { d: 55, w: 56, n: 57, e: 58, s: 59 } switch: { w: 56, n: 57, e: 58, s: 59 }
- row: - row:
- offset: [4.5, -0.25] - offset: [4.5, -0.25]
switch: { d: 5, e: 6, n: 7, w: 8, s: 9 } switch: { e: 6, n: 7, w: 8, s: 9 }
- offset: [3, -0.25] - offset: [3, -0.25]
switch: { d: 50, w: 51, n: 52, e: 53, s: 54 } switch: { w: 51, n: 52, e: 53, s: 54 }

View File

@@ -0,0 +1,37 @@
name: M4G
col:
# Ring / Middle
- offset: [2, 0]
row:
- switch: { e: 26, n: 27, w: 28, s: 29 }
- switch: { e: 21, n: 22, w: 23, s: 24 }
- offset: [4, 0]
switch: { w: 66, n: 67, e: 68, s: 69 }
- switch: { w: 71, n: 72, e: 73, s: 74 }
- offset: [2, 0]
row:
- switch: { e: 41, n: 42, w: 43, s: 44 }
- switch: { e: 36, n: 37, w: 38, s: 39 }
- offset: [4, 0]
switch: { w: 81, n: 82, e: 83, s: 84 }
- switch: { w: 86, n: 87, e: 88, s: 89 }
# Pinkie / Index
- offset: [0, -3]
row:
- switch: { e: 31, n: 32, w: 33, s: 34 }
- offset: [4, 0]
switch: { e: 16, n: 17, w: 18, s: 19 }
- switch: { w: 61, n: 62, e: 63, s: 64 }
- offset: [4, 0]
switch: { w: 76, n: 77, e: 78, s: 79 }
# Thumbs
- row:
- offset: [5.5, 0.5]
switch: { e: 11, n: 12, w: 13, s: 14 }
- offset: [1, 0.5]
switch: { w: 56, n: 57, e: 58, s: 59 }
- row:
- offset: [4.5, -0.25]
switch: { e: 6, n: 7, w: 8, s: 9 }
- offset: [3, -0.25]
switch: { w: 51, n: 52, e: 53, s: 54 }

View File

@@ -13,7 +13,7 @@
"GTM stands for Generative Text Menu and can be used to change your device's settings anywhere", "GTM stands for Generative Text Menu and can be used to change your device's settings anywhere",
"Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use", "Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use",
"Chentry stands for character entry, or typing letter by letter on a chording enabled device", "Chentry stands for character entry, or typing letter by letter on a chording enabled device",
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjucations and more", "Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjugations and more",
"You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets", "You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets",
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated", "An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!", "Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { getContext } from "svelte";
import type { ReplayPlayer } from "./core/player";
import { TextPlugin } from "./core/plugins/text";
const player: { player: ReplayPlayer | undefined } = getContext("replay");
let { text = $bindable("") } = $props();
$effect(() => {
if (!player.player) return;
const tracker = new TextPlugin();
tracker.register(player.player);
const unsubscribe = tracker.subscribe((value) => {
text = value;
});
return unsubscribe;
});
</script>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { getContext } from "svelte";
import { WpmReplayPlugin } from "./core/plugins/wpm";
import type { ReplayPlayer } from "./core/player";
const player: { player: ReplayPlayer | undefined } = getContext("replay");
let { wpm = $bindable(0) } = $props();
$effect(() => {
if (!player.player) return;
const tracker = new WpmReplayPlugin();
tracker.register(player.player);
const unsubscribe = tracker.subscribe((value) => {
wpm = value;
});
return unsubscribe;
});
</script>

View File

@@ -85,7 +85,6 @@ export class ChordsReplayPlugin
} }
} }
} }
console.log(this.tokens);
clearTimeout(this.timeout); clearTimeout(this.timeout);
if (replay.stepper.held.size === 0) { if (replay.stepper.held.size === 0) {

View File

@@ -0,0 +1,23 @@
import type { ReplayPlayer } from "../player";
import type { ReplayPlugin, StoreContract } from "../types";
export class TextPlugin implements StoreContract<string>, ReplayPlugin {
private subscribers = new Set<(value: string) => void>();
register(replay: ReplayPlayer) {
replay.subscribe(() => {
if (this.subscribers.size === 0) return;
const text = replay.stepper.text
.filter((it) => it.source !== "ghost")
.map((it) => it.text)
.join("");
for (const subscription of this.subscribers) {
subscription(text);
}
});
}
subscribe(subscription: (value: string) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
}

View File

@@ -36,6 +36,7 @@ export class TextRenderer {
); );
this.cursorNode.setAttribute("x", "0"); this.cursorNode.setAttribute("x", "0");
this.cursorNode.setAttribute("y", "0"); this.cursorNode.setAttribute("y", "0");
this.cursorNode.setAttribute("class", "cursor");
this.svg.appendChild(this.cursorNode); this.svg.appendChild(this.cursorNode);
} }

View File

@@ -11,6 +11,9 @@
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } = const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
getContext<VisualLayoutConfig>("visual-layout-config"); getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer"); const activeLayer = getContext<Writable<number>>("active-layer");
const currentAction = getContext<Writable<Set<number>> | undefined>(
"highlight-action",
);
let { let {
key, key,
@@ -47,6 +50,7 @@
]} ]}
{@const hasIcon = !dynamicMapping && !!icon} {@const hasIcon = !dynamicMapping && !!icon}
<text <text
class:hidden={$currentAction?.has(actionId) === false}
fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"} fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"}
font-weight={isApplied ? "" : "bold"} font-weight={isApplied ? "" : "bold"}
text-anchor="middle" text-anchor="middle"
@@ -96,4 +100,8 @@
text:focus-within { text:focus-within {
outline: none; outline: none;
} }
text.hidden {
opacity: 0.2;
}
</style> </style>

View File

@@ -8,11 +8,14 @@
KeyboardEventHandler, KeyboardEventHandler,
MouseEventHandler, MouseEventHandler,
} from "svelte/elements"; } from "svelte/elements";
import { type Writable } from "svelte/store";
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>( const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
"visual-layout-config", "visual-layout-config",
); );
const highlight = getContext<Writable<Set<number>> | undefined>("highlight");
let { let {
i, i,
key, key,
@@ -35,6 +38,8 @@
<g <g
class="key-group" class="key-group"
class:highlight={$highlight?.has(key.id) === true}
class:faded={$highlight?.has(key.id) === false}
{onclick} {onclick}
{onkeypress} {onkeypress}
{onfocusin} {onfocusin}
@@ -131,12 +136,14 @@
stroke-opacity: 0.3; stroke-opacity: 0.3;
} }
g.faded,
g:hover { g:hover {
cursor: default; cursor: default;
opacity: 0.6; opacity: 0.6;
transition: opacity #{$transition} ease; transition: opacity #{$transition} ease;
} }
g.highlight,
g:focus-within { g:focus-within {
color: var(--md-sys-color-primary); color: var(--md-sys-color-primary);
outline: none; outline: none;

View File

@@ -37,6 +37,10 @@
import("$lib/assets/layouts/m4g.yml").then( import("$lib/assets/layouts/m4g.yml").then(
(it) => it.default as VisualLayout, (it) => it.default as VisualLayout,
), ),
M4GR: () =>
import("$lib/assets/layouts/m4gr.yml").then(
(it) => it.default as VisualLayout,
),
}; };
</script> </script>
@@ -70,6 +74,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
max-height: 20cm;
} }
fieldset { fieldset {

View File

@@ -26,6 +26,7 @@ const KEY_COUNTS = {
LITE: 67, LITE: 67,
X: 256, X: 256,
M4G: 90, M4G: 90,
M4GR: 90,
} as const; } as const;
if ( if (
@@ -36,6 +37,13 @@ if (
await import("./tauri-serial"); await import("./tauri-serial");
} }
if (browser && navigator.serial === undefined && navigator.usb !== undefined) {
// @ts-expect-error polyfill
navigator.serial = await import("web-serial-polyfill").then(
({ serial }) => serial,
);
}
export async function getViablePorts(): Promise<SerialPort[]> { export async function getViablePorts(): Promise<SerialPort[]> {
return navigator.serial.getPorts().then((ports) => return navigator.serial.getPorts().then((ports) =>
ports.filter((it) => { ports.filter((it) => {
@@ -126,9 +134,9 @@ export class CharaDevice {
await this.port.close(); await this.port.close();
this.version = new SemVer( this.version = new SemVer(
await this.send(1, "VERSION").then(([version]) => version), await this.send(1, ["VERSION"]).then(([version]) => version),
); );
const [company, device, chipset] = await this.send(3, "ID"); const [company, device, chipset] = await this.send(3, ["ID"]);
this.company = company as typeof this.company; this.company = company as typeof this.company;
this.device = device as typeof this.device; this.device = device as typeof this.device;
this.chipset = chipset as typeof this.chipset; this.chipset = chipset as typeof this.chipset;
@@ -177,9 +185,12 @@ export class CharaDevice {
}); });
} }
private async internalRead() { private async internalRead(timeoutMs: number | undefined) {
try { try {
const { value } = await timeout(this.reader.read(), 5000); const { value } =
timeoutMs !== undefined
? await timeout(this.reader.read(), timeoutMs)
: await this.reader.read();
serialLog.update((it) => { serialLog.update((it) => {
it.push({ it.push({
type: "output", type: "output",
@@ -270,14 +281,15 @@ export class CharaDevice {
*/ */
async send<T extends number>( async send<T extends number>(
expectedLength: T, expectedLength: T,
...command: string[] command: string[],
timeout: number | undefined = 5000,
): Promise<LengthArray<string, T>> { ): Promise<LengthArray<string, T>> {
return this.runWith(async (send, read) => { return this.runWith(async (send, read) => {
await send(...command); await send(...command);
const commandString = command const commandString = command
.join(" ") .join(" ")
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); .replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
const readResult = await read(); const readResult = await read(timeout);
if (readResult === undefined) { if (readResult === undefined) {
console.error("No response"); console.error("No response");
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray< return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
@@ -299,7 +311,7 @@ export class CharaDevice {
} }
async getChordCount(): Promise<number> { async getChordCount(): Promise<number> {
const [count] = await this.send(1, "CML C0"); const [count] = await this.send(1, ["CML", "C0"]);
return Number.parseInt(count); return Number.parseInt(count);
} }
@@ -307,7 +319,11 @@ export class CharaDevice {
* Retrieves a chord by index * Retrieves a chord by index
*/ */
async getChord(index: number | number[]): Promise<Chord> { async getChord(index: number | number[]): Promise<Chord> {
const [actions, phrase] = await this.send(2, `CML C1 ${index}`); const [actions, phrase] = await this.send(2, [
"CML",
"C1",
index.toString(),
]);
return { return {
actions: parseChordActions(actions), actions: parseChordActions(actions),
phrase: parsePhrase(phrase), phrase: parsePhrase(phrase),
@@ -318,29 +334,30 @@ export class CharaDevice {
* Retrieves the phrase for a set of actions * Retrieves the phrase for a set of actions
*/ */
async getChordPhrase(actions: number[]): Promise<number[] | undefined> { async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
const [phrase] = await this.send( const [phrase] = await this.send(1, [
1, "CML",
`CML C2 ${stringifyChordActions(actions)}`, "C2",
); stringifyChordActions(actions),
]);
return phrase === "2" ? undefined : parsePhrase(phrase); return phrase === "2" ? undefined : parsePhrase(phrase);
} }
async setChord(chord: Chord) { async setChord(chord: Chord) {
const [status] = await this.send( const [status] = await this.send(1, [
1,
"CML", "CML",
"C3", "C3",
stringifyChordActions(chord.actions), stringifyChordActions(chord.actions),
stringifyPhrase(chord.phrase), stringifyPhrase(chord.phrase),
); ]);
if (status !== "0") console.error(`Failed with status ${status}`); if (status !== "0") console.error(`Failed with status ${status}`);
} }
async deleteChord(chord: Pick<Chord, "actions">) { async deleteChord(chord: Pick<Chord, "actions">) {
const status = await this.send( const status = await this.send(1, [
1, "CML",
`CML C4 ${stringifyChordActions(chord.actions)}`, "C4",
); stringifyChordActions(chord.actions),
]);
if (status?.at(-1) !== "2" && status?.at(-1) !== "0") if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
throw new Error(`Failed with status ${status}`); throw new Error(`Failed with status ${status}`);
} }
@@ -352,7 +369,13 @@ export class CharaDevice {
* @param action the assigned action id * @param action the assigned action id
*/ */
async setLayoutKey(layer: number, id: number, action: number) { async setLayoutKey(layer: number, id: number, action: number) {
const [status] = await this.send(1, `VAR B4 A${layer} ${id} ${action}`); const [status] = await this.send(1, [
"VAR",
"B4",
`A${layer}`,
id.toString(),
action.toString(),
]);
if (status !== "0") throw new Error(`Failed with status ${status}`); if (status !== "0") throw new Error(`Failed with status ${status}`);
} }
@@ -363,7 +386,12 @@ export class CharaDevice {
* @returns the assigned action id * @returns the assigned action id
*/ */
async getLayoutKey(layer: number, id: number) { async getLayoutKey(layer: number, id: number) {
const [position, status] = await this.send(2, `VAR B3 A${layer} ${id}`); const [position, status] = await this.send(2, [
"VAR",
"B3",
`A${layer}`,
id.toString(),
]);
if (status !== "0") throw new Error(`Failed with status ${status}`); if (status !== "0") throw new Error(`Failed with status ${status}`);
return Number(position); return Number(position);
} }
@@ -376,7 +404,7 @@ export class CharaDevice {
* **This does not need to be called for chords** * **This does not need to be called for chords**
*/ */
async commit() { async commit() {
const [status] = await this.send(1, "VAR B0"); const [status] = await this.send(1, ["VAR", "B0"]);
if (status !== "0") throw new Error(`Failed with status ${status}`); if (status !== "0") throw new Error(`Failed with status ${status}`);
} }
@@ -387,10 +415,12 @@ export class CharaDevice {
* To permanently store the settings, you *must* call commit. * To permanently store the settings, you *must* call commit.
*/ */
async setSetting(id: number, value: number) { async setSetting(id: number, value: number) {
const [status] = await this.send( const [status] = await this.send(1, [
1, "VAR",
`VAR B2 ${id.toString(16).toUpperCase()} ${value}`, "B2",
); id.toString(16).toUpperCase(),
value.toString(),
]);
if (status !== "0") throw new Error(`Failed with status ${status}`); if (status !== "0") throw new Error(`Failed with status ${status}`);
} }
@@ -398,10 +428,11 @@ export class CharaDevice {
* Retrieves a setting from the device * Retrieves a setting from the device
*/ */
async getSetting(id: number): Promise<number> { async getSetting(id: number): Promise<number> {
const [value, status] = await this.send( const [value, status] = await this.send(2, [
2, "VAR",
`VAR B1 ${id.toString(16).toUpperCase()}`, "B1",
); id.toString(16).toUpperCase(),
]);
if (status !== "0") if (status !== "0")
throw new Error( throw new Error(
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`, `Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
@@ -413,14 +444,14 @@ export class CharaDevice {
* Reboots the device * Reboots the device
*/ */
async reboot() { async reboot() {
await this.send(0, "RST"); await this.send(0, ["RST"]);
} }
/** /**
* Reboots the device to the bootloader * Reboots the device to the bootloader
*/ */
async bootloader() { async bootloader() {
await this.send(0, "RST BOOTLOADER"); await this.send(0, ["RST", "BOOTLOADER"]);
} }
/** /**
@@ -429,7 +460,12 @@ export class CharaDevice {
async reset( async reset(
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC", type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
) { ) {
await this.send(0, `RST ${type}`); await this.send(0, ["RST", type]);
}
async queryKey(): Promise<number> {
const [value] = await this.send(1, ["QRY", "KEY"], undefined);
return Number(value);
} }
/** /**
@@ -438,7 +474,7 @@ export class CharaDevice {
* This is useful for debugging when there is a suspected heap or stack issue. * This is useful for debugging when there is a suspected heap or stack issue.
*/ */
async getRamBytesAvailable(): Promise<number> { async getRamBytesAvailable(): Promise<number> {
return Number(await this.send(1, "RAM").then(([bytes]) => bytes)); return Number(await this.send(1, ["RAM"]).then(([bytes]) => bytes));
} }
async updateFirmware(file: File | Blob): Promise<void> { async updateFirmware(file: File | Blob): Promise<void> {

15
src/lib/util/shuffle.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm
*/
export function shuffleInPlace<T>(array: T[]) {
for (let i = array.length - 1; i >= 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j]!, array[i]!];
}
}
export function shuffle<T>(array: T[]): T[] {
const result = [...array];
shuffleInPlace(result);
return result;
}

View File

@@ -27,9 +27,9 @@
external: true, external: true,
}, },
{ href: "/editor", icon: "edit_document", title: "Editor", wip: true }, { href: "/editor", icon: "edit_document", title: "Editor", wip: true },
{ href: "https://chat.dev.charachorder.io", icon: "chat", title: "Chat", wip: true },
], ],
/*[ /*[
{ href: "/chat", icon: "chat", title: "Chat", wip: true },
{ href: "/plugin", icon: "code", title: "Plugin", wip: true }, { href: "/plugin", icon: "code", title: "Plugin", wip: true },
],*/ ],*/
] satisfies { ] satisfies {

View File

@@ -2,6 +2,7 @@
import { downloadBackup } from "$lib/backup/backup"; import { downloadBackup } from "$lib/backup/backup";
import { initSerial, serialPort } from "$lib/serial/connection"; import { initSerial, serialPort } from "$lib/serial/connection";
import { fade, slide } from "svelte/transition"; import { fade, slide } from "svelte/transition";
import type { LoaderOptions, ESPLoader } from "esptool-js";
let { data } = $props(); let { data } = $props();
@@ -9,7 +10,12 @@
let success = $state(false); let success = $state(false);
let error = $state<Error | undefined>(undefined); let error = $state<Error | undefined>(undefined);
let terminalOutput = $state("");
let step = $state(0); let step = $state(0);
let eraseAll = $state(false);
let espLoader;
async function update() { async function update() {
working = true; working = true;
@@ -18,7 +24,9 @@
const port = $serialPort!; const port = $serialPort!;
$serialPort = undefined; $serialPort = undefined;
try { try {
const file = await fetch(otaUrl!).then((it) => it.blob()); const file = await fetch(
`${data.meta.path}/${data.meta.update.ota?.name}`,
).then((it) => it.blob());
await port.updateFirmware(file); await port.updateFirmware(file);
@@ -36,18 +44,7 @@
: undefined, : undefined,
); );
let isCorrectDevice = $derived( let isCorrectDevice = $derived(
currentDevice ? currentDevice === data.device : undefined, currentDevice ? currentDevice === data.meta.target : undefined,
);
let uf2Url = $derived(
data.uf2
? `${import.meta.env.VITE_FIRMWARE_URL}${data.device}/${data.version}/${data.uf2.name}`
: undefined,
);
let otaUrl = $derived(
data.ota
? `${import.meta.env.VITE_FIRMWARE_URL}${data.device}/${data.version}/${data.ota.name}`
: undefined,
); );
/** /**
@@ -84,10 +81,12 @@
} }
async function getFileSystem() { async function getFileSystem() {
if (!uf2Url) return; if (!data.meta.update.uf2) return;
const uf2Promise = fetch(uf2Url).then((it) => it.blob()); const uf2Promise = fetch(
`${data.meta.path}/${data.meta.update.uf2.name}`,
).then((it) => it.blob());
const handle = await window.showSaveFilePicker({ const handle = await window.showSaveFilePicker({
id: `${data.device}-update`, id: `${data.meta.target}-update`,
suggestedName: "CURRENT.UF2", suggestedName: "CURRENT.UF2",
excludeAcceptAllOption: true, excludeAcceptAllOption: true,
types: [ types: [
@@ -102,21 +101,104 @@
await uf2.stream().pipeTo(writable); await uf2.stream().pipeTo(writable);
step = 4; step = 4;
} }
async function espBootloader() {
$serialPort?.forget();
const port = await navigator.serial.requestPort();
port.open({ baudRate: 1200 });
}
async function connectEsp(port: SerialPort): Promise<ESPLoader> {
const esptool = data.meta.update.esptool!;
const { Transport, ESPLoader } = await import("esptool-js");
const espLoader = new ESPLoader({
transport: new Transport(port),
baudrate: 9600, // Number(esptool.baud),
romBaudrate: 9600, // Number(esptool.baud),
debugLogging: true,
terminal: {
clean: () => {
terminalOutput = "";
},
writeLine: (data) => {
terminalOutput += data + "\n";
},
write: (data) => {
terminalOutput += data;
},
},
} satisfies LoaderOptions);
await espLoader.detectChip(esptool.before);
if (!espLoader.IS_STUB) {
await espLoader.runStub();
}
return espLoader;
}
async function flashImages() {
const port = await navigator.serial.requestPort();
try {
const esptool = data.meta.update.esptool!;
espLoader = await connectEsp(port);
const fileArray = await Promise.all(
Object.entries(esptool.files).map(([offset, name]) =>
fetch(`${data.meta.path}/${name}`)
.then((it) => it.blob())
.then((it) => it.text())
.then((it) => ({
address: Number(offset),
data: it,
})),
),
);
await espLoader.writeFlash({
flashSize: esptool.flash_size,
flashMode: esptool.flash_mode,
flashFreq: esptool.flash_freq,
compress: true,
eraseAll,
fileArray,
});
} finally {
port.close();
}
}
async function eraseSPI() {
const port = await navigator.serial.requestPort();
try {
console.log(data.meta);
const spiFlash = data.meta.spi_flash!;
espLoader = await connectEsp(port);
/*espLoader.flashSpiAttach(
(spiFlash.connection.clk << 0) |
(spiFlash.connection.q << 8) |
(spiFlash.connection.d << 16) |
(spiFlash.connection.cs << 24),
);
espLoader.flashId();*/
} finally {
port.close();
}
}
</script> </script>
<div class="container"> <div class="container">
<h2> <h2>
<a class="inline-link" href="/ccos">CCOS</a> / <a class="inline-link" href="/ccos">CCOS</a> /
<a <a
href="/ccos/{data.device}" href="/ccos/{data.meta.target}"
class="device inline-link" class="device inline-link"
class:correct-device={isCorrectDevice === true} class:correct-device={isCorrectDevice === true}
class:incorrect-device={isCorrectDevice === false}>{data.device}</a class:incorrect-device={isCorrectDevice === false}>{data.meta.target}</a
> >
/ <em class="version">{data.version}</em> / <em class="version">{data.meta.version}</em>
</h2> </h2>
{#if data.ota && !data.device.endsWith("m0")} {#if data.meta.update.ota && !data.meta.target.endsWith("m0")}
{@const buttonError = error || (!success && isCorrectDevice === false)} {@const buttonError = error || (!success && isCorrectDevice === false)}
<section> <section>
<button <button
@@ -136,7 +218,7 @@
{$serialPort.chipset}</b {$serialPort.chipset}</b
> >
will be updated from <b class="version">{$serialPort.version}</b> to will be updated from <b class="version">{$serialPort.version}</b> to
<b class="version">{data.version}</b> <b class="version">{data.meta.version}</b>
</div> </div>
{:else if $serialPort && isCorrectDevice === false} {:else if $serialPort && isCorrectDevice === false}
<div class="error" transition:slide> <div class="error" transition:slide>
@@ -158,27 +240,6 @@
<h3>Manual Update</h3> <h3>Manual Update</h3>
{/if} {/if}
<ul class="files">
{#if data.uf2}
<li>
<a target="_blank" download href={uf2Url}
>{data.uf2.name} <span class="icon">download</span><span class="size"
>{toByteUnit(data.uf2.size)}</span
></a
>
</li>
{/if}
{#if data.ota}
<li>
<a target="_blank" download href={otaUrl}
>{data.ota.name} <span class="icon">download</span><span class="size"
>{toByteUnit(data.uf2.size)}</span
></a
>
</li>
{/if}
</ul>
{#if isCorrectDevice === false} {#if isCorrectDevice === false}
<div transition:slide class="incorrect-device"> <div transition:slide class="incorrect-device">
These files are incompatible with your device These files are incompatible with your device
@@ -186,7 +247,6 @@
{/if} {/if}
<section> <section>
<h4>UF2 Instructions</h4>
<ol> <ol>
<li> <li>
<button class="inline-button" onclick={connect} <button class="inline-button" onclick={connect}
@@ -227,6 +287,37 @@
</li> </li>
</ol> </ol>
</section> </section>
{#if data.meta.update.esptool}
<section>
<h3>Factory Flash (WIP)</h3>
<p>
If everything else fails, you can go through the same process that is
being used in the factory.
</p>
<p>
This will temporarily brick your device if the process is not done
completely or incorrectly.
</p>
<div class="esp-buttons">
<button onclick={espBootloader}
><span class="icon">memory</span>ESP Bootloader</button
>
<button onclick={flashImages}
><span class="icon">developer_board</span>Flash Images</button
>
<label
><input type="checkbox" id="erase" bind:checked={eraseAll} />Erase All</label
>
<button onclick={eraseSPI}
><span class="icon">developer_board</span>Erase SPI Flash</button
>
</div>
<pre>{terminalOutput}</pre>
</section>
{/if}
</div> </div>
<style lang="scss"> <style lang="scss">
@@ -239,6 +330,10 @@
margin-block-start: 4em; margin-block-start: 4em;
} }
pre {
overflow: auto;
}
.primary { .primary {
color: var(--md-sys-color-primary); color: var(--md-sys-color-primary);
} }
@@ -249,6 +344,7 @@
.container { .container {
width: calc(min(100%, 16cm)); width: calc(min(100%, 16cm));
overflow: auto;
} }
@keyframes rotate { @keyframes rotate {
@@ -402,4 +498,8 @@
.incorrect-device { .incorrect-device {
color: var(--md-sys-color-error); color: var(--md-sys-color-error);
} }
.esp-buttons {
display: flex;
}
</style> </style>

View File

@@ -1,20 +1,50 @@
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
import type { FileListing, Listing } from "../../listing"; import type { FileListing, Listing } from "../../listing";
import type { VersionMeta } from "./meta";
export const load = (async ({ fetch, params }) => { export const load = (async ({ fetch, params }) => {
const result = await fetch( const result = await fetch(
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/${params.version}/`, `${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/${params.version}/`,
); );
const data: Listing[] = await result.json(); const data: Listing[] = await result.json();
const meta: VersionMeta | undefined = data.some(
(entry) => entry.type === "file" && entry.name === "meta.json",
)
? await fetch(
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/${params.version}/meta.json`,
).then((res) => res.json())
: undefined;
return { return {
uf2: data.find( meta: {
(entry) => entry.type === "file" && entry.name === "CURRENT.UF2", version: meta?.version ?? params.version,
) as FileListing, target: meta?.target ?? params.device,
ota: data.find( path: `${import.meta.env.VITE_FIRMWARE_URL}${params.device}/${params.version}`,
(entry) => entry.type === "file" && entry.name === "firmware.bin", git_commit: meta?.git_commit ?? "",
), git_is_dirty: meta?.git_is_dirty ?? false,
version: params.version, git_date: meta?.git_date ?? data[0]?.mtime ?? "",
device: params.device, public_build: meta?.public_build ?? !params.version.startsWith("."),
development_mode: meta?.development_mode ?? 0,
update: {
uf2:
(data.find(
(entry) =>
entry.type === "file" &&
entry.name === (meta?.update?.uf2 ?? "CURRENT.UF2"),
) as FileListing) ?? undefined,
ota:
data.find(
(entry) =>
entry.type === "file" &&
entry.name === (meta?.update?.ota ?? "firmware.bin"),
) ?? undefined,
esptool: meta?.update?.esptool ?? undefined,
},
files: data.filter(
(entry) =>
entry.type === "file" && (!meta?.files || entry.name in meta.files),
) as FileListing[],
spi_flash: meta?.spi_flash ?? undefined,
},
}; };
}) satisfies PageLoad; }) satisfies PageLoad;

View File

@@ -0,0 +1,41 @@
export interface VersionMeta {
version: string;
target: string;
git_commit: string;
git_is_dirty: boolean;
git_date: string;
public_build: boolean;
development_mode: number;
update: {
ota: string | null;
uf2: string | null;
esptool: EspToolData | null;
};
files: string[];
spi_flash: SPIFlashInfo | null;
}
export interface SPIFlashInfo {
type: string;
size: string;
connection: SPIConnection;
}
export interface SPIConnection {
clk: number;
q: number;
d: number;
hd: number;
cs: number;
}
export interface EspToolData {
chip: string;
baud: string;
before: string;
after: string;
flash_mode: string;
flash_freq: string;
flash_size: string;
files: Record<string, string>;
}

View File

@@ -43,7 +43,7 @@
buildIndex($chords, $osLayout).then(searchIndex.set); buildIndex($chords, $osLayout).then(searchIndex.set);
}); });
function encodeChord(chord: ChordInfo, osLayout: Map<string, string>) { function encodeChord(chord: ChordInfo, osLayout: Map<string, string>, onlyPhrase: boolean = false) {
const plainPhrase: string[] = [""]; const plainPhrase: string[] = [""];
const extraActions: string[] = []; const extraActions: string[] = [];
const extraCodes: string[] = []; const extraCodes: string[] = [];
@@ -103,6 +103,10 @@
return result ?? `0x${it.toString(16)}`; return result ?? `0x${it.toString(16)}`;
}); });
if (onlyPhrase) {
return plainPhrase.join();
}
return [ return [
...plainPhrase, ...plainPhrase,
`+${input.join("+")}`, `+${input.join("+")}`,
@@ -182,7 +186,7 @@
function downloadVocabulary() { function downloadVocabulary() {
const vocabulary = new Set( const vocabulary = new Set(
$chords.map((it) => $chords.map((it) =>
"phrase" in it ? plainPhrase(it.phrase, $osLayout).trim() : "", "phrase" in it ? encodeChord(it, $osLayout, true).trim() : "",
), ),
); );
vocabulary.delete(""); vocabulary.delete("");

View File

@@ -1,12 +1,13 @@
import type { Action } from "svelte/action"; import type { Action } from "svelte/action";
import ConfirmChallenge from "./ConfirmChallenge.svelte"; import ConfirmChallenge from "./ConfirmChallenge.svelte";
import tippy from "tippy.js"; import tippy from "tippy.js";
import { mount, unmount } from "svelte";
export const confirmChallenge: Action< export const confirmChallenge: Action<
HTMLElement, HTMLElement,
{ onConfirm: () => void; challenge: string } { onConfirm: () => void; challenge: string }
> = (node, { onConfirm, challenge }) => { > = (node, { onConfirm, challenge }) => {
let component: ConfirmChallenge | undefined; let component: {} | undefined;
let target: HTMLElement | undefined; let target: HTMLElement | undefined;
const edit = tippy(node, { const edit = tippy(node, {
interactive: true, interactive: true,
@@ -15,15 +16,22 @@ export const confirmChallenge: Action<
target = instance.popper.querySelector(".tippy-content") as HTMLElement; target = instance.popper.querySelector(".tippy-content") as HTMLElement;
target.classList.add("active"); target.classList.add("active");
if (component === undefined) { if (component === undefined) {
component = new ConfirmChallenge({ target, props: { challenge } }); component = mount(ConfirmChallenge, {
component.$on("confirm", () => { target,
edit.hide(); props: {
onConfirm(); challenge,
onconfirm() {
edit.hide();
onConfirm();
},
},
}); });
} }
}, },
onHidden() { onHidden() {
component?.$destroy(); if (component) {
unmount(component);
}
target?.classList.remove("active"); target?.classList.remove("active");
component = undefined; component = undefined;
}, },

View File

@@ -1,231 +1,24 @@
<script lang="ts"> <script lang="ts">
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import {
words,
nextWord,
scores,
learnConfigDefault,
learnConfig,
learnConfigStored,
} from "$lib/learn/chords";
import { blur, fade } from "svelte/transition";
import ChordActionEdit from "../config/chords/ChordActionEdit.svelte";
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import type { InferredChord } from "$lib/charrecorder/core/types";
let recorder = $derived(new ReplayRecorder($nextWord));
let start = performance.now();
$effect(() => {
start = recorder && performance.now();
});
let chords: InferredChord[] = $state([]);
function onkeyboard(event: KeyboardEvent) {
recorder.next(event);
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
$inspect(chords);
$effect(() => {
const [chord] = chords;
if (!chord) return;
console.log(chord);
if (chord.output.trim() === $nextWord) {
scores.update((scores) => {
const score = Math.max(
$learnConfig.minScore,
$learnConfig.maxScore - (performance.now() - start) / 1000,
);
if (!scores[$nextWord]) {
scores[$nextWord] = {
score,
lastTyped: performance.now(),
total: 1,
};
return scores;
}
const oldScore = scores[$nextWord].score;
scores[$nextWord].score = lerp(
score,
oldScore,
$learnConfig.scoreBlend,
);
scores[$nextWord].lastTyped = performance.now();
scores[$nextWord].total += 1;
return scores;
});
}
});
function skip() {
button?.blur();
scores.update((scores) => {
return scores;
});
}
let button = $state<HTMLButtonElement>();
</script> </script>
<h2>WIP</h2> <ul>
<li><a href="/learn/layout/">Layout</a></li>
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} /> <li><a href="/learn/chords/">Chords</a></li>
<li><a href="/learn/sentence/">Sentences</a></li>
{#key $nextWord} </ul>
<h3>
{$nextWord}
{#if $scores[$nextWord!] === undefined}
<sup class="new-word">new</sup>
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
<sup class="weak">weak</sup>
{/if}
</h3>
<div class="chord" in:fade>
<CharRecorder replay={recorder.player} cursor={true}>
<TrackChords bind:chords />
</CharRecorder>
</div>
{/key}
{#key $nextWord}
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
</div>
{/key}
<button onclick={skip} bind:this={button}>skip</button>
<section class="stats">
<table>
<thead>
<tr><th>Weak</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => a.score - b.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Strong</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.score - a.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Rehearse</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<details>
<summary>Settings</summary>
<button onclick={() => ($scores = {})}>Reset</button>
<table>
<tbody>
{#each Object.entries(learnConfigDefault) as [key, value]}
<tr>
<th>{key}</th>
<td
><input
type="number"
value={$learnConfig[key] ?? value}
step="0.1"
oninput={(event) =>
($learnConfigStored[key] = event.target.value)}
/>
</td>
<td>
<button
disabled={!$learnConfigStored[key]}
onclick={() => ($learnConfigStored[key] = undefined)}></button
>
</td>
</tr>
{/each}
</tbody>
</table>
</details>
<style lang="scss"> <style lang="scss">
@use "sass:math"; ul {
margin: 16px;
input {
background: none;
font: inherit;
color: inherit;
border: none;
width: 5ch;
text-align: right;
}
div {
min-width: 20ch;
padding: 1ch;
display: flex; display: flex;
flex-direction: column; gap: 16px;
align-items: center; list-style-type: none;
justify-content: center; padding: 0;
} }
.stats { a {
display: flex; border: 1px solid var(--md-sys-color-outline);
gap: 3em; width: 128px;
} height: 128px;
sup {
font-weight: normal;
font-size: 0.8em;
&.new-word {
color: var(--md-sys-color-primary);
}
&.weak {
color: var(--md-sys-color-error);
}
}
@for $i from 1 through 10 {
tr.decay:nth-child(#{$i}) {
opacity: 1 - math.div($i, 10);
}
} }
</style> </style>

View File

@@ -0,0 +1,231 @@
<script lang="ts">
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import {
words,
nextWord,
scores,
learnConfigDefault,
learnConfig,
learnConfigStored,
} from "$lib/learn/chords";
import { blur, fade } from "svelte/transition";
import ChordActionEdit from "../../config/chords/ChordActionEdit.svelte";
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import type { InferredChord } from "$lib/charrecorder/core/types";
let recorder = $derived(new ReplayRecorder($nextWord));
let start = performance.now();
$effect(() => {
start = recorder && performance.now();
});
let chords: InferredChord[] = $state([]);
function onkeyboard(event: KeyboardEvent) {
recorder.next(event);
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
$inspect(chords);
$effect(() => {
const [chord] = chords;
if (!chord) return;
console.log(chord);
if (chord.output.trim() === $nextWord) {
scores.update((scores) => {
const score = Math.max(
$learnConfig.minScore,
$learnConfig.maxScore - (performance.now() - start) / 1000,
);
if (!scores[$nextWord]) {
scores[$nextWord] = {
score,
lastTyped: performance.now(),
total: 1,
};
return scores;
}
const oldScore = scores[$nextWord].score;
scores[$nextWord].score = lerp(
score,
oldScore,
$learnConfig.scoreBlend,
);
scores[$nextWord].lastTyped = performance.now();
scores[$nextWord].total += 1;
return scores;
});
}
});
function skip() {
button?.blur();
scores.update((scores) => {
return scores;
});
}
let button = $state<HTMLButtonElement>();
</script>
<h2>WIP</h2>
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} />
{#key $nextWord}
<h3>
{$nextWord}
{#if $scores[$nextWord!] === undefined}
<sup class="new-word">new</sup>
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
<sup class="weak">weak</sup>
{/if}
</h3>
<div class="chord" in:fade>
<CharRecorder replay={recorder.player} cursor={true}>
<TrackChords bind:chords />
</CharRecorder>
</div>
{/key}
{#key $nextWord}
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
</div>
{/key}
<button onclick={skip} bind:this={button}>skip</button>
<section class="stats">
<table>
<thead>
<tr><th>Weak</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => a.score - b.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Strong</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.score - a.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Rehearse</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<details>
<summary>Settings</summary>
<button onclick={() => ($scores = {})}>Reset</button>
<table>
<tbody>
{#each Object.entries(learnConfigDefault) as [key, value]}
<tr>
<th>{key}</th>
<td
><input
type="number"
value={$learnConfig[key] ?? value}
step="0.1"
oninput={(event) =>
($learnConfigStored[key] = event.target.value)}
/>
</td>
<td>
<button
disabled={!$learnConfigStored[key]}
onclick={() => ($learnConfigStored[key] = undefined)}></button
>
</td>
</tr>
{/each}
</tbody>
</table>
</details>
<style lang="scss">
@use "sass:math";
input {
background: none;
font: inherit;
color: inherit;
border: none;
width: 5ch;
text-align: right;
}
div {
min-width: 20ch;
padding: 1ch;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.stats {
display: flex;
gap: 3em;
}
sup {
font-weight: normal;
font-size: 0.8em;
&.new-word {
color: var(--md-sys-color-primary);
}
&.weak {
color: var(--md-sys-color-error);
}
}
@for $i from 1 through 10 {
tr.decay:nth-child(#{$i}) {
opacity: 1 - math.div($i, 10);
}
}
</style>

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import { share } from "$lib/share";
import tippy from "tippy.js";
import { mount, setContext, unmount } from "svelte";
import Layout from "$lib/components/layout/Layout.svelte";
import { charaFileToUriComponent } from "$lib/share/share-url";
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
import { writable, derived } from "svelte/store";
import { layout } from "$lib/undo-redo";
import Action from "$lib/components/Action.svelte";
import { serialPort } from "$lib/serial/connection";
let hasStarted = $state(false);
setContext<VisualLayoutConfig>("visual-layout-config", {
scale: 50,
inactiveScale: 0.5,
inactiveOpacity: 0.4,
strokeWidth: 1,
margin: 5,
fontSize: 9,
iconFontSize: 14,
});
const actions = derived(layout, (layout) => {
const result = new Set<number>();
for (const layer of layout) {
for (const key of layer) {
result.add(key.action);
}
}
return [...result];
});
const currentAction = writable(0);
const expected = derived(
[layout, currentAction],
([layout, currentAction]) => {
const result: Array<{ layer: number; key: number }> = [];
for (let layer = 0; layer <= layout.length; layer++) {
if (layout[layer] === undefined) {
continue;
}
for (let key = 0; key <= layout[layer].length; key++) {
if (layout[layer][key]?.action === currentAction) {
result.push({ layer, key });
}
}
}
return result;
},
);
const highlight = derived(
expected,
(expected) => new Set(expected.map(({ key }) => key)),
);
const highlightAction = derived(
currentAction,
(currentAction) => new Set([currentAction]),
);
const currentLayer = writable(0);
setContext("highlight", highlight);
setContext("highlight-action", highlightAction);
setContext("active-layer", currentLayer);
async function next() {
console.log("Next");
const nextAction = $actions[Math.floor(Math.random() * $actions.length)];
if (nextAction !== undefined) {
currentAction.set(nextAction);
currentLayer.set($expected[0]?.layer ?? 0);
const key = await $serialPort?.queryKey();
if ($expected.some(({ key: expectedKey }) => expectedKey === key)) {
console.log("Correct", key);
} else {
console.log("Incorrect", key);
}
next();
}
}
$effect(() => {
if ($serialPort && $layout[0]?.[0] && !hasStarted) {
hasStarted = true;
next();
}
});
</script>
<section>
<div class="challenge">
<Action display="inline-keys" action={$currentAction}></Action>
</div>
<Layout />
</section>
<style lang="scss">
.challenge {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100px;
font-size: 24px;
}
.input {
width: 100%;
height: 100px;
border: 1px solid black;
}
section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,580 @@
<script lang="ts">
import { page } from "$app/stores";
import { SvelteMap } from "svelte/reactivity";
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import { shuffleInPlace } from "$lib/util/shuffle";
import { fade, fly, slide } from "svelte/transition";
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import ChordHud from "$lib/charrecorder/ChordHud.svelte";
import type { InferredChord } from "$lib/charrecorder/core/types";
import { onMount } from "svelte";
import TrackText from "$lib/charrecorder/TrackText.svelte";
import { browser } from "$app/environment";
import { expoIn, expoOut } from "svelte/easing";
function viaLocalStorage<T>(key: string, initial: T) {
try {
return JSON.parse(localStorage.getItem(key) ?? "");
} catch {
return initial;
}
}
let masteryThresholds: [slow: number, fast: number, title: string][] = $state(
viaLocalStorage("mastery-thresholds", [
[1500, 1050, "Words"],
[3000, 2500, "Pairs"],
[5000, 3500, "Trios"],
]),
);
const avgWordLength = 5;
function reset() {
localStorage.removeItem("mastery-thresholds");
localStorage.removeItem("idle-timeout");
window.location.reload();
}
let inputSentence = $derived(
(browser && $page.url.searchParams.get("sentence")) || "Hello World",
);
let wpmTarget = $derived(
(browser && Number($page.url.searchParams.get("wpm"))) || 250,
);
let devTools = $derived(
browser && $page.url.searchParams.get("dev") === "true",
);
let sentenceWords = $derived(inputSentence.split(" "));
let msPerChar = $derived((1 / ((wpmTarget / 60) * avgWordLength)) * 1000);
let totalMs = $derived(inputSentence.length * msPerChar);
let msPerWord = $derived(
(inputSentence.length * msPerChar) / inputSentence.split(" ").length,
);
let currentWord = $state("");
let wordStats = new SvelteMap<string, number[]>();
let wordMastery = new SvelteMap<string, number>();
let text = $state("");
let level = $state(0);
let bestWPM = $state(0);
let wpm = $state(0);
let chords: InferredChord[] = $state([]);
let recorder = $state(new ReplayRecorder());
let idle = $state(true);
let idleTime = $state(viaLocalStorage("idle-timeout", 100));
let idleTimeout: ReturnType<typeof setTimeout> | null = null;
let cooldown = $state(false);
onMount(() => {
selectNextWord();
});
$effect(() => {
if (wpm > bestWPM) {
bestWPM = wpm;
}
});
$effect(() => {
localStorage.setItem("idle-timeout", idleTime.toString());
});
$effect(() => {
localStorage.setItem(
"mastery-thresholds",
JSON.stringify(masteryThresholds),
);
});
let words = $derived.by(() => {
const words = inputSentence.trim().split(" ");
switch (level) {
case 0: {
shuffleInPlace(words);
return words;
}
case 1: {
const pairs = [];
for (let i = 0; i < words.length - 1; i++) {
pairs.push(`${words[i]} ${words[i + 1]}`);
}
shuffleInPlace(pairs);
return pairs;
}
case 2: {
const trios = [];
for (let i = 0; i < words.length - 2; i++) {
trios.push(`${words[i]} ${words[i + 1]} ${words[i + 2]}`);
}
shuffleInPlace(trios);
return trios;
}
default: {
return [inputSentence];
}
}
});
$effect(() => {
for (const [word, speeds] of wordStats.entries()) {
const level = word.split(" ").length - 1;
const masteryThreshold = masteryThresholds[level];
if (masteryThreshold === undefined) continue;
const averageSpeed = speeds.reduce((a, b) => a + b) / speeds.length;
wordMastery.set(
word,
1 -
Math.min(
1,
Math.max(
0,
(averageSpeed - masteryThreshold[1]) /
(masteryThreshold[0] - masteryThreshold[1]),
),
),
);
}
});
let progress = $derived(
level === masteryThresholds.length
? Math.min(1, Math.max(0, bestWPM / wpmTarget))
: words.length > 0
? words.reduce((a, word) => a + (wordMastery.get(word) ?? 0), 0) /
words.length
: 0,
);
let mastered = $derived(
words.length > 0
? words.filter((it) => wordMastery.get(it) === 1).length / words.length
: 0,
);
$effect(() => {
if (progress === 1 && level < masteryThresholds.length) {
level++;
}
});
function selectNextWord() {
const unmasteredWords = words
.map((it) => [it, wordMastery.get(it) ?? 0] as const)
.filter(([, it]) => it !== 1);
unmasteredWords.sort(([, a], [, b]) => a - b);
let nextWord = unmasteredWords[0]?.[0] ?? words[0] ?? "ERROR";
for (const [word] of unmasteredWords) {
if (word === currentWord || Math.random() > 0.5) continue;
nextWord = word;
break;
}
currentWord = nextWord;
recorder = new ReplayRecorder(nextWord);
}
function checkInput() {
if (recorder.player.stepper.challenge.length === 0) return;
const replay = recorder.finish(false);
const elapsed = replay.finish - replay.start! - idleTime;
if (elapsed < masteryThresholds[level]![0]) {
const prevStats = wordStats.get(currentWord) ?? [];
prevStats.push(elapsed);
wordStats.set(currentWord, prevStats.slice(-10));
}
text = "";
setTimeout(() => {
selectNextWord();
});
}
$effect(() => {
if (!idle || !text) return;
if (text.trim() !== currentWord.trim()) return;
if (level === masteryThresholds.length) {
const replay = recorder.finish();
const elapsed = replay.finish - replay.start!;
text = "";
recorder = new ReplayRecorder(currentWord);
console.log(elapsed, totalMs);
wpm = (totalMs / elapsed) * wpmTarget;
} else {
checkInput();
}
});
function onkey(event: KeyboardEvent) {
if (idleTimeout) {
clearTimeout(idleTimeout);
}
idle = false;
recorder.next(event);
idleTimeout = setTimeout(() => {
idle = true;
}, idleTime);
}
</script>
<div>
<h1>Sentence Trainer</h1>
<div class="levels">
{#each masteryThresholds as [, , title], i}
<button
class:active={level === i}
class:mastered={i < level || progress === 1}
class="threshold"
onclick={() => {
level = i;
selectNextWord();
}}
>
{title}
</button>
{/each}
<button
class:active={level === masteryThresholds.length}
class:mastered={masteryThresholds.length < level || progress === 1}
class="threshold"
onclick={() => {
level = masteryThresholds.length;
selectNextWord();
}}
>
{wpmTarget} WPM
</button>
{#each masteryThresholds as _, i}
<div
class="progress"
style:--progress="{-100 *
(1 - (level === i ? progress : i < level ? 1 : 0))}%"
style:--mastered="{-100 *
(1 - (level === i ? mastered : i < level ? 1 : 0))}%"
class:active={level === i}
></div>
{/each}
<div
class="progress"
style:--progress="-100%"
style:--mastered="{-100 *
(1 -
(level === masteryThresholds.length
? progress
: masteryThresholds.length < level
? 1
: 0))}%"
class:active={level === masteryThresholds.length}
></div>
</div>
<div class="sentence">
{#each sentenceWords as _, i}
{#if i !== sentenceWords.length - 1}
{@const word = sentenceWords.slice(i, i + 2).join(" ")}
{@const mastery = wordMastery.get(word) ?? 0}
<div
class="arch"
class:mastered={mastery === 1}
style:opacity={mastery}
style:grid-row={(i % 2) + 1}
style:grid-column="{i + 1} / span 2"
style:border-bottom="none"
></div>
{/if}
{/each}
{#each sentenceWords as word, i}
{@const mastery = wordMastery.get(word)}
<div
class="word"
class:mastered={mastery === 1}
style:opacity={mastery ?? 0}
style:grid-row={3}
style:grid-column={i + 1}
>
{word}
</div>
{/each}
{#each sentenceWords as _, i}
{#if i < sentenceWords.length - 2}
{@const word = sentenceWords.slice(i, i + 3).join(" ")}
{@const mastery = wordMastery.get(word) ?? 0}
<div
class="arch"
class:mastered={mastery === 1}
style:opacity={mastery}
style:grid-row={(i % 3) + 4}
style:grid-column="{i + 1} / span 3"
style:border-top="none"
></div>
{/if}
{/each}
</div>
{#if level === masteryThresholds.length}
{@const maxDigits = 4}
{@const indices = Array.from({ length: maxDigits }, (_, i) => i)}
{@const wpmString = Math.floor(bestWPM).toString().padStart(maxDigits, " ")}
<div class="finish" transition:slide>
<div
class="wpm"
style:grid-template-columns="repeat({maxDigits}, 1ch) 1ch auto"
style:opacity={progress}
style:font-size="3rem"
style:color="var(--md-sys-color-{progress === 1
? 'primary'
: 'on-background'})"
style:scale={(progress + 0.5) / 2}
>
{#each indices as i}
{@const char = wpmString[i]}
{#key char}
<div
style:grid-column={i + 1}
in:fly={{ y: 20, duration: 1000, easing: expoOut }}
out:fly={{ y: -20, duration: 1000, easing: expoOut }}
>
{char}
</div>
{/key}
{/each}
<div style:grid-column={maxDigits + 3} style:justify-self="start">
WPM
</div>
</div>
<div
class="wpm"
style:grid-template-columns="4ch 1ch auto"
style:font-size="1.5rem"
>
{#key wpm}
<div
style:grid-column={1}
style:justify-self="end"
transition:fade={{ duration: 200 }}
>
{Math.floor(wpm)}
</div>
{/key}
<div style:grid-column={3} style:justify-self="start">WPM</div>
</div>
</div>
{/if}
<ChordHud {chords} />
<div class="container">
<div
class="input-section"
onkeydown={onkey}
onkeyup={onkey}
tabindex="0"
role="textbox"
>
{#key recorder}
<div class="input" transition:fade={{ duration: 200 }}>
<CharRecorder replay={recorder.player} cursor={true} keys={true}>
<TrackText bind:text />
<TrackChords bind:chords />
</CharRecorder>
</div>
{/key}
</div>
</div>
{#if devTools}
<div>Dev Tools</div>
<button onclick={reset}>Reset</button>
<label>Idle Time <input bind:value={idleTime} /></label>
<table>
<tbody>
<tr>
<th>Total</th>
<td
><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(totalMs)}</span
>ms</td
>
</tr>
<tr>
<th>Char</th>
<td
><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(msPerChar)}</span
>ms</td
>
</tr>
<tr>
<th>Word</th>
<td
><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(msPerWord)}</span
>ms</td
>
</tr>
</tbody>
</table>
<table>
<tbody>
{#each masteryThresholds as _, i}
<tr>
<th>L{i + 1}</th>
<td><input bind:value={masteryThresholds[i]![0]} /></td>
<td><input bind:value={masteryThresholds[i]![1]} /></td>
<td><input bind:value={masteryThresholds[i]![2]} /></td>
</tr>
{/each}
</tbody>
</table>
<table>
<tbody>
{#each wordStats.entries() as [word, stats]}
{@const mastery = wordMastery.get(word) ?? 0}
<tr>
<th>{word}</th>
<td
style:color="var(--md-sys-color-{mastery === 1
? 'primary'
: 'tertiary'})">{Math.round(mastery * 100)}%</td
>
{#each stats as stat}
<td>{stat}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<style lang="scss">
.levels {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2px;
button {
margin: 0;
font-size: 1rem;
}
}
.wpm {
width: min-content;
display: grid;
transition: scale 0.2s ease;
* {
grid-row: 1;
}
}
.finish {
display: grid;
grid-template-rows: repeat(2, 1fr);
font-weight: bold;
justify-items: center;
align-items: center;
}
.sentence {
display: grid;
width: min-content;
gap: 4px 1ch;
grid-template-rows: repeat(4, auto);
margin-block: 1rem;
.word,
.arch {
transition: opacity 0.2s ease;
&.mastered {
color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
}
.arch {
border: 2px solid var(--md-sys-color-outline);
height: 8px;
}
}
.progress {
position: relative;
height: 1rem;
width: auto;
background: var(--md-sys-color-outline-variant);
border: none;
overflow: hidden;
grid-row: 2;
&::before,
&::after {
position: absolute;
content: "";
display: block;
height: 100%;
width: 100%;
transition: transform 0.2s;
}
&::before {
background: var(--md-sys-color-outline);
transform: translateX(var(--progress));
}
&::after {
background: var(--md-sys-color-primary);
transform: translateX(var(--mastered));
}
}
.threshold {
width: auto;
justify-self: center;
opacity: 0.5;
transition: opacity 0.2s;
grid-row: 1;
&.mastered,
&.active {
opacity: 1;
}
&.mastered {
color: var(--md-sys-color-primary);
}
}
.input-section {
display: grid;
cursor: text;
:global(.cursor) {
opacity: 0;
}
}
.input {
display: flex;
grid-row: 1;
grid-column: 1;
font-size: 1.5rem;
padding: 1rem;
max-width: 16cm;
outline: 2px dashed transparent;
border-radius: 0.25rem;
margin-block: 1rem;
transition:
outline 0.2s ease,
border-radius 0.2s ease;
}
.input-section:focus-within {
outline: none;
.input {
outline-color: var(--md-sys-color-primary);
border-radius: 1rem;
}
:global(.cursor) {
opacity: 1;
}
}
</style>

View File

@@ -58,6 +58,7 @@ export default defineConfig({
"client/**/*.{js,css,ico,woff2,csv,png,webp,svg,webmanifest}", "client/**/*.{js,css,ico,woff2,csv,png,webp,svg,webmanifest}",
"prerendered/**/*.html", "prerendered/**/*.html",
], ],
globIgnores: ["prerendered/pages/ccos/**/*"],
ignoreURLParametersMatching: [/^import|redirectUrl|loginToken$/], ignoreURLParametersMatching: [/^import|redirectUrl|loginToken$/],
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
}, },