Phase 1: wrap the reference web app in Capacitor for Android

Wraps reference/sp404-layers as a sideloadable APK without editing the
reference, which stays the frozen spec for the Phase 2 React port.

- sync-www.sh builds www/ from reference/ + native/, asserting each patch
  anchor so a reference change fails loudly rather than shipping broken
- drops the service worker (would serve a stale app after an APK update)
- haptics via a navigator.vibrate override, so buzz() needs no change
- localStorage mirrored into Preferences; sync load()/save() untouched
- hardware back: sheet -> Explore tab -> exit
- edge-to-edge handled in MainActivity: Android 16 + targetSdk 36 removes
  the opt-out, and a WebView never fills env(safe-area-inset-*), so insets
  are pushed into CSS custom properties instead

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 00:26:35 +03:00
commit 1a8a12f7f3
75 changed files with 3542 additions and 0 deletions

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
node_modules/
# Generated by scripts/sync-www.sh from reference/ - never edit by hand.
www/
# Capacitor's generated Android project. Tracked selectively: the few files we
# hand-edit (MainActivity, manifest, styles, variables.gradle) are force-added.
android/.gradle/
android/build/
android/app/build/
android/app/src/main/assets/public/
android/app/src/main/assets/capacitor.config.json
android/app/src/main/assets/capacitor.plugins.json
android/capacitor-cordova-android-plugins/
android/local.properties
android/.idea/
*.apk
*.aab
*.keystore
!debug.keystore

119
CLAUDE.md Normal file
View File

@@ -0,0 +1,119 @@
# SP-404MK2 layers
A combo trainer for the Roland SP-404MK2, wrapped as a sideloaded Android APK.
**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
tests first, then UI, then multitouch input.
## Layout
```
reference/ The original working web app. FROZEN — this is the spec.
Never edit it. Behaviour, data and styling are all defined here.
native/ Native-only additions (native.css, native.js). Not in reference.
scripts/ install-android-toolchain.sh, sync-www.sh
www/ GENERATED by sync-www.sh. Never edit by hand, never committed.
android/ Generated by `npx cap add android`. A few files are hand-edited
(see "Hand-edited native files" below).
```
## Commands
| Command | What it does |
|---|---|
| `npm run sync:www` | Rebuild `www/` from `reference/` + `native/` |
| `npm run sync` | The above, then `npx cap sync android` |
| `npm run apk` | Sync, then `./gradlew assembleDebug` |
| `npm run install:apk` | `adb install -r` the debug APK |
| `npm run run:android` | Build + install in one go — the usual loop |
| `npm run logcat` | Tail Capacitor + web console output from the device |
APK lands at `android/app/build/outputs/apk/debug/app-debug.apk`.
The Gradle build needs these in the environment (persisted to `~/.zshrc` by the
install script):
```
JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
ANDROID_HOME=$HOME/Android/Sdk
```
## Toolchain (verified, do not bump blind)
Pinned against Capacitor 8.5.2's `capacitor/build.gradle`:
`compileSdk 36`, `targetSdk 36`, `minSdk 24`, Java 21.
- JDK **21** — required by Capacitor 8; `openjdk-21-jdk` from apt
- cmdline-tools **15859902**
- build-tools **36.1.0** — pairs with compileSdk 36
- platforms **android-35**, **android-36**
`sdkmanager` prints a deprecation warning pointing at the newer `android` CLI.
It still works, and it writes the `licenses/` directory that Gradle checks. The
replacement downloads itself on first run and gates on an interactive ToS, which
is worse in an idempotent install script. Revisit if it actually breaks.
## Decisions
**`reference/` is never edited.** It is the Phase 2 spec, so it has to stay
byte-for-byte comparable. Everything native-specific is either an additive file
or one of three removals applied by `sync-www.sh`, which asserts each anchor
exists and fails loudly if the reference ever changes shape.
The three removals:
1. **Service worker registration** — would cache `index.html` and serve a stale
app after an APK update. Assets are already local, so it buys nothing.
2. **Web manifest link** — the APK's own manifest governs the app.
3. Nothing else. `native.css` and `native.js` are *injected*, not substituted.
**Haptics needs no reference change.** `buzz()` is a `const` and cannot be
reassigned, but it calls `navigator.vibrate`, which can be overridden. `native.js`
redefines that to dispatch to `@capacitor/haptics`: scalar → `impact`, array →
`notification({type:"SUCCESS"})`, `>15` → `notification({type:"ERROR"})`.
**Storage mirrors rather than migrates.** `localStorage` stays the synchronous
source of truth so `load()`/`save()` keep their signatures. `save()` is wrapped
to mirror into `@capacitor/preferences` (SharedPreferences), which survives a
"clear app data" wipe. On boot, if `localStorage` is empty but Preferences is
not, it restores and re-renders. A full async port was rejected as a refactor of
code about to be rewritten in React anyway.
**Back button** is owned by `native.js`: sheet open → close it; otherwise not on
Explore → go to Explore; otherwise exit. Registering a listener suppresses
Capacitor's default history navigation.
**Edge-to-edge is handled natively, not avoided.** Android 16 is API 36 and
Capacitor 8 targets SDK 36, which is exactly the combination where
`windowOptOutEdgeToEdgeEnforcement` stops working. An Android WebView never
populates `env(safe-area-inset-*)`, so `MainActivity` installs an
`OnApplyWindowInsetsListener`, converts px → dp, and pushes the values into
`--ins-t/r/b/l` via `window.__applyInsets()`. `native.css` re-points the
reference's `--sab` at `--ins-b`, so `.bnav` and `.sheet` need no changes.
The push retries on a bounded schedule (25 × 200ms) because insets typically
arrive before the page has run any JS.
**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.
## Hand-edited native files
`cap sync` does not regenerate these — keep them when recreating `android/`:
- `android/app/src/main/java/dev/ncode/sp404layers/MainActivity.java` — inset bridge
- `android/app/src/main/AndroidManifest.xml` — `android:screenOrientation="portrait"`
## Gotchas
- The reference's multitouch relies on `touch-action:none` on `.unit` plus
per-`pointerId` tracking in a `Map`. This is the most delicate part to port in
Phase 2 — read `exDown`/`exUp`/`drDown`/`drUp` before touching it.
- `store`, `st` and `SK` are `const`/`let` at classic-script top level: reachable
by bare name from `native.js`, but absent from `window`. `save`/`render`/
`closeSheet` are function declarations, so they *are* on `window` and can be
wrapped.

101
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

2
android/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

54
android/app/build.gradle Normal file
View File

@@ -0,0 +1,54 @@
apply plugin: 'com.android.application'
android {
namespace = "dev.ncode.sp404layers"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "dev.ncode.sp404layers"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View File

@@ -0,0 +1,21 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-haptics')
implementation project(':capacitor-preferences')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

21
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -0,0 +1,81 @@
package dev.ncode.sp404layers;
import android.os.Bundle;
import android.view.View;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.BridgeActivity;
/**
* Android 15+ forces edge-to-edge for apps targeting SDK 35, and Android 16
* removes the windowOptOutEdgeToEdgeEnforcement escape hatch entirely at
* targetSdk 36 (which is Capacitor 8's default). An Android WebView does not
* populate env(safe-area-inset-*), so the web layer has no way to discover the
* system bar geometry on its own.
*
* This bridges the gap: the app draws behind the bars, and the real insets are
* pushed into CSS custom properties that native.css consumes.
*/
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Draw behind the system bars.
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
// The UI is dark, so the status/nav bar icons must be light.
WindowInsetsControllerCompat controller =
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
if (controller != null) {
controller.setAppearanceLightStatusBars(false);
controller.setAppearanceLightNavigationBars(false);
}
final View webView = getBridge().getWebView();
ViewCompat.setOnApplyWindowInsetsListener(webView, (v, windowInsets) -> {
Insets bars = windowInsets.getInsets(
WindowInsetsCompat.Type.systemBars()
| WindowInsetsCompat.Type.displayCutout());
// CSS pixels are density-independent; WindowInsets are physical px.
float density = getResources().getDisplayMetrics().density;
final int top = Math.round(bars.top / density);
final int right = Math.round(bars.right / density);
final int bottom = Math.round(bars.bottom / density);
final int left = Math.round(bars.left / density);
// Returns "1" once applied, "0" while native.js has not loaded yet.
final String js = String.format(
java.util.Locale.US,
"(window.__applyInsets?(window.__applyInsets(%d,%d,%d,%d),1):0)",
top, right, bottom, left);
// Insets usually arrive before the page has run any JS, so retry on
// a bounded schedule instead of firing once into the void.
applyWithRetry(js, 0);
// Do not consume: other listeners (and the IME) may still need these.
return windowInsets;
});
}
private static final int INSET_RETRY_LIMIT = 25; // ~5s at 200ms
private static final long INSET_RETRY_DELAY_MS = 200L;
/** Pushes the inset JS, retrying until native.js has defined __applyInsets. */
private void applyWithRetry(final String js, final int attempt) {
final View webView = getBridge().getWebView();
webView.post(() -> getBridge().getWebView().evaluateJavascript(js, value -> {
boolean applied = value != null && value.contains("1");
if (!applied && attempt < INSET_RETRY_LIMIT) {
webView.postDelayed(() -> applyWithRetry(js, attempt + 1), INSET_RETRY_DELAY_MS);
}
}));
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,34 @@
<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

@@ -0,0 +1,170 @@
<?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.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

View File

@@ -0,0 +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="package_name">dev.ncode.sp404layers</string>
<string name="custom_url_scheme">dev.ncode.sp404layers</string>
</resources>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View File

@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

29
android/build.gradle Normal file
View File

@@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@@ -0,0 +1,12 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')

22
android/gradle.properties Normal file
View File

@@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android/gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

5
android/settings.gradle Normal file
View File

@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'

16
android/variables.gradle Normal file
View File

@@ -0,0 +1,16 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}

12
capacitor.config.json Normal file
View File

@@ -0,0 +1,12 @@
{
"appId": "dev.ncode.sp404layers",
"appName": "SP-404MK2 layers",
"webDir": "www",
"android": {
"backgroundColor": "#151719",
"allowMixedContent": false
},
"server": {
"androidScheme": "https"
}
}

36
native/native.css Normal file
View File

@@ -0,0 +1,36 @@
/* Native-only overrides.
reference/ is never edited; sync-www.sh injects this as the last stylesheet
in <head>, so on equal specificity these rules win. */
:root{
/* Filled in from Android WindowInsets by MainActivity via __applyInsets().
The env() defaults keep the page correct in a plain desktop browser, where
env() works and the native bridge is absent. */
--ins-t: env(safe-area-inset-top, 0px);
--ins-b: env(safe-area-inset-bottom, 0px);
--ins-l: env(safe-area-inset-left, 0px);
--ins-r: env(safe-area-inset-right, 0px);
/* The reference derives --sab from env(safe-area-inset-bottom), which is
always 0px inside an Android WebView. Re-point it at the real inset.
.bnav and .sheet both key off --sab, so they need no further changes. */
--sab: var(--ins-b);
}
@media (max-width:820px){
/* The reference sets `padding: 8px 8px calc(var(--nav) + var(--sab) + 8px)`.
Bottom already accounts for --sab; add the top and side insets so the unit
panel clears the status bar and any display cutout. */
.app{
padding-top: calc(8px + var(--ins-t));
padding-left: calc(8px + var(--ins-l));
padding-right: calc(8px + var(--ins-r));
}
/* Bottom nav is position:fixed and spans the full width, so it needs the
side insets itself. Its height/padding-bottom already follow --sab. */
.bnav{
padding-left: var(--ins-l);
padding-right: var(--ins-r);
}
}

113
native/native.js Normal file
View File

@@ -0,0 +1,113 @@
/* Native glue for the Capacitor wrapper.
*
* Loaded as a classic script AFTER the app script, so it shares the global
* lexical scope and can reach the reference's top-level bindings directly
* (store, st, render, closeSheet, save). Nothing in reference/ is modified.
*
* Plugins are reached through window.Capacitor.Plugins rather than ESM imports,
* because there is no bundler in Phase 1. The native side registers them during
* `cap sync`, so the bridge proxies are present without any JS import.
*
* Everything degrades silently when the bridge is absent (desktop browser).
*/
(function () {
"use strict";
var cap = window.Capacitor;
var P = (cap && cap.Plugins) || {};
/* Called from MainActivity's window-inset listener. Defined unconditionally
so the native side never has to feature-detect it. Values arrive in dp. */
window.__applyInsets = function (top, right, bottom, left) {
var s = document.documentElement.style;
s.setProperty("--ins-t", top + "px");
s.setProperty("--ins-r", right + "px");
s.setProperty("--ins-b", bottom + "px");
s.setProperty("--ins-l", left + "px");
};
if (!(cap && cap.isNativePlatform && cap.isNativePlatform())) return;
/* ---------- haptics ----------
The reference's buzz() is a `const`, so it cannot be reassigned. It calls
navigator.vibrate, which can be — so this needs no reference change at all.
Call sites: buzz() on every key press, buzz([12,40,12]) on a correct
answer, buzz(40) on a wrong one. */
var H = P.Haptics;
if (H) {
Object.defineProperty(navigator, "vibrate", {
configurable: true,
writable: true,
value: function (pattern) {
try {
if (Array.isArray(pattern)) H.notification({ type: "SUCCESS" });
else if (pattern > 15) H.notification({ type: "ERROR" });
else H.impact({ style: "LIGHT" });
} catch (e) {}
return true; // buzz() only checks truthiness of navigator.vibrate
}
});
}
/* ---------- storage ----------
localStorage stays the synchronous source of truth so load()/save() keep
their signatures. Every write is mirrored into SharedPreferences, which
survives a "clear app data" wipe of the WebView. */
var Pref = P.Preferences;
var SK = "sp404mk2-layers-v1"; // must match SK in reference/index.html
if (Pref) {
var origSave = window.save;
window.save = function () {
origSave.apply(this, arguments);
try {
Pref.set({ key: SK, value: localStorage.getItem(SK) || "{}" });
} catch (e) {}
};
/* The app already ran load() before this script executed, so rehydration is
a repair pass: only when localStorage is empty and Preferences is not. */
Pref.get({ key: SK })
.then(function (r) {
var backup = r && r.value;
if (!backup) return;
if (localStorage.getItem(SK)) return; // localStorage wins when present
var o = JSON.parse(backup);
if (!o || typeof o !== "object") return;
localStorage.setItem(SK, backup);
store = o; // global lexical binding from the reference
store.boxes = store.boxes || {};
st.ctx = store.ctx || st.ctx;
st.deck = store.deck || st.deck;
st.dir = store.dir || st.dir;
st.input = store.input || st.input;
render();
})
.catch(function () {});
}
/* ---------- hardware back ----------
Registering a listener suppresses Capacitor's default history navigation,
so this owns the whole back stack: sheet -> Explore tab -> exit.
closeSheet() does its own history.back(), which keeps the pushState entry
the sheet added from leaking. */
var App = P.App;
if (App) {
App.addListener("backButton", function () {
var sheet = document.getElementById("sheet");
if (sheet && sheet.classList.contains("open")) {
closeSheet();
return;
}
if (st.tab !== "explore") {
st.tab = "explore";
render();
window.scrollTo(0, 0);
return;
}
App.exitApp();
});
}
})();

1267
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "sp404-layers",
"version": "1.0.0",
"private": true,
"description": "SP-404MK2 combo trainer, wrapped as an Android app",
"scripts": {
"sync:www": "bash scripts/sync-www.sh",
"sync": "npm run sync:www && npx cap sync android",
"apk": "npm run sync && cd android && ./gradlew assembleDebug",
"install:apk": "adb install -r android/app/build/outputs/apk/debug/app-debug.apk",
"run:android": "npm run apk && npm run install:apk",
"logcat": "adb logcat -s Capacitor:V Capacitor/Console:V chromium:V"
},
"dependencies": {
"@capacitor/android": "^8.5.2",
"@capacitor/app": "^8.1.1",
"@capacitor/core": "^8.5.2",
"@capacitor/haptics": "^8.0.2",
"@capacitor/preferences": "^8.0.1"
},
"devDependencies": {
"@capacitor/cli": "^8.5.2"
}
}

12
reference/README.md Normal file
View File

@@ -0,0 +1,12 @@
# SP-404MK2 layers (PWA)
Static files only. Serve this folder over HTTPS (required for install and offline).
Quick options:
- Nginx container behind Nginx Proxy Manager: mount this folder at /usr/share/nginx/html.
- `docker run -d --name 404layers -p 8404:80 -v $PWD:/usr/share/nginx/html:ro nginx:alpine`, then add a proxy host in NPM with a Let's Encrypt cert.
- Any static host (Cloudflare Pages, GitHub Pages).
On the phone: open the URL in Chrome, menu, "Install app" / "Add to Home screen".
Updating: edit files, bump `V` in sw.js, redeploy. The page is fetched network-first, so it updates on next open when online.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

676
reference/index.html Normal file
View File

@@ -0,0 +1,676 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#2a2d30">
<title>SP-404MK2 layers</title>
<link rel="manifest" href="manifest.webmanifest"><link rel="icon" href="icons/icon-192.png"><link rel="apple-touch-icon" href="icons/icon-192.png"><meta name="mobile-web-app-capable" content="yes">
<style>@font-face{font-family:"Barlow";font-weight:400;font-style:normal;font-display:swap;src:url(fonts/barlow-400.woff2) format("woff2")}@font-face{font-family:"Barlow";font-weight:500;font-style:normal;font-display:swap;src:url(fonts/barlow-500.woff2) format("woff2")}@font-face{font-family:"Barlow";font-weight:600;font-style:normal;font-display:swap;src:url(fonts/barlow-600.woff2) format("woff2")}@font-face{font-family:"Barlow Condensed";font-weight:500;font-style:normal;font-display:swap;src:url(fonts/barlow-condensed-500.woff2) format("woff2")}@font-face{font-family:"Barlow Condensed";font-weight:600;font-style:normal;font-display:swap;src:url(fonts/barlow-condensed-600.woff2) format("woff2")}@font-face{font-family:"Barlow Condensed";font-weight:700;font-style:normal;font-display:swap;src:url(fonts/barlow-condensed-700.woff2) format("woff2")}</style>
<style>
:root{
--page:#dcdfe2; --ink:#1d2124; --muted:#5d656c; --line:#b9bfc5; --card:#eceef0; --sheet:#f4f5f6;
--body:#2a2d30; --key:#43474b; --key-hi:#50555a; --keyink:#c3c8cc; --pad:#3a3e42;
--lit:#ff7a1a; --lit-soft:rgba(255,122,26,.22); --held:#ffd08a; --ok:#2fb86a; --bad:#e0483d;
--lcd:#06080a; --lcdink:#e4eef2;
--f:"Barlow",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--fc:"Barlow Condensed","Roboto Condensed","Arial Narrow",system-ui,sans-serif;
--nav:58px; --sab:env(safe-area-inset-bottom,0px);
}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
--page:#151719; --ink:#e3e6e9; --muted:#8f979e; --line:#30353a; --card:#1d2023; --sheet:#1f2225; --body:#26292c; --key:#3d4145; --key-hi:#4a4f54; --pad:#34383c;
}}
:root[data-theme="dark"]{--page:#151719; --ink:#e3e6e9; --muted:#8f979e; --line:#30353a; --card:#1d2023; --sheet:#1f2225; --body:#26292c; --key:#3d4145; --key-hi:#4a4f54; --pad:#34383c;}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{overscroll-behavior:none}
body{margin:0;background:var(--page);color:var(--ink);font-family:var(--f);font-size:16px;line-height:1.45;touch-action:manipulation}
button{font:inherit;color:inherit}
:focus-visible{outline:2px solid var(--lit);outline-offset:2px}
[hidden]{display:none!important}
.muted{color:var(--muted)}
.cap{display:inline-block;font-family:var(--fc);font-weight:600;font-size:.82rem;background:var(--key);color:#e8ebee;border-radius:5px;padding:1px 7px;margin:1px 0;white-space:nowrap}
.plus{color:var(--muted);margin:0 3px;font-size:.8rem}
.btn{font-family:var(--fc);font-weight:600;font-size:1.05rem;background:var(--ink);color:var(--page);border:0;border-radius:8px;padding:9px 16px;cursor:pointer;min-height:42px}
.btn.sm{font-size:.95rem;min-height:36px;padding:5px 12px}
.ihead{display:flex;gap:8px;align-items:flex-start;justify-content:space-between}
.ihead>div:first-child{min-width:0}
.ihead .ihr{display:flex;gap:6px;flex:0 0 auto;align-items:center}
.btn.ghost{background:none;color:var(--ink);box-shadow:inset 0 0 0 1px var(--line)}
.btn:disabled{opacity:.4}
.chips{display:flex;flex-wrap:wrap;gap:6px}
.chip{font-size:.9rem;border:1px solid var(--line);background:var(--card);border-radius:999px;padding:6px 12px;cursor:pointer;white-space:nowrap;min-height:34px}
.chip[aria-pressed="true"]{background:var(--ink);color:var(--page);border-color:var(--ink)}
.chip small{opacity:.6;margin-left:3px}
.lbl{font-size:.82rem;color:var(--muted);margin:12px 0 6px}
/* ---------- layout: desktop ---------- */
.app{max-width:1080px;margin:0 auto;padding:20px 16px 40px;display:grid;grid-template-columns:minmax(300px,400px) 1fr;column-gap:28px;
grid-template-areas:"head head" "unit tabs" "unit strip" "unit info" "unit pane";grid-template-rows:auto auto auto auto 1fr;align-items:start}
.head{grid-area:head;margin-bottom:12px}
.head h1{font-family:var(--fc);font-weight:700;font-size:2rem;line-height:1;margin:0 0 4px}
.head p{margin:0;color:var(--muted);font-size:.95rem}
#unitwrap{grid-area:unit;position:sticky;top:12px}
.tabs{grid-area:tabs;display:flex;gap:4px;border-bottom:1px solid var(--line);margin-bottom:12px}
.tab{font-family:var(--fc);font-size:1.1rem;font-weight:600;background:none;border:0;color:var(--muted);padding:6px 12px 8px;cursor:pointer;border-bottom:3px solid transparent;margin-bottom:-1px}
.tab[aria-selected="true"]{color:var(--ink);border-color:var(--lit)}
#strip{grid-area:strip}
#info{grid-area:info;background:var(--card);border-radius:10px;padding:12px 14px;margin-top:12px}
#pane{grid-area:pane;margin-top:10px}
.bnav{display:none}
body[data-tab="find"] #info,body[data-tab="rules"] #info{display:none}
body[data-tab="drill"] #pane{display:none}
.onlym{display:none}
/* ---------- the unit ---------- */
.unit{background:var(--body);border-radius:18px;padding:12px;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;touch-action:none;display:flex;flex-direction:column;gap:9px}
.row{display:flex;gap:6px}
.k{font-family:var(--fc);font-weight:600;color:var(--keyink);background:var(--key);border:0;border-radius:6px;font-size:10px;line-height:1.05;padding:3px 2px;min-height:32px;flex:1;min-width:0;cursor:pointer;display:flex;align-items:center;justify-content:center;text-align:center;position:relative;overflow:hidden;letter-spacing:.02em;transition:background .1s,color .1s,box-shadow .1s,transform .06s}
@media (hover:hover){.k:not(.target):not(.held):not(.chord):not(.good):hover{background:var(--key-hi)}}
.k.down{transform:scale(.95)}
.k.round{border-radius:50%;aspect-ratio:1;min-height:0;flex:0 0 auto}
.knobs{justify-content:space-between;padding:0 6px}
.knobs .k{width:46px;background:#1b1d1f;box-shadow:inset 0 0 0 3px #34383b}
.fx{display:grid;grid-template-columns:76px 1fr 76px;gap:8px}
.fx .col{display:flex;flex-direction:column;gap:6px}
.fx .k{min-height:34px;border-radius:14px}
.lcd{background:var(--lcd);border-radius:50%/42%;color:var(--lcdink);display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:6px 10px;font-family:var(--fc);min-height:0;overflow:hidden}
.lcd .l1{font-size:11px;opacity:.7;letter-spacing:.06em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.lcd .l2{font-size:21px;font-weight:700;line-height:1.05;margin:2px 0}
.lcd .l3{font-size:11px;opacity:.75;max-width:160px;line-height:1.15}
.ctl{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,5fr);gap:10px}
.blk{display:flex;flex-direction:column;gap:6px}
.ctl .k{font-size:9.5px;letter-spacing:0;min-height:34px}
.valk{width:36px;background:#1b1d1f!important;box-shadow:inset 0 0 0 3px #34383b;font-size:8px!important}
.bank{gap:4px}.bank .k{min-height:28px!important;font-size:9px}
.pads{display:grid;grid-template-columns:repeat(4,1fr) .9fr;grid-auto-rows:minmax(52px,1fr);gap:6px}
.pads .k{background:var(--pad);font-size:11px;min-height:0}
.pads .k.side{font-size:10px}
.pads .k .n{font-size:17px;font-weight:700;color:#7b8288}
.k.target{background:#5a3a22;color:#ffe3cc;box-shadow:0 0 0 1px var(--lit) inset,0 0 12px var(--lit-soft)}
.pads .k.target .n{display:none}
.k.target.sel{background:var(--lit);color:#1a0d02}
.k.held{background:var(--lit);color:#1a0d02;box-shadow:0 0 14px var(--lit-soft)}
.k.chord{background:var(--held);color:#231400}
.k.good{background:var(--ok);color:#04160b}
.k.miss{box-shadow:0 0 0 2px var(--bad) inset}
.k .cnt{position:absolute;top:2px;right:3px;font-size:8px;opacity:.8}
/* ---------- info / pane content ---------- */
.ititle{font-family:var(--fc);font-size:1.35rem;font-weight:700;line-height:1.1;margin:4px 0 2px}
.idesc{margin:2px 0}
.irow{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px}
.fb{font-weight:600}.fb.ok{color:var(--ok)}.fb.no{color:var(--bad)}
.opts{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:8px}
.opt{text-align:left;font-size:.9rem;line-height:1.2;background:var(--page);border:1px solid var(--line);border-radius:8px;padding:8px 10px;min-height:44px;cursor:pointer}
.list{border-top:1px solid var(--line)}
.item{display:grid;grid-template-columns:minmax(120px,190px) 1fr auto;gap:10px;align-items:baseline;padding:10px 4px;border:0;border-bottom:1px solid var(--line);cursor:pointer;background:none;width:100%;text-align:left}
.item.on{background:var(--card);box-shadow:inset 3px 0 0 var(--lit)}
.item .nm{font-weight:600}
.item .pg{color:var(--muted);font-size:.8rem}
@media (max-width:520px){.item{grid-template-columns:1fr auto}.item .combo{grid-column:1/-1;order:3}}
input[type=search],textarea{width:100%;font:inherit;padding:10px 12px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink)}
textarea{font-family:ui-monospace,monospace;font-size:.75rem;height:90px}
.stripbtn{display:flex;align-items:center;gap:10px;width:100%;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:8px 12px;cursor:pointer;text-align:left;min-height:48px}
.stripbtn b{font-family:var(--fc);font-size:1.05rem}
.mini{display:flex;gap:2px;align-items:flex-end;height:22px;width:70px;flex:0 0 auto}
.mini div{flex:1;background:var(--line);border-radius:2px 2px 0 0;min-height:2px}
.mini div:last-child{background:var(--lit)}
.rules{display:grid;gap:14px}
.rule h3{font-family:var(--fc);font-size:1.25rem;margin:0 0 2px;line-height:1.15}
.rule p{margin:0;max-width:68ch}
.grid4{display:grid;grid-template-columns:repeat(4,1fr);gap:4px;margin-top:6px;max-width:420px}
.grid4 div{background:var(--card);border-radius:5px;padding:5px 6px;font-size:.8rem;line-height:1.15}
.grid4 b{display:block;font-family:var(--fc);font-size:.95rem}
.rowlbl{grid-column:1/-1;font-size:.78rem;color:var(--muted);margin-top:4px}
/* ---------- sheet ---------- */
.scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:20;opacity:0;pointer-events:none;transition:opacity .15s}
.sheet{position:fixed;left:50%;bottom:0;transform:translate(-50%,105%);width:min(640px,100%);max-height:82dvh;background:var(--sheet);border-radius:16px 16px 0 0;z-index:21;display:flex;flex-direction:column;transition:transform .18s ease-out;padding-bottom:var(--sab)}
.sheet.open{transform:translate(-50%,0)}
.scrim.open{opacity:1;pointer-events:auto}
.shead{display:flex;align-items:center;justify-content:space-between;padding:10px 12px 6px 16px;border-bottom:1px solid var(--line)}
.shead b{font-family:var(--fc);font-size:1.25rem}
.sclose{background:none;border:0;font-size:1.6rem;line-height:1;width:44px;height:44px;cursor:pointer}
.sbody{overflow:auto;padding:4px 16px 16px;-webkit-overflow-scrolling:touch}
/* ---------- layout: phone ---------- */
@media (max-width:820px){
.app{display:flex;flex-direction:column;align-items:stretch;height:100dvh;padding:8px 8px calc(var(--nav) + var(--sab) + 8px);gap:8px}
body[data-tab="find"] .app,body[data-tab="rules"] .app{height:auto;min-height:100dvh}
.app>*{min-width:0;max-width:100%}
.head,.tabs{display:none}
.onlym{display:initial}
.onlyd{display:none!important}
#unitwrap{position:static;flex:1 1 auto;display:flex;flex-direction:column;min-height:0}
.unit{flex:1 1 auto;min-height:0;padding:8px;gap:7px;border-radius:16px;overflow:hidden}
.knobs .k{width:36px}
.fx{grid-template-columns:64px 1fr 64px;gap:6px}
.fx .col{gap:5px}
.fx .k{min-height:30px;font-size:9px}
.lcd .l2{font-size:19px}
.ctl{gap:6px}
.blk{gap:5px}.blk .row{gap:4px}
.ctl .k{font-size:8.5px;min-height:36px}
.bank .k{font-size:8px;min-height:32px!important}
.pads{flex:1 1 auto;min-height:0;gap:5px;grid-auto-rows:minmax(34px,1fr)}
#strip .chips{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;margin:0 -8px;padding:0 8px}
#strip .chips::-webkit-scrollbar{display:none}
#strip .lbl{display:none}
#strip .chips+.chips{margin-top:6px}
#info{margin:0;flex:0 0 auto;padding:10px 12px;min-height:116px}
.clamp{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.ititle{font-size:1.2rem;margin:2px 0 1px}
.opts{margin-top:6px}
body[data-tab="explore"] #pane{display:none}
body[data-tab="find"] #unitwrap,body[data-tab="rules"] #unitwrap{display:none}
#pane{margin:0}
.bnav{display:flex;position:fixed;left:0;right:0;bottom:0;height:calc(var(--nav) + var(--sab));padding-bottom:var(--sab);background:var(--sheet);border-top:1px solid var(--line);z-index:10}
.bnav button{flex:1;background:none;border:0;font-family:var(--fc);font-weight:600;font-size:1rem;color:var(--muted);position:relative;cursor:pointer}
.bnav button[aria-selected="true"]{color:var(--ink)}
.bnav button[aria-selected="true"]::before{content:"";position:absolute;top:0;left:30%;right:30%;height:3px;background:var(--lit);border-radius:0 0 3px 3px}
.item{padding:12px 2px}
}
@media (max-width:820px) and (max-height:730px){
.knobs .k{width:30px}.fx .k{min-height:24px}.ctl .k{min-height:31px}.bank .k{min-height:28px!important}.lcd .l3{display:none}
}
@media (max-width:820px) and (max-height:680px){ .knobs{display:none!important}.pads{grid-auto-rows:minmax(29px,1fr)}.opt{min-height:36px;padding:5px 8px}#info{min-height:0;padding:8px 10px}.ititle{font-size:1.1rem}.unit{gap:6px} }
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body data-tab="explore">
<div class="app">
<header class="head"><h1>SP-404MK2 layers</h1><p>Hold a modifier and the panel relabels itself. From reference manual v5.50.</p></header>
<div class="tabs" role="tablist">
<button class="tab" role="tab" data-tab="explore" aria-selected="true">Explore</button>
<button class="tab" role="tab" data-tab="drill" aria-selected="false">Drill</button>
<button class="tab" role="tab" data-tab="find" aria-selected="false">Find</button>
<button class="tab" role="tab" data-tab="rules" aria-selected="false">The rules</button>
</div>
<div id="strip"></div>
<div id="unitwrap"><div class="unit" id="unit"></div></div>
<div id="info" aria-live="polite"></div>
<div id="pane"></div>
</div>
<nav class="bnav" role="tablist">
<button data-tab="explore" aria-selected="true">Explore</button>
<button data-tab="drill" aria-selected="false">Drill</button>
<button data-tab="find" aria-selected="false">Find</button>
<button data-tab="rules" aria-selected="false">Rules</button>
</nav>
<div class="scrim" id="scrim"></div>
<div class="sheet" id="sheet" role="dialog" aria-modal="true" aria-labelledby="stitle">
<div class="shead"><b id="stitle"></b><button class="sclose" id="sclose" aria-label="Close">×</button></div>
<div class="sbody" id="sbody"></div>
</div>
<script>
/* ============ panel ============ */
const L={vol:"VOLUME",c1:"CTRL 1",c2:"CTRL 2",c3:"CTRL 3",fx_fd:"FILTER +DRIVE",fx_res:"RESONATOR",fx_dly:"DELAY",fx_iso:"ISOLATOR",fx_djl:"DJFX LOOPER",mfx:"MFX",
pat_sel:"PATTERN SELECT",pat_edit:"PATTERN EDIT",rec_set:"RECORD SETTING",del:"DEL",rec:"REC",resample:"RESAMPLE",exit:"EXIT",copy:"COPY",remain:"REMAIN",
start_end:"START/END",pitch:"PITCH/SPEED",mark:"MARK",value:"VALUE",bpm_sync:"BPM SYNC",gate:"GATE",loop:"LOOP",reverse:"REVERSE",roll:"ROLL",
bank_a:"A/F",bank_b:"B/G",bank_c:"C/H",bank_d:"D/I",bank_e:"E/J",shift:"SHIFT",busfx:"BUS FX",hold:"HOLD",ext:"EXT SOURCE",subpad:"SUB PAD"};
for(let i=1;i<=16;i++)L["p"+i]="PAD "+i;
const PL={pat_sel:"PTN SEL",pat_edit:"PTN EDIT",rec_set:"REC SET",resample:"RE­SAMPLE",reverse:"REV­ERSE",remain:"RE­MAIN",start_end:"START /END",pitch:"PITCH /SPEED"};
const PLAB=id=>PL[id]||L[id];
const GROUPS={pads:[...Array(16)].map((_,i)=>"p"+(i+1)),ctrls:["c1","c2","c3"],effects:["fx_fd","fx_res","fx_dly","fx_iso","fx_djl","mfx"],banks:["bank_a","bank_b","bank_c","bank_d","bank_e"]};
const GNAME={pads:"PAD 1–16",ctrls:"CTRL 1–3",effects:"FX button",banks:"BANK A–E"};
const NOLATCH=new Set(["vol","c1","c2","c3",...GROUPS.pads]);
const isPad=id=>/^p\d/.test(id);
function K(id,cls=""){return `<button class="k ${cls}" data-id="${id}" aria-label="${L[id]}"><span class="t">${isPad(id)?`<span class="n">${id.slice(1)}</span>`:PLAB(id)}</span></button>`}
const $=id=>document.getElementById(id);
$("unit").innerHTML=`
<div class="row knobs">${K("vol","round")}${K("c1","round")}${K("c2","round")}${K("c3","round")}</div>
<div class="fx"><div class="col">${K("fx_fd")}${K("fx_res")}${K("fx_dly")}</div>
<div class="lcd" id="lcd"><div class="l1"></div><div class="l2"></div><div class="l3"></div></div>
<div class="col">${K("fx_iso")}${K("fx_djl")}${K("mfx")}</div></div>
<div class="ctl">
<div class="blk"><div class="row">${K("pat_sel")}${K("pat_edit")}${K("rec_set")}</div><div class="row">${K("del")}${K("rec")}${K("resample")}</div><div class="row">${K("exit")}${K("copy")}${K("remain")}</div></div>
<div class="blk"><div class="row">${K("start_end")}${K("pitch")}${K("mark")}${K("value","round valk")}</div>
<div class="row">${K("bpm_sync")}${K("gate")}${K("loop")}${K("reverse")}${K("roll")}</div>
<div class="row bank">${K("bank_a")}${K("bank_b")}${K("bank_c")}${K("bank_d")}${K("bank_e")}${K("shift")}</div></div>
</div>
<div class="pads">
${[1,2,3,4].map(i=>K("p"+i)).join("")}${K("busfx","side")}
${[5,6,7,8].map(i=>K("p"+i)).join("")}${K("hold","side")}
${[9,10,11,12].map(i=>K("p"+i)).join("")}${K("ext","side")}
${[13,14,15,16].map(i=>K("p"+i)).join("")}${K("subpad","side")}
</div>`;
const KEY={};document.querySelectorAll(".k").forEach(b=>KEY[b.dataset.id]=b);
/* ============ data: [mods, target, ctx, short, name, desc, page, how] ============ */
const RAW=[
// SHIFT + pads
[["shift"],"p1","S","FIX VEL","Fixed velocity","Every hit plays at 127. Stored per sample; same combo turns it off.",24],
[["shift"],"p2","S","16 VEL","16 velocity","Pads become 16 velocity steps of the selected sample. EXIT to leave.",24],
[["shift"],"p3","S","CUE","Cue mode","Send a sample to headphones only. CTRL 3 balances cue against the main mix. EXIT to leave.",101],
[["shift"],"p4","S","CHROMA","Chromatic mode","Play the selected sample across the pads in pitch. EXIT to leave.",25],
[["shift"],"p5","S P","SWAP","Exchange pads","Swap two samples (or two patterns in pattern mode). Bank buttons reach other banks. Confirm with VALUE or COPY.",60],
[["shift"],"p6","S","INIT","Init sample params","Resets start/end, tempo, pitch and the rest for the selected pad.",59],
[["shift"],"p7","S","LINK","Pad link groups","Groups A–J of up to 4 pads that fire together from any member.",26],
[["shift"],"p8","S","MUTE GRP","Mute groups","Choke groups A–J: only the last-hit sample in a group sounds.",28],
[["shift"],"p9","S P L","METRO","Metronome on/off","Also works while sampling and recording patterns.",38],
[["shift"],"p10","S P","COUNT-IN","Count-in","Cycles 1 bar, 2 bars, WAIT (starts on input level), off.",38],
[["shift"],"p11","S","TEMPO","Tempo select / tap tempo","TEMPO SEL screen: CTRL 1 picks project or bank tempo, VALUE sets it (SHIFT+VALUE for decimals). SUB PAD blinks: tap it for tap tempo.",91],
[["shift"],"p12","S","GAIN","Input gain","Jumps to UTILITY > SYSTEM > GAIN.",160],
[["shift"],"p13","S","UTILITY","Utility menu","System settings, rename, factory reset.",111],
[["shift"],"p14","S","IMP/EXP","Import / export","SD import and export, backup and restore, format SD card.",123],
[["shift"],"p15","S","PAD SET","Pad settings","Pad LED mode, bus colors and other pad behaviour.",112],
[["shift"],"p16","S","EFX SET","Effect settings","BUS 3/4 effects, side chain, DIRECT FX button assignment, input FX, routing.",113],
// SHIFT + buttons
[["shift"],"busfx","S P","MUTE BUS","Mute bus","Cuts the feed into BUS 1/2 so only reverb or delay tails ring out. Turning it off also turns the bus effect off.",34],
[["shift"],"hold","S","PAUSE","Pause all","Pauses every playing sample; again to resume.",28],
[["shift"],"ext","S","IN SET","Input settings","Opens the input setting screen.",160],
[["shift"],"subpad","S","PROJECT","Project select","Pads load projects 1–16. Inside this screen, SUB PAD loads projects straight from SD (/EXPORT/PROJECT/PROJECT_01…16).",104],
[["shift"],"mark","S","SAVE FX","Save BUS 1/2 effects","Hold MARK for 3 seconds. Saves the main parameters of the BUS 1/2 effects so they survive power-off.",33,"Hold SHIFT, hold MARK 3 s"],
[["shift"],"pitch","S E","ENVELOPE","Envelope","Attack, hold, release per sample.",53],
[["shift"],"start_end","S E","MARKERS","Marker / chop screen","Place markers with CTRL 1 + MARK, or tap pads 2–16 in time while pad 1 plays. VALUE opens AUTO MARK, ASSIGN TO PAD (chop), DELETE ALL MARKERS.",50],
[["shift"],"pat_sel","P L","UNDO","Undo last take","While recording a pattern: undo what you just recorded. In the looper: undo, and again to redo.",66],
[["shift"],"roll","S","ROLL RATE","Roll interval","Cycles 1/4 down to 1/64, then the triplet values.",23],
[["shift"],"loop","S","PINGPONG","Ping-pong loop","Loop alternates forward and backward (LOOP blinks).",22],
[["shift"],"gate","S","GATE ALL","Gate all in bank","Toggles GATE for every sample in the bank.",21],
[["shift"],"bpm_sync","S","SYNC ALL","BPM sync all in bank","Toggles BPM SYNC for every sample in the bank.",20],
[["shift"],"rec","P","TR-REC","Jump into TR-REC","While a pattern plays: step-record on top of it.",64],
[["shift"],"banks","S","BANK VOL","Bank volume","Level for a whole bank, to even out banks.",19],
[["shift"],"remain","S","LIVE","Live mode","Hold REMAIN 3 seconds. Disables DEL, REC, RESAMPLE, COPY and MARK so you cannot wreck a set. Same again to exit.",29,"Hold SHIFT, hold REMAIN 3 s"],
[["shift"],"copy","S","PROTECT","Bank protect","Locks samples and patterns of the selected bank. Same again to unlock.",62],
[["shift"],"value","S","MARK FN","MARK = skip-back or looper","Pressing the VALUE knob flips what MARK does.",44,"Hold SHIFT, press VALUE"],
[["shift"],"resample","S","MERGE","Sample merge","Pick up to 4 pads. CTRL 1: SUM (mix down) or MUL (multiply, ring-mod style). CTRL 3 per-pad level. Pick destination, press VALUE.",27],
[["shift"],"rec_set","S","SYNTH","Sound generator","Built-in oscillator (sine to noise) playable on pads or via MIDI notes; sample the result.",46],
[["shift"],"exit","S P D L","STOP","Stop everything","Stops all samples and patterns. Same as tapping EXIT four times fast.",28],
[["shift"],"c1","S E","FINE","Fine CTRL 1","Finer steps for REC BPM, SPEED and similar values.",56,"Hold SHIFT, turn CTRL 1"],
[["shift"],"value","E","NUM/VINYL","Numeric entry / vinyl mode","START/END screen: press VALUE to type point positions on pads 1–10. PITCH/SPEED screen: turn VALUE to toggle VINYL mode (speed and pitch linked like a turntable).",48,"Hold SHIFT, press or turn VALUE"],
[["shift"],"c2","E","FINE PIT","Fine pitch","PITCH/SPEED screen: cents. START/END and marker screens: SHIFT + any CTRL zooms.",57,"Hold SHIFT, turn CTRL 2"],
[["shift"],"c3","E","PAN","Sample pan","PITCH/SPEED screen.",57,"Hold SHIFT, turn CTRL 3"],
[["shift"],"del","P","REVERT","Revert chain edits","Pattern chain editor: discard edits (the * mark).",75],
[["shift"],"mark","P","ERASE FX","FX motion erase on/off","During real-time pattern recording: press once where erasing should start, again where it should stop.",78],
// SHIFT + REVERSE
[["shift","reverse"],"pads","S P","MUTE","Pad mute","Mute or unmute pads while they play (muted pads light red). Not on pads holding patterns.",26],
[["shift","reverse"],"remain","S","LATCH","Pad mute latch","Keeps SHIFT and REVERSE held so you can tap mutes freely. EXIT cancels.",26],
[["shift","reverse"],"remain","P","MUTE REC","Pad mute motion rec","During real-time pattern recording: records your pad mutes into the pattern. DEL flips to erase mode.",79],
// plain presses that hide things
[[],"exit","S P D L","STOP ×4","Panic stop","Four quick taps stop everything and also clear pad mutes.",28,"Tap EXIT four times fast"],
[[],"remain","S","INFO","Pad info","Hold to see the selected pad's parameters; while it plays, the remaining time.",59],
[[],"mark","S","SKIPBACK","Skip-back sampling","When MARK blinks, the unit has been capturing the last 25 s (40 s with SBS Long). MARK, then REC, then pick a pad. Lost on EXIT or power-off.",41],
[["bank_d"],"bank_e","S","DJ MODE","Enter DJ mode","Press D/I and E/J together.",93,"Press D/I and E/J together"],
[[],"value","E","MENU","Hidden edit menu","START/END screen: TRUNCATE, NORMALIZE, EMPHASIS. Marker screen: AUTO MARK, ASSIGN TO PAD, DELETE ALL MARKERS.",49,"Press VALUE on an edit screen"],
[[],"resample","E","0-CROSS","Snap to zero-cross","On START/END and marker screens a lit RESAMPLE snaps points to zero crossings.",49],
[[],"mark","E","SET S/E","Set start/end by ear","START/END screen, while playing: MARK sets start, then end. Marker screen: MARK drops a marker at the cursor.",48],
[[],"roll","E","PREV/GROOVE","Preview end / groove","START/END screen: hold to hear the seconds before the end point. PITCH/SPEED screen: ROLL opens the Groove template screen (humanize, rate, swing).",48],
[[],"remain","E","LOCK PTS","Point lock","START/END screen: stops the start point from pushing the loop and end points.",48],
[[],"del","E","RESET","Reset points","START/END screen: resets start/end. Marker screen: deletes the selected marker.",48],
[["remain"],"ctrls","E","WHOLE BANK","Edit the whole bank","PITCH/SPEED or ENVELOPE screen: knob moves while holding REMAIN change every sample in the bank.",58,"Hold REMAIN, turn CTRL"],
[["copy"],"ctrls","E","MUTE GRP","Edit the mute group","Same as above, but only samples in the same mute group.",58,"Hold COPY, turn CTRL"],
[["mark"],"pads","E","SILENT","Select silently","ENVELOPE screen: pick a sample without playing it.",53],
// REMAIN layer
[["remain"],"pads","S","BUS","Pad → bus","Holding REMAIN shows bus colors (orange BUS 1, green BUS 2, white DRY). Tap a pad to toggle it between the bus picked by BUS FX and DRY.",34],
[["remain"],"banks","S","BANK BUS","Bank → bus","Route an entire bank at once.",34],
[["remain"],"ext","S","IN BUS","Input → bus","Route INPUT / LINE IN: lit BUS 1, blinking BUS 2, dark DRY.",34],
[["remain"],"effects","S","LOCK","Lock FX screen","Effect edit screen stays up. VALUE flips main/sub parameters. EXIT or REMAIN to leave.",33],
[["remain"],"busfx","S","SWAP 1⇄2","Swap BUS 1 and 2","Swaps the effects and keeps their values.",162],
[["remain"],"value","S","COLOR","Pad color","With Pad LED Mode set to SAMPLE (SHIFT + pad 15): hold REMAIN, turn VALUE to Color, press, pick.",59,"Hold REMAIN, turn VALUE"],
// VALUE layer
[["value"],"pads","S P","SILENT","Select without playing","Pick a sample, or a pattern in pattern mode, with no sound.",162],
[["value"],"busfx","S","BYP 3/4","Bypass BUS 3 and 4","Temporarily bypass the master-bus effects.",162],
[["value"],"subpad","S","TIGHT","Tighten timing","Reduces flams when triggering several samples at once.",162],
[["value"],"effects","S","GRAB","Effect grab","Effect buttons become momentary instead of toggles.",32],
[["value"],"gate","S","1-SHOT","One-shot mode","Sample plays once to its end regardless of pad release.",162],
[["value"],"ctrls","S","SUB PRM","Effect sub-parameters","Knobs edit the current effect's hidden second page.",32,"Hold VALUE, turn CTRL"],
// MFX layer
[["mfx"],"pads","S","FX 1–16","Pick MFX by pad","Pads select effects 1–16 directly.",32],
[["mfx"],"fx_djl","S","#17+","Pads = MFX 17–32","Shifts the pad map to the second page of effects.",32],
[["mfx"],"fx_iso","S","#33+","Pads = MFX 33+","Third page of effects.",32],
[["mfx"],"value","S","BROWSE","Browse MFX","Turn VALUE or CTRL 3 to scroll the effect list.",32,"Hold MFX, turn VALUE"],
// DEL / COPY
[["del"],"exit","S P","DEL BANK","Delete bank","Choose a bank, confirm with VALUE or DEL.",62],
[["copy"],"exit","S P","COPY BANK","Copy bank","Source bank, turn VALUE, destination bank, press VALUE. CTRL 3 targets another project.",61],
[["del"],"mark","P","CLR FX","Clear FX motion","Pattern edit screen: wipes all recorded effect moves.",78],
[["del"],"reverse","P","CLR MUTE","Clear mute motion","Pattern edit screen: wipes all recorded pad mutes.",80],
[["del"],"value","P","DEL PTNS","Delete patterns in loop","Pattern edit: hold DEL (loop range lights blue), tap pads to pick, press VALUE.",81,"Hold DEL, tap pads, press VALUE"],
[["del"],"bank_a","T","CLR BAR","Clear bar, this pad","TR-REC: erase one measure of the selected pad.",69],
[["del"],"bank_b","T","CLR BAR*","Clear bar, all pads","TR-REC: erase one measure for every pad.",69],
// ROLL / HOLD / SUB PAD
[["roll"],"pads","S","ROLL","Roll a pad","Up to 4 at once. Also: hold a pad, then press ROLL.",23],
[["roll"],"value","S","FASTER","Speed up a roll live","Turn right to shorten the interval while rolling; turning left returns to the set value.",24,"Hold ROLL, turn VALUE"],
[["roll"],"pads","P","ROLL","Record a roll","During real-time recording the roll is written into the pattern.",80],
[["roll"],"ctrls","T","KNOB REC","Step-record knobs","TR-REC in TRIG mode: records CTRL moves per step.",70,"Hold ROLL, turn CTRL"],
[["hold"],"pads","S","SUSTAIN","Sustain gated pads","Hold pads, then press HOLD: gated samples keep playing. HOLD with a looping pad retriggers it from the start.",21,"Hold pad(s), press HOLD"],
[["hold"],"pads","P","CHAIN","Pattern chain","Pick chain 1–16. Then pads add patterns, VALUE moves the cursor, DEL removes, REMAIN sets repeat, SUB PAD plays it.",74],
[["subpad"],"pads","P","NOW","Switch pattern instantly","No waiting for the current pattern to finish.",64],
[[],"subpad","P","CHAIN ▶","Play pattern chain","Inside the pattern chain screen: start and stop the chain.",75],
// pattern plain
[[],"mark","P","FX REC","FX motion record","During real-time pattern recording: MARK starts recording effect moves and knob turns.",77],
[[],"start_end","P","FX PLAY","FX motion playback","Blinking: recorded effect moves play back. Unlit: ignored.",78],
[[],"resample","P","RESAMPLE","Resample a pattern","In pattern mode: RECORD SETTING, ROUTING Mix, pick an empty pad, pick the pattern. Keeps effects, unlike bounce.",72],
[[],"rec","P","REHEARSE","Rehearsal toggle","While recording a pattern, REC flips between writing and rehearsal (keeps looping, writes nothing).",66],
[[],"del","P","ERASE","Erase while recording","During pattern recording: DEL enters erase mode; hold pads to wipe their notes as the loop passes.",66],
[[],"remain","P","MODE","REMAIN in pattern screens","RECORD SETTING: Real-Time (lit) or TR-REC (blinking). Chain screen: repeat on/off. Copy screen: pick only some pads to extract.",65],
[[],"pat_edit","P","EDIT","Pattern edit","BPM, loop start, length. VALUE: DUPLICATE, CROP. RECORD SETTING: quantize.",80],
[[],"rec_set","P","QUANTIZE","Quantize","On the pattern edit screen: quantize recorded notes. Cannot be undone.",83],
[[],"copy","P","COPY/BOUNCE","Copy, extract, bounce","COPY, source, destination. REMAIN in between extracts only chosen pads. To bounce to audio: pick with COPY, press PATTERN SELECT, choose a pad. COPY then HOLD + pad bounces a chain.",85],
// TR-REC
[["subpad"],"pads","T","PICK","Choose pad to program","Selects which sample's steps you're editing. Press VALUE too to choose silently.",68],
[["pat_edit"],"pads","T","MICRO","Microscope","Edit a recorded note: CTRL 1 note, CTRL 2 pitch, CTRL 3 velocity, VALUE nudges timing, DEL deletes.",71],
[[],"remain","T","TRIG/TIE","Step mode","TRIG: each step hits. HOLD STEP: steps tie together (forces GATE on).",69],
[[],"hold","T","SOLO","Solo selected pad","Hear only the pad you are programming.",69],
[[],"pitch","T","PITCH MD","Step pitch mode","CHROMATIC: pitch per step (VALUE picks scale). PAD: one fixed pitch.",68],
[[],"ctrls","T","SUB/PIT/VEL","Step knobs","CTRL 1 substep (or tie length), CTRL 2 pitch, CTRL 3 velocity. With SHIFT: CTRL 2 shuffle, CTRL 3 start offset.",68],
[[],"value","T","BAR","Select bar","Turn to page through measures.",69],
[[],"rec","T","TAP","Tap record","Press REC in time to drop the selected pad in. RECORD SETTING enables quantize and shuffle for it.",71],
// Looper
[[],"mark","L","EXIT","Leave looper","MARK toggles looper mode (when MARK function is set to Looper).",45],
[[],"rec","L","REC","Loop rec / stop","Starts and stops recording.",45],
[[],"resample","L","OVERDUB","Overdub","Light it before recording to switch to overdub after the first pass.",44],
[[],"del","L","CLEAR","Clear loop","Only when stopped.",45],
[[],"copy","L","TO PAD","Save loop to pad","Pick a pad, press VALUE.",45],
[[],"ctrls","L","LEN/TRIG/BPM","Looper knobs","CTRL 1 length (free, 1, 2, 4 bars), CTRL 2 auto trigger, CTRL 3 BPM or play rate.",44],
[["shift"],"c1","L","BUS","Looper bus","DRY, BUS 1, BUS 2 or LOOPER. Use LOOPER when overdubbing.",44,"Hold SHIFT, turn CTRL 1"],
[["shift"],"c2","L","ROUTING","Looper source","Mix (everything) or ExtIn (input only).",44,"Hold SHIFT, turn CTRL 2"],
[["shift"],"value","L","LVL/FB","Rec level / feedback","Press VALUE to choose which one the knob edits.",44,"Hold SHIFT, turn VALUE"],
[["pitch"],"pads","L","TEMPO←","Take a pad's tempo","Looper tempo becomes that pad's BPM.",45],
// Chromatic / synth
[[],"remain","X","PLAY MD","Legato / mono / poly","Chromatic mode: cycles legato (slow blink), mono (lit), poly (fast blink).",25],
[[],"value","X","SCALE","Scale and range","Chromatic mode: press to cycle major, minor, dorian, phrygian, major and minor pentatonic. Turn to move the key range.",26],
[["roll"],"value","X","ROOT","Scale root","Chromatic mode: hold ROLL and turn VALUE.",26,"Hold ROLL, turn VALUE"],
[[],"ctrls","X","OSC","Synth knobs","Sound generator: CTRL 1 waveform, CTRL 2 frequency, CTRL 3 level. With SHIFT: pad length, duty cycle, balance.",46],
// DJ
[[],"p13","D","CH1 ▶‖","CH1 play / pause","",94],[[],"p15","D","CH2 ▶‖","CH2 play / pause","",94],
[[],"p9","D","CH1 ⏮","CH1 back to start","",94],[[],"p11","D","CH2 ⏮","CH2 back to start","",94],
[[],"p14","D","CH1 CUE","CH1 to headphones","",94],[[],"p16","D","CH2 CUE","CH2 to headphones","",94],
[[],"p10","D","SYNC 1→2","CH1 follows CH2 tempo","",94],[[],"p12","D","SYNC 2→1","CH2 follows CH1 tempo","",94],
[[],"p2","D","CH1 BPM+","CH1 tempo up","",94],[[],"p6","D","CH1 BPM−","CH1 tempo down","",94],
[[],"p4","D","CH2 BPM+","CH2 tempo up","",94],[[],"p8","D","CH2 BPM−","CH2 tempo down","",94],
[[],"p1","D","CH1 BEND+","CH1 nudge faster","Pitch and speed rise while held, like pushing a platter.",94],[[],"p5","D","CH1 BEND−","CH1 nudge slower","",94],
[[],"p3","D","CH2 BEND+","CH2 nudge faster","",94],[[],"p7","D","CH2 BEND−","CH2 nudge slower","",94],
[["del"],["p2","p6"],"D","CH1 RST","CH1 tempo reset","",94],[["del"],["p4","p8"],"D","CH2 RST","CH2 tempo reset","",94],
[["remain"],"p14","D","CH1 BUS","CH1 bus cycle","BUS 1, BUS 2, DRY.",94],[["remain"],"p16","D","CH2 BUS","CH2 bus cycle","BUS 1, BUS 2, DRY.",94],
[["roll"],"p13","D","CH1 ROLL","CH1 roll","Fails if the roll size is longer than the sample.",164],[["roll"],"p15","D","CH2 ROLL","CH2 roll","",164],
[["roll"],"p1","D","¼ BAR","Roll: quarter bar","Changes the interval while rolling.",164],[["roll"],"p2","D","½ BAR","Roll: half bar","",164],
[["roll"],"p3","D","1 BAR","Roll: 1 bar","",164],[["roll"],"p4","D","2 BARS","Roll: 2 bars","",164],
[["shift"],"roll","D","ROLL SZ","Roll size","1/4, 1/2, 1 or 2 bars. Set before rolling.",164],
[["resample"],"value","D","CURVE","Fader curve","FAST CUT, LINEAR, SQUARE, CUBIC.",95,"Hold RESAMPLE, press VALUE"],
[[],"hold","D","PAD MODE","DJ / pad mode","Switch between DJ controls and normal pad playback.",96],
[[],"start_end","D","X-FADE","CTRL 3: cue mix / crossfade","Swaps what CTRL 3 does.",94],
[[],"pitch","D","BPM .00","BPM decimals","Cycles BPM display precision.",163],
[[],"mark","D","VIEW","EFX / mixer view","Toggles the screen.",163],
[[],"bpm_sync","D","REV CH","Reverse channel","Chooses whether REVERSE acts on CH1 or CH2.",163],
[["shift"],"pads","D","CUE PT","Play from marker","Jump to a marker. Pads 1/2 are CH1/CH2 starts, others map to markers 1–7 (manual p. 96).",96],
[["shift"],"remain","D","SHIFT LCK","Shift latch","Keeps SHIFT held for marker juggling. EXIT cancels.",163],
[["shift"],"mark","D","+MARKER","Add marker","Drop markers while playing.",97],
[["shift"],"start_end","D","EDIT MRK","Edit markers","Move markers while playing.",97],
[["shift","del"],"pads","D","DEL MRK","Delete a marker","Pad map as for playing from markers. Later markers shift down.",98],
[["shift","reverse"],["p13","p15"],"D","MUTE","Channel mute","Pad 13 mutes CH1, pad 15 mutes CH2.",163],
];
const CTX={S:"Sample mode",E:"Edit screens",P:"Pattern",T:"TR-REC",L:"Looper",D:"DJ mode",X:"Chromatic / synth"};
const CTXHINT={S:"Top screen, pads orange.",E:"START/END, marker, PITCH/SPEED and envelope screens.",P:"PATTERN SELECT lit, pads purple. Includes real-time recording and the chain editor.",T:"Step recording (REC in pattern mode, REMAIN until it blinks).",L:"MARK function set to Looper, then MARK.",D:"Press D/I and E/J together.",X:"SHIFT + pad 4, or SHIFT + RECORD SETTING."};
const MODORDER=["shift","reverse","remain","value","del","copy","mfx","roll","hold","subpad","pat_edit","mark","pitch","resample","bank_d"];
const ENTRIES=RAW.map((r,i)=>{const [mods,target,ctx,short,name,desc,page,how]=r;
const mk=[...mods].sort((a,b)=>MODORDER.indexOf(a)-MODORDER.indexOf(b));
const tset=Array.isArray(target)?target:(GROUPS[target]||[target]);
return {i,mods:mk,key:mk.join("+"),target,tset,ctx:ctx.split(" "),short,name,desc,page,how,id:mk.join("+")+">"+[].concat(target).join("/")+"@"+ctx+"#"+short};
});
const tName=t=>Array.isArray(t)?t.map(x=>L[x]).join(" / "):(GNAME[t]||L[t]);
const mName=m=>L[m];
const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const caps=arr=>arr.map(p=>`<span class="cap">${esc(p)}</span>`).join('<span class="plus">+</span>');
function combo(e,withHow=true){return caps([...e.mods.map(mName),tName(e.target)])+(withHow&&e.how?`<div class="muted" style="font-size:.8rem;margin-top:2px">${esc(e.how)}</div>`:"")}
const mobile=()=>matchMedia("(max-width:820px)").matches;
const buzz=(ms=8)=>{try{navigator.vibrate&&navigator.vibrate(ms)}catch(e){}};
/* ============ storage ============ */
const SK="sp404mk2-layers-v1";
function load(){try{return JSON.parse(localStorage.getItem(SK))||{}}catch(e){return {}}}
function save(){try{localStorage.setItem(SK,JSON.stringify(store))}catch(e){}}
let store=load();store.boxes=store.boxes||{};
const st={tab:"explore",ctx:store.ctx||"S",held:[],sel:null,
deck:store.deck||"shiftpads",dir:store.dir||"play",
input:store.input||(matchMedia("(pointer:coarse)").matches?"hold":"tap"),
card:null,chord:[],answered:false,last:null,opts:null};
const layerKey=a=>[...a].sort((x,y)=>MODORDER.indexOf(x)-MODORDER.indexOf(y)).join("+");
const layerEntries=()=>{const k=layerKey(st.held);return ENTRIES.filter(e=>e.ctx.includes(st.ctx)&&e.key===k)};
function lcd(a,b,c){const d=$("lcd");d.children[0].textContent=a;d.children[1].textContent=b;d.children[2].textContent=c}
function clearPanel(){for(const id in KEY){const b=KEY[id];b.classList.remove("target","sel","held","chord","good","miss");b.querySelector(".t").innerHTML=isPad(id)?`<span class="n">${id.slice(1)}</span>`:PLAB(id);const c=b.querySelector(".cnt");if(c)c.remove()}}
/* ============ sheet ============ */
let sheetOnClose=null;
function openSheet(title,html,after){$("stitle").textContent=title;$("sbody").innerHTML=html;$("sheet").classList.add("open");$("scrim").classList.add("open");$("sbody").scrollTop=0;
try{history.pushState({sheet:1},"")}catch(e){} if(after)after($("sbody"));}
function closeSheet(fromPop){if(!$("sheet").classList.contains("open"))return;$("sheet").classList.remove("open");$("scrim").classList.remove("open");
if(!fromPop){try{if(history.state&&history.state.sheet)history.back()}catch(e){}} if(sheetOnClose){const f=sheetOnClose;sheetOnClose=null;f()}}
$("scrim").onclick=()=>closeSheet();$("sclose").onclick=()=>closeSheet();
addEventListener("popstate",()=>closeSheet(true));
/* ============ explore ============ */
function listHTML(es){return `<div class="list">${es.map(e=>`<button class="item ${st.sel===e?"on":""}" data-e="${e.i}"><span class="nm">${esc(e.name)}</span><span class="combo">${combo(e,false)}</span><span class="pg">p.${e.page}</span></button>`).join("")}</div>`}
function renderExplore(){
clearPanel();
st.held.forEach(h=>KEY[h].classList.add("held"));
const es=layerEntries(),byT={};
es.forEach(e=>e.tset.forEach(t=>(byT[t]=byT[t]||[]).push(e)));
for(const t in byT){const b=KEY[t];b.classList.add("target");
if(st.sel&&es.includes(st.sel)&&st.sel.tset.includes(t))b.classList.add("sel");
b.querySelector(".t").textContent=byT[t][0].short;
if(byT[t].length>1){const c=document.createElement("span");c.className="cnt";c.textContent=byT[t].length;b.appendChild(c)}}
const lname=st.held.length?st.held.map(mName).join(" + "):"no modifier";
if(st.sel)lcd(lname.toUpperCase(),st.sel.short,st.sel.name);
else lcd(CTX[st.ctx].toUpperCase(),st.held.length?lname:"—",es.length?es.length+" functions here":"nothing on this layer");
// strip
const counts={};ENTRIES.filter(e=>e.ctx.includes(st.ctx)).forEach(e=>counts[e.key]=(counts[e.key]||0)+1);
const keys=Object.keys(counts).sort((a,b)=>a===""?-1:b===""?1:(MODORDER.indexOf(a.split("+")[0])-MODORDER.indexOf(b.split("+")[0]))||a.length-b.length);
const cur=layerKey(st.held);
$("strip").innerHTML=`<div class="lbl">Where you are</div><div class="chips">${Object.keys(CTX).map(c=>`<button class="chip" data-ctx="${c}" aria-pressed="${c===st.ctx}">${CTX[c]}</button>`).join("")}</div>
<div class="lbl">Layers in this mode</div><div class="chips">${keys.map(k=>`<button class="chip" data-layer="${k}" aria-pressed="${k===cur}">${k===""?"No modifier":k.split("+").map(mName).join(" + ")}<small>${counts[k]}</small></button>`).join("")}</div>`;
keepChipsInView();
// info
const m=mobile();
const listBtn=m?`<button class="btn ghost sm" data-openlist>List ${es.length}</button>`:"";
const heldBtn=st.held.length?`<button class="btn ghost sm onlyd" data-release>Release ${esc(st.held.map(mName).join(" + "))}</button>`:"";
if(st.sel){const e=st.sel;const long=m&&e.desc&&e.desc.length>80;
$("info").innerHTML=`<div class="ihead"><div>${combo(e,!m)}</div><div class="ihr"><span class="muted" style="font-size:.78rem">p.${e.page}</span>${long?`<button class="btn ghost sm" data-more="${e.i}">More</button>`:""}${listBtn}</div></div>
<div class="ititle">${esc(e.name)}</div>${e.desc?`<div class="idesc ${m?"clamp":""}">${esc(e.desc)}</div>`:""}
${m?(e.how?`<div class="muted" style="font-size:.8rem">${esc(e.how)}</div>`:""):`<div class="muted" style="font-size:.8rem;margin-top:2px">${esc(e.ctx.map(c=>CTX[c]).join(", "))}</div><div class="irow">${heldBtn}</div>`}`;
}else{
$("info").innerHTML=`<div class="ihead"><div class="muted">${esc(CTXHINT[st.ctx])}</div><div class="ihr">${listBtn}</div></div><div style="margin-top:4px">${m?"Hold a button to peek at its layer, tap it to lock, tap again to release. Tap a lit control to read it.":"Click a button to hold it. Click a lit control or a row to read it."}</div>${m?"":`<div class="irow">${heldBtn}</div>`}`;
}
if(!m)$("pane").innerHTML=listHTML(es);
}
function keepChipsInView(){requestAnimationFrame(()=>document.querySelectorAll('#strip .chip[aria-pressed="true"]').forEach(c=>{const p=c.parentElement;if(p.scrollWidth>p.clientWidth){p.scrollLeft=c.offsetLeft-p.clientWidth/2+c.offsetWidth/2}}))}
/* ============ drill ============ */
const DECKS={
shiftpads:{n:"SHIFT + pads",f:e=>e.key==="shift"&&/^p\d+$/.test(e.target)&&e.ctx.includes("S")},
shiftbtn:{n:"SHIFT + buttons",f:e=>e.key.startsWith("shift")&&!/^p\d+$/.test(e.target)&&e.ctx.includes("S")},
mods:{n:"REMAIN, VALUE, DEL, COPY, MFX",f:e=>/^(remain|value|del|copy|mfx)/.test(e.key)&&e.ctx.some(c=>"SE".includes(c))},
pattern:{n:"Pattern + TR-REC",f:e=>e.ctx.some(c=>c==="P"||c==="T")},
dj:{n:"DJ mode",f:e=>e.ctx.includes("D")},
edit:{n:"Edit, looper, chromatic",f:e=>e.ctx.some(c=>"ELX".includes(c))},
all:{n:"Everything",f:()=>true},
};
function pressable(e){if(e.target==="ctrls")return false;if(e.how&&/turn|four|tap pads/i.test(e.how))return false;if(!e.mods.length&&e.tset.length>1)return false;return true}
const pool=()=>ENTRIES.filter(DECKS[st.deck].f).filter(e=>st.dir==="name"||pressable(e));
const boxOf=e=>store.boxes[e.id]||1;
function pick(){const p=pool();if(!p.length)return null;
const w=p.map(e=>e===st.last&&p.length>1?0:Math.pow(2,5-boxOf(e)));let r=Math.random()*w.reduce((a,b)=>a+b,0);
for(let i=0;i<p.length;i++){r-=w[i];if(r<=0)return p[i]}return p[p.length-1]}
function grade(ok){store.boxes[st.card.id]=ok?Math.min(5,boxOf(st.card)+1):1;save();st.answered=true;st.lastOk=ok;buzz(ok?[12,40,12]:40)}
function nextCard(){st.card=pick();st.last=st.card;st.chord=[];st.answered=false;st.opts=null;renderDrill()}
function isCorrect(chord){const e=st.card,ch=new Set(chord);if(!e.mods.every(m=>ch.has(m)))return false;
const rest=[...ch].filter(x=>!e.mods.includes(x));return rest.length===1&&e.tset.includes(rest[0])}
function dist(){const b=[0,0,0,0,0];pool().forEach(e=>b[boxOf(e)-1]++);return b}
function renderDrill(){
clearPanel();
const p=pool();
if(!st.card||!p.includes(st.card)){st.card=pick();st.last=st.card;st.chord=[];st.answered=false;st.opts=null}
const b=dist(),mx=Math.max(1,...b);
$("strip").innerHTML=`<button class="stripbtn" data-settings><span style="flex:1;min-width:0"><b>${esc(DECKS[st.deck].n)}</b><br><span class="muted" style="font-size:.82rem">${st.dir==="play"?"Function → press it":"Combo → name it"}${st.dir==="play"?(st.input==="hold"?", hold together":", tap then check"):""}. ${b[4]} of ${p.length} known.</span></span>
<span class="mini" aria-hidden="true">${b.map(v=>`<div style="height:${Math.max(2,v/mx*22)}px"></div>`).join("")}</span><span aria-hidden="true">⚙</span></button>`;
const e=st.card,info=$("info");
if(!e){info.innerHTML="<div>No cards in this deck.</div>";lcd("DRILL","—","");return}
const ctx=e.ctx.map(x=>CTX[x]).join(", ");
const m=mobile();
if(st.dir==="play"){
if(!st.answered)st.chord.forEach(h=>KEY[h]&&KEY[h].classList.add("chord"));
if(st.answered){
e.mods.forEach(x=>KEY[x].classList.add("good"));
if(e.tset.length<=4)e.tset.forEach(t=>KEY[t].classList.add("good"));else e.tset.forEach(t=>KEY[t].classList.add("target"));
st.chord.forEach(h=>{if(!e.mods.includes(h)&&!e.tset.includes(h))KEY[h].classList.add("miss")});
lcd(ctx.toUpperCase(),e.name,st.lastOk?"correct":"not quite");
info.innerHTML=`<div class="ihead"><div><div class="fb ${st.lastOk?"ok":"no"}">${st.lastOk?"Correct.":"Not quite."}</div><div>${combo(e,false)}</div></div><div class="ihr"><button class="btn" data-next>Next</button></div></div>${e.desc?`<div class="idesc ${m?"clamp":""}">${esc(e.desc)}</div>`:""}
<div class="muted" style="font-size:.8rem">${e.how?esc(e.how)+". ":""}p.${e.page}${m?". Tap the panel for the next card.":""}</div>`;
}else{
lcd(ctx.toUpperCase(),e.name,st.input==="hold"?"hold the combo":"tap the controls");
const chordLine=st.chord.length?caps(st.chord.map(mName)):`<span class="muted">${st.input==="hold"?"Press every control of the combo together, then let go. It checks on release.":"Tap every control involved, in any order, then check."}</span>`;
info.innerHTML=`<div class="ihead"><div><div class="muted" style="font-size:.82rem">${esc(ctx)}</div><div class="ititle">${esc(e.name)}</div></div><div class="ihr"><button class="btn ghost sm" data-show>Show me</button></div></div><div>${chordLine}</div>
${st.input==="tap"?`<div class="irow"><button class="btn sm" data-check ${st.chord.length?"":"disabled"}>Check</button><button class="btn ghost sm" data-clear>Clear</button></div>`:""}`;
}
}else{
e.mods.forEach(x=>KEY[x].classList.add(st.answered?"good":"held"));
e.tset.forEach(t=>KEY[t].classList.add(st.answered&&e.tset.length<=4?"good":"target"));
if(!st.opts){
const others=ENTRIES.filter(x=>x.name!==e.name),near=others.filter(x=>x.ctx.some(c=>e.ctx.includes(c)));
const src=(near.length>=3?near:others).sort(()=>Math.random()-.5),names=[];
for(const x of src){if(!names.includes(x.name))names.push(x.name);if(names.length===3)break}
st.opts=[...names,e.name].sort(()=>Math.random()-.5);
}
lcd(ctx.toUpperCase(),st.answered?e.short:"?",[...e.mods.map(mName),tName(e.target)].join(" + "));
if(st.answered){
info.innerHTML=`<div class="ihead"><div><div class="fb ${st.lastOk?"ok":"no"}">${st.lastOk?"Correct.":"Not quite."}</div><div class="ititle">${esc(e.name)}</div></div><div class="ihr"><button class="btn" data-next>Next</button></div></div>${e.desc?`<div class="idesc ${m?"clamp":""}">${esc(e.desc)}</div>`:""}
<div class="muted" style="font-size:.8rem">${combo(e,false)} p.${e.page}</div>`;
}else{
info.innerHTML=`<div class="muted" style="font-size:.82rem">${esc(ctx)}. What does the lit combo do?</div>${m?"":`<div>${combo(e)}</div>`}<div class="opts">${st.opts.map((o,i)=>`<button class="opt" data-opt="${i}">${esc(o)}</button>`).join("")}</div>`;
}
}
}
function settingsHTML(){
const b=dist(),n=pool().length;
const chips=(attr,items,cur)=>`<div class="chips">${items.map(([k,l])=>`<button class="chip" data-${attr}="${k}" aria-pressed="${k===cur}">${esc(l)}</button>`).join("")}</div>`;
return `<div class="lbl">Deck</div>${chips("deck",Object.keys(DECKS).map(k=>[k,DECKS[k].n]),st.deck)}
<div class="lbl">Direction</div>${chips("dir",[["play","Function → press it"],["name","Combo → name it"]],st.dir)}
<div class="lbl">How you answer “press it” cards</div>${chips("input",[["hold","Hold together (multitouch)"],["tap","Tap each, then check"]],st.input)}
<div class="lbl">This deck</div><div>${b[4]} of ${n} known, ${b[0]} new or missed.</div>
<div class="irow"><button class="btn ghost" data-reset>Reset all progress</button></div>
<div class="lbl">Backup (progress lives in this browser only)</div>
<textarea id="bk" spellcheck="false">${esc(JSON.stringify(store))}</textarea>
<div class="irow"><button class="btn ghost" data-copybk>Copy</button><button class="btn ghost" data-loadbk>Load pasted backup</button><span id="bkmsg" class="muted" style="font-size:.85rem"></span></div>`;
}
/* ============ find / rules ============ */
function renderFind(){
if(!$("q")){$("strip").innerHTML=`<input type="search" id="q" placeholder="undo, chop, metronome, side chain…" aria-label="Search functions" enterkeyhint="search">`;$("q").addEventListener("input",renderFindResults)}
renderFindResults();
}
function renderFindResults(){
const q=$("q").value.trim().toLowerCase();
const res=q?ENTRIES.filter(e=>(e.name+" "+e.short+" "+e.desc+" "+e.mods.map(mName).join(" ")+" "+tName(e.target)+" "+e.ctx.map(c=>CTX[c]).join(" ")).toLowerCase().includes(q)):[];
$("pane").innerHTML=q?(res.length?`<div class="list">${res.map(e=>`<button class="item" data-go="${e.i}"><span class="nm">${esc(e.name)}<div class="muted" style="font-size:.78rem;font-weight:400">${esc(e.ctx.map(c=>CTX[c]).join(", "))}</div></span><span class="combo">${combo(e,false)}</span><span class="pg">p.${e.page}</span></button>`).join("")}</div>`:`<p class="muted">No match. Try the name as printed on screen (CHOP, TR-REC, MUTE BUS).</p>`):`<p class="muted">Search by function name or by button. Tap a result to see it lit on the panel.</p>`;
}
const PADMAP=[["Row 1: how pads play",["Fixed vel","16 vel","Cue","Chromatic"]],["Row 2: organise pads",["Exchange","Init","Link","Mute group"]],["Row 3: time and input",["Metronome","Count-in","Tempo / tap","Gain"]],["Row 4: menus",["Utility","Import/export","Pad set","EFX set"]]];
const RULES=`<div class="rules">
<div class="rule"><h3>SHIFT + pad is a 4×4 map, row by row</h3><p>Learn the row themes first; the individual pads fall out of them.</p>
<div class="grid4">${PADMAP.map(([r,a],ri)=>`<div class="rowlbl">${r}</div>`+a.map((x,i)=>`<div><b>${ri*4+i+1}</b>${x}</div>`).join("")).join("")}</div></div>
<div class="rule"><h3>SHIFT + a playback button = the same thing, bigger or weirder</h3><p>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.</p></div>
<div class="rule"><h3>SHIFT + an edit button = its sibling screen</h3><p>START/END → markers and chop. PITCH/SPEED → envelope. RESAMPLE → merge. RECORD SETTING → synth. SUB PAD → projects. PATTERN SELECT → undo.</p></div>
<div class="rule"><h3>REMAIN means “where does it go, and keep it there”</h3><p>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.</p></div>
<div class="rule"><h3>Holding VALUE means “quietly, or only for now”</h3><p>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.</p></div>
<div class="rule"><h3>DEL or COPY + EXIT acts on a whole bank</h3><p>DEL + EXIT deletes a bank, COPY + EXIT copies it. COPY on edit screens scopes changes to the mute group instead of the bank.</p></div>
<div class="rule"><h3>Three-second holds guard things you do not want by accident</h3><p>SHIFT + REMAIN (live mode) and SHIFT + MARK (save BUS 1/2 effects).</p></div>
<div class="rule"><h3>Pattern mode reuses keys by meaning</h3><p>HOLD + pad = chains. SUB PAD + pad = switch now. MARK while recording = FX motion; SHIFT + MARK erases it. DEL while recording = erase notes.</p></div>
<div class="rule"><h3>Lost? EXIT four times.</h3><p>Stops everything and clears pad mutes. EXIT alone backs out one screen.</p></div></div>`;
/* ============ render ============ */
function render(){
document.body.dataset.tab=st.tab;
document.querySelectorAll("[data-tab]").forEach(x=>{if(x.tagName==="BUTTON")x.setAttribute("aria-selected",x.dataset.tab===st.tab)});
if(st.tab!=="find"&&$("q"))$("strip").innerHTML="";
if(st.tab==="explore")renderExplore();
else if(st.tab==="drill"){$("pane").innerHTML="";renderDrill()}
else if(st.tab==="find"){clearPanel();renderFind()}
else{clearPanel();$("strip").innerHTML="";$("pane").innerHTML=`<header class="onlym"><h1 style="font-family:var(--fc);margin:6px 0 2px;font-size:1.7rem">SP-404MK2 layers</h1><p class="muted" style="margin:0 0 14px">From reference manual v5.50.</p></header>`+RULES}
}
/* ============ pointer input (multitouch) ============ */
const active=new Map(); // pointerId -> control id (null = swallowed)
const pressedEl=new Map();
const downAt={},used={},pendingRelease=new Set();
let gesture=new Set();
function exDown(id){
if(st.held.includes(id)){pendingRelease.add(id);return}
const hit=layerEntries().filter(e=>e.tset.includes(id));
if(hit.length){const i=hit.indexOf(st.sel);st.sel=hit[(i+1)%hit.length];
for(const v of active.values())if(v&&st.held.includes(v))used[v]=true;
renderExplore();return}
if(!NOLATCH.has(id)){st.held.push(id);downAt[id]=performance.now();used[id]=false;st.sel=null;renderExplore();return}
st.sel=null;renderExplore();lcd(L[id],"—","nothing on this layer");
}
function exUp(id){
if(pendingRelease.has(id)){pendingRelease.delete(id);st.held=st.held.filter(h=>h!==id);renderExplore();return}
if(downAt[id]!=null){const dur=performance.now()-downAt[id];delete downAt[id];
if(used[id]||dur>350){st.held=st.held.filter(h=>h!==id);renderExplore()}}
}
function drDown(id,pid){
if(st.answered){active.set(pid,null);nextCard();return}
if(st.dir!=="play")return;
if(st.input==="hold"){gesture.add(id);st.chord=[...gesture];renderDrill()}
else{st.chord=st.chord.includes(id)?st.chord.filter(x=>x!==id):[...st.chord,id];renderDrill()}
}
function drUp(){
if(st.dir!=="play"||st.input!=="hold"||st.answered)return;
if([...active.values()].some(v=>v))return;
if(!gesture.size)return;
const ch=[...gesture];gesture=new Set();st.chord=ch;grade(isCorrect(ch));renderDrill();
}
const unit=$("unit");
unit.addEventListener("pointerdown",ev=>{
const b=ev.target.closest(".k");if(!b)return;
ev.preventDefault();
const id=b.dataset.id;active.set(ev.pointerId,id);pressedEl.set(ev.pointerId,b);b.classList.add("down");buzz();
if(st.tab==="explore")exDown(id);else if(st.tab==="drill")drDown(id,ev.pointerId);
});
function onUp(ev){
if(!active.has(ev.pointerId))return;
const id=active.get(ev.pointerId);active.delete(ev.pointerId);
const el=pressedEl.get(ev.pointerId);pressedEl.delete(ev.pointerId);if(el)el.classList.remove("down");
if(!id)return;
if(st.tab==="explore")exUp(id);else if(st.tab==="drill")drUp();
}
addEventListener("pointerup",onUp);addEventListener("pointercancel",onUp);
unit.addEventListener("contextmenu",e=>e.preventDefault());
unit.addEventListener("keydown",ev=>{
if(ev.key!=="Enter"&&ev.key!==" ")return;const b=ev.target.closest(".k");if(!b)return;ev.preventDefault();
const id=b.dataset.id,pid="kb"+id;
if(st.tab==="explore"){exDown(id);exUp(id);}
else if(st.tab==="drill"){ if(st.answered){nextCard();return} if(st.dir==="play"){ if(st.input==="hold"){st.input="tap"} st.chord=st.chord.includes(id)?st.chord.filter(x=>x!==id):[...st.chord,id];renderDrill()} }
});
/* ============ clicks outside the panel ============ */
document.addEventListener("click",ev=>{
const t=ev.target.closest("button,[data-e]");if(!t||t.closest("#unit"))return;
const d=t.dataset;
if(d.tab){st.tab=d.tab;closeSheet();render();scrollTo(0,0);return}
if(d.ctx){st.ctx=d.ctx;store.ctx=st.ctx;save();st.held=[];st.sel=null;renderExplore();return}
if(d.layer!==undefined){st.held=d.layer?d.layer.split("+"):[];Object.keys(downAt).forEach(k=>delete downAt[k]);st.sel=null;renderExplore();return}
if(d.e){st.sel=ENTRIES[+d.e];closeSheet();renderExplore();return}
if(d.release!==undefined){st.held=[];renderExplore();return}
if(d.openlist!==undefined){openSheet(`${st.held.length?st.held.map(mName).join(" + "):"No modifier"} in ${CTX[st.ctx]}`,listHTML(layerEntries()));return}
if(d.more){const e=ENTRIES[+d.more];openSheet(e.name,`<div style="margin:10px 0">${combo(e)}</div><p>${esc(e.desc)}</p><p class="muted">${esc(e.ctx.map(c=>CTX[c]).join(", "))}. Manual p. ${e.page}.</p>`);return}
if(d.go){const e=ENTRIES[+d.go];st.ctx=e.ctx[0];st.held=[...e.mods];st.sel=e;st.tab="explore";render();scrollTo(0,0);return}
if(d.next!==undefined){nextCard();return}
if(d.check!==undefined){grade(isCorrect(st.chord));renderDrill();return}
if(d.clear!==undefined){st.chord=[];renderDrill();return}
if(d.show!==undefined){grade(false);renderDrill();return}
if(d.opt){st.picked=st.opts[+d.opt];grade(st.picked===st.card.name);renderDrill();return}
if(d.settings!==undefined){openSheet("Drill settings",settingsHTML());return}
const resettings=()=>{$("sbody").innerHTML=settingsHTML();renderDrill()};
if(d.deck){st.deck=d.deck;store.deck=st.deck;save();st.card=null;resettings();return}
if(d.dir){st.dir=d.dir;store.dir=st.dir;save();st.card=null;resettings();return}
if(d.input){st.input=d.input;store.input=st.input;save();st.chord=[];resettings();return}
if(d.reset!==undefined){if(confirm("Reset all drill progress?")){store.boxes={};save();st.card=null;resettings()}return}
if(d.copybk!==undefined){const ta=$("bk");ta.select();let ok=false;try{navigator.clipboard.writeText(ta.value);ok=true}catch(e){try{ok=document.execCommand("copy")}catch(_){}}$("bkmsg").textContent=ok?"Copied.":"Select the text and copy it manually.";return}
if(d.loadbk!==undefined){try{const o=JSON.parse($("bk").value);if(!o||typeof o.boxes!=="object")throw 0;store=o;save();st.deck=store.deck||st.deck;st.dir=store.dir||st.dir;st.card=null;resettings();$("bkmsg").textContent="Backup loaded."}catch(e){$("bkmsg").textContent="That is not a valid backup."}return}
});
matchMedia("(max-width:820px)").addEventListener("change",()=>render());
render();
</script>
<script>if("serviceWorker" in navigator&&location.protocol!=="file:")addEventListener("load",()=>navigator.serviceWorker.register("sw.js"));</script>
</body>
</html>

View File

@@ -0,0 +1,29 @@
{
"name": "SP-404MK2 layers",
"short_name": "404 layers",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "portrait",
"background_color": "#151719",
"theme_color": "#2a2d30",
"description": "Combo trainer for the Roland SP-404MK2",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

14
reference/sw.js Normal file
View File

@@ -0,0 +1,14 @@
const V="404layers-v2";
const FILES=["./", "index.html", "manifest.webmanifest", "icons/icon-192.png", "icons/icon-512.png", "icons/maskable-512.png", "fonts/barlow-condensed-600.woff2", "fonts/barlow-400.woff2", "fonts/barlow-condensed-700.woff2", "fonts/barlow-500.woff2", "fonts/barlow-condensed-500.woff2", "fonts/barlow-600.woff2"];
self.addEventListener("install",e=>e.waitUntil(caches.open(V).then(c=>c.addAll(FILES)).then(()=>self.skipWaiting())));
self.addEventListener("activate",e=>e.waitUntil(caches.keys().then(ks=>Promise.all(ks.filter(k=>k!==V).map(k=>caches.delete(k)))).then(()=>self.clients.claim())));
self.addEventListener("fetch",e=>{
if(e.request.method!=="GET")return;
const url=new URL(e.request.url);if(url.origin!==location.origin)return;
// network first for the page (so updates land), cache first for everything else
if(e.request.mode==="navigate"){
e.respondWith(fetch(e.request).then(r=>{const c=r.clone();caches.open(V).then(x=>x.put("index.html",c));return r}).catch(()=>caches.match("index.html")));
return;
}
e.respondWith(caches.match(e.request).then(r=>r||fetch(e.request)));
});

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Installs the Android toolchain needed to build a Capacitor APK on Pop!_OS 24.04.
# Headless: JDK 21 + cmdline-tools + platform-tools (adb). No Android Studio.
# Idempotent - safe to re-run.
set -euo pipefail
# Versions below are verified against Capacitor 8.5.2's capacitor/build.gradle
# (compileSdk 36, targetSdk 36, minSdk 24, Java 21) - do not bump blind.
SDK_ROOT="${ANDROID_HOME:-$HOME/Android/Sdk}"
CMDLINE_BUILD="15859902" # newest per developer.android.com/studio
BUILD_TOOLS="36.1.0" # pairs with compileSdk 36
SHELL_RC="$HOME/.zshrc"
MARKER="# >>> android sdk (sp404-layers) >>>"
[ "$(id -u)" -ne 0 ] || { echo "Run as your normal user, not root. It will sudo when needed."; exit 1; }
echo "==> 1/6 apt packages (needs sudo)"
sudo apt-get update -qq
# android-sdk-platform-tools-common ships the udev rules that let adb see the phone.
sudo apt-get install -y openjdk-21-jdk android-sdk-platform-tools-common unzip curl
echo "==> 2/6 command-line tools -> $SDK_ROOT"
if [ ! -x "$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" ]; then
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
url="https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_BUILD}_latest.zip"
echo " downloading $url"
curl -fL --progress-bar -o "$tmp/cmdline.zip" "$url"
unzip -q "$tmp/cmdline.zip" -d "$tmp"
mkdir -p "$SDK_ROOT/cmdline-tools"
rm -rf "$SDK_ROOT/cmdline-tools/latest"
# the zip unpacks to cmdline-tools/, sdkmanager insists on being at cmdline-tools/latest/
mv "$tmp/cmdline-tools" "$SDK_ROOT/cmdline-tools/latest"
else
echo " already present, skipping"
fi
# Derive JAVA_HOME from the javac we just installed rather than hardcoding a path.
JAVA_HOME="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")"
[ -x "$JAVA_HOME/bin/javac" ] || { echo "Could not locate JAVA_HOME (got '$JAVA_HOME')"; exit 1; }
case "$("$JAVA_HOME/bin/javac" -version 2>&1)" in
*"javac 21."*) : ;;
*) echo "WARNING: Capacitor 8 wants Java 21, found: $("$JAVA_HOME/bin/javac" -version 2>&1)" ;;
esac
export JAVA_HOME
export ANDROID_HOME="$SDK_ROOT"
export ANDROID_SDK_ROOT="$SDK_ROOT"
export PATH="$SDK_ROOT/cmdline-tools/latest/bin:$SDK_ROOT/platform-tools:$JAVA_HOME/bin:$PATH"
echo "==> 3/6 accepting SDK licenses"
yes 2>/dev/null | sdkmanager --licenses >/dev/null || true
echo "==> 4/6 SDK packages (this is the slow part, ~1-2 GB)"
sdkmanager --install \
"platform-tools" \
"platforms;android-35" \
"platforms;android-36" \
"build-tools;${BUILD_TOOLS}"
echo "==> 5/6 persisting env to $SHELL_RC"
if ! grep -qF "$MARKER" "$SHELL_RC" 2>/dev/null; then
cat >> "$SHELL_RC" <<EOF
$MARKER
export JAVA_HOME=$JAVA_HOME
export ANDROID_HOME=$SDK_ROOT
export ANDROID_SDK_ROOT=$SDK_ROOT
export PATH=\$ANDROID_HOME/cmdline-tools/latest/bin:\$ANDROID_HOME/platform-tools:\$JAVA_HOME/bin:\$PATH
# <<< android sdk (sp404-layers) <<<
EOF
echo " added"
else
echo " already there, skipping"
fi
echo "==> 6/6 usb device access"
if ! id -nG "$USER" | grep -qw plugdev; then
sudo usermod -aG plugdev "$USER"
echo " added $USER to plugdev - LOG OUT AND BACK IN for this to take effect"
else
echo " already in plugdev"
fi
sudo udevadm control --reload-rules && sudo udevadm trigger || true
cat <<'EOF'
------------------------------------------------------------
Done. Open a NEW terminal, then verify:
java -version # expect 21.x
sdkmanager --list_installed
adb devices # plug the phone in, tap "Allow USB debugging"
If adb shows "no permissions" or nothing, log out and back in
(the plugdev group change needs a fresh session).
------------------------------------------------------------
EOF

58
scripts/sync-www.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Builds www/ from reference/ for the Capacitor wrapper.
#
# reference/ is the frozen spec and is NEVER edited. Everything native-specific
# is either an additive file (native.css, native.js) or one of the three
# removals below. Each patch asserts its anchor exists, so if the reference
# ever changes shape this fails loudly instead of silently shipping a broken app.
set -euo pipefail
cd "$(dirname "$0")/.."
SRC="reference"
OUT="www"
[ -f "$SRC/index.html" ] || { echo "missing $SRC/index.html"; exit 1; }
rm -rf "$OUT"
mkdir -p "$OUT"
# sw.js and README.md are deliberately not copied:
# - the service worker would cache index.html and serve a stale app after an
# APK update; assets are already local so it buys nothing.
# - README documents the web deployment, irrelevant inside the APK.
cp -r "$SRC/fonts" "$SRC/icons" "$OUT/"
cp "$SRC/manifest.webmanifest" "$OUT/" # harmless, keeps icon refs valid
cp "$SRC/index.html" "$OUT/index.html"
cp native/native.css native/native.js "$OUT/"
python3 - "$OUT/index.html" <<'PY'
import sys, re
path = sys.argv[1]
html = open(path, encoding="utf-8").read()
def patch(html, anchor, replacement, label):
if anchor not in html:
sys.exit(f"sync-www: anchor not found for '{label}':\n {anchor[:90]}")
return html.replace(anchor, replacement, 1)
# 1. drop the service worker registration
sw = '<script>if("serviceWorker" in navigator&&location.protocol!=="file:")addEventListener("load",()=>navigator.serviceWorker.register("sw.js"));</script>\n'
html = patch(html, sw, "", "service worker registration")
# 2. drop the web manifest link (the APK's own manifest governs the app)
html = patch(html, '<link rel="manifest" href="manifest.webmanifest">', "", "manifest link")
# 3. native stylesheet last in <head> so its overrides win on equal specificity
html = patch(html, "</head>",
'<link rel="stylesheet" href="native.css">\n</head>', "head close")
# 4. native script after the app script, so its globals already exist
html = patch(html, "</body>",
'<script src="native.js"></script>\n</body>', "body close")
open(path, "w", encoding="utf-8").write(html)
print("sync-www: patched index.html (4/4 anchors matched)")
PY
echo "sync-www: $OUT/ built from $SRC/"