Compare commits

..

13 Commits

Author SHA1 Message Date
e65591431c Stop device:launch crying wolf on release builds
A release APK is not DEBUGGABLE, so Capacitor never enables web-contents
debugging and the devtools socket is never opened. page_loaded waited for that
socket regardless, so it reported "renderer denied" three times for a healthy
app, force-stopping and restarting it on each retry. It now branches: debug
builds still wait for the page to parse; release builds check the process
survived with no denial against our package, and say the page load is assumed
rather than claiming a verification that is not possible over adb.

The failure diagnostic was unfiltered and unbounded, so it printed other apps'
denials from minutes earlier. It is now scoped to our package and to the
current launch.

Also fixes keep-awake, which still ran the "usb" form that CLAUDE.md had
already recorded as ineffective on this AC-charging phone, and corrects the
documented value: "true" measures as 15 here, not 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:56:59 +03:00
1d2cbda50d Ship a signed release APK
The release build type reuses the debug signing config, so a downloaded APK
installs over an existing one instead of demanding an uninstall. This never
goes to Play, which rejects debug keys anyway, so there is no private key
worth safeguarding for years and no way to strand anyone on a version that
can no longer be updated.

That choice is load-bearing in one direction: a release build is not
DEBUGGABLE, so adb shell run-as stops reading app data back out, and there is
no in-app restore. A signature change would have cost the drill progress.

npm run release stages release/404-Layers-<version>.apk with a checksum and
verifies the signature, since an unsigned APK builds fine and only fails at
install time, on someone else's phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:51:01 +03:00
09feb899d6 Drop the Design section from the README
It described the visual language rather than telling a reader anything
they need in order to build, run or understand the app. The fonts it
mentioned are still covered by public/fonts/README.md and the licence
section, so nothing is orphaned by removing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:35:36 +03:00
c919e160ee Add an MIT licence and a README for people who are not me
LICENSE is MIT, copyright Georgi Durankiev.

The README is written for a stranger: what the device hides behind
modifier combos and why that needs an app at all, what each of the four
tabs does, how the Leitner drill works, how to build and install it, and
a map of the source with the domain and UI split explained.

It also records the four things someone reading the code would otherwise
have to discover: that the combo table is generated from reference/ and
must never be typed by hand, that Entry.id and STORAGE_KEY are
load-bearing for migrating progress off the old version, that
touch-action:none and window-level pointerup are what make chorded
answers possible, and that the domain is pure specifically so it can be
tested - which is how a real bug in the weighted card pick surfaced.

Screenshots show Explore with nothing held beside the same screen
holding SHIFT: three functions becoming thirty-five, pads turning from
numerals into FIX VEL, 16 VEL, CUE and CHROMA. That pair explains the
app faster than the prose does. Captured headlessly at the phone's real
360x735, which needed the held layer temporarily patched in source and
restored afterwards.

Notes the data comes from the SP-404MK2 reference manual v5.50 and that
this is unofficial and unaffiliated with Roland, and points at the
bundled OFL font licences.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:32:48 +03:00
2b28f27e79 Close the keystore gitignore trap, and ship the font licences
.gitignore had "*.keystore" followed by "!debug.keystore", which
deliberately un-ignored a signing key. Nothing leaked - no keystore has
ever been in the tree - but had one landed there it would have been
committed silently. The negation is gone and *.jks is covered too.

public/fonts/ redistributes Space Grotesk and Space Mono, and the SIL
Open Font License requires its text to accompany the fonts. Both are
included, fetched from each typeface's own repository rather than
written from memory, and kept as separate files because the copyright
lines differ: Space Grotesk 2020, Space Mono 2016.

Added a README there recording which file is which family, why only the
latin subsets are shipped, and the two glyphs that fall outside them and
so render from a system fallback - which is why the pickers draw their
caret as SVG instead of typing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:27:48 +03:00
283c5d8d0a Rename to 404 Layers, and give it a real icon
The name was SP-404MK2 layers, which is 16 characters and truncates in
the launcher. It is set in five places, one of which cap sync
regenerates from appName, so all five move together: capacitor.config,
strings.xml, index.html, package.json and CLAUDE.md. reference/ keeps
the old name because it is frozen. appId is untouched - changing it
would make this a different app to Android, meaning a fresh install and
the drill progress on the phone orphaned.

The icon is a 3x3 pad grid with the centre pad lit: the app's own
subject, legible when a launcher masks it to a circle, and in the
settled palette. Three columns rather than the hardware's four so the
cells survive 48px, which I checked at 800% magnification.

scripts/make-icon.mjs draws it with ImageMagick primitives, because
there is no SVG rasteriser here - no Inkscape, no rsvg-convert, and no
SVG delegate in ImageMagick 6. It is in the repo because the icon would
otherwise be 15 binary PNGs with no record of how they were made.

Two things that took a few attempts. The draw commands have to be built
as a real array: three tries in bash collapsed all nine into one
argument, which ImageMagick read as a filename. And the convert calls
need -strip, because without it the pixels are identical between runs
but the bytes are not, so the PNGs churned and the script could not be
shown to be their source. Two consecutive runs are now byte-identical.

Also removed two orphaned Capacitor vectors, 204 lines of gradient
swoosh and grid pattern that nothing referenced once the adaptive icon
pointed at @mipmap and @color.

Icons total 52KB. Built and installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:22:09 +03:00
6ddf6f9a77 Show the gesture, and offer the full text when the card clips
Two faults behind one report. The card clipped long descriptions, and it
never rendered the how field at all - so for SHIFT + REMAIN, where the
instruction IS a three-second hold, the most important part was missing
rather than merely shortened.

Measured rather than assumed: that entry is Live mode, how is "Hold
SHIFT, hold REMAIN 3 s", desc is 112 characters, and the pair renders
43px tall into a 29px two-line clamp. A full line was being lost.

The gesture now leads the card text in the accent colour, and a MORE
button opens a sheet with the combo, the gesture, the full description,
the modes it applies in and the manual page. Whether to offer MORE is
decided by measuring scrollHeight against clientHeight, not by guessing
a character count, so it appears exactly when text is really cut. 39 of
159 entries qualify: 24 carry a gesture, 17 have a description over 110
characters.

The card stays 84px because the panel needs that space, so a sheet is
the right place for the overflow, as in the reference.

141 tests, typecheck clean, installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:10:41 +03:00
6889167fcf Fix lit pads not highlighting; stop swallowing haptics errors
A lit pad kept its grey text and only its border turned orange, so pads
looked unhighlighted next to every other key. Cause was cascade order,
not logic: .pad is a single-class selector like .lit, so equal
specificity meant whichever came last won, and .pad was declared after.
The state classes now sit at the bottom of the file with a comment
saying why, and a lit pad computes rgb(255,74,0) for text and border.

native.ts reported nothing when haptics failed because I wrote
catch(() => {}) and discarded the reason. It now reports once per
distinct failure - enough to read in logcat, quiet enough not to spam on
every key press.

That reporting shows the software path is fine: isNativePlatform true,
Haptics available and bridged, VIBRATE granted, and both impact and
vibrate resolve without rejecting. So the silence is below the bridge -
most likely the phone's own touch-vibration setting - and not something
this code can detect.

141 tests, typecheck clean, installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:05:01 +03:00
d0f872fc8b Record two device lessons that cost real time
adb shell input tap is not trustworthy on this phone. It produced six
rounds of false conclusions in one session, including a confident
diagnosis that pointer events were dead which was actually a re-locked
screen eating the input. The CSS-to-device mapping is not css times
devicePixelRatio: 735 CSS px at dpr 3 is 2205 device px inside a 2460px
screen, and the 255px of system bars means the offset must be measured.
Two rival offsets both failed. When a known-good control looks dead,
check the lock state before suspecting the code.

svc power stayon usb writes stay_on_while_plugged_in = 2, and this phone
charges over AC, so it silently never engages. stayon true writes 7 and
works. The 30s screen timeout is a separate setting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:00:52 +03:00
b95cff1be1 Phase 2: Explore's latch, haptics, back button, label overflow
Four things reported on the device, all real.

Tapping a modifier did nothing. setLayer was only reachable from a
search result or the layer sheet, so the reference's latch was simply
never ported. It is now, with the semantics intact rather than reduced
to a toggle: hold to peek and it lifts away, tap to lock and it stays,
tap again to release, and reading a lit key while holding a modifier
spends that modifier so lifting drops it. Knobs and pads never latch,
which is what NOLATCH is for. held is now the source of truth and the
layer key is derived from it, as in the reference.

That needed per-control release events. Panel knew which control each
pointer held and threw it away; it now reports onRelease before the
gesture-wide callback, because the latch decides hold-versus-tap from
how long one key was down.

Haptics and the hardware back button were missing entirely - Phase 1 did
both in native.js and the port had no equivalent. native.ts adds them,
no-ops in a browser so the dev server is unaffected. Back owns the whole
stack: sheet, then Explore, then exit.

Grade haptics fire from the transition into answered, not from the four
dispatch sites. Grading happens in the reducer, so deciding correctness
again at each call site would be a second source of truth free to drift.

PINGPONG overflowed its key. Lit keys show the entry's short label, which
comes from the data and can be one unbreakable word; the soft hyphens
only cover printed labels. Keys now break anywhere and clip.

141 tests, typecheck clean, installed and running on the device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:00:23 +03:00
7ddaf3d949 Phase 2: cut the APK over to the React app
webDir moves from www to dist, and npm run apk now builds with Vite
instead of copying the reference. The Phase 1 pipeline is still there as
apk:phase1, so the wrapper that has been installable throughout can be
rebuilt if the port turns out to have a problem on the device.

Verified without the phone: Vite builds, Capacitor copies dist, all three
plugins are still found, Gradle packages, and the APK now contains the
React bundle and the three self-hosted woff2 files with none of the old
Phase 1 assets left behind.

What cannot be checked from here is the part that matters most - a real
two-finger chord, haptics, the back button, and whether the drill boxes
already on the phone survive the switch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:52:43 +03:00
470ebf41dc Phase 2: multitouch
Panel used onClick, which cannot express a chord at all - so hold mode,
which is the persisted default answering mode, was unusable. It now
tracks pointers per pointerId and reports onPress per control plus
onGestureEnd when the last finger lifts.

The split is deliberate: Panel owns pointer bookkeeping because it owns
the DOM, and App keeps deciding what a chord means. Hold mode grades on
onGestureEnd, tap mode accumulates presses and waits for Check, and
Explore still selects on press. Both map onto reducer actions that
already existed.

Two details carried over from the reference that are easy to delete by
accident, and are now written down:

- touch-action: none on the panel. Without it the browser claims the
  second finger as a pinch or a pan and no chord can ever form.
- pointerup is heard on the window, not the button, because fingers
  routinely lift outside the key they went down on.

Keyboard still works: Enter or Space presses and ends the gesture.

141 tests, typecheck clean, touch-action confirmed in the built CSS, and
the panel renders unchanged. Actual multi-finger behaviour cannot be
verified headlessly - that needs the phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:52:04 +03:00
6d1f219e8a Phase 2: Find and Rules
Both tabs were rendering Explore. They now take the whole screen, as the
reference does by hiding the unit on each: a list or a page of prose
wants the room more than the panel does. Picking a search result jumps
to Explore with that combo lit, which is the reference's data-go.

The rules text is extracted verbatim rather than retyped or reworded.
Two bugs on the way there, both worth recording:

- RULES is a template literal that itself contains template literals
  inside a ${} interpolation, so the bracket scanner used for the combo
  table stops at the first inner backtick. The nine title and body pairs
  are matched out of the source instead, with a count assertion so drift
  fails loudly rather than silently dropping a rule.
- PADMAP was read with g() but never added to the list of declarations
  evaluated into the vm, so it was simply not in scope.

The extractor now asserts 9 rules and 4 pad-map rows alongside the 159
combos.

141 tests, typecheck clean. Both tabs verified by rendering them, Find
with a seeded query since headless Firefox cannot type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:49:50 +03:00
52 changed files with 1652 additions and 308 deletions

8
.gitignore vendored
View File

@@ -20,8 +20,14 @@ android/local.properties
android/.idea/
*.apk
*.aab
# Staged release artifacts. The APK is covered above; this also catches the
# checksum file that sits beside it.
release/
# No exception for debug.keystore: an earlier "!debug.keystore" here deliberately
# un-ignored it, which would have committed a signing key silently had one ever
# landed in the tree. Signing keys belong in ~/.android, not in the repo.
*.keystore
!debug.keystore
*.jks
# Seeded design canvas — regenerated from design/canvas/*.dc.html
design/canvas/sp404-layers-redesign.html

118
CLAUDE.md
View File

@@ -1,6 +1,26 @@
# SP-404MK2 layers
# 404 Layers
A combo trainer for the Roland SP-404MK2, wrapped as a sideloaded Android APK.
A combo trainer for the Roland SP-404MK2, sideloaded to an Android phone.
**The name is set in five places** and `cap sync` regenerates one of them:
`capacitor.config.json` (`appName`, which Capacitor writes into `strings.xml`),
`android/.../values/strings.xml` (`app_name`, `title_activity_main`),
`index.html` `<title>`, `package.json`, and this file. `reference/` keeps the old
name on purpose — it is frozen.
**`appId` must stay `dev.ncode.sp404layers`.** Changing it makes a different app
as far as Android is concerned: a fresh install, and the drill progress on the
phone orphaned.
**The launcher icon is generated, not hand-drawn:** `npm run icon` runs
`scripts/make-icon.mjs`, which draws a 3×3 pad grid with the centre pad lit using
ImageMagick primitives — there is no SVG rasteriser on this machine. It writes
the five adaptive foregrounds (art inside the inner 66%, because the launcher
masks the rest), the five legacy square/round pairs, and previews to
`design/icon/` since a PNG on disk is the only way to actually look at it. The
convert calls pass `-strip`: without it the pixels are identical between runs but
the bytes are not, so the committed PNGs churn and the script cannot be shown to
be their source.
**Phase 1 (current):** the original single-file web app, wrapped in Capacitor as-is.
**Phase 2 (next):** port to React + TypeScript via Vite — data and pure logic with
@@ -56,7 +76,7 @@ Device notes), but nothing on screen can be verified through a lockscreen and
| Command | What it does |
|---|---|
| `npm run device:keep-awake` | Stay awake while charging. Do this at the start of a session. |
| `npm run device:launch` | Check awake/unlocked, force-stop, start, wait for the page to actually parse |
| `npm run device:launch` | Check awake/unlocked, force-stop, start, wait for the page (debug builds) |
| `npm run device:awake` | Just the check — prints a loud warning if locked |
| `npm run device:sleep-normal` | Revert the stay-awake setting when finished |
@@ -64,6 +84,22 @@ Device notes), but nothing on screen can be verified through a lockscreen and
starts fine even when the renderer was denied. On failure it prints the
`AppBootManager` denial lines rather than leaving a blank window to puzzle over.
**On a release build it cannot do that, and says so.** A release APK is not
`DEBUGGABLE`, so Capacitor never calls `setWebContentsDebuggingEnabled` and
`webview_devtools_remote_<pid>` is never opened — the page simply cannot be
inspected over adb. Demanding that socket anyway made the helper report
`renderer denied` three times for a perfectly healthy app, force-stopping and
restarting it each time. It now checks that the process survived and that
nothing denied *our* renderer, then prints `page load assumed, not verified`. A
blank grey window and a working one are genuinely indistinguishable from here,
so that wording is the honest limit, not hedging.
The denial grep is filtered by package and bounded to the current launch
(`logcat -d -T`, timestamp from `adb shell "date +'%m-%d %H:%M:%S.000'"` — the
format must be one quoted argument or toybox `date` rejects it). Unfiltered, it
printed `com.xiaomi.wearable` denials from minutes earlier and sent one session
chasing a denial that had never happened to us.
It also reads the pid *after* starting the app. `adb install -r` kills the
running process, so a pid captured earlier points at a dead process and the
devtools socket lookup fails while the app itself is healthy — that produced one
@@ -152,9 +188,29 @@ now empty and bar styling is configured in `capacitor.config.json`
**No community plugins.** Only first-party: `@capacitor/core`, `/cli`,
`/android`, `/haptics`, `/preferences`, `/app`.
**Debug signing.** The debug keystore is stable per-machine, so Phase 2's APK
installs over Phase 1's without an uninstall and drill progress survives.
`appId` is `dev.ncode.sp404layers` and must not change for the same reason.
**Debug signing, releases included.** The debug keystore is stable per-machine,
so Phase 2's APK installs over Phase 1's without an uninstall and drill progress
survives. `appId` is `dev.ncode.sp404layers` and must not change for the same
reason.
The `release` build type reuses that key on purpose, via the plugin's built-in
`signingConfig signingConfigs.debug`, so no keystore path or password lands in a
tracked file. This only ever ships as a sideloaded APK, never to Play — which
rejects debug keys anyway. The trade: anyone can build an APK that upgrades over
it, against which there is no key to keep safe for years and no way to strand
anyone on a version that can no longer be updated.
Verified rather than assumed: the release APK's signer digest is
`2494c983…551cc`, identical to every debug build, so it installs in place. That
matters more than it looks, because a release build is **not `DEBUGGABLE`** — the
`adb shell run-as` trick that reads `CapacitorStorage.xml` back out stops working
the moment one is installed, and there is no in-app restore to fall back on. A
signature change would therefore have cost the drill progress outright.
`npm run release` (`scripts/release.mjs`) builds it, stages
`release/404-Layers-<version>.apk` with a `.sha256` beside it, and fails loudly
if apksigner reports no certificate: an unsigned APK builds happily and only
fails at install time, on someone else's phone. `release/` is gitignored.
## Phase 2: the React port
@@ -167,6 +223,7 @@ kit — the app is four tabs and a reducer, and none of those earn their keep.
| `npm run test:watch` | Watch mode |
| `npm run typecheck` | `tsc --noEmit` |
| `npm run extract:data` | Regenerate the combo table from `reference/` |
| `npm run release` | Build the signed release APK into `release/`, with checksum |
```
src/domain/
@@ -180,18 +237,61 @@ src/domain/
search.ts the Find tab's matcher
storage.ts the persisted blob, backup parsing, defaults
src/domain/
rules.ts GENERATED — the nine rules and the SHIFT + pad map
src/ui/
panelLayout.ts where every control sits; captions as silkscreened
Panel.tsx the panel, presentational: told what is lit, reports presses
cx.ts joins class names, dropping blanks
panelLayout.ts where every control sits; captions as silkscreened
Panel.tsx the panel: told what is lit, owns pointer bookkeeping
drillReducer.ts drill transitions, pure and React-free
persistedState.ts the only code that touches localStorage
Sheet.tsx bottom sheet, shared by both pickers and drill settings
InfoCard.tsx Explore's card · DrillStrip/DrillCard Drill's
FindPanel.tsx search and results · RulesPanel.tsx the rules page
cx.ts joins class names, dropping blanks
src/styles/base.css tokens: palette, fonts, the vertical budget
```
**Multitouch lives in `Panel`, meaning in `App`.** Answering a drill card means
holding two or three controls at once, so `Panel` tracks pointers per
`pointerId` and reports `onPress` per control plus `onGestureEnd` when the last
finger lifts. What a chord *means* stays in `App`: hold mode grades on
`onGestureEnd`, tap mode accumulates via `press` and waits for Check.
Two things make it work and are easy to delete by accident: `touch-action: none`
on the panel, without which the browser claims the second finger as a pinch and
no chord can ever form; and listening for `pointerup` on the **window**, because
fingers routinely lift outside the key they went down on.
**CSS modules type as `string | undefined`** under `noUncheckedIndexedAccess`, so
`cx()` absorbs that rather than the flag being turned off — it has caught real
bugs. The cost is that a mistyped class renders with no class at all rather than
failing, which is why the layout is pinned by tests instead.
**Do not trust `adb shell input tap` on this device.** It cost six rounds of
false conclusions in one session: taps that hit a neighbouring key, taps that hit
nothing, and an entire diagnosis of "pointer events are dead" that turned out to
be a re-locked screen swallowing the input. The CSS-to-device mapping is not
simply `css × devicePixelRatio` — the viewport is 735 CSS px at dpr 3 = 2205
device px inside a 2460px screen, and the remaining 255px of system bars means
the offset has to be measured, not assumed. Two rival offsets both failed.
When a *known-good* control appears dead, check the lock state before suspecting
the code. For anything touch-related, one report from a person holding the phone
beats an afternoon of synthetic input.
**Stay awake needs `svc power stayon true`, not `usb`.** The `usb` form writes
`stay_on_while_plugged_in = 2`, and this phone charges over AC (`USB powered:
false`), so it silently never engages — `mStayOn` stays false. `true` sets every
charging-source bit and works; measured as **15** here (AC|USB|wireless|dock),
not the 7 an earlier note claimed — 7 predates dock charging. The screen timeout
is separate and was 30s:
```
adb shell svc power stayon true
adb shell settings put system screen_off_timeout 1800000 # restore: 30000
```
**Seeing the UI without the phone.** There is no Chromium on this machine, but
Firefox can screenshot headlessly with no extra dependency:

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Georgi Durankiev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

163
README.md Normal file
View File

@@ -0,0 +1,163 @@
# 404 Layers
A trainer for the Roland SP-404MK2's button combinations, built as a sideloaded
Android app.
The SP-404MK2 has 54 controls and roughly 160 functions. The difference is hidden
behind modifiers: hold `SHIFT` and the pads stop being pads and become
`FIXED VELOCITY`, `16 VELOCITY`, `CUE`, `CHROMATIC`, and so on. Hold `REMAIN` and
they become bus routing. The same physical key does something different in sample
mode, pattern mode, TR-REC, the looper, DJ mode and chromatic mode.
This app draws the panel, lets you hold a modifier, and relabels every control
with what it does on that layer. Then it drills you on them.
![Explore with no modifier held, and the same screen holding SHIFT](design/screenshots/explore-and-shift.png)
Left: sample mode, nothing held — three functions available. Right: the same
screen with `SHIFT` held — thirty-five, and every pad now shows what it becomes.
## Install
Download the APK from the [latest release](https://git.jawhng.xyz/jawhng/404-Layers/releases),
open it on your phone, and allow installing from unknown sources when Android
asks. It needs **Android 7.0 or newer** and is about 3 MB.
It isn't on the Play Store, so Android will warn you it came from outside the
store — that warning is about where the file came from, not what's in it. Two
things worth knowing before you trust a stranger's APK:
- It's signed with a **debug key**, not a private one. Nothing here is worth
protecting with a key I'd have to keep safe for years, and it means a lost
keystore can never strand you on a version that can't be updated.
- It asks for `INTERNET` and `VIBRATE`. The vibration is the drill feedback;
the internet permission is Capacitor's default and the app never uses it —
there are no network calls in the source, and everything it needs is bundled.
Each release lists a `sha256` if you want to check the download.
## What it does
| Tab | |
|---|---|
| **Explore** | Hold or tap a modifier; the panel relabels itself. Tap a lit control to read what it does, where it applies, and its page in the manual. |
| **Drill** | Spaced repetition over the combos. Either "here's a function, press it" — answered by holding the actual chord on the panel — or "here's a combo, name it". |
| **Find** | Search by function, by button, or by mode. Pick a result and it opens on the panel, lit. |
| **Rules** | The nine patterns behind the combos, so you can stop memorising 160 things and learn the handful of rules they follow. |
Drill uses a five-box Leitner system. A right answer promotes a card, a wrong one
sends it back to box one, and the chance of being asked halves with each box — so
the combos you keep missing keep coming back. Progress is kept on the device.
## Status
It works and it's in daily use on one phone. It is **not** on any app store:
it's a sideloaded APK, either from the release page above or built from source
below.
It was built in two passes, and both are still in the repo:
- **`reference/`** — the original single-file web app: one HTML file, vanilla JS,
no build step. It is **frozen**. It remains the authority on behaviour and on
the combo data, and the React port is checked against it.
- **`src/`** — the current app: React, TypeScript, Vite, wrapped with Capacitor.
## Running it
Needs Node, a JDK, and the Android SDK. The toolchain script sets up the SDK side
on a Debian/Ubuntu machine:
```bash
./scripts/install-android-toolchain.sh # JDK 21, cmdline-tools, platform-tools
npm install
```
Then, with a phone connected and USB debugging on:
```bash
npm run apk # vite build, cap sync, gradle assembleDebug
npm run install:apk # adb install -r
npm run device:launch
```
Or just work in a browser, which is where most of the UI was built:
```bash
npm run dev # http://localhost:5173
```
### Everything else
| Command | |
|---|---|
| `npm run release` | Build the signed release APK into `release/`, with its checksum |
| `npm test` | The domain suite |
| `npm run typecheck` | `tsc --noEmit` |
| `npm run extract:data` | Regenerate the combo table from `reference/` |
| `npm run icon` | Redraw the launcher icon |
| `npm run apk:phase1` | Build the old wrapper instead of the React app |
## How it's put together
```
reference/ the original app. Frozen: the behaviour and data spec
src/domain/ pure logic, no React, fully tested
raw.ts GENERATED — the 159-row combo table
labels.ts GENERATED — control labels, groups, contexts
rules.ts GENERATED — the nine rules and the pad map
entries.ts derives Entry[] from the raw table
layers.ts which combos exist for a context and held modifiers
drill.ts decks, Leitner boxes, answer checking, card selection
search.ts the Find matcher
storage.ts the persisted blob and backup parsing
format.ts ids to printed words
src/ui/ React. Panel, the four screens, sheets, drill reducer
android/ the Capacitor project
scripts/ toolchain, data extraction, icon, device helpers
```
The split is deliberate: everything in `src/domain/` is pure and has no React in
it, so the interesting logic is testable without rendering anything. The UI layer
decides what a gesture *means*; the domain decides what is *true*.
### Things worth knowing if you're reading the code
**The combo table is never typed by hand.** `scripts/extract-reference-data.mjs`
lifts it out of `reference/index.html`, evaluating only pure-data declarations in
a `vm` with no DOM, and writes `raw.ts`, `labels.ts` and `rules.ts`. Re-run it
after editing the reference; don't edit those three files. Retyping 159 rows is
exactly where silent errors would creep in.
**Two strings are load-bearing.** `Entry.id` (`shift>p1@S#FIX VEL`) and
`STORAGE_KEY` (`sp404mk2-layers-v1`) are unchanged from the original app, so
progress earned in the old version is picked up by this one rather than orphaned.
There's a test that parses a payload copied verbatim off a phone.
**Multitouch is the whole point of the Drill input.** Answering means holding two
or three controls at once, so `Panel` tracks pointers per `pointerId` and grades
when the last finger lifts. Two things make it work and are easy to delete by
accident: `touch-action: none` on the panel, without which the browser claims the
second finger as a pinch; and listening for `pointerup` on the **window**,
because fingers routinely lift outside the key they went down on.
**The domain is pure so it can be tested.** The original mutated module-level
state and called `Math.random()` inline. Here, boxes go in and new boxes come
out, and the random source is injected — which is what made the card selection
testable, and immediately surfaced a real bug in the weighted pick.
`CLAUDE.md` holds the fuller engineering notes, including a number of
Android-specific traps that cost real time.
## Data
The combo data is derived from the SP-404MK2 reference manual (v5.50): control
names, short labels, one-line descriptions, and page numbers for looking things
up. Roland owns the device, its manual and its trademarks; this is an unofficial
tool and has nothing to do with them.
## Licence
MIT — see [LICENSE](LICENSE).
The bundled fonts are under the SIL Open Font License, with their licence texts
in [`public/fonts/`](public/fonts/).

View File

@@ -8,7 +8,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionName "1.0.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -20,6 +20,11 @@ android {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
// Debug key on purpose: this only ever ships as a sideloaded APK, never
// to Play. Same signature as every build so far, so a release installs
// over an existing one instead of demanding an uninstall. Using the
// built-in config keeps the path and password out of this file.
signingConfig signingConfigs.debug
}
}
}

View File

@@ -1,34 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View File

@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 850 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 536 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 992 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 940 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
<color name="ic_launcher_background">#e9e9e6</color>
</resources>

View File

@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">SP-404MK2 layers</string>
<string name="title_activity_main">SP-404MK2 layers</string>
<string name="app_name">404 Layers</string>
<string name="title_activity_main">404 Layers</string>
<string name="package_name">dev.ncode.sp404layers</string>
<string name="custom_url_scheme">dev.ncode.sp404layers</string>
</resources>

View File

@@ -1,7 +1,7 @@
{
"appId": "dev.ncode.sp404layers",
"appName": "SP-404MK2 layers",
"webDir": "www",
"appName": "404 Layers",
"webDir": "dist",
"android": {
"backgroundColor": "#151719",
"allowMixedContent": false

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -14,7 +14,7 @@
-->
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>SP-404MK2 layers</title>
<title>404 Layers</title>
</head>
<body>
<div id="root"></div>

View File

@@ -3,12 +3,16 @@
"version": "1.0.0",
"private": true,
"type": "module",
"description": "SP-404MK2 combo trainer, wrapped as an Android app",
"description": "404 Layers — an SP-404MK2 combo trainer for Android",
"scripts": {
"sync:www": "bash scripts/sync-www.sh",
"sync": "npm run sync:www && npx cap sync android",
"sync": "npm run build && npx cap sync android",
"apk": "npm run sync && cd android && ./gradlew assembleDebug",
"apk:release": "npm run sync && cd android && ./gradlew assembleRelease",
"apk:phase1": "npm run sync:www && npx cap sync android && cd android && ./gradlew assembleDebug",
"install:apk": "adb install -r android/app/build/outputs/apk/debug/app-debug.apk",
"install:release": "adb install -r android/app/build/outputs/apk/release/app-release.apk",
"release": "node scripts/release.mjs",
"device:awake": "bash scripts/device.sh awake",
"device:keep-awake": "bash scripts/device.sh keep-awake",
"device:sleep-normal": "bash scripts/device.sh sleep-normal",
@@ -19,6 +23,7 @@
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"extract:data": "node scripts/extract-reference-data.mjs",
"icon": "node scripts/make-icon.mjs",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"

View File

@@ -0,0 +1,93 @@
Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
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:
http://scripts.sil.org/OFL
-----------------------------------------------------------
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.

View File

@@ -0,0 +1,93 @@
Copyright 2016 The Space Mono Project Authors (https://github.com/googlefonts/spacemono)
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:
http://scripts.sil.org/OFL
-----------------------------------------------------------
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.

23
public/fonts/README.md Normal file
View File

@@ -0,0 +1,23 @@
# Fonts
Self-hosted so the app works with no network, as the Phase 1 wrapper did.
| File | Family | Weights | Licence |
|---|---|---|---|
| `space-grotesk-var-latin.woff2` | Space Grotesk | variable, 400–700 | `OFL-Space-Grotesk.txt` |
| `space-mono-400-latin.woff2` | Space Mono | 400 | `OFL-Space-Mono.txt` |
| `space-mono-700-latin.woff2` | Space Mono | 700 | `OFL-Space-Mono.txt` |
**Latin subsets only.** Dropped `latin-ext` and `vietnamese`, which takes the set
from nine files to three, 68 KB against Barlow's 133 KB. Space Grotesk is a
variable font, so one file covers every weight the app uses.
Two glyphs the app needs fall outside the Latin subset and will render from a
system fallback: `⏮` (U+23EE, in some DJ-mode labels) and `▾` (U+25BE). That is
why the pickers draw their caret as inline SVG rather than typing the character.
Both families are under the SIL Open Font License 1.1. The licence text is
reproduced here because the OFL requires it to accompany the fonts; the two
files differ, since the copyright lines are not the same.
`@font-face` declarations live in `src/styles/base.css`.

View File

@@ -49,7 +49,10 @@ ensure_awake() {
}
keep_awake() {
"$ADB" shell svc power stayon usb >/dev/null
# `usb` writes stay_on_while_plugged_in=2 (USB only) and this phone charges
# over AC, so it silently never engages. `true` sets every charging-source bit:
# measured as 15 here (AC|USB|wireless|dock), not the 7 that older notes claim.
"$ADB" shell svc power stayon true >/dev/null
echo "stay-awake while charging: ON (revert with: scripts/device.sh sleep-normal)"
}
@@ -58,6 +61,28 @@ sleep_normal() {
echo "stay-awake: reverted to system default"
}
# Release builds are not DEBUGGABLE, so Capacitor never calls
# setWebContentsDebuggingEnabled and the devtools socket is never opened. Asking
# for it anyway reports "renderer denied" for a perfectly healthy app.
is_debuggable() {
"$ADB" shell dumpsys package "$PKG" | grep -q DEBUGGABLE
}
# Device-side clock, for bounding logcat to this launch. The format has to be
# quoted as one argument: toybox date rejects a bare `+FMT` after `adb shell`.
device_now() {
"$ADB" shell "date +'%m-%d %H:%M:%S.000'" | tr -d '\r'
}
# Did the renderer get denied since $1? These lines are emitted whether or not
# the build is debuggable, and the package filter matters - other apps are
# denied constantly and their lines mean nothing here.
denied_since() {
"$ADB" logcat -d -T "$1" 2>/dev/null \
| grep -E "AppBootManager.*Caller\[$PKG\]|cr_ChildProcessConn: Failed" \
| grep -v 'Result\[true\]'
}
# Wait until the page is actually parsed, not merely until the activity is up:
# the activity starts fine even when the renderer was denied.
page_loaded() {
@@ -67,6 +92,19 @@ page_loaded() {
# app itself is healthy - that produced one false "page never loaded".
pid=$("$ADB" shell pidof "$PKG" | tr -d '\r')
[ -n "$pid" ] || return 1
# Release build: the page cannot be inspected from here at all, so prove what
# can be proved - the process survived and nothing denied its renderer. A
# blank grey window is indistinguishable from a working one over adb, which is
# why this says "assumed" rather than claiming the page loaded.
if ! is_debuggable; then
sleep 2
[ -n "$("$ADB" shell pidof "$PKG" | tr -d '\r')" ] || return 1
[ -z "$(denied_since "$1")" ] || return 1
echo "app running (pid $pid, release build: page load assumed, not verified)"
return 0
fi
for i in $(seq 1 40); do
sock=$("$ADB" shell cat /proc/net/unix | grep -o "webview_devtools_remote_$pid" | head -1)
if [ -n "$sock" ]; then
@@ -82,19 +120,23 @@ page_loaded() {
launch() {
ensure_awake || return 1
local attempt
local attempt started
for attempt in $(seq 1 $ATTEMPTS); do
started=$(device_now)
"$ADB" shell am force-stop "$PKG"
"$ADB" shell am start -W -n "$ACT" >/dev/null
if page_loaded; then
if page_loaded "$started"; then
[ "$attempt" -gt 1 ] && echo "(took $attempt attempts - post-install renderer denial)"
return 0
fi
echo "attempt $attempt: renderer denied, retrying" >&2
echo "attempt $attempt: no page, retrying" >&2
done
echo "ERROR: page never loaded after $ATTEMPTS attempts." >&2
"$ADB" logcat -d | grep -E 'AppBootManager.*Result\[false\]|ChildProcessConn: Failed' | tail -4 >&2
# Bounded to this launch and to our package. Unfiltered, this printed other
# apps' denials from minutes earlier and sent one debugging session chasing a
# denial that had never happened to us.
denied_since "$started" | tail -4 >&2
return 1
}

View File

@@ -35,7 +35,18 @@ function literalAfter(name) {
throw new Error(`unbalanced literal: ${name}`);
}
const NAMES = ["L", "PL", "GROUPS", "GNAME", "NOLATCH", "MODORDER", "RAW", "CTX", "CTXHINT"];
const NAMES = [
"L",
"PL",
"GROUPS",
"GNAME",
"NOLATCH",
"MODORDER",
"RAW",
"CTX",
"CTXHINT",
"PADMAP",
];
const ctx = vm.createContext({});
for (const n of NAMES) vm.runInContext(`var ${n} = ${literalAfter(n)};`, ctx);
// The reference adds the 16 pad labels in a loop right after declaring L.
@@ -73,7 +84,34 @@ writeFileSync(
`export const CTXHINT: Record<ContextCode, string> = ${j(g("CTXHINT"))};\n`
);
// The nine rules live in a template literal that itself contains template
// literals inside a ${...} interpolation, so the bracket scanner above would
// stop at the first inner backtick. Match the pairs out of the source instead.
const rulePairs = [...src.matchAll(/<h3>(.*?)<\/h3><p>(.*?)<\/p>/gs)].map((m) => ({
title: m[1].trim(),
body: m[2].trim(),
}));
if (rulePairs.length !== 9) {
throw new Error(`expected 9 rules, found ${rulePairs.length}`);
}
const padMap = g("PADMAP").map(([label, pads]) => ({ label, pads }));
if (padMap.length !== 4) {
throw new Error(`expected 4 pad-map rows, found ${padMap.length}`);
}
writeFileSync(
"src/domain/rules.ts",
header("The Rules tab: nine rules, and the SHIFT + pad map.") +
`export interface Rule {\n readonly title: string;\n readonly body: string;\n}\n\n` +
`export interface PadMapRow {\n readonly label: string;\n readonly pads: readonly string[];\n}\n\n` +
`export const RULES: readonly Rule[] = ${j(rulePairs)};\n\n` +
`export const PAD_MAP: readonly PadMapRow[] = ${j(padMap)};\n`
);
const raw = g("RAW");
console.log(`rules ${rulePairs.length}`);
console.log(`pad-map rows ${padMap.length}`);
console.log(`RAW entries ${raw.length}`);
console.log(`control labels ${Object.keys(g("L")).length}`);
console.log(`contexts ${Object.keys(g("CTX")).join(", ")}`);

114
scripts/make-icon.mjs Normal file
View File

@@ -0,0 +1,114 @@
/**
* Draws the launcher icon: a 3x3 pad grid with the centre pad lit.
*
* Kept in the repo because the icon is otherwise only 15 binary PNGs with no
* record of how they were made — changing a colour or a gap would mean redrawing
* it from scratch. Re-run with: npm run icon
*
* There is no SVG rasteriser on this machine (no Inkscape, no rsvg-convert, and
* ImageMagick 6 here has no SVG delegate), so the artwork is ImageMagick
* primitives. The command is built as a real array: three attempts at this in
* bash collapsed the nine draw commands into one argument, which ImageMagick
* then read as a filename.
*/
import { execFileSync } from "node:child_process";
import { mkdirSync, rmSync } from "node:fs";
const BODY = "#e9e9e6";
const PAD = "#1b1b1b";
const ORANGE = "#ff4a00";
const INK = "#111111";
const RES = "android/app/src/main/res";
/** Adaptive foregrounds: 108dp canvas, art confined to the inner 66%. */
const FOREGROUND = [
["mdpi", 108],
["hdpi", 162],
["xhdpi", 216],
["xxhdpi", 324],
["xxxhdpi", 432],
];
/** Legacy icons, for launchers older than adaptive support. */
const LEGACY = [
["mdpi", 48],
["hdpi", 72],
["xhdpi", 96],
["xxhdpi", 144],
["xxxhdpi", 192],
];
/**
* @param pct how much of the canvas the grid may use. 66 for adaptive
* foregrounds, because the launcher masks everything outside that; 82 for
* legacy icons, which are shown whole.
*/
function grid(size, out, pct, bg) {
const inner = Math.floor((size * pct) / 100);
const off = Math.floor((size - inner) / 2);
const cell = Math.floor((inner * 26) / 100); // 3 cells + 2 gaps of 11%
const gap = Math.floor((inner * 11) / 100);
const rad = Math.max(1, Math.floor((cell * 18) / 100)); // as on the pads
const stroke = Math.max(1, Math.round(size / 110));
const args = ["-size", `${size}x${size}`, `xc:${bg}`];
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 3; col++) {
const x = off + col * (cell + gap);
const y = off + row * (cell + gap);
const lit = row === 1 && col === 1;
args.push(
"-fill", lit ? ORANGE : PAD,
"-stroke", INK,
"-strokewidth", String(stroke),
"-draw", `roundrectangle ${x},${y} ${x + cell},${y + cell} ${rad},${rad}`,
);
}
}
// -strip removes the tIME chunk. Without it the pixels are identical between
// runs but the bytes are not, so the committed PNGs churn on every re-run and
// this script cannot be shown to be their source.
args.push("-strip", out);
execFileSync("convert", args);
}
function circleMask(src, size, out) {
const half = Math.floor(size / 2);
execFileSync("convert", [
src,
"(", "-size", `${size}x${size}`, "xc:none",
"-fill", "white", "-draw", `circle ${half},${half} ${half},0`, ")",
"-alpha", "set", "-compose", "DstIn", "-composite", "-strip", out,
]);
}
for (const [density, px] of FOREGROUND) {
grid(px, `${RES}/mipmap-${density}/ic_launcher_foreground.png`, 66, "none");
}
for (const [density, px] of LEGACY) {
const square = `${RES}/mipmap-${density}/ic_launcher.png`;
grid(px, square, 82, BODY);
circleMask(square, px, `${RES}/mipmap-${density}/ic_launcher_round.png`);
}
// Previews, since a PNG on disk is the only way to actually look at this.
mkdirSync("design/icon", { recursive: true });
grid(512, "design/icon/preview-square.png", 82, BODY);
circleMask("design/icon/preview-square.png", 512, "design/icon/preview-round.png");
grid(432, "design/icon/.fg.png", 66, "none");
execFileSync("convert", [
"-size", "432x432", `xc:${BODY}`, "design/icon/.fg.png", "-composite",
"(", "-size", "432x432", "xc:none", "-fill", "white",
"-draw", "roundrectangle 0,0 431,431 108,108", ")",
"-alpha", "set", "-compose", "DstIn", "-composite",
"design/icon/preview-as-launcher-shows-it.png",
]);
rmSync("design/icon/.fg.png", { force: true });
console.log(
`icon drawn: ${FOREGROUND.length} adaptive foregrounds, ` +
`${LEGACY.length} legacy pairs, 3 previews in design/icon/`,
);

44
scripts/release.mjs Normal file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env node
/**
* Builds the release APK and stages it under release/ under a name a stranger
* can recognise, next to the checksum to verify a download against.
*
* It verifies the signature rather than trusting that the signing config took:
* an unsigned APK builds happily and only fails at install time, on someone
* else's phone.
*/
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
execFileSync("npm", ["run", "apk:release"], { cwd: root, stdio: "inherit" });
const built = join(root, "android/app/build/outputs/apk/release/app-release.apk");
const name = `404-Layers-${version}.apk`;
const out = join(root, "release", name);
mkdirSync(dirname(out), { recursive: true });
copyFileSync(built, out);
const bytes = readFileSync(out);
const sha = createHash("sha256").update(bytes).digest("hex");
writeFileSync(`${out}.sha256`, `${sha} ${name}\n`);
// build-tools is pinned in CLAUDE.md; a missing apksigner should fail loudly.
const sdk = process.env.ANDROID_HOME ?? join(process.env.HOME ?? "", "Android/Sdk");
const certs = execFileSync(join(sdk, "build-tools/36.1.0/apksigner"), ["verify", "--print-certs", out], {
encoding: "utf8",
});
const digest = certs.match(/certificate SHA-256 digest: (\w+)/)?.[1] ?? "";
if (digest === "") throw new Error(`apksigner reported no certificate:\n${certs}`);
console.log(`\n${name} ${(bytes.length / 1e6).toFixed(2)} MB`);
console.log(`file sha256 ${sha}`);
console.log(`cert sha256 ${digest}`);
console.log(`\nStaged at ${out}`);

View File

@@ -112,3 +112,29 @@
.sheetLabel:first-child {
margin-top: 0;
}
/* The detail sheet: what the 84px card cannot hold. */
.detailCombo {
margin: 0;
font-family: var(--mono);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--accent);
}
/* The gesture is the instruction for the 24 entries that have one. */
.detailHow {
margin: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
.detailBody {
margin: 0;
font-size: 13px;
line-height: 1.4;
color: var(--muted);
text-wrap: pretty;
}

View File

@@ -7,31 +7,38 @@
* third argument is a lazy initialiser, which is how the stored blob is read
* once at startup instead of on every render.
*/
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { DECKS, boxDistribution, deckPool, knownCount, pickCard } from "./domain/drill";
import type { DeckId, Direction, InputMode } from "./domain/drill";
import { ENTRIES } from "./domain/entries";
import { controlName } from "./domain/format";
import { CTX, CTXHINT } from "./domain/labels";
import { ENTRIES, layerKeyOf } from "./domain/entries";
import { comboText, contextNames, controlName } from "./domain/format";
import { CTX, CTXHINT, NOLATCH } from "./domain/labels";
import {
entriesByTarget,
entriesForLayer,
layerCounts,
orderLayerKeys,
} from "./domain/layers";
import { searchEntries } from "./domain/search";
import type { ContextCode, ControlId, Entry } from "./domain/types";
import { DrillCard } from "./ui/DrillCard";
import { DrillStrip } from "./ui/DrillStrip";
import { FindPanel } from "./ui/FindPanel";
import { InfoCard } from "./ui/InfoCard";
import { Panel } from "./ui/Panel";
import { RulesPanel } from "./ui/RulesPanel";
import { Sheet, SheetOption } from "./ui/Sheet";
import { drillReducer, initialDrillState } from "./ui/drillReducer";
import { exitApp, gradeFeedback, useBackButton } from "./ui/native";
import { loadState, saveState } from "./ui/persistedState";
import styles from "./App.module.css";
const TABS = ["Explore", "Drill", "Find", "Rules"] as const;
type Tab = (typeof TABS)[number];
type OpenSheet = null | "mode" | "layer" | "settings";
type OpenSheet = null | "mode" | "layer" | "settings" | "detail";
/** Longer than this and the press was a hold to peek, not a tap to lock. */
const HOLD_MS = 350;
function Caret() {
return (
@@ -66,20 +73,51 @@ export function App() {
const [tab, setTab] = useState<Tab>("Explore");
const [sheet, setSheet] = useState<OpenSheet>(null);
// Hardware back owns the whole stack: a sheet first, then back to Explore,
// and only then leave. Registering at all suppresses Capacitor's default,
// which would otherwise just exit from anywhere.
useBackButton(
useCallback(() => {
if (sheet !== null) setSheet(null);
else if (tab !== "Explore") setTab("Explore");
else exitApp();
}, [sheet, tab]),
);
// --- Explore ---
const [ctx, setCtx] = useState<ContextCode>(stored.ctx);
const [layer, setLayer] = useState("");
/** Modifiers currently down or latched. The layer key is derived from these. */
const [held, setHeld] = useState<readonly ControlId[]>([]);
const [selected, setSelected] = useState<Entry | null>(null);
// Latch bookkeeping, from the reference: when each key went down, which held
// modifiers have been "used" by reading something, and which are due to be
// dropped on release because they were already latched when tapped again.
const downAt = useRef(new Map<ControlId, number>());
const used = useRef(new Set<ControlId>());
const pendingRelease = useRef(new Set<ControlId>());
const heldKey = layerKeyOf(held);
const counts = useMemo(() => layerCounts(ENTRIES, ctx), [ctx]);
const layers = useMemo(() => orderLayerKeys(counts.keys()), [counts]);
const activeLayer = counts.has(layer) ? layer : "";
const onLayer = useMemo(
() => entriesForLayer(ENTRIES, ctx, activeLayer),
[ctx, activeLayer],
() => entriesForLayer(ENTRIES, ctx, heldKey),
[ctx, heldKey],
);
const exploreLit = useMemo(() => entriesByTarget(onLayer), [onLayer]);
// --- Find ---
const [query, setQuery] = useState("");
const results = useMemo(() => searchEntries(ENTRIES, query), [query]);
/** Jump from a search result to that combo, lit on the panel. */
function goTo(entry: Entry) {
setCtx(entry.ctx[0] ?? "S");
setHeld(entry.mods);
setSelected(entry);
setTab("Explore");
}
// --- Drill ---
const [drill, dispatch] = useReducer(drillReducer, stored, (s) =>
initialDrillState(s.deck, s.dir, s.input, s.boxes),
@@ -120,7 +158,97 @@ export function App() {
return entriesByTarget([drill.card]);
}, [drill.card, drill.answered]);
// Haptics on a graded answer, driven by the transition into `answered` rather
// than by the dispatch sites. Grading lives in the reducer, so re-deciding
// correctness at each call site would be a second source of truth that could
// drift from it. This fires once per card, however it was graded.
const buzzedFor = useRef<string | null>(null);
useEffect(() => {
if (!drill.answered || !drill.card) return;
if (buzzedFor.current === drill.card.id) return;
buzzedFor.current = drill.card.id;
gradeFeedback(drill.lastCorrect);
}, [drill.answered, drill.card, drill.lastCorrect]);
const isDrill = tab === "Drill";
// Find and Rules take the whole screen: the reference hides the unit on both,
// and a list or a page of prose wants the room more than the panel does.
const isPanelTab = tab === "Explore" || isDrill;
/**
* Explore's press, ported from the reference.
*
* A lit control reads out, cycling if it carries several. Anything latchable
* goes down as a modifier. Knobs and pads never latch.
*/
function explorePress(id: ControlId) {
if (held.includes(id)) {
// Already down: decide on release, so a tap can un-latch it.
pendingRelease.current.add(id);
return;
}
const here = exploreLit.get(id);
if (here && here.length > 0) {
const at = selected ? here.indexOf(selected) : -1;
setSelected(here[(at + 1) % here.length]!);
// Reading something while holding a modifier spends it: lifting drops it.
for (const mod of held) used.current.add(mod);
return;
}
if (!NOLATCH.includes(id)) {
setHeld((current) => [...current, id]);
downAt.current.set(id, performance.now());
used.current.delete(id);
setSelected(null);
return;
}
setSelected(null);
}
/** Hold to peek and it lifts away; tap to lock and it stays. */
function exploreRelease(id: ControlId) {
if (pendingRelease.current.has(id)) {
pendingRelease.current.delete(id);
setHeld((current) => current.filter((c) => c !== id));
return;
}
const wentDown = downAt.current.get(id);
if (wentDown === undefined) return;
downAt.current.delete(id);
const wasHold = performance.now() - wentDown > HOLD_MS;
if (wasHold || used.current.has(id)) {
setHeld((current) => current.filter((c) => c !== id));
}
}
if (!isPanelTab) {
return (
<div className={styles.app}>
{tab === "Find" ? (
<FindPanel query={query} results={results} onQuery={setQuery} onPick={goTo} />
) : (
<RulesPanel />
)}
<nav className={styles.nav}>
{TABS.map((name) => (
<button
key={name}
className={name === tab ? styles.tabOn : styles.tab}
onClick={() => setTab(name)}
>
{name.toUpperCase()}
</button>
))}
</nav>
</div>
);
}
return (
<div className={styles.app}>
@@ -139,7 +267,7 @@ export function App() {
<Caret />
</button>
<button className={styles.picker} onClick={() => setSheet("layer")}>
<b>{layerLabel(activeLayer)}</b>
<b>{layerLabel(heldKey)}</b>
<span className={styles.count}>{onLayer.length}</span>
</button>
</div>
@@ -147,21 +275,25 @@ export function App() {
<Panel
lit={isDrill ? drillLit : exploreLit}
held={isDrill ? drill.chord : activeLayer === "" ? [] : activeLayer.split("+")}
held={isDrill ? drill.chord : held}
selected={isDrill ? null : (selected?.tset[0] ?? null)}
onPress={(id) => {
if (isDrill) {
if (drill.answered) deal();
else dispatch({ type: "press", id });
if (!isDrill) {
explorePress(id);
return;
}
const here = exploreLit.get(id);
if (!here || here.length === 0) {
setSelected(null);
return;
if (drill.answered) deal();
else if (drill.input === "tap") dispatch({ type: "press", id });
}}
onRelease={(id) => {
if (!isDrill) exploreRelease(id);
}}
onGestureEnd={(ids) => {
// Hold-to-answer grades when the last finger lifts. Tap mode waits for
// the Check button instead, and Explore has nothing to grade.
if (isDrill && drill.input === "hold" && !drill.answered) {
dispatch({ type: "release", chord: ids });
}
const at = selected ? here.indexOf(selected) : -1;
setSelected(here[(at + 1) % here.length]!);
}}
screen={
isDrill
@@ -173,7 +305,7 @@ export function App() {
}
: {
top: CTX[ctx].toUpperCase(),
right: layerLabel(activeLayer),
right: layerLabel(heldKey),
main: selected ? selected.short : plural(onLayer.length, "function"),
chips: selected ? [] : onLayer.slice(0, 3).map((e) => e.short),
}
@@ -190,7 +322,13 @@ export function App() {
onPickOption={(name) => dispatch({ type: "pickOption", name })}
/>
) : (
<InfoCard entry={selected} ctx={ctx} hint={CTXHINT[ctx]} count={onLayer.length} />
<InfoCard
entry={selected}
ctx={ctx}
hint={CTXHINT[ctx]}
count={onLayer.length}
onMore={() => setSheet("detail")}
/>
)}
<nav className={styles.nav}>
@@ -205,6 +343,36 @@ export function App() {
))}
</nav>
<Sheet
open={sheet === "detail" && selected !== null}
title={selected ? selected.name : ""}
onClose={() => setSheet(null)}
>
{selected ? (
<>
<p className={styles.sheetLabel}>Combo</p>
<p className={styles.detailCombo}>{comboText(selected)}</p>
{selected.how ? (
<>
<p className={styles.sheetLabel}>Gesture</p>
<p className={styles.detailHow}>{selected.how}</p>
</>
) : null}
<p className={styles.sheetLabel}>What it does</p>
<p className={styles.detailBody}>
{selected.desc || "No description in the reference."}
</p>
<p className={styles.sheetLabel}>Where</p>
<p className={styles.detailBody}>
{contextNames(selected.ctx).join(", ")} &middot; manual p.{selected.page}
</p>
</>
) : null}
</Sheet>
<Sheet open={sheet === "mode"} title="Where you are" onClose={() => setSheet(null)}>
{(Object.keys(CTX) as ContextCode[]).map((code) => (
<SheetOption
@@ -231,9 +399,13 @@ export function App() {
key={key || "none"}
label={layerLabel(key)}
hint={plural(counts.get(key) ?? 0, "function")}
selected={key === activeLayer}
selected={key === heldKey}
onSelect={() => {
setLayer(key);
// Picking a layer latches its modifiers, as if they were tapped.
setHeld(key === "" ? [] : key.split("+"));
downAt.current.clear();
used.current.clear();
pendingRelease.current.clear();
setSelected(null);
setSheet(null);
}}

90
src/domain/rules.ts Normal file
View File

@@ -0,0 +1,90 @@
// GENERATED from reference/index.html by scripts/extract-reference-data.mjs — do not edit by hand.
// The Rules tab: nine rules, and the SHIFT + pad map.
export interface Rule {
readonly title: string;
readonly body: string;
}
export interface PadMapRow {
readonly label: string;
readonly pads: readonly string[];
}
export const RULES: readonly Rule[] = [
{
"title": "SHIFT + pad is a 4×4 map, row by row",
"body": "Learn the row themes first; the individual pads fall out of them."
},
{
"title": "SHIFT + a playback button = the same thing, bigger or weirder",
"body": "GATE and BPM SYNC apply to the whole bank. LOOP becomes ping-pong. ROLL sets its rate. HOLD pauses. EXIT stops everything. REC in pattern mode becomes TR-REC."
},
{
"title": "SHIFT + an edit button = its sibling screen",
"body": "START/END → markers and chop. PITCH/SPEED → envelope. RESAMPLE → merge. RECORD SETTING → synth. SUB PAD → projects. PATTERN SELECT → undo."
},
{
"title": "REMAIN means “where does it go, and keep it there”",
"body": "With pads, banks or EXT SOURCE it sets bus routing. With an FX button it pins the screen. On edit screens it applies knob moves to the whole bank. Alone it shows pad info."
},
{
"title": "Holding VALUE means “quietly, or only for now”",
"body": "Pads select without sound, FX buttons turn momentary, knobs reach sub-parameters, BUS FX bypasses 3/4 for a moment. Pressing VALUE on any edit screen opens its hidden menu."
},
{
"title": "DEL or COPY + EXIT acts on a whole bank",
"body": "DEL + EXIT deletes a bank, COPY + EXIT copies it. COPY on edit screens scopes changes to the mute group instead of the bank."
},
{
"title": "Three-second holds guard things you do not want by accident",
"body": "SHIFT + REMAIN (live mode) and SHIFT + MARK (save BUS 1/2 effects)."
},
{
"title": "Pattern mode reuses keys by meaning",
"body": "HOLD + pad = chains. SUB PAD + pad = switch now. MARK while recording = FX motion; SHIFT + MARK erases it. DEL while recording = erase notes."
},
{
"title": "Lost? EXIT four times.",
"body": "Stops everything and clears pad mutes. EXIT alone backs out one screen."
}
];
export const PAD_MAP: readonly PadMapRow[] = [
{
"label": "Row 1: how pads play",
"pads": [
"Fixed vel",
"16 vel",
"Cue",
"Chromatic"
]
},
{
"label": "Row 2: organise pads",
"pads": [
"Exchange",
"Init",
"Link",
"Mute group"
]
},
{
"label": "Row 3: time and input",
"pads": [
"Metronome",
"Count-in",
"Tempo / tap",
"Gain"
]
},
{
"label": "Row 4: menus",
"pads": [
"Utility",
"Import/export",
"Pad set",
"EFX set"
]
}
];

105
src/ui/FindPanel.module.css Normal file
View File

@@ -0,0 +1,105 @@
/* Takes the space the panel would use, and scrolls. */
.find {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
overflow: hidden;
}
.input {
flex: 0 0 auto;
width: 100%;
height: var(--strip-h);
background: var(--card);
border: var(--outline);
box-shadow: var(--shadow-sm);
color: var(--ink);
font-family: var(--font);
font-size: 13px;
padding: 0 10px;
}
.input::placeholder {
color: var(--muted);
}
.note {
margin: 0;
font-size: 12.5px;
line-height: 1.3;
color: var(--muted);
}
.count {
margin: 0;
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.12em;
color: var(--muted);
}
.list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 5px;
}
.row {
display: grid;
grid-template-columns: 1fr auto;
gap: 1px 8px;
width: 100%;
min-height: 44px;
text-align: left;
padding: 6px 9px;
background: var(--card);
border: var(--outline);
color: var(--ink);
}
.name {
font-size: 13px;
font-weight: 700;
line-height: 1.15;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page {
grid-row: 1;
grid-column: 2;
font-family: var(--mono);
font-size: 9px;
color: var(--muted);
align-self: center;
}
.where {
grid-column: 1 / -1;
font-family: var(--mono);
font-size: 8.5px;
letter-spacing: 0.08em;
color: var(--muted);
}
.combo {
grid-column: 1 / -1;
font-family: var(--mono);
font-size: 9.5px;
color: var(--accent);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

65
src/ui/FindPanel.tsx Normal file
View File

@@ -0,0 +1,65 @@
/**
* Find: search everything printed about a combo, then jump to it on the panel.
*
* Fills the whole screen rather than sitting under the panel — the reference
* hides the unit on this tab, and a result list wants the room.
*/
import { comboText, contextNames } from "../domain/format";
import type { Entry } from "../domain/types";
import styles from "./FindPanel.module.css";
export interface FindPanelProps {
readonly query: string;
readonly results: readonly Entry[];
readonly onQuery: (query: string) => void;
readonly onPick: (entry: Entry) => void;
}
export function FindPanel({ query, results, onQuery, onPick }: FindPanelProps) {
const searched = query.trim() !== "";
return (
<div className={styles.find}>
<input
className={styles.input}
type="search"
value={query}
placeholder="undo, chop, metronome, side chain…"
aria-label="Search functions"
enterKeyHint="search"
onChange={(e) => onQuery(e.target.value)}
/>
{!searched ? (
<p className={styles.note}>
Search by function, by button, or by the mode it belongs to. Pick a result
to see it lit on the panel.
</p>
) : results.length === 0 ? (
<p className={styles.note}>
No match. Try the name as it appears on screen — CHOP, TR-REC, MUTE BUS.
</p>
) : (
<>
<p className={styles.count}>
{results.length} match{results.length === 1 ? "" : "es"}
</p>
<ul className={styles.list}>
{results.map((entry) => (
<li key={entry.id}>
<button className={styles.row} onClick={() => onPick(entry)}>
<span className={styles.name}>{entry.name}</span>
<span className={styles.where}>
{contextNames(entry.ctx).join(", ")}
</span>
<span className={styles.combo}>{comboText(entry)}</span>
<span className={styles.page}>P.{entry.page}</span>
</button>
</li>
))}
</ul>
</>
)}
</div>
);
}

View File

@@ -35,6 +35,30 @@
flex: 0 0 auto;
}
.metaRight {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 6px;
}
.more {
font-family: var(--font);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--ink);
background: none;
border: var(--outline);
padding: 2px 6px;
}
/* The gesture, when the table gives one: read this before the description. */
.how {
color: var(--accent);
font-weight: 700;
}
.combo {
display: flex;
align-items: center;

View File

@@ -1,5 +1,6 @@
/** The card under the panel: the layer's hint, or the selected combo. */
import { comboParts, contextNames } from "../domain/format";
import { useEffect, useRef, useState } from "react";
import { comboParts } from "../domain/format";
import type { ContextCode, Entry } from "../domain/types";
import styles from "./InfoCard.module.css";
@@ -8,9 +9,21 @@ export interface InfoCardProps {
readonly ctx: ContextCode;
readonly hint: string;
readonly count: number;
/** Open the full description. Offered when the card cannot show it all. */
readonly onMore: () => void;
}
export function InfoCard({ entry, hint, count }: InfoCardProps) {
export function InfoCard({ entry, hint, count, onMore }: InfoCardProps) {
const body = useRef<HTMLParagraphElement>(null);
const [clipped, setClipped] = useState(false);
// Measured rather than guessed from a character count: the card is a fixed
// 84px, so whether the text fits depends on how it wraps.
useEffect(() => {
const el = body.current;
setClipped(el !== null && el.scrollHeight > el.clientHeight + 1);
}, [entry]);
if (!entry) {
return (
<div className={styles.card}>
@@ -27,6 +40,10 @@ export function InfoCard({ entry, hint, count }: InfoCardProps) {
);
}
// The gesture matters as much as the description — SHIFT + REMAIN is a
// three-second hold — so a card carrying one always offers the full text.
const showMore = clipped || entry.how !== undefined;
return (
<div className={styles.card}>
<div className={styles.meta}>
@@ -38,11 +55,19 @@ export function InfoCard({ entry, hint, count }: InfoCardProps) {
</span>
))}
</span>
<span className={styles.page}>P.{entry.page}</span>
<span className={styles.metaRight}>
{showMore ? (
<button className={styles.more} onClick={onMore}>
MORE
</button>
) : null}
<span className={styles.page}>P.{entry.page}</span>
</span>
</div>
<h2 className={styles.title}>{entry.name}</h2>
<p className={styles.body}>
{entry.desc || contextNames(entry.ctx).join(", ")}
<p className={styles.body} ref={body}>
{entry.how ? <b className={styles.how}>{entry.how}. </b> : null}
{entry.desc}
</p>
</div>
);

View File

@@ -1,4 +1,10 @@
/* Measured geometry: 8 + 36 + 8 + 124 + 10 + 134 + 10 + 187 + 8 = 525. */
/* Measured geometry: 8 + 36 + 8 + 124 + 10 + 134 + 10 + 187 + 8 = 525.
*
* Ordering matters here: the state classes (.lit, .sel, .held, .down) live at
* the BOTTOM of this file. They are single-class selectors, same specificity as
* .pad or .fx, so whichever comes last wins. With .pad declared after .lit, a
* lit pad kept its grey text and only the border turned orange — pads looked
* unhighlighted next to every other key. */
.panel {
height: var(--panel-h);
@@ -8,6 +14,12 @@
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
/* Without this the browser claims a second finger as a pinch or a pan, and a
chord can never be formed. This is what makes multitouch work at all. */
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
/* ---- shared key look ---- */
@@ -34,28 +46,12 @@
padding: 2px 1px;
border-radius: 3px;
hyphens: manual;
overflow-wrap: break-word;
}
/* Lit is an outline, not a fill. The SHIFT layer lights 35 of 54 controls, and
35 solid orange keys is a wall; solid is reserved for the selected one. */
.lit {
border-color: var(--accent);
color: var(--accent);
font-weight: 700;
}
.sel {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
font-weight: 700;
}
.held {
background: var(--ink);
color: var(--page);
font-weight: 700;
/* Lit keys show the entry's short label, which comes from the data and can be
one long unbreakable word — SHIFT + LOOP is PINGPONG. Soft hyphens only
cover the printed labels, so allow a break anywhere and clip what is left,
rather than letting it spill outside the key. */
overflow-wrap: anywhere;
overflow: hidden;
}
.badge {
@@ -86,20 +82,6 @@
color: var(--pad-ink);
}
/* A round key cannot hold a lit function's label at full size: SHIFT + VALUE is
"MARK FN", which spilled outside the circle. Clip it, and shrink when lit. */
.knob,
.valueKnob {
overflow: hidden;
}
.knob.lit,
.knob.sel,
.valueKnob.lit,
.valueKnob.sel {
font-size: 6px;
}
/* ---- fx and the screen ---- */
.screenRow {
@@ -270,6 +252,11 @@
border-radius: 4px;
}
.side {
color: var(--accent);
font-weight: 700;
}
/* The pad's number, as printed on the hardware. */
.num {
font-family: var(--mono);
@@ -278,7 +265,39 @@
color: var(--muted);
}
.side {
/* ---- state: LAST in the file, so it wins over .pad, .fx, .side and .knob ---- */
/* Lit is an outline, not a fill. The SHIFT layer lights 35 of 54 controls, and
35 solid orange keys is a wall; solid is reserved for the selected one. */
.lit {
border-color: var(--accent);
color: var(--accent);
font-weight: 700;
}
.sel {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
font-weight: 700;
}
.held {
background: var(--ink);
color: var(--page);
font-weight: 700;
}
/* A round key cannot hold a lit function's label at full size: SHIFT + VALUE is
MARK FN, which spilled outside the circle. Shrink it when lit. */
.knob.lit,
.knob.sel,
.valueKnob.lit,
.valueKnob.sel {
font-size: 6px;
}
/* Pressed, whether or not it is lit. */
.down {
transform: scale(0.95);
}

View File

@@ -1,10 +1,17 @@
/**
* The SP-404MK2 panel. Presentational: it is told what is lit and reports
* presses, holding no state of its own.
* The SP-404MK2 panel. Presentational: it is told what is lit, and it reports
* presses. It owns pointer bookkeeping only because it owns the DOM — what a
* chord *means* stays in App.
*
* Multitouch matters here: answering a drill card means holding two or three
* controls at once. Pointers are tracked per pointerId, and the gesture ends
* when the last finger lifts, which is when a held chord is graded.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { panelLabel } from "../domain/format";
import type { ControlId, Entry } from "../domain/types";
import { cx } from "./cx";
import { tapFeedback } from "./native";
import { CONTROL_GROUPS, FX_LEFT, FX_RIGHT, KNOBS, PAD_ROWS } from "./panelLayout";
import styles from "./Panel.module.css";
@@ -12,33 +19,36 @@ export interface ScreenText {
readonly top: string;
readonly right: string;
readonly main: string;
/** Inverted chips along the bottom of the LCD — the layer's functions. */
readonly chips: readonly string[];
}
export interface PanelProps {
/** Controls carrying a function on the current layer, and which entries. */
readonly lit: ReadonlyMap<ControlId, Entry[]>;
readonly held: readonly ControlId[];
readonly selected?: ControlId | null;
readonly screen: ScreenText;
/** A control went down. */
readonly onPress?: ((id: ControlId) => void) | undefined;
/** That control's finger lifted. Explore's latch needs the timing per key. */
readonly onRelease?: ((id: ControlId) => void) | undefined;
/** The last finger lifted; every control touched during the gesture. */
readonly onGestureEnd?: ((ids: ControlId[]) => void) | undefined;
}
interface KeyProps {
readonly id: ControlId;
/** Comes from a CSS module, so possibly undefined. See cx. */
readonly className: string | undefined;
readonly lit: ReadonlyMap<ControlId, Entry[]>;
readonly held: readonly ControlId[];
readonly selected: ControlId | null;
readonly onPress?: ((id: ControlId) => void) | undefined;
readonly down: ReadonlySet<ControlId>;
readonly onKey: (id: ControlId) => void;
}
/** Pads print their number, not "PAD 7" — until a layer lights them up. */
const padNumber = (id: ControlId): string | undefined => /^p(\d+)$/.exec(id)?.[1];
function Key({ id, className, lit, held, selected, onPress }: KeyProps) {
function Key({ id, className, lit, held, selected, down, onKey }: KeyProps) {
const entries = lit.get(id);
const isHeld = held.includes(id);
const isLit = entries !== undefined && entries.length > 0;
@@ -50,6 +60,7 @@ function Key({ id, className, lit, held, selected, onPress }: KeyProps) {
type="button"
className={cx(
className,
down.has(id) && styles.down,
isHeld && styles.held,
!isHeld && isSel && styles.sel,
!isHeld && isLit && !isSel && styles.lit,
@@ -57,7 +68,12 @@ function Key({ id, className, lit, held, selected, onPress }: KeyProps) {
data-control={id}
aria-label={panelLabel(id)}
aria-pressed={isHeld || isSel}
onClick={onPress ? () => onPress(id) : undefined}
// Pointer events drive presses; this is the keyboard equivalent.
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
onKey(id);
}}
>
{isLit ? (
entries[0]!.short
@@ -73,11 +89,86 @@ function Key({ id, className, lit, held, selected, onPress }: KeyProps) {
);
}
export function Panel({ lit, held, selected, screen, onPress }: PanelProps) {
const key = { lit, held, selected: selected ?? null, onPress };
export function Panel({
lit,
held,
selected,
screen,
onPress,
onRelease,
onGestureEnd,
}: PanelProps) {
/** pointerId to the control it is holding. */
const active = useRef(new Map<number, ControlId>());
/** Everything touched since the first finger went down. */
const gesture = useRef(new Set<ControlId>());
const [down, setDown] = useState<ReadonlySet<ControlId>>(new Set());
// Kept in refs so the window listeners below can be attached once and still
// call the current callbacks.
const pressRef = useRef(onPress);
const releaseRef = useRef(onRelease);
const endRef = useRef(onGestureEnd);
pressRef.current = onPress;
releaseRef.current = onRelease;
endRef.current = onGestureEnd;
useEffect(() => {
// Fingers often lift outside the key they went down on, so releases are
// heard on the window rather than the button.
function release(e: PointerEvent) {
const id = active.current.get(e.pointerId);
if (id === undefined) return;
active.current.delete(e.pointerId);
setDown(new Set(active.current.values()));
// Per-control, and before the gesture-wide callback: Explore's latch
// decides hold-versus-tap from how long this one key was down.
releaseRef.current?.(id);
if (active.current.size > 0) return;
const ids = [...gesture.current];
gesture.current.clear();
if (ids.length > 0) endRef.current?.(ids);
}
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
return () => {
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
};
}, []);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const el = (e.target as HTMLElement).closest<HTMLElement>("[data-control]");
if (!el) return;
const id = el.dataset["control"];
if (!id) return;
// Stops the browser treating a second finger as a pinch or a pan.
e.preventDefault();
active.current.set(e.pointerId, id);
gesture.current.add(id);
setDown(new Set(active.current.values()));
// Every key gets a tick, including the second and third finger of a chord.
tapFeedback();
pressRef.current?.(id);
}, []);
/** Keyboard: a full press, release and gesture in one go, so taps latch. */
const onKey = useCallback((id: ControlId) => {
pressRef.current?.(id);
releaseRef.current?.(id);
endRef.current?.([id]);
}, []);
const key = { lit, held, selected: selected ?? null, down, onKey };
return (
<div className={styles.panel}>
<div
className={styles.panel}
onPointerDown={onPointerDown}
onContextMenu={(e) => e.preventDefault()}
>
<div className={styles.knobs}>
{KNOBS.map((id) => (
<Key key={id} id={id} className={styles.knob} {...key} />

View File

@@ -0,0 +1,88 @@
/* Takes the space the panel would use, and scrolls. */
.rules {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding-bottom: 4px;
}
.map {
background: var(--card);
border: var(--outline);
box-shadow: var(--shadow-sm);
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.mapHead {
margin: 0;
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.12em;
color: var(--muted);
}
.mapRow {
display: flex;
flex-direction: column;
gap: 3px;
}
.mapLabel {
margin: 0;
font-family: var(--mono);
font-size: 8.5px;
letter-spacing: 0.06em;
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
}
.cell {
background: var(--page);
border: 1px solid var(--rule);
padding: 4px 5px;
font-size: 9.5px;
line-height: 1.15;
min-width: 0;
overflow: hidden;
}
.cellNum {
display: block;
font-family: var(--mono);
font-size: 11px;
color: var(--accent);
}
.rule {
background: var(--card);
border: var(--outline);
box-shadow: var(--shadow-sm);
padding: 8px 10px;
}
.title {
margin: 0 0 3px;
font-size: 13.5px;
font-weight: 700;
line-height: 1.15;
}
.body {
margin: 0;
font-size: 12px;
line-height: 1.35;
color: var(--muted);
text-wrap: pretty;
}

38
src/ui/RulesPanel.tsx Normal file
View File

@@ -0,0 +1,38 @@
/**
* Rules: the nine patterns behind the combos, and the SHIFT + pad map.
*
* The text is extracted verbatim from the reference, never retyped — see
* scripts/extract-reference-data.mjs.
*/
import { PAD_MAP, RULES } from "../domain/rules";
import styles from "./RulesPanel.module.css";
export function RulesPanel() {
return (
<div className={styles.rules}>
<section className={styles.map}>
<p className={styles.mapHead}>SHIFT + PAD, ROW BY ROW</p>
{PAD_MAP.map((row, rowIndex) => (
<div key={row.label} className={styles.mapRow}>
<p className={styles.mapLabel}>{row.label}</p>
<div className={styles.grid}>
{row.pads.map((pad, i) => (
<div key={pad} className={styles.cell}>
<b className={styles.cellNum}>{rowIndex * 4 + i + 1}</b>
{pad}
</div>
))}
</div>
</div>
))}
</section>
{RULES.map((rule) => (
<section key={rule.title} className={styles.rule}>
<h3 className={styles.title}>{rule.title}</h3>
<p className={styles.body}>{rule.body}</p>
</section>
))}
</div>
);
}

58
src/ui/native.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* The native bits Phase 1 did in native.js: haptics and the hardware back
* button. No-ops in a browser, so the dev server behaves normally.
*/
import { useEffect } from "react";
import { App as CapacitorApp } from "@capacitor/app";
import { Capacitor } from "@capacitor/core";
import { Haptics, ImpactStyle, NotificationType } from "@capacitor/haptics";
const isNative = (): boolean => Capacitor.isNativePlatform();
/**
* Swallowing these was a mistake: haptics did nothing on the device and the
* reason had been discarded. Report once per distinct failure — enough to read
* in logcat, quiet enough not to spam on every key press.
*/
const reported = new Set<string>();
function report(where: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
const key = where + ":" + message;
if (reported.has(key)) return;
reported.add(key);
console.error("haptics " + where + " failed:", message);
}
/** A light tick under every key press, as the reference's buzz() did. */
export function tapFeedback(): void {
if (!isNative()) return;
Haptics.impact({ style: ImpactStyle.Light }).catch((e) => report("impact", e));
}
/** Shaped feedback on a graded answer: right and wrong should feel different. */
export function gradeFeedback(correct: boolean): void {
if (!isNative()) return;
Haptics.notification({
type: correct ? NotificationType.Success : NotificationType.Error,
}).catch((e) => report("notification", e));
}
export function exitApp(): void {
if (!isNative()) return;
void CapacitorApp.exitApp();
}
/**
* Hardware back. Registering a listener suppresses Capacitor's default history
* navigation, so `onBack` owns the whole stack: sheet, then tab, then exit.
*/
export function useBackButton(onBack: () => void): void {
useEffect(() => {
if (!isNative()) return;
const handle = CapacitorApp.addListener("backButton", () => onBack());
return () => {
void handle.then((h) => h.remove());
};
}, [onBack]);
}