Compare commits
10 Commits
b875342864
...
c919e160ee
| Author | SHA1 | Date | |
|---|---|---|---|
| c919e160ee | |||
| 2b28f27e79 | |||
| 283c5d8d0a | |||
| 6ddf6f9a77 | |||
| 6889167fcf | |||
| d0f872fc8b | |||
| b95cff1be1 | |||
| 7ddaf3d949 | |||
| 470ebf41dc | |||
| 6d1f219e8a |
5
.gitignore
vendored
@@ -20,8 +20,11 @@ android/local.properties
|
||||
android/.idea/
|
||||
*.apk
|
||||
*.aab
|
||||
# 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
|
||||
|
||||
71
CLAUDE.md
@@ -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
|
||||
@@ -180,18 +200,59 @@ 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` writes 7
|
||||
(any source) and works. 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
@@ -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.
|
||||
153
README.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
## 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 debug-signed APK, sideloaded over `adb`.
|
||||
|
||||
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 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.
|
||||
|
||||
## Design
|
||||
|
||||
The look follows Teenage Engineering's EP-133 manual: flat fills, heavy outlines,
|
||||
hard zero-blur shadows, one orange accent that means exactly one thing (this
|
||||
control is live), and monospaced technical annotation. The panel is laid out from
|
||||
the hardware — the control groups carry the same silkscreen captions as the
|
||||
device, and the screen is a circular housing behind a rectangular LCD.
|
||||
|
||||
Type is Space Grotesk and Space Mono, self-hosted so the app works with no
|
||||
network.
|
||||
|
||||
## 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/).
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 850 B |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 536 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 992 B |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 940 B |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.1 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
<color name="ic_launcher_background">#e9e9e6</color>
|
||||
</resources>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"appId": "dev.ncode.sp404layers",
|
||||
"appName": "SP-404MK2 layers",
|
||||
"webDir": "www",
|
||||
"appName": "404 Layers",
|
||||
"webDir": "dist",
|
||||
"android": {
|
||||
"backgroundColor": "#151719",
|
||||
"allowMixedContent": false
|
||||
|
||||
BIN
design/icon/preview-as-launcher-shows-it.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
design/icon/preview-round.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
design/icon/preview-square.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
design/screenshots/explore-and-shift.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
design/screenshots/explore-shift.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
design/screenshots/explore.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
@@ -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>
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
"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: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",
|
||||
"device:awake": "bash scripts/device.sh awake",
|
||||
"device:keep-awake": "bash scripts/device.sh keep-awake",
|
||||
@@ -19,6 +20,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"
|
||||
|
||||
93
public/fonts/OFL-Space-Grotesk.txt
Normal 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.
|
||||
93
public/fonts/OFL-Space-Mono.txt
Normal 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
@@ -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`.
|
||||
@@ -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
@@ -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/`,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
220
src/App.tsx
@@ -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(", ")} · 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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
113
src/ui/Panel.tsx
@@ -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} />
|
||||
|
||||
88
src/ui/RulesPanel.module.css
Normal 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
@@ -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
@@ -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]);
|
||||
}
|
||||