3 Commits

Author SHA1 Message Date
048dee0a6d fix: can't select empty chord outputs
update dependencies
use new title popover
2025-07-29 20:18:13 +02:00
977bdf3043 fix: revamp reset popup 2025-07-29 18:59:11 +02:00
9ca30f412e feat: update compound calculation
feat: add "will my compound break" page
2025-07-29 16:40:54 +02:00
14 changed files with 1007 additions and 755 deletions

View File

@@ -36,61 +36,61 @@
"devDependencies": {
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/language": "^6.11.0",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/language": "^6.11.2",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.5",
"@fontsource-variable/material-symbols-rounded": "^5.2.8",
"@fontsource-variable/noto-sans-mono": "^5.2.6",
"@codemirror/view": "^6.38.1",
"@fontsource-variable/material-symbols-rounded": "^5.2.17",
"@fontsource-variable/noto-sans-mono": "^5.2.7",
"@lezer/highlight": "^1.2.1",
"@material/material-color-utilities": "^0.3.0",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.6",
"@modyfi/vite-plugin-yaml": "^1.1.1",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.20.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@sveltejs/kit": "^2.26.1",
"@sveltejs/vite-plugin-svelte": "^6.1.0",
"@tauri-apps/api": "^1.6.0",
"@tauri-apps/cli": "^1.6.0",
"@types/dom-view-transitions": "^1.0.6",
"@types/semver": "^7.7.0",
"@types/w3c-web-serial": "^1.0.8",
"@types/w3c-web-usb": "^1.0.10",
"@types/wicg-file-system-access": "^2023.10.5",
"@types/wicg-file-system-access": "^2023.10.6",
"@vite-pwa/sveltekit": "^1.0.0",
"autoprefixer": "^10.4.21",
"codemirror": "^6.0.1",
"cypress": "^14.2.1",
"codemirror": "^6.0.2",
"cypress": "^14.5.3",
"d3": "^7.9.0",
"esptool-js": "^0.5.4",
"flexsearch": "^0.8.147",
"esptool-js": "^0.5.6",
"flexsearch": "^0.8.205",
"fontkit": "^2.0.4",
"glob": "^11.0.1",
"jsdom": "^26.0.0",
"matrix-js-sdk": "^37.2.0",
"glob": "^11.0.3",
"jsdom": "^26.1.0",
"matrix-js-sdk": "^37.12.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"rxjs": "^7.8.2",
"sass": "^1.86.0",
"sass": "^1.89.2",
"semver": "^7.7.2",
"socket.io-client": "^4.8.1",
"stylelint": "^16.17.0",
"stylelint": "^16.23.0",
"stylelint-config-clean-order": "^7.0.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-recommended-scss": "^14.1.0",
"stylelint-config-standard-scss": "^14.0.0",
"svelte": "5.25.3",
"svelte-check": "^4.1.5",
"stylelint-config-recommended-scss": "^15.0.1",
"stylelint-config-standard-scss": "^15.0.1",
"svelte": "5.37.1",
"svelte-check": "^4.3.0",
"svelte-preprocess": "^6.0.3",
"tippy.js": "^6.3.7",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.8.2",
"vite": "^6.2.4",
"typescript": "^5.8.3",
"vite": "^7.0.6",
"vite-plugin-mkcert": "^1.17.8",
"vite-plugin-pwa": "^1.0.0",
"vitest": "^3.1.1",
"vite-plugin-pwa": "^1.0.2",
"vitest": "^3.2.4",
"web-serial-polyfill": "^1.0.15",
"workbox-window": "^7.3.0"
},

1323
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import type { KeyInfo } from "$lib/serial/keymap-codes";
import { action as title } from "$lib/title";
import { osLayout } from "$lib/os-layout";
import { tooltip } from "$lib/hover-popover";
let {
action,
@@ -16,42 +16,49 @@
);
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
let tooltip = $derived(
`&lt;${info.id ?? `0x${info.code.toString(16)}`}&gt; ` +
(info.title ?? "") +
(info.variant === "left"
? " (left)"
: info.variant === "right"
? " (right)"
: ""),
);
let popover: HTMLElement | undefined = $state(undefined);
</script>
{#snippet popoverSnippet()}
<div bind:this={popover} popover="hint">
&lt;{info.id ?? `0x${info.code.toString(16)}`}&gt;
{#if info.title}
{info.title}
{/if}
{#if info.variant === "left"}
(Left)
{:else if info.variant === "right"}
(Right)
{/if}
</div>
{/snippet}
{#if display === "keys"}
<kbd
class:icon={!!info.icon}
class:left={info.variant === "left"}
class:right={info.variant === "right"}
use:title={{ title: tooltip }}
{@attach tooltip(popover)}
>
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}
{@render popoverSnippet()}
</kbd>
{:else if display === "inline-keys"}
{#if !info.icon && dynamicMapping?.length === 1}
<span
use:title={{ title: tooltip }}
{@attach tooltip(popover)}
class:left={info.variant === "left"}
class:right={info.variant === "right"}>{dynamicMapping}</span
class:right={info.variant === "right"}>{dynamicMapping}{@render popoverSnippet()}</span
>
{:else if !info.icon && info.id?.length === 1}
<span
use:title={{ title: tooltip }}
{@attach tooltip(popover)}
class:left={info.variant === "left"}
class:right={info.variant === "right"}>{info.id}</span
class:right={info.variant === "right"}>{info.id}{@render popoverSnippet()}</span
>
{:else}
<kbd
@@ -59,13 +66,13 @@
class:left={info.variant === "left"}
class:right={info.variant === "right"}
class:icon={!!info.icon}
use:title={{ title: tooltip }}
{@attach tooltip(popover)}
>
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}</kbd
`0x${info.code.toString(16)}`}{@render popoverSnippet()}</kbd
>
{/if}
{/if}

39
src/lib/hover-popover.ts Normal file
View File

@@ -0,0 +1,39 @@
import type { Attachment } from "svelte/attachments";
export const hotkeys = new Map<string, HTMLElement>();
export function tooltip(
target: HTMLElement | undefined,
shortcut?: string,
): Attachment<HTMLElement> {
return (node: HTMLElement) => {
function show() {
if (!target) return;
target.showPopover({ source: node });
}
function hide() {
if (!target) return;
target.hidePopover();
}
node.addEventListener("mouseenter", show);
node.addEventListener("focus", show);
node.addEventListener("mouseout", hide);
node.addEventListener("blur", hide);
if (shortcut && node instanceof HTMLElement) {
hotkeys.set(shortcut, node);
}
return () => {
node.removeEventListener("mouseenter", show);
node.removeEventListener("focus", show);
node.removeEventListener("mouseout", hide);
node.removeEventListener("blur", hide);
if (shortcut && node instanceof HTMLElement) {
hotkeys.delete(shortcut);
}
};
};
}

View File

@@ -69,5 +69,8 @@ export function hashChord(actions: number[]) {
for (let i = 0; i < 16; i++) {
hash = Math.imul(hash ^ view.getUint8(i), 16777619);
}
if ((hash & 0xff) === 0xff) {
hash ^= 0xff;
}
return hash & 0x3fff_ffff;
}

View File

@@ -0,0 +1,67 @@
$animation-duration: 150ms;
$translate: translateY(8px);
[popover] {
position: absolute;
inset: unset;
margin: 0;
padding: 8px;
border: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
font-family: "Noto Sans Mono", monospace;
font-size: initial;
font-weight: initial;
color: var(--md-sys-color-on-surface);
opacity: 0;
background: var(--md-sys-color-surface);
transition:
transform $animation-duration ease,
opacity $animation-duration linear,
overlay $animation-duration allow-discrete,
display $animation-duration allow-discrete;
position-area: bottom span-all;
position-try-fallbacks:
top span-all,
bottom span-right,
top span-right,
bottom span-left,
top span-left;
position-visibility: no-overflow;
&:popover-open {
transform: translateY(0);
opacity: 1;
}
> h1:first-child,
h2:first-child,
h3:first-child {
margin-top: 0;
text-align: center;
}
}
[popover="auto"] {
transform: $translate;
}
[popover="hint"] {
margin-top: 0.5rem;
font-size: 0.9rem;
}
@starting-style {
[popover]:popover-open {
opacity: 0;
}
[popover="auto"] {
transform: $translate;
}
}

View File

@@ -8,6 +8,7 @@
@use "print";
@use "elements/h1";
@use "elements/popover";
body {
overflow: hidden;

View File

@@ -5,6 +5,9 @@ import Tooltip from "$lib/components/Tooltip.svelte";
export const hotkeys = new Map<string, HTMLElement>();
/**
* @deprecated Use `tooltip` instead.
*/
export const action: Action<Element, { title?: string; shortcut?: string }> = (
node: Element,
{ title, shortcut },

View File

@@ -183,7 +183,13 @@
);
</script>
<div class="wrapper" class:edited={!chord.deleted && chord.phraseChanged}>
<div
class="wrapper"
class:edited={!chord.deleted && chord.phraseChanged}
onclick={() => {
box.focus();
}}
>
{#if supportsAutospace}
<label
class="auto-space-edit"

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import { serializeActions } from "$lib/serial/chord";
import { chords } from "$lib/undo-redo";
import ChordEdit from "../ChordEdit.svelte";
export function hashChord(actions: number[]) {
const chord = new Uint8Array(16);
const view = new DataView(chord.buffer);
const serialized = serializeActions(actions);
view.setBigUint64(0, serialized & 0xffff_ffff_ffff_ffffn, true);
view.setBigUint64(8, serialized >> 64n, true);
let hash = 2166136261;
for (let i = 0; i < 16; i++) {
hash = Math.imul(hash ^ view.getUint8(i), 16777619);
}
return hash & 0x3fff_ffff;
}
const broken = $derived(
$chords.filter((it) => (hashChord(it.actions) & 0xff) === 0xff),
);
</script>
<h1>Will my compound break</h1>
<p>
Pre-2.2.0 there was a bug where creating a compound with specific chords as a
base could corrupt your library.
</p>
{#if broken.length > 0}
<p class="warning">Chords have been detected.</p>
<p>
If you have ever tried to create a compound chord with <b class="warning"
>any of these as a base</b
>, your library might have been corrupted.
</p>
{#each broken as chord}
<ChordEdit {chord} onduplicate={() => {}} />
{/each}
{:else}
<p>No problematic chords found</p>
{/if}
<style lang="scss">
.warning {
color: var(--md-sys-color-error);
font-weight: bold;
}
.chord {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.compound {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
p {
max-width: 600px;
}
</style>

View File

@@ -161,7 +161,10 @@
><span class="icon">reset_settings</span>Reset Settings</button
>
{/if}
<button use:popup={ResetPopup}>Recovery...</button>
<button popovertarget="reset-device" popovertargetaction="toggle"
>Recovery...</button
>
<div id="reset-device" popover="auto"><ResetPopup /></div>
{/if}
</div>
</div>

View File

@@ -1,43 +0,0 @@
<script lang="ts">
import { serialPort } from "$lib/serial/connection";
let { challenge, onconfirm }: { challenge: string; onconfirm: () => void } =
$props();
let challengeInput = $state("");
let challengeString = $derived(`${challenge} ${$serialPort!.device}`);
let isValid = $derived(challengeInput === challengeString);
</script>
<h3>Type the following to confirm the action</h3>
<p>{challengeString}</p>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
bind:value={challengeInput}
placeholder={challengeString}
/>
<button disabled={!isValid} onclick={onconfirm}>Confirm {challenge}</button>
<style lang="scss">
input[type="text"] {
color: inherit;
font-family: inherit;
background: none;
border: none;
border-bottom: 1px solid currentcolor;
width: 100%;
&:focus {
outline: none;
border-color: var(--md-sys-color-secondary);
}
}
button {
color: var(--md-sys-color-error);
}
</style>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { confirmChallenge } from "./confirm-challenge";
import { serialPort } from "$lib/serial/connection";
const options = [
@@ -17,21 +16,27 @@
</script>
<h3>Reset Device</h3>
<p>Resetting might take <b>up to 2 Minutes</b>.</p>
{#each options as category, i}
{#if i > 0}
<hr />
{/if}
{#each category as [command, description]}
<button
class="error"
use:confirmChallenge={{
onConfirm() {
$serialPort?.reset(command);
$serialPort = undefined;
},
challenge: description,
}}>{description}...</button
<form
onsubmit={(event) => {
event.preventDefault();
$serialPort?.reset(command);
$serialPort = undefined;
}}
>
<input
type="text"
placeholder={description}
required
pattern="^{description}$"
/>
<button class="icon" type="submit">send</button>
</form>
{/each}
{/each}
@@ -39,4 +44,43 @@
hr {
opacity: 0.25;
}
p {
width: 22ch;
}
button.icon {
font-size: 20px;
opacity: 0.5;
}
form {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
input[type="text"] {
color: inherit;
font-family: inherit;
background: none;
border: none;
border-bottom: 1px solid transparent;
width: fit-content;
&:focus {
outline: none;
border-color: var(--md-sys-color-outline);
}
}
input[type="text"]:valid {
color: var(--md-sys-color-error);
& + button {
color: var(--md-sys-color-error);
opacity: 1;
}
}
</style>

View File

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