feat(studio): redesign editor with Tailwind and fix connection feedback
This commit is contained in:
parent
d15b0ec711
commit
c26ea25f93
11 changed files with 2136 additions and 967 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -15,3 +15,5 @@ pytest-*/*
|
|||
build-*/*
|
||||
.ignore
|
||||
src/firmware/platform/pico/switch2_wake_config.h
|
||||
node_modules
|
||||
.npm
|
||||
|
|
|
|||
55
README.md
55
README.md
|
|
@ -350,7 +350,47 @@ Output mode is selected before TinyUSB starts and never changes while mounted. A
|
|||
|
||||
Development USB identities are `CAFE:4010` (XInput), `CAFE:4020` (DInput), and `CAFE:4021` (Mac). DInput and Mac expose four input-only generic HID interfaces and no rumble. Mac uses X/Y/Z/Rx sticks plus Simulation Brake/Accelerator triggers. Switch reports input, rumble, and motion capability; XInput reports input and rumble.
|
||||
|
||||
`profiles edit` starts a local-only browser editor at `http://127.0.0.1:8765/`. It exposes every profile field: all 16 buttons plus the L2/R2 analog triggers can be remapped to ordinary buttons, triggers, or Switch SL/SR rail outputs; both sticks and triggers retain independent deadzone/saturation/curve settings; and rumble, confirmation, Turbo/Auto Burst, built-in action chords, and four custom macro sequences are editable. Its live playtest compares current raw stick and trigger input with the unsaved draft, shows deadzone/saturation boundaries and digital thresholds, and highlights pressed physical controls. Select a controller identity and one of its eight profile slots, use **Start from defaults** for a new draft, then **Save to Pico**. The backend validates the complete profile before using the existing chunked atomic transaction; invalid drafts never reach flash. Use `profiles edit --no-browser` for a printed URL or `profiles edit --port PORT` to choose another local port.
|
||||
`profiles edit` starts **Controller Studio**, a local-only browser editor at
|
||||
`http://127.0.0.1:8765/`. Select a controller and one of its eight profile slots
|
||||
in the sidebar. The editor has seven focused sections: **Button mapping**,
|
||||
**Sticks & triggers**, **Shift layer**, **Profile shortcuts**, **Rumble**,
|
||||
**Turbo**, and **Actions & macros**. Section links support keyboard navigation,
|
||||
browser history, and direct URLs such as `/#analog`; switching sections keeps
|
||||
the current draft. **Reset defaults** starts a new default draft,
|
||||
**Save to Pico** stores it, and **Make active** separately selects it for play.
|
||||
Rename and controller alias actions save their metadata immediately; Copy
|
||||
replaces the destination profile and name, including any unsaved draft settings.
|
||||
|
||||
The editor exposes every profile field: button, analog-trigger and rail-output
|
||||
mappings; independent stick/trigger deadzones, saturation and response curves;
|
||||
rumble and confirmation; Turbo/Auto Burst; action chords; and four custom macros.
|
||||
Live playtest compares controller input with the draft and highlights pressed
|
||||
controls. Detailed diagram, native-output and adapter help is expandable.
|
||||
Saving an invalid field opens its section and focuses the field. The backend
|
||||
validates the complete profile before the existing chunked atomic transaction;
|
||||
invalid drafts never reach flash. Use `profiles edit --no-browser` for a printed
|
||||
URL or `profiles edit --port PORT` to choose another local port.
|
||||
|
||||
USB failures clear the adapter connection indicator and disable device actions
|
||||
without discarding the draft. Discovery checks once per request; it does not
|
||||
hold the editor's single-threaded server for the transaction timeout while the
|
||||
Pico is absent. Background tabs show **Connection check paused** and recheck on
|
||||
return. Controller disconnection and unsupported live-input features are
|
||||
separate from adapter availability.
|
||||
|
||||
**Studio styling:** Tailwind CSS is compiled locally and the resulting
|
||||
`src/switch_pico_bridge/web/profile_editor.css` ships with the Python package.
|
||||
Running Studio needs neither Node.js nor an internet connection. When changing
|
||||
the HTML, JavaScript class names, or `web/input.css`, rebuild the stylesheet from
|
||||
the repository root using the pinned development dependencies:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build:css
|
||||
```
|
||||
|
||||
`npm run watch:css` rebuilds while developing. Include the generated
|
||||
`profile_editor.css` with source changes; no Tailwind CDN is used at runtime.
|
||||
|
||||
Switch 2's **C, GL, GR, Left SL/SR and Right SL/SR** are additional input controls. Base and Shift mappings can route them to ordinary buttons, triggers, or rail outputs; they also remain available in action/macro chords, cancellation and modifiers. Extra input mappings default to disabled. **Left SL, Left SR, Right SL and Right SR** are real output destinations in both native Joy-Con and Switch Pro reports. C/GL/GR remain input sources, not destinations. XInput and generic HID have no rail-button equivalents. Macro and swing output actions retain their existing ordinary-button contract.
|
||||
|
||||
|
|
@ -358,6 +398,13 @@ Controller Studio uses the supplied lightweight SVGs for Switch 2 Pro, Joy-Con 2
|
|||
|
||||
**Auto** uses matching-owner live metadata to distinguish Joy-Con pairs/solo halves and Wii horizontal, vertical, and Nunchuk layouts. **Preview** changes only the editor's diagram and source labels; it does not pair controllers or change saved mappings, and physical highlighting is disabled. Source choices reflect the layout while stored unavailable mappings are retained. Legacy Wii metadata without orientation uses a visibly labeled horizontal reference with physical highlighting disabled.
|
||||
|
||||
Disconnected Wii-family owners use a labeled Wii Remote reference instead of
|
||||
the generic gamepad artwork. The reference shows default horizontal controls,
|
||||
keeps every source mapping editable, and does not highlight physical input.
|
||||
Saved Wii-family identifiers cannot establish orientation, extensions, or
|
||||
distinguish a Wii U Pro Controller. Matching live metadata replaces the reference
|
||||
with the detected layout; reconnecting or disconnecting never changes the draft.
|
||||
|
||||
**Wii orientation** is separate from layout preview. Select a connected Wii Remote owner, choose **Horizontal** or **Vertical**, and click **Apply orientation**. Studio waits for firmware confirmation before reporting success. This changes the current connection's physical button mapping, not saved profiles or adapter configuration. Nunchuk mappings remain vertical while attached; unplugging restores the selected standalone orientation. Changing orientation clears old held-input/macro/capture state and advances the logical input generation without reconnecting Bluetooth. Reconnect uses the horizontal default, or vertical if + is held while connecting.
|
||||
|
||||
**Native Joy-Con output layout:** for a Pro Controller, DualSense or other full
|
||||
|
|
@ -378,7 +425,7 @@ profile, then assign the controller in Switch **Change Grip/Order** with the
|
|||
mapped SL+SR combination. Keep existing game profiles intact rather than
|
||||
replacing them. Console/game compatibility still requires hardware qualification.
|
||||
|
||||
**Swap left & right sticks (including clicks)** is in Analog. It works in both
|
||||
**Swap left & right sticks (including clicks)** is in **Sticks & triggers**. It works in both
|
||||
native Joy-Con and ordinary Pro-emulation firmware (and other output modes):
|
||||
each physical stick is calibrated first, then the output axis pairs and mapped
|
||||
stick clicks exchange sides. Final-output macro overrides remain downstream.
|
||||
|
|
@ -399,7 +446,7 @@ swapping**, before native sideways rotation. Opposite directions cancel per
|
|||
axis. Cardinals use full scale; diagonals are normalized inside the stick's
|
||||
radius. Any nonzero mapped-left analog vector takes priority over the entire
|
||||
digital vector. If analog drift prevents D-pad movement, adjust the physical
|
||||
stick's calibration/inner deadzone in Analog; no hidden deadzone is added.
|
||||
stick's calibration/inner deadzone in **Sticks & triggers**; no hidden deadzone is added.
|
||||
Movement releases when its sources release and never presses the stick click.
|
||||
Final-output macro overrides retain their existing downstream behavior.
|
||||
|
||||
|
|
@ -512,7 +559,7 @@ The AIO firmware enables motion automatically for original Wii Remotes with an e
|
|||
- **Nunchuk stick travel:** the parser reads the 16-byte extension calibration at `A40020` before activating MotionPlus. It validates both checksum bytes and the X/Y min/center/max ordering, then scales each side of each axis independently into the full normalized range, clamps overshoot, and inverts Y. It preserves the notched gate rather than expanding diagonals into square corners. Unreadable/invalid calibration uses documented nominal travel (center 128, ±96), not the full 0–255 byte range. Replacement Nunchuks reload their own calibration. Existing left-stick profile tuning applies; saved right-stick tuning is not copied or overwritten.
|
||||
- **Motion:** ordinary remotes use continuous `0x31`; MotionPlus and Nunchuk use combined `0x35` reports. The parser checks primary/backup accelerometer factory calibration and both fast/slow gyro calibration blocks, including checksums. Invalid calibration disables only the affected sensor rather than inventing readings. MotionPlus selects sensitivity independently for each axis; factory-only zero correction can retain temperature-dependent gyro drift.
|
||||
- **Nunchuk passthrough:** MotionPlus and Nunchuk samples alternate. Held Nunchuk controls persist across gyro packets, moved C/Z bits are decoded, and the remote's accelerometer remains the motion source. Attaching or removing a Nunchuk triggers serialized extension discovery, updates Studio's layout, and clears detached stick/C/Z state. Extension identifiers accept the upstream-compatible type suffix rather than requiring a vendor-specific prefix; initializing extensions get bounded reply-paced retries. Periodic discovery of an inactive external MotionPlus attached alone and Classic Controller MotionPlus passthrough remain unimplemented. Existing ordinary Classic Controller, Wii U Pro, Balance Board and uDraw paths remain separate.
|
||||
- **Motion gesture bindings:** Studio's Macro section provides **Wii Remote swing**, **Nunchuk swing**, and **Both together**. Each can select a final output button or one of the four configured macros, plus an optional held modifier. Remote and Nunchuk have independent Low/Medium/High sensitivity. All new bindings default to disabled; existing Remote bindings are preserved. No sensor bar or MotionPlus is required, and gestures remain available when gyro output is disabled. For Zelda on Switch, choose **Y** (logical `west`; use the Switch preview for Nintendo button labels).
|
||||
- **Motion gesture bindings:** Studio's **Actions & macros** section provides **Wii Remote swing**, **Nunchuk swing**, and **Both together**. Each can select a final output button or one of the four configured macros, plus an optional held modifier. Remote and Nunchuk have independent Low/Medium/High sensitivity. All new bindings default to disabled; existing Remote bindings are preserved. No sensor bar or MotionPlus is required, and gestures remain available when gyro output is disabled. For Zelda on Switch, choose **Y** (logical `west`; use the Switch preview for Nintendo button labels).
|
||||
- **Combined priority:** when Both together is enabled and both calibrated sensors are fresh, individual gestures wait for the combination window (30–200 ms, default 100 ms). One full swing can pair with smaller sustained movement from the other sensor, before or after it within the inclusive window. Confirmation uses half that sensor's normal acceleration threshold while retaining the force/tilt guard and fresh-sample evidence requirement; individual thresholds are unchanged. Two small movements alone do not trigger an action. Confirmation is consumed once, including the confirming source's rearm/cooldown, so a later full swing from that same stroke cannot leak an individual action. Without an eligible combined binding or a fresh Nunchuk stream, Remote actions do not acquire the delay. Disconnects, lost modifiers, stale samples and active macros discard pending evidence rather than replaying it.
|
||||
- **Stroke detection and macros:** initially settle each enabled sensor for 120 ms. Further strokes need only a 20 ms lower-force gap and at least 200 ms between presses; early rebounds are discarded. Buttons generate 80 ms presses. Gesture macros run one cycle regardless of their physical-trigger playback mode and must contain a positive-duration step before binding. Physical macro triggers and cancel controls take priority; gestures during active macros are ignored, not queued. The detector retains stroke history so repeated gesture macros do not require full stops. Profile changes and reserved hotkeys cancel pending gesture output; physical button holds remain intact.
|
||||
- **Independent Nunchuk acceleration:** plain and MotionPlus passthrough reports use separate calibrated samples and freshness counters. Nunchuk acceleration never replaces the Remote's console IMU. Invalid factory accelerometer calibration disables Nunchuk/combined gestures without disabling the stick, C/Z buttons, or Remote gestures; unplugging and replacement discard the old Nunchuk sample.
|
||||
|
|
|
|||
1130
package-lock.json
generated
Normal file
1130
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
14
package.json
Normal file
14
package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "switch-pico-profile-editor",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Tailwind CSS toolchain for Switch Pico Profile Studio",
|
||||
"scripts": {
|
||||
"build:css": "tailwindcss -i ./src/switch_pico_bridge/web/input.css -o ./src/switch_pico_bridge/web/profile_editor.css --minify",
|
||||
"watch:css": "tailwindcss -i ./src/switch_pico_bridge/web/input.css -o ./src/switch_pico_bridge/web/profile_editor.css --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "4.3.1",
|
||||
"tailwindcss": "4.3.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -184,9 +184,10 @@ def _controller_presentation(
|
|||
(0x054C, 0x0CE6): ("Sony DualSense", "playstation", "dualsense"),
|
||||
(0x054C, 0x0DF2): ("Sony DualSense Edge", "playstation", "dualsense"),
|
||||
(0x054C, 0x09CC): ("Sony DualShock 4", "playstation", "generic"),
|
||||
# VID/PID cannot distinguish Wii extensions or Wii U Pro from Remote Plus.
|
||||
(0x057E, 0x0306): ("Nintendo Wii controller", "generic", "generic"),
|
||||
(0x057E, 0x0330): ("Nintendo Wii / Wii U controller", "generic", "generic"),
|
||||
# Wii-family IDs do not establish orientation, extensions, or Wii U Pro.
|
||||
# Use family reference artwork without narrowing the available sources.
|
||||
(0x057E, 0x0306): ("Nintendo Wii controller", "wii", "wii-reference"),
|
||||
(0x057E, 0x0330): ("Nintendo Wii / Wii U controller", "wii", "wii-reference"),
|
||||
}
|
||||
exact = known.get((identity.vendor_id, identity.product_id))
|
||||
if exact is not None:
|
||||
|
|
@ -278,8 +279,10 @@ class ProfileEditorServer(HTTPServer):
|
|||
|
||||
def find_device(self) -> config_manager.UsbDevice:
|
||||
if self._device is None:
|
||||
# Browser polling handles reconnection. Waiting the transaction
|
||||
# timeout here blocks every request on this single-threaded server.
|
||||
self._device = config_manager.find_pico(
|
||||
self.bus, self.device_address, self.operation_timeout
|
||||
self.bus, self.device_address, timeout=0.0
|
||||
)
|
||||
return self._device
|
||||
|
||||
|
|
|
|||
|
|
@ -193,5 +193,11 @@ const ControllerLayouts = (() => {
|
|||
note: "Nunchuk stick uses LEFT axes with calibrated travel and has no click. B→south, A→east, 1/2→left/right shoulder, Nunchuk C→west and Z→north. Nunchuk C is a normal face source, not the Switch 2 C extra. Power is not remappable.",
|
||||
},
|
||||
};
|
||||
layouts["wii-reference"] = {
|
||||
...layouts["wii-remote"],
|
||||
name: "Wii Remote · reference layout",
|
||||
referenceOnly: true,
|
||||
note: "Wii-family reference, shown with the default horizontal controls. The saved identity cannot confirm orientation, extensions, or distinguish a Wii U Pro Controller. Connect the controller for its detected layout, or choose a diagram preview. All source mappings remain available; physical highlighting is off.",
|
||||
};
|
||||
return layouts;
|
||||
})();
|
||||
|
|
|
|||
350
src/switch_pico_bridge/web/input.css
Normal file
350
src/switch_pico_bridge/web/input.css
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
@import "tailwindcss" source(none);
|
||||
@source "./profile_editor.html";
|
||||
@source "./profile_editor.js";
|
||||
|
||||
@theme {
|
||||
--color-slate-950: #0b1019;
|
||||
--color-slate-900: #121a27;
|
||||
--color-slate-850: #17202e;
|
||||
--color-slate-800: #202c3e;
|
||||
--color-slate-700: #3c4c63;
|
||||
--color-blue-500: #80afff;
|
||||
--color-blue-600: #427cdd;
|
||||
--color-sky-500: #80afff;
|
||||
--color-emerald-400: #6ed4b1;
|
||||
--color-amber-400: #ebc174;
|
||||
--color-rose-400: #f19aaa;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root { color-scheme: dark; }
|
||||
body { @apply text-sm; }
|
||||
[hidden] { display: none !important; }
|
||||
h2, h3, h4 { @apply font-semibold tracking-tight text-slate-100; }
|
||||
h2, h3 { @apply text-xl; }
|
||||
h4 { @apply text-base; }
|
||||
button, select, summary { @apply cursor-pointer; }
|
||||
button:disabled, select:disabled, input:disabled { @apply cursor-not-allowed opacity-45; }
|
||||
:is(button, input, select, a, summary, [tabindex]):focus-visible { outline: 2px solid var(--color-blue-500); outline-offset: 3px; }
|
||||
input[type="range"], progress { accent-color: var(--color-blue-500); }
|
||||
input[type="range"] { @apply w-full; }
|
||||
input, select, fieldset { @apply min-w-0; }
|
||||
progress { @apply h-2 w-full overflow-hidden rounded-full; }
|
||||
progress::-webkit-progress-bar { @apply rounded-full bg-slate-800; }
|
||||
progress::-webkit-progress-value { @apply rounded-full bg-blue-500; }
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--color-slate-700) transparent; }
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.topbar { @apply sticky top-0 z-30 flex min-h-20 items-center justify-between gap-6 border-b border-slate-800 bg-slate-950 px-6; }
|
||||
.brand { @apply flex items-center gap-3; }
|
||||
.brand-mark { @apply grid size-9 shrink-0 grid-cols-2 gap-1 rounded-lg border border-blue-500/30 bg-blue-500/10 p-2; }
|
||||
.brand-mark span { @apply rounded-sm bg-blue-500; }
|
||||
.brand-mark span:last-child { @apply bg-blue-500/50; }
|
||||
.topbar-meta { @apply flex items-center gap-4; }
|
||||
.connection { @apply flex min-w-0 items-center gap-2 text-xs text-slate-300; }
|
||||
.connection-dot { @apply size-2 shrink-0 rounded-full bg-amber-400; }
|
||||
.connection[data-state="ready"] .connection-dot { @apply bg-emerald-400; }
|
||||
.connection[data-state="error"] .connection-dot { @apply bg-rose-400; }
|
||||
.workspace { @apply mx-auto my-6 grid w-full max-w-[1720px] grid-cols-[248px_minmax(0,1fr)] items-start gap-6 px-6; }
|
||||
.sidebar { @apply sticky top-26 max-h-[calc(100dvh-120px)] overflow-y-auto rounded-xl border border-slate-800 bg-slate-900 p-5; }
|
||||
.sidebar-heading, .adapter-settings-heading { @apply flex items-center justify-between gap-3; }
|
||||
.eyebrow { @apply mb-1 text-[11px] font-semibold tracking-wider text-slate-400 uppercase; }
|
||||
.icon-button { @apply flex size-9 shrink-0 items-center justify-center rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800; }
|
||||
.field-label { @apply mt-5 mb-2 block text-xs font-medium text-slate-300; }
|
||||
.field-help, .help-details p { @apply mt-2 text-xs leading-relaxed text-slate-400; }
|
||||
.identity-actions { @apply mt-2 flex flex-wrap gap-2; }
|
||||
.identity-actions .button { @apply flex-1 px-2; }
|
||||
.controller-details { @apply mt-3 text-xs leading-relaxed text-slate-400; overflow-wrap: anywhere; }
|
||||
.sidebar-note { @apply mt-5 flex gap-2 border-t border-slate-800 pt-4 text-xs leading-relaxed text-slate-400; }
|
||||
.note-icon { @apply flex size-4 shrink-0 items-center justify-center rounded-full border border-slate-700 text-[10px]; }
|
||||
.profile-list { @apply grid gap-1.5; }
|
||||
.profile-button { @apply flex min-h-11 items-center justify-between gap-2 rounded-lg border border-transparent border-l-[3px] px-3 py-2 text-left text-slate-300 hover:bg-slate-800; border-left-color: var(--profile-color); }
|
||||
/* Slot colors follow kProfileLightbarPalette in bluepad32_input_backend.cpp. */
|
||||
.profile-button[data-profile-index="0"] { --profile-color: #0055ff; }
|
||||
.profile-button[data-profile-index="1"] { --profile-color: #00cc66; }
|
||||
.profile-button[data-profile-index="2"] { --profile-color: #ffaa00; }
|
||||
.profile-button[data-profile-index="3"] { --profile-color: #cc33ff; }
|
||||
.profile-button[data-profile-index="4"] { --profile-color: #ff4444; }
|
||||
.profile-button[data-profile-index="5"] { --profile-color: #00dddd; }
|
||||
.profile-button[data-profile-index="6"] { --profile-color: #ff66bb; }
|
||||
.profile-button[data-profile-index="7"] { --profile-color: #ccff33; }
|
||||
.profile-button.selected { @apply text-slate-100; border-color: var(--profile-color); background: color-mix(in srgb, var(--profile-color) 12%, var(--color-slate-900)); }
|
||||
.profile-number { @apply flex min-w-0 items-center gap-3 text-xs font-medium; overflow-wrap: anywhere; }
|
||||
.profile-number span { @apply flex size-6 shrink-0 items-center justify-center rounded text-xs font-semibold text-slate-950; background: var(--profile-color); }
|
||||
.profile-button[data-profile-index="0"] .profile-number span { @apply text-white; }
|
||||
.mini-active { @apply shrink-0 text-[10px] font-medium text-emerald-400; }
|
||||
.editor-shell { @apply min-w-0; }
|
||||
.editor-header { @apply mb-4 flex flex-wrap items-center justify-between gap-5 rounded-xl border border-slate-800 bg-slate-900 p-5; }
|
||||
.title-line { @apply flex-wrap; }
|
||||
.profile-name-editor { @apply mt-3 flex items-center gap-2; }
|
||||
.profile-name-editor input { @apply h-10 w-full max-w-60 rounded-lg border border-slate-700 bg-slate-950 px-3 text-sm; }
|
||||
.header-actions { @apply flex flex-col items-end gap-2; }
|
||||
.action-group-primary { order: -1; }
|
||||
.active-badge, .dirty-badge { @apply inline-flex rounded-md border px-2 py-0.5 text-[11px] font-medium; }
|
||||
.active-badge { @apply border-emerald-400/20 bg-emerald-400/10 text-emerald-400; }
|
||||
.dirty-badge { @apply border-amber-400/20 bg-amber-400/10 text-amber-400; }
|
||||
.button { @apply inline-flex min-h-10 items-center justify-center gap-2 rounded-lg border border-transparent px-3 py-2 text-xs font-semibold transition-colors; }
|
||||
.button-primary { @apply border-blue-500 bg-blue-500 text-slate-950 hover:bg-blue-400; }
|
||||
.button-secondary { @apply border-slate-700 bg-slate-800/50 text-slate-200 hover:bg-slate-800; }
|
||||
.button-ghost { @apply text-slate-400 hover:bg-slate-800 hover:text-slate-100; }
|
||||
.select, .number-input { @apply w-full min-w-0 rounded-lg border border-slate-700 bg-slate-950 px-3 py-2.5 text-sm text-slate-100; }
|
||||
.section-nav { @apply sticky top-20 z-20 mb-4 flex gap-1 overflow-x-auto border-b border-slate-700 bg-slate-950 py-2; }
|
||||
.section-nav a { @apply shrink-0 rounded-lg px-3 py-2.5 text-xs font-medium text-slate-400 hover:bg-slate-800 hover:text-slate-100; }
|
||||
.section-nav a[aria-current="page"] { @apply bg-blue-500/15 text-blue-500; }
|
||||
.panel { @apply mb-6 min-w-0 scroll-mt-40 rounded-xl border border-slate-800 bg-slate-900 p-6; }
|
||||
.panel-heading { @apply mb-6 flex items-start justify-between gap-5; }
|
||||
.panel-heading-actions { @apply flex max-w-sm flex-col items-end gap-2 text-right; }
|
||||
.panel-heading-actions p { @apply text-xs leading-relaxed text-slate-400; }
|
||||
.section-reset { @apply min-h-8 text-xs font-medium text-blue-500 hover:underline; }
|
||||
.help-details { @apply mt-4 border-t border-slate-800 pt-3; }
|
||||
.help-details summary, .joycon-mode-help summary { @apply text-xs font-medium text-blue-500; }
|
||||
.help-details strong { @apply text-slate-200; }
|
||||
.controller-mapper { @apply grid grid-cols-[minmax(0,1fr)_240px] items-start gap-4; }
|
||||
.controller-canvas { @apply min-w-0 overflow-hidden rounded-xl border border-slate-800 bg-slate-950 p-4; }
|
||||
.controller-caption { @apply flex flex-wrap items-center justify-between gap-2; }
|
||||
.controller-caption strong { @apply text-sm font-medium; }
|
||||
.controller-live { @apply text-[11px] text-amber-400; }
|
||||
.controller-live[data-state="live"] { @apply text-emerald-400; }
|
||||
.controller-viewport { @apply max-w-full overflow-x-auto overflow-y-hidden py-4; }
|
||||
.controller-photo-wrap { position: relative; width: 100%; min-width: var(--diagram-min, 640px); max-width: var(--diagram-max, 900px); margin: auto; aspect-ratio: var(--diagram-ratio, 800 / 552); }
|
||||
.controller-stage { position: absolute; top: 50%; left: 50%; width: var(--stage-width, 100%); height: var(--stage-height, 100%); transform: translate(-50%, -50%) rotate(var(--stage-angle, 0deg)); }
|
||||
.controller-photo { @apply block size-full pointer-events-none select-none; }
|
||||
.controller-hotspots { @apply absolute inset-0; }
|
||||
.controller-hotspots button { @apply absolute grid size-[30px] place-items-center rounded-full border-2 border-blue-500 bg-slate-950/95 p-0 text-[10px] leading-none font-bold text-slate-100; left: var(--x, 50%); top: var(--y, 50%); transform: translate(-50%, -50%) rotate(var(--label-angle, 0deg)); }
|
||||
.controller-hotspots button:hover { @apply z-10 bg-slate-800; }
|
||||
.controller-hotspots button.selected { @apply z-10 border-blue-500 bg-blue-500 text-slate-950 ring-4 ring-blue-500/20; }
|
||||
.controller-hotspots button.disabled-map:not(.selected):not(.pressed) { @apply border-slate-500 text-slate-400; }
|
||||
.controller-hotspots button.pressed { @apply z-20 border-emerald-400 bg-emerald-400 text-slate-950 ring-4 ring-emerald-400/20; }
|
||||
.controller-hotspots button[data-controller-button^="dpad_"] { @apply size-[26px]; }
|
||||
.controller-annotation { @apply absolute max-w-40 rounded border border-slate-500 bg-slate-800 p-1.5 text-center text-[11px]; left: var(--x); top: var(--y); transform: translate(-50%, -50%) rotate(var(--label-angle, 0deg)); }
|
||||
.controller-hint { @apply mt-2 text-center text-xs text-slate-400; }
|
||||
.extra-controls { @apply mt-2 flex flex-wrap gap-2; }
|
||||
.extra-controls button { @apply min-h-11 min-w-11 rounded-lg border border-blue-500/50 bg-slate-800 px-3 py-2 text-xs font-medium; }
|
||||
.extra-controls button.disabled-map { @apply border-slate-700 text-slate-400; }
|
||||
.extra-controls button.selected { @apply ring-2 ring-blue-500; }
|
||||
.extra-controls button.pressed { @apply border-emerald-400 bg-emerald-400 text-slate-950; }
|
||||
.source-unavailable { @apply text-amber-400; }
|
||||
.mapping-inspector { @apply min-w-0 rounded-xl border border-blue-500/25 bg-slate-850 p-5; }
|
||||
.selected-control-mark { @apply my-4 grid size-12 place-items-center rounded-xl bg-blue-500/15 text-lg font-bold text-blue-500; }
|
||||
.mapping-inspector > p:not(.eyebrow) { @apply mt-2 text-xs leading-relaxed text-slate-400; }
|
||||
.mapping-key { @apply mt-6 flex flex-wrap gap-4 text-xs text-slate-400; }
|
||||
.mapping-key span { @apply inline-flex items-center gap-2; }
|
||||
.key-dot { @apply size-2 rounded-full border-2 border-blue-500; }
|
||||
.key-dot.disabled { @apply border-slate-500; }
|
||||
.native-joycon-control, .wii-orientation-control { @apply mt-5 rounded-xl border border-slate-700 bg-slate-850/50 p-4; }
|
||||
.native-joycon-control > .field-label, .wii-orientation-control > .field-label { @apply mt-0; }
|
||||
.native-joycon-actions, .wii-orientation-actions { @apply flex flex-wrap items-center gap-2; }
|
||||
.native-joycon-actions .select, .wii-orientation-actions .select { @apply flex-[1_1_240px]; }
|
||||
.joycon-mode-status, .wii-orientation-status { @apply mt-3 text-xs leading-relaxed text-emerald-400; overflow-wrap: anywhere; }
|
||||
.wii-orientation-status[data-state="idle"] { @apply hidden; }
|
||||
:is(.joycon-mode-status, .wii-orientation-status):is([data-state="loading"], [data-state="locked"]) { @apply text-slate-400; }
|
||||
:is(.joycon-mode-status, .wii-orientation-status):is([data-state="pending"], [data-state="unsupported"]) { @apply text-amber-400; }
|
||||
:is(.joycon-mode-status, .wii-orientation-status)[data-state="error"] { @apply text-rose-400; }
|
||||
.mapping-grid { @apply grid grid-cols-4 gap-3; }
|
||||
.modifier-controls { @apply mb-5 grid grid-cols-2 gap-4; }
|
||||
.control-card, .action-card { @apply min-w-0 rounded-xl border border-slate-700/60 bg-slate-850 p-4; }
|
||||
.control-card label, .number-field label { @apply mb-2 block text-xs font-medium text-slate-300; }
|
||||
.analog-grid, .feedback-grid, .builtin-actions { @apply grid grid-cols-2 gap-4; }
|
||||
.subpanel { @apply min-w-0 rounded-xl border border-slate-700/60 bg-slate-850 p-4; }
|
||||
.subpanel-heading { @apply mb-4 flex flex-wrap items-start justify-between gap-3; }
|
||||
.subpanel-heading > span { @apply max-w-60 text-xs leading-relaxed text-slate-400; }
|
||||
.number-grid { @apply grid grid-cols-2 gap-3; }
|
||||
.number-field.wide, .toggle-row { @apply col-span-full; }
|
||||
.toggle-row, .check-grid { @apply flex flex-wrap gap-2; }
|
||||
.checkbox-pill { @apply relative inline-block; }
|
||||
.checkbox-pill input { @apply sr-only; }
|
||||
.checkbox-pill span { @apply block rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-xs font-medium text-slate-300; }
|
||||
.checkbox-pill input:checked + span { @apply border-blue-500/70 bg-blue-500/20 text-slate-100; }
|
||||
.checkbox-pill input:focus-visible + span { outline: 2px solid var(--color-blue-500); outline-offset: 3px; }
|
||||
.checkbox-pill input:disabled + span { @apply cursor-not-allowed opacity-40; }
|
||||
.controller-choice span { @apply flex min-h-14 min-w-14 flex-col items-center justify-center gap-1 text-center; }
|
||||
.controller-choice b { @apply text-base; }
|
||||
.controller-choice small { @apply max-w-20 text-[10px]; }
|
||||
.range-row { @apply mb-4; }
|
||||
.range-meta { @apply mb-2 flex justify-between gap-2 text-xs text-slate-300; }
|
||||
.range-meta output { @apply font-medium tabular-nums; }
|
||||
.curve-editor { @apply mb-4 grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-4 rounded-lg border border-slate-700/50 bg-slate-950 p-3; }
|
||||
.curve-editor svg { @apply min-h-28 w-full overflow-visible border-b border-l border-slate-700; }
|
||||
.curve-editor path { fill: none; vector-effect: non-scaling-stroke; }
|
||||
.curve-line { stroke: var(--color-blue-500); stroke-width: 2.5; }
|
||||
.curve-guide { stroke: var(--color-slate-500); stroke-dasharray: 3 3; opacity: 0.5; }
|
||||
.curve-marker { fill: var(--color-amber-400); stroke: var(--color-slate-950); stroke-width: 2; opacity: 0; }
|
||||
.curve-marker.visible { opacity: 1; }
|
||||
.curve-controls { @apply flex min-w-0 flex-col gap-3; }
|
||||
.curve-controls label { @apply text-xs text-slate-300; }
|
||||
.curve-controls output { @apply float-right text-slate-100; }
|
||||
.copy-side { @apply mt-auto; }
|
||||
.stick-swap-panel { @apply mt-4; }
|
||||
.playtest-panel { @apply mb-5 rounded-xl border border-blue-500/25 bg-slate-950/40 p-4; }
|
||||
.playtest-heading, .playtest-card-heading { @apply flex flex-wrap items-center justify-between gap-2; }
|
||||
.playtest-help { @apply my-3 text-xs leading-relaxed text-slate-400; }
|
||||
.playtest-status { @apply rounded-md border border-current px-2 py-1 text-[11px] text-amber-400; }
|
||||
.playtest-panel[data-state="live"] .playtest-status { @apply text-emerald-400; }
|
||||
.playtest-panel[data-state="error"] .playtest-status { @apply text-rose-400; }
|
||||
.playtest-panel[data-state="waiting"] .playtest-grid { @apply opacity-50; }
|
||||
.playtest-grid { @apply grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.4fr)] gap-3; }
|
||||
.playtest-card { @apply min-w-0 rounded-lg border border-slate-700/50 bg-slate-850 p-3; }
|
||||
.playtest-card-heading { @apply mb-3 text-xs; }
|
||||
.playtest-card-heading span { @apply text-[11px] text-slate-400 tabular-nums; }
|
||||
.stick-scope { --raw-x: 50%; --raw-y: 50%; --output-x: 50%; --output-y: 50%; --inner-size: 0%; --outer-size: 100%; @apply relative mx-auto aspect-square w-full max-w-40 overflow-hidden rounded-full border border-slate-700 bg-slate-950; }
|
||||
.stick-axis { @apply absolute bg-slate-700/70; }
|
||||
.stick-axis-x { @apply inset-x-[10%] top-1/2 h-px; }
|
||||
.stick-axis-y { @apply inset-y-[10%] left-1/2 w-px; }
|
||||
.stick-ring { @apply pointer-events-none absolute top-1/2 left-1/2 rounded-full border; transform: translate(-50%, -50%); }
|
||||
.stick-ring-inner { width: var(--inner-size); height: var(--inner-size); @apply border-rose-400/80 bg-rose-400/5; }
|
||||
.stick-ring-outer { width: var(--outer-size); height: var(--outer-size); @apply border-blue-500/60; }
|
||||
.stick-dot { @apply absolute rounded-full; transform: translate(-50%, -50%); }
|
||||
.stick-dot-raw { left: var(--raw-x); top: var(--raw-y); @apply z-10 size-3.5 border-2 border-amber-400; }
|
||||
.stick-dot-output { left: var(--output-x); top: var(--output-y); @apply z-20 size-2 border border-slate-950 bg-blue-500; }
|
||||
.trigger-meter { @apply mt-3 grid grid-cols-[24px_minmax(0,1fr)] items-center gap-2 text-xs; }
|
||||
.trigger-meter output { @apply col-start-2 text-[11px] text-slate-400 tabular-nums; }
|
||||
.trigger-track { --raw: 0%; --output: 0%; --threshold: 35%; @apply relative h-7 overflow-hidden rounded-md border border-slate-700 bg-slate-950; }
|
||||
.trigger-bar { @apply absolute left-0 h-1.5 rounded-r; }
|
||||
.trigger-bar-raw { width: var(--raw); @apply top-1 bg-amber-400; }
|
||||
.trigger-bar-output { width: var(--output); @apply bottom-1 bg-blue-500; }
|
||||
.trigger-threshold { left: var(--threshold); @apply absolute inset-y-0 w-0.5 bg-rose-400; }
|
||||
.playtest-legend { @apply mt-4 flex flex-wrap gap-3 text-[11px] text-slate-400; }
|
||||
.playtest-legend span { @apply inline-flex items-center gap-1.5; }
|
||||
.playtest-legend i { @apply size-2 rounded-full; }
|
||||
.legend-raw { @apply bg-amber-400; }
|
||||
.legend-output { @apply bg-blue-500; }
|
||||
.legend-threshold { @apply bg-rose-400; }
|
||||
.mapping-playtest { @apply mt-4 space-y-2 text-xs text-slate-300; }
|
||||
.turbo-grid { @apply grid grid-cols-3 gap-3; }
|
||||
.turbo-setting-grid { @apply mt-3 grid grid-cols-3 gap-2; }
|
||||
.turbo-setting-grid .number-input { @apply px-2; }
|
||||
.turbo-setting-grid label { @apply min-h-8; }
|
||||
.turbo-override { @apply my-3 block; }
|
||||
.turbo-defaults { @apply mb-4; }
|
||||
.timing-notice { @apply mb-4 rounded-lg border border-slate-700 bg-slate-850 p-3 text-xs leading-relaxed text-slate-400; }
|
||||
.timing-notice.warning, .turbo-card.narrow-pulse { @apply border-amber-400/50; }
|
||||
.timing-notice.warning { @apply text-amber-400; }
|
||||
.builtin-actions { @apply mb-4; }
|
||||
.action-card-heading { @apply mb-4; }
|
||||
.action-card-heading > span { @apply mt-2 block text-xs leading-relaxed text-slate-400; }
|
||||
.action-kind { @apply text-[11px] font-medium text-blue-500; }
|
||||
.swing-controls { @apply mb-5; }
|
||||
.swing-cards { @apply mb-4 flex flex-wrap gap-3; }
|
||||
.swing-card { @apply min-w-0 flex-[1_1_280px]; }
|
||||
.swing-card:last-child { @apply basis-full; }
|
||||
.swing-card-grid { @apply grid gap-3; }
|
||||
.swing-card:last-child .swing-card-grid { @apply grid-cols-3; }
|
||||
.macro-controls { @apply mb-5 grid grid-cols-2 gap-4; }
|
||||
.macro-picker { @apply col-span-full flex flex-wrap items-stretch gap-4 rounded-xl border border-slate-700 bg-slate-950 p-3; }
|
||||
.macro-tabs { @apply grid flex-[1_1_320px] grid-cols-4 gap-2; }
|
||||
.macro-tab { @apply rounded-lg border border-slate-700 bg-slate-850 px-3 py-2 text-xs font-medium text-slate-300; }
|
||||
.macro-tab small { @apply mt-1 block text-[10px]; }
|
||||
.macro-tab.selected { @apply border-blue-500/60 bg-blue-500/20 text-slate-100; }
|
||||
.macro-budget { @apply flex flex-[0_1_180px] flex-col justify-center gap-2 text-xs text-slate-400; }
|
||||
.macro-budget strong { @apply text-slate-100; }
|
||||
.macro-playback-fields { @apply mt-4 grid grid-cols-2 gap-3; }
|
||||
.macro-steps-heading, .macro-preview-heading { @apply mb-3 flex flex-wrap items-center justify-between gap-3; }
|
||||
.macro-step-action, .macro-preview-actions { @apply flex flex-wrap gap-2; }
|
||||
.macro-steps { @apply grid gap-4; }
|
||||
.macro-step { @apply min-w-0 rounded-xl border border-slate-700 bg-slate-850 p-4; }
|
||||
.macro-step.end { @apply border-dashed p-5; }
|
||||
.step-header { @apply mb-4 flex flex-wrap items-center justify-between gap-3; }
|
||||
.step-identity, .step-tools { @apply flex flex-wrap items-center gap-2; }
|
||||
.step-number { @apply text-xs font-semibold text-slate-100; }
|
||||
.step-timing { @apply text-xs text-slate-400 tabular-nums; }
|
||||
.step-tool, .remove-step { @apply min-h-9 rounded-md border border-slate-700 bg-slate-950 px-2.5 py-1.5 text-xs text-slate-300; }
|
||||
.remove-step { @apply border-rose-400/30 text-rose-400; }
|
||||
.step-handle { @apply cursor-grab active:cursor-grabbing; touch-action: pan-y; }
|
||||
.step-grid { @apply grid grid-cols-3 gap-3; }
|
||||
.step-group { @apply mt-4 border-t border-slate-700 pt-4; }
|
||||
.step-group-title { @apply mb-2 block text-xs font-medium text-slate-300; }
|
||||
.macro-step.dragging { @apply opacity-40; }
|
||||
.macro-step.drop-before { box-shadow: 0 -4px 0 var(--color-blue-500); }
|
||||
.macro-step.drop-after { box-shadow: 0 4px 0 var(--color-blue-500); }
|
||||
.macro-step.preview-active { @apply border-emerald-400; }
|
||||
.macro-notice, .capture-notice { @apply my-3 text-xs leading-relaxed text-slate-300; }
|
||||
.macro-notice:empty, .capture-notice:empty, .capture-events:empty { @apply hidden; }
|
||||
.macro-notice.error { @apply text-rose-400; }
|
||||
.macro-preview, .macro-capture { @apply mb-5 rounded-xl border border-slate-700 bg-slate-950/40 p-4; }
|
||||
.macro-preview[data-state="playing"] { @apply border-emerald-400; }
|
||||
.macro-capture[data-state="recording"] { @apply border-rose-400; }
|
||||
.macro-preview > .field-help, .macro-capture > .field-help { @apply my-3; }
|
||||
.macro-preview-timing { @apply my-3 flex flex-wrap justify-between gap-2 text-xs text-slate-300 tabular-nums; }
|
||||
.macro-preview-buttons { @apply my-4 rounded-lg border border-slate-700/50 bg-slate-850 p-3; }
|
||||
.preview-button { @apply inline-flex min-w-14 flex-col gap-1 rounded-md border border-slate-700 px-2 py-1.5 text-xs text-slate-400; }
|
||||
.preview-button small { @apply text-[10px]; }
|
||||
.preview-button.pressed { @apply border-emerald-400 bg-emerald-400 text-slate-950; }
|
||||
.macro-preview [data-mode="override"] { @apply border-emerald-400/70; }
|
||||
.macro-preview [data-mode="override"] [data-preview-mode] { @apply text-emerald-400; }
|
||||
.macro-preview [data-mode="passthrough"] .stick-dot-output { visibility: hidden; }
|
||||
.macro-preview [data-mode="passthrough"] progress { @apply opacity-35; }
|
||||
.macro-preview [data-preview-value] { @apply mt-2 block text-xs text-slate-400 tabular-nums; }
|
||||
.macro-preview-triggers { @apply flex flex-col justify-center gap-4; }
|
||||
.macro-preview-triggers > div { @apply rounded-lg border border-dashed border-slate-700 p-3; }
|
||||
.capture-options { @apply my-4 min-w-0 rounded-lg border border-slate-700 p-3; }
|
||||
.capture-options legend { @apply px-1 text-xs text-slate-300; }
|
||||
.capture-options:disabled { @apply opacity-60; }
|
||||
.capture-number-fields { @apply mt-4 grid grid-cols-3 gap-3; }
|
||||
.capture-events { @apply my-3 max-h-64 list-decimal space-y-2 overflow-auto pl-6 text-xs text-slate-400 tabular-nums; }
|
||||
.capture-events li { @apply rounded border border-slate-700/50 bg-slate-850 p-2; overflow-wrap: anywhere; }
|
||||
.macro-capture:is([data-state="full"], [data-state="disconnected"], [data-state="error"]) .capture-notice { @apply text-amber-400; }
|
||||
.loading-card p { @apply max-w-md px-4 text-center leading-relaxed; overflow-wrap: anywhere; }
|
||||
body:has(.connection[data-state="error"]) .loading-card .spinner { display: none; }
|
||||
#profileForm [inert] { @apply opacity-50; }
|
||||
.loading-card { @apply grid min-h-72 place-content-center justify-items-center gap-4 rounded-xl border border-dashed border-slate-700 text-sm text-slate-400; }
|
||||
.spinner { @apply size-7 animate-spin rounded-full border-2 border-slate-700 border-t-blue-500; }
|
||||
.toast { @apply pointer-events-none fixed right-5 bottom-5 z-50 max-w-[min(440px,calc(100vw-40px))] translate-y-3 rounded-xl border border-slate-700 bg-slate-850 px-4 py-3 text-sm text-slate-100 opacity-0 shadow-xl transition-all; }
|
||||
.toast.show { @apply translate-y-0 opacity-100; }
|
||||
.toast.error { @apply border-rose-400 text-rose-400; }
|
||||
dialog { @apply fixed inset-0 m-auto max-h-[calc(100dvh-32px)] w-[min(480px,calc(100%-32px))] overflow-y-auto rounded-xl border border-slate-700 bg-slate-900 p-6 text-slate-100 shadow-2xl; }
|
||||
.adapter-settings-dialog { @apply w-[min(600px,calc(100%-32px))]; }
|
||||
dialog::backdrop { @apply bg-slate-950/80; }
|
||||
.dialog-actions { @apply mt-6 flex flex-wrap justify-end gap-2 border-t border-slate-700 pt-4; }
|
||||
.joycon-mode { @apply mt-5 border-t border-slate-700 pt-5; }
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
.workspace { grid-template-columns: 220px minmax(0, 1fr); gap: 16px; padding-inline: 16px; }
|
||||
.controller-mapper { grid-template-columns: minmax(0, 1fr); }
|
||||
.mapping-inspector { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px 16px; }
|
||||
.mapping-inspector .eyebrow, .mapping-inspector h4, .mapping-inspector > p, .mapping-key { grid-column: 1 / -1; }
|
||||
.mapping-inspector .field-label { margin-top: 8px; }
|
||||
.selected-control-mark { display: none; }
|
||||
.mapping-grid, .turbo-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.curve-editor { grid-template-columns: 1fr; }
|
||||
.playtest-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.trigger-playtest, .macro-preview-triggers { grid-column: 1 / -1; }
|
||||
}
|
||||
@media (max-width: 899px) {
|
||||
.topbar { position: relative; flex-wrap: wrap; padding-block: 16px; gap: 16px; }
|
||||
.topbar-meta { flex-wrap: wrap; }
|
||||
.workspace { grid-template-columns: minmax(0, 1fr); }
|
||||
.sidebar { position: static; max-height: none; }
|
||||
.profile-list { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.profile-button { flex-wrap: wrap; }
|
||||
.identity-tools { margin-top: 16px; }
|
||||
.section-nav { top: 0; }
|
||||
.panel { scroll-margin-top: 72px; }
|
||||
.header-actions { align-items: flex-start; }
|
||||
}
|
||||
@media (max-width: 599px) {
|
||||
.topbar { padding-inline: 16px; }
|
||||
.topbar-meta { width: 100%; justify-content: space-between; gap: 8px; }
|
||||
.connection { max-width: 48%; }
|
||||
.workspace { margin-block: 16px; padding-inline: 12px; }
|
||||
.sidebar, .panel, .editor-header { padding: 16px; }
|
||||
.profile-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.panel-heading { flex-direction: column; gap: 12px; }
|
||||
.panel-heading-actions { align-items: flex-start; text-align: left; }
|
||||
.controller-canvas { padding: 12px; }
|
||||
.mapping-inspector { display: block; }
|
||||
.selected-control-mark { display: grid; }
|
||||
.mapping-grid, .modifier-controls, .analog-grid, .feedback-grid, .builtin-actions, .macro-controls, .turbo-grid, .playtest-grid, .swing-card:last-child .swing-card-grid, .capture-number-fields { grid-template-columns: minmax(0, 1fr); }
|
||||
.number-grid, .step-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.macro-tabs { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.button, .step-tool, .remove-step, .checkbox-pill span { min-height: 44px; }
|
||||
.subpanel-heading { flex-direction: column; }
|
||||
.native-joycon-actions .button { flex: 1 1 auto; }
|
||||
.header-actions { width: 100%; }
|
||||
.action-group-primary { width: 100%; }
|
||||
.action-group-primary .button { flex: 1; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -3,160 +3,161 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Switch Pico Profile Studio</title>
|
||||
<title>Switch Pico Controller Studio</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="ambient ambient-one"></div>
|
||||
<div class="ambient ambient-two"></div>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen antialiased selection:bg-sky-500/30 selection:text-sky-200 font-sans">
|
||||
<!-- Accessibility Skip Link -->
|
||||
<a href="#main-content" class="sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-50 focus:px-4 focus:py-2 focus:bg-sky-500 focus:text-slate-950 focus:font-bold focus:rounded-lg focus:shadow-lg focus:outline-none focus:ring-2 focus:ring-white">
|
||||
Skip to main content
|
||||
</a>
|
||||
|
||||
<!-- Header / App Topbar -->
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark" aria-hidden="true"><span></span><span></span></div>
|
||||
<div class="brand-mark" aria-hidden="true">
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="eyebrow">Switch Pico</p>
|
||||
<h1>Controller Studio</h1>
|
||||
<h1 class="text-base sm:text-lg font-bold tracking-tight text-white leading-tight">Controller Studio</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-meta">
|
||||
<p>Map · tune · automate</p>
|
||||
<div class="connection" id="connectionState" data-state="loading" role="status" aria-live="polite">
|
||||
<span class="connection-dot"></span>
|
||||
<span id="connectionText">Connecting to adapter…</span>
|
||||
<span class="connection-dot" aria-hidden="true"></span>
|
||||
<span id="connectionText" class="truncate">Connecting to adapter…</span>
|
||||
</div>
|
||||
<button class="button button-secondary adapter-settings-button" id="adapterSettingsButton" type="button" aria-haspopup="dialog" aria-controls="adapterSettingsDialog">Adapter settings</button>
|
||||
|
||||
<button class="button button-secondary adapter-settings-button" id="adapterSettingsButton" type="button" aria-haspopup="dialog" aria-controls="adapterSettingsDialog">
|
||||
<svg class="w-4 h-4 inline-block -mt-0.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<span>Adapter settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="workspace">
|
||||
<!-- Main Studio Workspace -->
|
||||
<main class="workspace" id="main-content" tabindex="-1">
|
||||
<!-- Sidebar: Profile Bank & Owner Selection -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Profile bank</p>
|
||||
<h2>Your layouts</h2>
|
||||
<p class="eyebrow">Your controllers</p>
|
||||
<h2 class="text-lg font-semibold">Profiles</h2>
|
||||
</div>
|
||||
<button class="icon-button" id="refreshButton" type="button" title="Refresh controllers" aria-label="Refresh controllers">↻</button>
|
||||
<button class="icon-button" id="refreshButton" type="button" title="Refresh controllers" aria-label="Refresh controllers">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<label class="field-label" for="identitySelect">Profile owner</label>
|
||||
<select id="identitySelect" class="select"></select>
|
||||
<p class="field-help">The default applies when a controller has no dedicated layout.</p>
|
||||
|
||||
<div class="identity-tools">
|
||||
<div class="mt-4">
|
||||
<label class="field-label" for="identitySelect">Controller</label>
|
||||
<select id="identitySelect" class="select"></select>
|
||||
<p class="field-help">The default profile applies to controllers without their own settings.</p>
|
||||
</div>
|
||||
|
||||
<details class="identity-tools help-details">
|
||||
<summary>Controller name & details</summary>
|
||||
<label class="field-label" for="controllerAlias">Controller alias</label>
|
||||
<input class="number-input" id="controllerAlias" maxlength="31" placeholder="Optional nickname">
|
||||
<input class="number-input" id="controllerAlias" maxlength="31" placeholder="Optional nickname" autocomplete="off">
|
||||
<div class="identity-actions">
|
||||
<button class="button button-secondary" id="saveAliasButton" type="button">Save alias</button>
|
||||
<button class="button button-ghost" id="identifyButton" type="button">Identify</button>
|
||||
</div>
|
||||
<p class="controller-details" id="controllerDetails"></p>
|
||||
</details>
|
||||
|
||||
<div class="mt-5">
|
||||
<p class="field-label mb-2">Profile slots</p>
|
||||
<div class="profile-list" id="profileList" aria-label="Profiles"></div>
|
||||
</div>
|
||||
|
||||
<div class="profile-list" id="profileList" aria-label="Profiles"></div>
|
||||
|
||||
<div class="sidebar-note">
|
||||
<span class="note-icon">i</span>
|
||||
<p>Each controller has eight independent slots. Tune a draft here, then save it atomically to the Pico.</p>
|
||||
<span class="note-icon" aria-hidden="true">i</span>
|
||||
<p>Eight profiles per controller. Save a draft to store its settings; Make active selects the profile used during play.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Editor Shell -->
|
||||
<section class="editor-shell">
|
||||
<!-- Profile Header / Identity & Core Actions -->
|
||||
<div class="editor-header">
|
||||
<div>
|
||||
<p class="eyebrow" id="profileOwner">Global fallback</p>
|
||||
<div class="title-line">
|
||||
<h2 id="profileTitle">Profile 1</h2>
|
||||
<div class="space-y-2">
|
||||
<p class="eyebrow" id="profileOwner">Default profile</p>
|
||||
<div class="title-line flex items-center gap-2.5">
|
||||
<h2 id="profileTitle" class="text-xl sm:text-2xl font-bold tracking-tight text-white">Profile 1</h2>
|
||||
<span class="active-badge" id="activeBadge" hidden>Active</span>
|
||||
<span class="dirty-badge" id="dirtyBadge" hidden>Unsaved</span>
|
||||
<span class="dirty-badge" id="dirtyBadge" hidden>Unsaved draft</span>
|
||||
</div>
|
||||
<div class="profile-name-editor">
|
||||
<label for="profileName">Profile name</label>
|
||||
<input id="profileName" maxlength="31" placeholder="Optional name">
|
||||
<button class="button button-secondary" id="saveProfileNameButton" type="button">Rename</button>
|
||||
<label for="profileName" class="sr-only">Profile name</label>
|
||||
<input id="profileName" maxlength="31" placeholder="Profile name (optional)" autocomplete="off">
|
||||
<button class="button button-secondary" id="saveProfileNameButton" type="button" title="Save this name to Pico immediately">Rename</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<button class="button button-ghost" id="copyProfileButton" type="button">Copy</button>
|
||||
<button class="button button-ghost" id="exportProfileButton" type="button">Export</button>
|
||||
<button class="button button-ghost" id="importProfileButton" type="button">Import</button>
|
||||
<input id="importProfileFile" type="file" accept="application/json,.json" hidden>
|
||||
<button class="button button-ghost" id="resetDraftButton" type="button">Start from defaults</button>
|
||||
<button class="button button-secondary" id="activateButton" type="button">Make active</button>
|
||||
<button class="button button-primary" id="saveButton" type="button">Save to Pico</button>
|
||||
<div class="action-group-secondary flex flex-wrap gap-2 items-center">
|
||||
<button class="button button-ghost" id="copyProfileButton" type="button" title="Copy draft to another slot">Copy</button>
|
||||
<button class="button button-ghost" id="exportProfileButton" type="button" title="Export profile as JSON">Export</button>
|
||||
<button class="button button-ghost" id="importProfileButton" type="button" title="Import profile from JSON">Import</button>
|
||||
<input id="importProfileFile" type="file" accept="application/json,.json" hidden>
|
||||
<button class="button button-ghost" id="resetDraftButton" type="button" title="Reset current draft to factory defaults">Reset defaults</button>
|
||||
</div>
|
||||
<div class="action-group-primary flex flex-wrap gap-2 items-center">
|
||||
<button class="button button-secondary" id="activateButton" type="button" title="Use this saved profile during play">Make active</button>
|
||||
<button class="button button-primary" id="saveButton" type="button" title="Save this profile's draft settings">Save to Pico</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seven Navigation Section Anchors -->
|
||||
<nav class="section-nav" aria-label="Editor sections">
|
||||
<a href="#mapping">Mapping</a>
|
||||
<a href="#shortcuts">Shortcuts</a>
|
||||
<a href="#shift">Shift</a>
|
||||
<a href="#analog">Analog</a>
|
||||
<a href="#feedback">Feedback</a>
|
||||
<a href="#mapping">Button mapping</a>
|
||||
<a href="#analog">Sticks & triggers</a>
|
||||
<a href="#shift">Shift layer</a>
|
||||
<a href="#shortcuts">Profile shortcuts</a>
|
||||
<a href="#feedback">Rumble</a>
|
||||
<a href="#turbo">Turbo</a>
|
||||
<a href="#macro">Macro</a>
|
||||
<a href="#macro">Actions & macros</a>
|
||||
</nav>
|
||||
|
||||
<!-- Loading State Overlay -->
|
||||
<div class="loading-card" id="loadingCard">
|
||||
<div class="spinner"></div>
|
||||
<p>Reading profile from the adapter…</p>
|
||||
<p class="text-sm font-medium text-slate-400">Reading profile from the adapter…</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile Form Containing All 7 Section Panels -->
|
||||
<form id="profileForm" hidden>
|
||||
<section class="panel" id="mapping">
|
||||
<!-- 01 · Mapping Section -->
|
||||
<section class="panel" id="mapping" aria-labelledby="mappingTitle">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">01 · Controls</p>
|
||||
<h3>Button mapping</h3>
|
||||
<h3 id="mappingTitle">Button mapping</h3>
|
||||
</div>
|
||||
<div class="panel-heading-actions">
|
||||
<p>Choose what each physical button produces.</p>
|
||||
<button class="section-reset" type="button" data-reset-section="mapping">Reset section</button>
|
||||
</div>
|
||||
<div class="panel-heading-actions"><p>Choose what each physical button should produce.</p><button class="section-reset" type="button" data-reset-section="mapping">Reset section</button></div>
|
||||
</div>
|
||||
|
||||
<div class="controller-mapper">
|
||||
<div class="controller-canvas" id="controllerCanvas" data-style="generic">
|
||||
<div class="controller-caption">
|
||||
<span class="controller-live" id="controllerLayoutStatus">Reference layout</span>
|
||||
<strong id="controllerModel">Generic controller</strong>
|
||||
</div>
|
||||
<div class="layout-picker">
|
||||
<label class="field-label" for="controllerLayoutPreview">Auto / layout preview</label>
|
||||
<select class="select" id="controllerLayoutPreview" aria-describedby="controllerLayoutHelp">
|
||||
<option value="auto">Auto · selected owner / live metadata</option>
|
||||
<option value="xbox">Preview · Xbox</option>
|
||||
<option value="dualsense">Preview · DualSense</option>
|
||||
<option value="switch-pro">Preview · original Switch Pro</option>
|
||||
<option value="switch2-pro">Preview · Switch 2 Pro</option>
|
||||
<option value="joycon2-pair">Preview · Joy-Con 2 pair</option>
|
||||
<option value="joycon2-left">Preview · Joy-Con 2 left solo</option>
|
||||
<option value="joycon2-right">Preview · Joy-Con 2 right solo</option>
|
||||
<option value="wii-horizontal">Preview · Wii horizontal / accelerometer</option>
|
||||
<option value="wii-vertical">Preview · Wii vertical</option>
|
||||
<option value="wii-nunchuk">Preview · Wii Remote + Nunchuk</option>
|
||||
</select>
|
||||
<p class="field-help" id="controllerLayoutHelp">Preview changes only this editor's source labels and diagram, never firmware topology or saved mappings.</p>
|
||||
</div>
|
||||
<div class="native-joycon-control" id="nativeJoyconControl">
|
||||
<label class="field-label" for="nativeJoyconLayoutSelect">Native Joy-Con layout</label>
|
||||
<div class="native-joycon-actions">
|
||||
<select class="select" id="nativeJoyconLayoutSelect" aria-describedby="nativeJoyconHelp">
|
||||
<option value="paired">Paired · standard dual Joy-Con orientation</option>
|
||||
<option value="left_solo">Left sideways · solo Joy-Con (L)</option>
|
||||
<option value="right_solo">Right sideways · solo Joy-Con (R)</option>
|
||||
</select>
|
||||
<button class="button button-secondary" id="mapShouldersButton" type="button" hidden>Map shoulders to SL/SR</button>
|
||||
<button class="button button-secondary" id="mapDpadButton" type="button">Map D-pad to left stick</button>
|
||||
</div>
|
||||
<p class="field-help" id="nativeJoyconHelp">Native-hub output only; this does not change Pro emulation's controller identity. Unlike physical Joy-Con grouping in Adapter settings or Wii orientation, this is a per-profile draft setting: choose Save to Pico to store it. Both USB devices remain enumerated; solo keeps the other side neutral. Assign solo on Switch Change Grip/Order by pressing the sources mapped to the selected side's SL+SR.</p>
|
||||
<p class="field-help">Changing layout never remaps buttons. “Map shoulders to SL/SR” changes only the two base shoulder destinations in this draft. “Map D-pad to left stick” changes only the four base D-pad destinations (Up, Down, Left, Right) to left-stick movement in this draft. You can also map any source, including D-pad directions, to the horizontal face positions. Solo uses the mapped left stick; enable stick swap below to use the physical right stick. Digital directions apply only when the mapped left analog stick is neutral within its deadzone (any nonzero analog stick input takes priority). In native sideways solo mode, wire rotation happens after mapped left directions. SL/SR mappings work in native and Pro emulation; C/GL/GR are physical input sources, not output destinations.</p>
|
||||
</div>
|
||||
<div class="wii-orientation-control" id="wiiOrientationControl" hidden>
|
||||
<label class="field-label" for="wiiOrientationSelect">Wii orientation</label>
|
||||
<div class="wii-orientation-actions">
|
||||
<select class="select" id="wiiOrientationSelect" aria-describedby="wiiOrientationHelp wiiOrientationStatus" disabled>
|
||||
<option value="horizontal">Horizontal · sideways D-pad & 1/2 buttons</option>
|
||||
<option value="vertical">Vertical · upright pointer orientation</option>
|
||||
</select>
|
||||
<button class="button button-secondary" id="applyWiiOrientationButton" type="button" disabled>Apply orientation</button>
|
||||
</div>
|
||||
<p class="field-help" id="wiiOrientationHelp">Applies to the active connection only (not saved in profile slots). Nunchuk automatically locks orientation to vertical.</p>
|
||||
<p class="wii-orientation-status" id="wiiOrientationStatus" data-state="idle" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<!-- Interactive Controller Viewport & Hotspots -->
|
||||
<div class="controller-viewport" tabindex="0" role="region" aria-label="Controller diagram; scroll horizontally on narrow screens">
|
||||
<div class="controller-photo-wrap" id="controllerPhotoWrap">
|
||||
<div class="controller-stage" id="controllerStage">
|
||||
|
|
@ -165,76 +166,125 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="controller-hint">Select a control on the illustration or below it. On narrow screens, swipe sideways or use the Source control menu.</p>
|
||||
<p class="field-label" id="extraControlsLabel">Off-art controls · rear, rails or unlocated sources</p>
|
||||
<p class="controller-hint">Select a control on the diagram or use the Source control menu.</p>
|
||||
|
||||
<!-- Layout Selection Controls -->
|
||||
<details class="help-details">
|
||||
<summary>Diagram preview & details</summary>
|
||||
<label class="field-label" for="controllerLayoutPreview">Diagram preview</label>
|
||||
<select class="select" id="controllerLayoutPreview" aria-describedby="controllerLayoutHelp">
|
||||
<option value="auto">Auto · selected controller</option>
|
||||
<option value="xbox">Preview · Xbox Wireless Controller</option>
|
||||
<option value="dualsense">Preview · PlayStation 5 DualSense</option>
|
||||
<option value="switch-pro">Preview · Nintendo Switch Pro</option>
|
||||
<option value="switch2-pro">Preview · Nintendo Switch 2 Pro</option>
|
||||
<option value="joycon2-pair">Preview · Joy-Con 2 Pair (L+R)</option>
|
||||
<option value="joycon2-left">Preview · Joy-Con 2 Left Solo</option>
|
||||
<option value="joycon2-right">Preview · Joy-Con 2 Right Solo</option>
|
||||
<option value="wii-horizontal">Preview · Wii Remote Horizontal</option>
|
||||
<option value="wii-vertical">Preview · Wii Remote Vertical</option>
|
||||
<option value="wii-nunchuk">Preview · Wii Remote + Nunchuk</option>
|
||||
</select>
|
||||
<p class="field-help" id="controllerLayoutHelp">Preview changes only the diagram and labels, not controller behavior or saved mappings.</p>
|
||||
<p class="field-help" id="controllerLayoutNote"></p>
|
||||
</details>
|
||||
|
||||
<!-- Native Joy-Con Per-Profile Mode Settings -->
|
||||
|
||||
<!-- Wii Remote Active Live Orientation -->
|
||||
<div class="wii-orientation-control" id="wiiOrientationControl" hidden>
|
||||
<label class="field-label" for="wiiOrientationSelect">Wii orientation · live setting</label>
|
||||
<div class="wii-orientation-actions">
|
||||
<select class="select" id="wiiOrientationSelect" aria-describedby="wiiOrientationHelp wiiOrientationStatus" disabled>
|
||||
<option value="horizontal">Horizontal · Sideways D-pad & 1/2 buttons</option>
|
||||
<option value="vertical">Vertical · Upright pointer orientation</option>
|
||||
</select>
|
||||
<button class="button button-secondary" id="applyWiiOrientationButton" type="button" disabled>Apply orientation</button>
|
||||
</div>
|
||||
<p class="field-help" id="wiiOrientationHelp">Changes the current connection immediately, not this profile. A connected Nunchuk requires vertical orientation.</p>
|
||||
<p class="wii-orientation-status" id="wiiOrientationStatus" data-state="idle" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<!-- Unlocated / Off-Art Controls -->
|
||||
<p class="field-label" id="extraControlsLabel">Additional controls</p>
|
||||
<div class="extra-controls" id="extraControls" role="group" aria-labelledby="extraControlsLabel"></div>
|
||||
<p class="field-help" id="controllerLayoutNote"></p>
|
||||
<p class="controller-credit">Original vector artwork supplied for Switch Pico Controller Studio.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Inspector Panel for Selected Control -->
|
||||
<aside class="mapping-inspector">
|
||||
<p class="eyebrow">Selected control</p>
|
||||
<label class="field-label" for="selectedSource">Source control</label>
|
||||
<select class="select" id="selectedSource" aria-describedby="selectedControlDescription"></select>
|
||||
<div class="selected-control-mark" id="selectedControlGlyph">S</div>
|
||||
|
||||
<div class="selected-control-mark" id="selectedControlGlyph" aria-hidden="true">S</div>
|
||||
<h4 id="selectedControlName">South button</h4>
|
||||
<p id="selectedControlDescription">Physical South currently produces South.</p>
|
||||
<p id="selectedControlDescription">Physical South produces South output.</p>
|
||||
|
||||
<label class="field-label" for="selectedMapping">Output action</label>
|
||||
<select class="select" id="selectedMapping"></select>
|
||||
|
||||
<div class="mapping-key">
|
||||
<span><i class="key-dot mapped"></i>Mapped</span>
|
||||
<span><i class="key-dot disabled"></i>Disabled</span>
|
||||
<span><i class="key-dot mapped" aria-hidden="true"></i> Mapped</span>
|
||||
<span><i class="key-dot disabled" aria-hidden="true"></i> Disabled</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="native-joycon-control" id="nativeJoyconControl">
|
||||
<label class="field-label" for="nativeJoyconLayoutSelect">Native Joy-Con layout</label>
|
||||
<div class="native-joycon-actions">
|
||||
<select class="select" id="nativeJoyconLayoutSelect" aria-describedby="nativeJoyconHelp">
|
||||
<option value="paired">Paired · Standard dual Joy-Con orientation</option>
|
||||
<option value="left_solo">Left sideways · Solo Joy-Con (L)</option>
|
||||
<option value="right_solo">Right sideways · Solo Joy-Con (R)</option>
|
||||
</select>
|
||||
<button class="button button-secondary" id="mapShouldersButton" type="button" hidden>Map shoulders to SL/SR</button>
|
||||
<button class="button button-secondary" id="mapDpadButton" type="button">Map D-pad to left stick</button>
|
||||
</div>
|
||||
<p class="field-help">Saved with this profile. Native layout applies only to native Joy-Con output, not Pro Controller emulation or Bluetooth grouping.</p>
|
||||
<details class="help-details mt-2">
|
||||
<summary>How solo layouts and quick mappings work</summary>
|
||||
<div class="space-y-2 mt-2 pt-2 border-t border-slate-800 text-xs text-slate-400 leading-relaxed" id="nativeJoyconHelp">
|
||||
<p>Native-hub output only; this does not alter Pro Controller emulation identity. Unlike adapter-wide grouping, this is saved inside the current profile draft (select <strong>Save to Pico</strong> to store). Both USB devices remain enumerated; solo keeps the other side neutral. Assign solo on Switch Change Grip/Order by pressing the sources mapped to the selected side's SL+SR.</p>
|
||||
<p>Changing layout never remaps buttons. The shoulder shortcut changes only the two base shoulder mappings; the D-pad shortcut changes only its four base mappings. Solo uses the mapped left stick. Enable stick swapping in Sticks & triggers to use the physical right stick.</p>
|
||||
<p>Digital directions apply only when the mapped left analog stick is neutral within its deadzone; any nonzero analog input takes priority. Sideways rotation is applied afterward. SL/SR outputs work in native and Pro emulation; C, GL and GR are input controls only.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="shortcuts" aria-labelledby="shortcutsTitle">
|
||||
<div class="panel-heading">
|
||||
<div><p class="eyebrow">Direct selection</p><h3 id="shortcutsTitle">Profile shortcuts</h3></div>
|
||||
<div class="panel-heading-actions"><p>Hold a modifier and press one face or D-pad button to select a profile directly.</p><button class="section-reset" type="button" data-reset-section="shortcuts">Reset section</button></div>
|
||||
</div>
|
||||
<div class="modifier-controls" id="shortcutModifierFields"></div>
|
||||
<div class="mapping-grid" id="shortcutFields"></div>
|
||||
<p class="field-help">Each selector is unique. Shortcut chords take priority over cycling, motion toggle, Shift and macros. Release before selecting again.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="shift" aria-labelledby="shiftTitle">
|
||||
<div class="panel-heading">
|
||||
<div><p class="eyebrow">Alternate layer</p><h3 id="shiftTitle">Shift button mapping</h3></div>
|
||||
<div class="panel-heading-actions"><p>Hold or toggle one modifier to use these alternate button outputs.</p><button class="section-reset" type="button" data-reset-section="shift">Reset section</button></div>
|
||||
</div>
|
||||
<div class="modifier-controls" id="shiftFields"></div>
|
||||
<div class="mapping-grid" id="shiftMapFields"></div>
|
||||
<p class="field-help">The modifier is consumed while Shift is enabled. Stick and trigger transforms stay on the base profile. Turbo settings and macro triggers remain tied to physical controls.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="analog">
|
||||
<!-- 02 · Analog Section -->
|
||||
<section class="panel" id="analog" aria-labelledby="analogTitle">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">02 · Response</p>
|
||||
<h3>Sticks & triggers</h3>
|
||||
<h3 id="analogTitle">Sticks & triggers</h3>
|
||||
</div>
|
||||
<div class="panel-heading-actions">
|
||||
<p>Tune deadzones, response curves, and analog routing.</p>
|
||||
<button class="section-reset" type="button" data-reset-section="analog">Reset section</button>
|
||||
</div>
|
||||
<div class="panel-heading-actions"><p>Values use the controller's normalized integer range.</p><button class="section-reset" type="button" data-reset-section="analog">Reset section</button></div>
|
||||
</div>
|
||||
|
||||
<!-- Live Playtest Scope Card -->
|
||||
<div class="playtest-panel" id="playtestPanel" data-state="waiting">
|
||||
<div class="playtest-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Live playtest</p>
|
||||
<p class="eyebrow">Live input scope</p>
|
||||
<h4 id="playtestTitle">Waiting for controller input</h4>
|
||||
</div>
|
||||
<span class="playtest-status" id="playtestStatus">Waiting</span>
|
||||
</div>
|
||||
<p class="playtest-help" id="playtestHelp">
|
||||
Select a connected controller to compare its raw input with this draft.
|
||||
</p>
|
||||
<p class="playtest-help" id="playtestHelp">Select a connected controller to compare its input with this draft.</p>
|
||||
|
||||
<div class="playtest-grid">
|
||||
<!-- Left Stick Scope -->
|
||||
<article class="playtest-card">
|
||||
<div class="playtest-card-heading">
|
||||
<strong>Left stick</strong>
|
||||
<span id="playtestLeftValues">0, 0 → 0, 0</span>
|
||||
</div>
|
||||
<div class="stick-scope" data-playtest-stick="left">
|
||||
<div class="stick-scope" data-playtest-stick="left" aria-label="Left analog stick scope">
|
||||
<span class="stick-ring stick-ring-outer"></span>
|
||||
<span class="stick-ring stick-ring-inner"></span>
|
||||
<span class="stick-axis stick-axis-x"></span>
|
||||
|
|
@ -243,12 +293,14 @@
|
|||
<i class="stick-dot stick-dot-output" title="Draft output"></i>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Right Stick Scope -->
|
||||
<article class="playtest-card">
|
||||
<div class="playtest-card-heading">
|
||||
<strong>Right stick</strong>
|
||||
<span id="playtestRightValues">0, 0 → 0, 0</span>
|
||||
</div>
|
||||
<div class="stick-scope" data-playtest-stick="right">
|
||||
<div class="stick-scope" data-playtest-stick="right" aria-label="Right analog stick scope">
|
||||
<span class="stick-ring stick-ring-outer"></span>
|
||||
<span class="stick-ring stick-ring-inner"></span>
|
||||
<span class="stick-axis stick-axis-x"></span>
|
||||
|
|
@ -257,9 +309,11 @@
|
|||
<i class="stick-dot stick-dot-output" title="Draft output"></i>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Triggers Playtest -->
|
||||
<article class="playtest-card trigger-playtest">
|
||||
<div class="playtest-card-heading">
|
||||
<strong>Triggers</strong>
|
||||
<strong>Analog triggers</strong>
|
||||
<span>Raw → draft output</span>
|
||||
</div>
|
||||
<div class="trigger-meter" data-playtest-trigger="left">
|
||||
|
|
@ -281,71 +335,132 @@
|
|||
<output>0 → 0</output>
|
||||
</div>
|
||||
<div class="playtest-legend">
|
||||
<span><i class="legend-raw"></i>Raw</span>
|
||||
<span><i class="legend-output"></i>Draft output</span>
|
||||
<span><i class="legend-threshold"></i>Digital threshold</span>
|
||||
<span><i class="legend-raw"></i> Raw input</span>
|
||||
<span><i class="legend-output"></i> Draft output</span>
|
||||
<span><i class="legend-threshold"></i> Digital click threshold</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="mapping-playtest">
|
||||
<p><strong>Raw extra inputs:</strong> <span id="playtestExtraInputs">None</span></p>
|
||||
<p><strong>Draft button output:</strong> <span id="playtestMappedButtons">None</span></p>
|
||||
<p class="field-help" id="playtestMappingHelp">Base and Shift mapping preview only; shortcuts, macros and Turbo are not simulated. Digital stick directions apply only when the mapped left analog vector is neutral within inner deadzones.</p>
|
||||
<p class="field-help" id="playtestMappingHelp">Base and Shift mapping preview only. Digital stick directions engage when the mapped left stick is neutral.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Analog Fields: Curves, Deadzones, Inversion -->
|
||||
<div class="analog-grid" id="analogFields"></div>
|
||||
|
||||
<!-- Stick Swap Routing Subpanel -->
|
||||
<div class="subpanel stick-swap-panel" id="stickSwapPanel">
|
||||
<div class="subpanel-heading">
|
||||
<h4>Stick swap</h4>
|
||||
<span>Output routing</span>
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-white">Stick swap</h4>
|
||||
<p class="text-xs text-slate-400 mt-0.5">Route left stick axes and clicks to right stick outputs.</p>
|
||||
</div>
|
||||
<span class="text-xs text-slate-400">Output routing</span>
|
||||
</div>
|
||||
<label class="checkbox-pill">
|
||||
<label class="checkbox-pill mt-2">
|
||||
<input type="checkbox" id="swapSticksCheckbox" data-kind="swap-sticks" aria-describedby="swapSticksHelp">
|
||||
<span>Swap left & right sticks (including clicks)</span>
|
||||
</label>
|
||||
<p class="field-help" id="swapSticksHelp">Swaps output axes and mapped stick clicks in both native and Pro emulation. Save to Pico to store this draft setting. Calibrations, deadzones, response curves and invert settings remain tied to their original physical sticks.</p>
|
||||
<p class="field-help" id="swapSticksHelp">Swaps stick outputs and mapped clicks in native and Pro emulation. Calibration, deadzones, response curves and inversion stay with their original physical sticks. Save to Pico to store this setting.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="feedback">
|
||||
<!-- 03 · Shift Layer Section -->
|
||||
<section class="panel" id="shift" aria-labelledby="shiftTitle">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">03 · Identity</p>
|
||||
<h3>Feedback</h3>
|
||||
<p class="eyebrow">03 · Alternate layer</p>
|
||||
<h3 id="shiftTitle">Shift button mapping</h3>
|
||||
</div>
|
||||
<div class="panel-heading-actions">
|
||||
<p>Hold or toggle a modifier to activate an alternate button map.</p>
|
||||
<button class="section-reset" type="button" data-reset-section="shift">Reset section</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-controls" id="shiftFields"></div>
|
||||
<div class="mapping-grid" id="shiftMapFields"></div>
|
||||
<p class="field-help mt-3">The Shift modifier is consumed while active. Analog sticks and triggers remain on the base profile.</p>
|
||||
</section>
|
||||
|
||||
<!-- 04 · Shortcuts Section -->
|
||||
<section class="panel" id="shortcuts" aria-labelledby="shortcutsTitle">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">04 · Direct selection</p>
|
||||
<h3 id="shortcutsTitle">Profile shortcuts</h3>
|
||||
</div>
|
||||
<div class="panel-heading-actions">
|
||||
<p>Hold a modifier and press a selector button to switch slots instantly.</p>
|
||||
<button class="section-reset" type="button" data-reset-section="shortcuts">Reset section</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-controls" id="shortcutModifierFields"></div>
|
||||
<div class="mapping-grid" id="shortcutFields"></div>
|
||||
<p class="field-help mt-3">Shortcut chords take top priority over cycling, Shift layers, and macro triggers.</p>
|
||||
</section>
|
||||
|
||||
<!-- 05 · Feedback Section -->
|
||||
<section class="panel" id="feedback" aria-labelledby="feedbackTitle">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">05 · Feedback</p>
|
||||
<h3 id="feedbackTitle">Rumble & confirmation</h3>
|
||||
</div>
|
||||
<div class="panel-heading-actions">
|
||||
<p>Adjust rumble strength and feedback when switching profiles.</p>
|
||||
<button class="section-reset" type="button" data-reset-section="feedback">Reset section</button>
|
||||
</div>
|
||||
<div class="panel-heading-actions"><p>Scale rumble and choose profile-change confirmation.</p><button class="section-reset" type="button" data-reset-section="feedback">Reset section</button></div>
|
||||
</div>
|
||||
<div class="subpanel" id="rumbleFields"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="turbo">
|
||||
<!-- 06 · Turbo Section -->
|
||||
<section class="panel" id="turbo" aria-labelledby="turboTitle">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">04 · Repeat</p>
|
||||
<h3>Turbo behavior</h3>
|
||||
<p class="eyebrow">06 · Repeat</p>
|
||||
<h3 id="turboTitle">Turbo behavior</h3>
|
||||
</div>
|
||||
<div class="panel-heading-actions">
|
||||
<p>Repeat while held, toggle continuous repetition, or play a fixed burst.</p>
|
||||
<button class="section-reset" type="button" data-reset-section="turbo">Reset section</button>
|
||||
</div>
|
||||
<div class="panel-heading-actions"><p>Hold Turbo repeats while pressed; Auto Burst toggles continuous repetition; Burst plays a fixed pulse count per fresh press.</p><button class="section-reset" type="button" data-reset-section="turbo">Reset section</button></div>
|
||||
</div>
|
||||
<div class="subpanel turbo-defaults" id="turboDefaultFields"></div>
|
||||
<p class="timing-notice" id="turboTimingNotice" role="status" aria-live="polite"></p>
|
||||
<div class="turbo-grid" id="turboFields"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="macro">
|
||||
<!-- 07 · Macro Section -->
|
||||
<section class="panel" id="macro" aria-labelledby="macroTitle">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">05 · Actions</p>
|
||||
<h3>Bindings & custom macro</h3>
|
||||
<p class="eyebrow">07 · Actions</p>
|
||||
<h3 id="macroTitle">Actions & macros</h3>
|
||||
</div>
|
||||
<div class="panel-heading-actions">
|
||||
<button class="section-reset" type="button" data-reset-section="macro">Reset section</button>
|
||||
</div>
|
||||
<div class="panel-heading-actions"><button class="section-reset" type="button" data-reset-section="macro">Reset section</button></div>
|
||||
</div>
|
||||
|
||||
<!-- Builtin Action Chords -->
|
||||
<div class="builtin-actions" id="builtinActions"></div>
|
||||
|
||||
<!-- Wii Motion Gestures / Swings Subpanel -->
|
||||
<div class="subpanel swing-controls" id="swingFields"></div>
|
||||
|
||||
<!-- Macro Selection Tabs & General Config -->
|
||||
<div class="macro-controls" id="macroControls"></div>
|
||||
|
||||
<!-- Macro Firmware Live Capture Recorder -->
|
||||
<section class="macro-capture" id="macroCapture" aria-labelledby="macroCaptureTitle" data-state="idle">
|
||||
<div class="macro-preview-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Live input · bounded firmware recorder</p>
|
||||
<p class="eyebrow">Record controller input</p>
|
||||
<h4 id="macroCaptureTitle">Record into macro 1</h4>
|
||||
</div>
|
||||
<div class="macro-preview-actions">
|
||||
|
|
@ -353,9 +468,10 @@
|
|||
<button class="button button-ghost" id="macroCaptureStop" type="button" disabled>Stop recording</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Records raw input before profile mapping, not controller output. Selected analog channels are explicitly quantized; this is not lossless arbitrary-rate capture. Firmware retains timestamped changes between UI polls, within its input-report precision and the budgets below.</p>
|
||||
<p class="field-help">Records input before profile mapping, not the output sent to the console. Analog values are rounded to the selected increments to fit the available macro storage; recording is not lossless.</p>
|
||||
|
||||
<fieldset id="macroCaptureOptions" class="capture-options">
|
||||
<legend>Channels to override in every recorded step</legend>
|
||||
<legend>Channels to record in each step</legend>
|
||||
<div class="check-grid">
|
||||
<label class="checkbox-pill"><input type="checkbox" data-capture-channel="1" checked><span>Buttons</span></label>
|
||||
<label class="checkbox-pill"><input type="checkbox" data-capture-channel="2"><span>Left stick</span></label>
|
||||
|
|
@ -369,6 +485,7 @@
|
|||
<div class="number-field"><label for="macroCaptureDuration">Time limit · ms</label><input class="number-input" id="macroCaptureDuration" form="macroCaptureSettings" type="number" min="1" max="80000" step="1" value="10000" required></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<p class="field-help" id="macroCaptureBudget"></p>
|
||||
<div class="macro-preview-timing">
|
||||
<strong id="macroCaptureState" role="status" aria-live="polite">Ready</strong>
|
||||
|
|
@ -377,17 +494,20 @@
|
|||
<progress id="macroCaptureProgress" max="1" value="0" aria-label="Recording event capacity"></progress>
|
||||
<p class="capture-notice" id="macroCaptureNotice" role="status" aria-live="polite"></p>
|
||||
<ol id="macroCaptureEvents" class="capture-events" aria-label="Retained recorded states"></ol>
|
||||
<div class="macro-preview-actions">
|
||||
|
||||
<div class="macro-preview-actions mt-3">
|
||||
<button class="button button-secondary" id="macroCaptureUse" type="button" disabled>Use recorded steps</button>
|
||||
<button class="button button-ghost" id="macroCaptureDiscard" type="button" disabled>Discard recording</button>
|
||||
<button class="button button-ghost" id="macroCaptureRecover" type="button" hidden>Read retained recording</button>
|
||||
</div>
|
||||
<p class="field-help">Use replaces only this macro’s steps in the unsaved draft. Trigger, cancel and playback settings stay unchanged. Nothing is written to flash until you choose Save to Pico. Stop is requested on hiding or leaving this page; recordings never resume automatically.</p>
|
||||
<p class="field-help">Use recorded steps replaces this macro’s steps in the draft, keeping its trigger, cancel and playback settings. Save to Pico stores the draft. Leaving this section or hiding the page requests recording to stop; it never resumes automatically.</p>
|
||||
</section>
|
||||
|
||||
<!-- Macro Visual Simulator & Preview -->
|
||||
<section class="macro-preview" id="macroPreview" aria-labelledby="macroPreviewTitle" data-state="stopped">
|
||||
<div class="macro-preview-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Visual only · never sent to the controller</p>
|
||||
<p class="eyebrow">Visual preview · no controller output</p>
|
||||
<h4 id="macroPreviewTitle">Macro 1 draft preview</h4>
|
||||
</div>
|
||||
<div class="macro-preview-actions">
|
||||
|
|
@ -396,17 +516,20 @@
|
|||
<button class="button button-ghost" id="macroPreviewRestart" type="button">Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Highlighted fields override input for this step. Passthrough fields keep controller input; their values are not simulated. Zero-duration steps take no preview time.</p>
|
||||
<p class="field-help" id="macroPreviewModeHelp"></p>
|
||||
<p class="field-help" id="macroPreviewModeHelp">Highlighted fields override input for this step. Passthrough channels retain physical input.</p>
|
||||
<p class="field-help">Only highlighted outputs are simulated. Other outputs keep live controller input during real playback. Zero-duration steps take no preview time.</p>
|
||||
|
||||
<div class="macro-preview-timing">
|
||||
<span id="macroPreviewStatus" role="status" aria-live="polite">Ready</span>
|
||||
<output id="macroPreviewTime">0 / 0 ms</output>
|
||||
</div>
|
||||
<progress id="macroPreviewProgress" max="1" value="0" aria-label="Draft preview elapsed time"></progress>
|
||||
|
||||
<div class="macro-preview-buttons" data-preview-field="buttons" data-mode="passthrough">
|
||||
<div class="playtest-card-heading"><strong>Buttons</strong><span data-preview-mode>Passthrough</span></div>
|
||||
<div class="check-grid" id="macroPreviewButtons"></div>
|
||||
</div>
|
||||
|
||||
<div class="playtest-grid">
|
||||
<article class="playtest-card" data-preview-field="left_stick" data-mode="passthrough">
|
||||
<div class="playtest-card-heading"><strong>Left stick</strong><span data-preview-mode>Passthrough</span></div>
|
||||
|
|
@ -438,6 +561,8 @@
|
|||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Macro Step Sequence List -->
|
||||
<div class="macro-steps-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Sequence</p>
|
||||
|
|
@ -448,62 +573,83 @@
|
|||
<button class="button button-secondary" id="addMacroStepButton" type="button">Add state step</button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="field-help" id="macroReorderHelp">Insert adds a 100 ms button step before the current step (5 bytes). Drag a step handle, use Move up / Move down, or press Alt + Arrow Up / Down on its handle to reorder.</p>
|
||||
<p class="field-help" id="macroReorderHelp">Drag handle, use Move up/down, or press Alt + Arrow Up/Down to reorder steps.</p>
|
||||
<p class="macro-notice" id="macroNotice" role="status" aria-live="polite"></p>
|
||||
<div class="macro-steps" id="macroSteps" role="list" aria-label="Selected macro steps" aria-describedby="macroReorderHelp"></div>
|
||||
</section>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<form id="macroCaptureSettings" hidden></form>
|
||||
|
||||
<!-- Adapter Settings Dialog -->
|
||||
<dialog class="adapter-settings-dialog" id="adapterSettingsDialog" aria-labelledby="adapterSettingsTitle" aria-describedby="adapterSettingsDescription">
|
||||
<div class="adapter-settings-heading">
|
||||
<h2 id="adapterSettingsTitle">Adapter settings</h2>
|
||||
<div>
|
||||
<p class="eyebrow">Hardware configuration</p>
|
||||
<h2 id="adapterSettingsTitle" class="text-lg font-bold text-white">Adapter settings</h2>
|
||||
</div>
|
||||
<button class="button button-ghost" id="closeAdapterSettingsButton" type="button" autofocus>Close</button>
|
||||
</div>
|
||||
<p class="field-help" id="adapterSettingsDescription">These settings apply to the whole adapter, not the selected profile. Use Apply to save changes; closing keeps unapplied selections.</p>
|
||||
<section class="joycon-mode" aria-labelledby="joyconModeTitle">
|
||||
<p class="eyebrow">Adapter-wide setting</p>
|
||||
<h3 id="joyconModeTitle">Joy-Con 2 default mode</h3>
|
||||
<label class="field-label" for="joyconModeSelect">Default arrangement</label>
|
||||
<select id="joyconModeSelect" class="select" aria-describedby="joyconModeHelp joyconModeStatus" disabled>
|
||||
<option value="paired">Paired · L + R together</option>
|
||||
<option value="individual">Individual · separate players</option>
|
||||
</select>
|
||||
<p class="field-help">Saved on the adapter; changing it applies live and clears temporary joins/splits. Not an artwork preview.</p>
|
||||
<details class="joycon-mode-help">
|
||||
<summary>Controller shortcut & profile banks</summary>
|
||||
<div id="joyconModeHelp">
|
||||
<p><strong>Paired:</strong> L + R share one player and a separate L+R profile bank. Unmatched halves stay separate.</p>
|
||||
<p><strong>Individual:</strong> each half is a sideways player using its own solo bank. Choose the L or R profile owner to edit it.</p>
|
||||
<p><strong>Join / split this pair:</strong> hold <strong>ZL + Minus</strong> on the left and <strong>ZR + Plus</strong> on the right for <strong>2 seconds</strong>. A short pulse confirms success. Release all four buttons before using it again.</p>
|
||||
<p>The shortcut affects only those halves and overrides this default until either disconnects. Other Joy-Cons are unchanged. Each half's complete two-button chord is reserved until both buttons release, even while waiting for the other half. Ambiguous solo selections do not join.</p>
|
||||
<p>All saved banks and unsaved profile drafts are kept. Your chosen owner does not change automatically.</p>
|
||||
<p>Saved preference is not a live pairing report. A pair waits for its L+R bank to be ready; the diagram and live playtest report the actual arrangement.</p>
|
||||
</div>
|
||||
</details>
|
||||
<button class="button button-secondary" id="applyJoyconModeButton" type="button" disabled>Apply default mode</button>
|
||||
<p class="joycon-mode-status" id="joyconModeStatus" data-state="loading" role="status" aria-live="polite">Reading saved preference…</p>
|
||||
</section>
|
||||
<p class="field-help" id="adapterSettingsDescription">These settings apply to the whole adapter. Apply saves them immediately; closing keeps your unapplied selection without changing the adapter.</p>
|
||||
|
||||
<section class="joycon-mode" aria-labelledby="joyconModeTitle">
|
||||
<p class="eyebrow">Adapter default</p>
|
||||
<h3 id="joyconModeTitle">Joy-Con 2 default mode</h3>
|
||||
<label class="field-label" for="joyconModeSelect">Default arrangement</label>
|
||||
<select id="joyconModeSelect" class="select" aria-describedby="joyconModeHelp joyconModeStatus" disabled>
|
||||
<option value="paired">Paired · L + R combined as one player</option>
|
||||
<option value="individual">Individual · Separate sideways players</option>
|
||||
</select>
|
||||
<p class="field-help">Stored in Pico flash; applying changes resets temporary Joy-Con splits or joins.</p>
|
||||
|
||||
<details class="joycon-mode-help mt-3">
|
||||
<summary>Join/split shortcut & profile behavior</summary>
|
||||
<div id="joyconModeHelp" class="mt-2 space-y-2 text-xs text-slate-400">
|
||||
<p><strong>Paired:</strong> Left and Right Joy-Cons share one player identity and a combined L+R profile bank.</p>
|
||||
<p><strong>Individual:</strong> Each half functions as an independent sideways controller using its own solo profile bank.</p>
|
||||
<p><strong>Quick join / split:</strong> Hold <strong>ZL + Minus</strong> on (L) and <strong>ZR + Plus</strong> on (R) for <strong>2 seconds</strong>. A short rumble pulse confirms the change.</p>
|
||||
<p>Release all four buttons before using the shortcut again. It affects only these halves and overrides the default until either disconnects; other Joy-Cons are unchanged. Unmatched halves stay separate.</p>
|
||||
<p>Paired and solo profiles are independent. All saved profiles and unsaved drafts are kept. The saved default is not a live pairing report; the diagram shows the current arrangement.</p>
|
||||
</div>
|
||||
</details>
|
||||
<div class="mt-4">
|
||||
<button class="button button-secondary w-full" id="applyJoyconModeButton" type="button" disabled>Apply default mode</button>
|
||||
</div>
|
||||
<p class="joycon-mode-status" id="joyconModeStatus" data-state="loading" role="status" aria-live="polite">Reading saved preference…</p>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<dialog class="copy-dialog" id="copyDialog">
|
||||
<form method="dialog">
|
||||
<p class="eyebrow">Duplicate profile</p>
|
||||
<h3>Choose a destination</h3>
|
||||
<p class="field-help">Copy this current draft, including unsaved settings, to the destination. This replaces its stored profile and name; the source draft is unchanged.</p>
|
||||
<label class="field-label" for="copyIdentity">Controller</label>
|
||||
<select class="select" id="copyIdentity"></select>
|
||||
<label class="field-label" for="copySlot">Profile slot</label>
|
||||
<select class="select" id="copySlot"></select>
|
||||
<div class="dialog-actions">
|
||||
<!-- Copy Profile Modal Dialog -->
|
||||
<dialog class="copy-dialog" id="copyDialog" aria-labelledby="copyDialogTitle">
|
||||
<form method="dialog" class="space-y-4">
|
||||
<div>
|
||||
<p class="eyebrow">Duplicate profile</p>
|
||||
<h3 id="copyDialogTitle" class="text-lg font-bold text-white">Choose destination</h3>
|
||||
</div>
|
||||
<p class="field-help">Copy this draft, including unsaved changes, to another profile. This immediately replaces the destination’s saved settings and name. The source draft is unchanged.</p>
|
||||
|
||||
<div>
|
||||
<label class="field-label" for="copyIdentity">Target controller</label>
|
||||
<select class="select" id="copyIdentity"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="copySlot">Profile slot</label>
|
||||
<select class="select" id="copySlot"></select>
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions pt-2">
|
||||
<button class="button button-ghost" value="cancel">Cancel</button>
|
||||
<button class="button button-primary" id="confirmCopyButton" value="default">Copy draft to Pico</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- System Toast Notifications -->
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- Runtime Scripts -->
|
||||
<script src="/playtest.js" defer></script>
|
||||
<script src="/assets/controller_layouts.js" defer></script>
|
||||
<script src="/app.js" defer></script>
|
||||
|
|
|
|||
|
|
@ -202,6 +202,64 @@ const elements = {
|
|||
toast: document.querySelector("#toast"),
|
||||
};
|
||||
|
||||
const sectionLinks = document.querySelectorAll(".section-nav a");
|
||||
const sectionPanels = Array.from(sectionLinks, (link) =>
|
||||
document.getElementById(link.hash.slice(1))
|
||||
);
|
||||
let selectedSection = null;
|
||||
|
||||
function showSection(id) {
|
||||
const panel = sectionPanels.find((section) => section.id === id) || sectionPanels[0];
|
||||
if (selectedSection === panel) return;
|
||||
if (selectedSection) {
|
||||
stopMacroPreview("Preview stopped: section changed.");
|
||||
clearMacroDrag();
|
||||
stopCapture("Recording stopped because you navigated to another section.");
|
||||
}
|
||||
selectedSection = panel;
|
||||
sectionPanels.forEach((section) => {
|
||||
section.hidden = section !== panel;
|
||||
});
|
||||
sectionLinks.forEach((link) => {
|
||||
if (link.hash === `#${panel.id}`) {
|
||||
link.setAttribute("aria-current", "page");
|
||||
// Keep the current section visible in the horizontally scrolling mobile nav.
|
||||
link.parentElement.scrollLeft = link.offsetLeft - (link.parentElement.clientWidth - link.offsetWidth) / 2;
|
||||
} else link.removeAttribute("aria-current");
|
||||
});
|
||||
}
|
||||
|
||||
function showSectionFromHash() {
|
||||
showSection(window.location.hash.slice(1));
|
||||
}
|
||||
|
||||
function revealInvalidControl(control) {
|
||||
// A modal copy dialog would otherwise keep the invalid draft field inert.
|
||||
if (elements.copyDialog.open) elements.copyDialog.close();
|
||||
const panel = sectionPanels.find((section) => section.contains(control));
|
||||
if (panel) {
|
||||
showSection(panel.id);
|
||||
if (window.location.hash !== `#${panel.id}`) window.location.hash = panel.id;
|
||||
}
|
||||
for (let ancestor = control.parentElement; ancestor && ancestor !== elements.form; ancestor = ancestor.parentElement) {
|
||||
if (ancestor.tagName === "DETAILS") ancestor.open = true;
|
||||
if (ancestor.hidden) ancestor.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reportProfileValidity() {
|
||||
// Validate only this form's controls, not the separate recording settings.
|
||||
// Report one field at a time so other hidden panels never receive focus.
|
||||
for (const control of elements.form.elements) {
|
||||
if (control.willValidate && !control.validity.valid) {
|
||||
revealInvalidControl(control);
|
||||
control.reportValidity();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
|
|
@ -282,8 +340,8 @@ function syncControllerPresentation() {
|
|||
const layout = ControllerLayouts[layoutId] || ControllerLayouts.generic;
|
||||
const reported = live?.source_controls || owner?.source_controls || state.schema.controls;
|
||||
const sources = preview ? Object.keys(layout.controls) : reported.filter(control =>
|
||||
layout.generic || Object.hasOwn(layout.controls, control));
|
||||
const topologyKnown = layoutId !== "wii-remote" &&
|
||||
layout.generic || layout.referenceOnly || Object.hasOwn(layout.controls, control));
|
||||
const topologyKnown = !layout.referenceOnly && layoutId !== "wii-remote" &&
|
||||
(!(layoutId.startsWith("joycon2-") || layoutId.startsWith("wii-")) || live?.layout === layoutId);
|
||||
const liveDiagram = Boolean(live && !preview && topologyKnown && !layout.generic);
|
||||
const style = preview ? layout.style : controller.style || layout.style;
|
||||
|
|
@ -459,8 +517,8 @@ function renderPlaytest(sample) {
|
|||
clearPlaytest(
|
||||
sample.connected
|
||||
? sample.identity?.is_joycon_pair
|
||||
? "Paired input belongs to the L+R profile owner. Select that owner for paired settings; this draft and its layout are unchanged."
|
||||
: "Live input belongs to another profile owner; this draft and its layout are unchanged."
|
||||
? "Paired input uses the L+R controller profiles. Select L+R for paired settings; this draft and its layout are unchanged."
|
||||
: "Live input belongs to another controller; this draft and its layout are unchanged."
|
||||
: "Connect or move the selected controller to compare its raw input with this draft."
|
||||
);
|
||||
return;
|
||||
|
|
@ -626,7 +684,7 @@ async function pollPlaytest() {
|
|||
profileIndex === state.profileIndex
|
||||
) {
|
||||
clearPlaytest(
|
||||
`${error.message}. Flash current firmware to enable live playtest.`,
|
||||
`Live input unavailable: ${error.message}`,
|
||||
"error"
|
||||
);
|
||||
}
|
||||
|
|
@ -643,24 +701,38 @@ function isDirty() {
|
|||
}
|
||||
|
||||
function setConnection(mode, text) {
|
||||
if (mode === "ready" && document.hidden) {
|
||||
mode = "loading";
|
||||
text = "Connection check paused";
|
||||
}
|
||||
const wasConnected = state.adapterConnected;
|
||||
state.adapterConnected = mode === "ready";
|
||||
if (wasConnected && !state.adapterConnected) stopMacroPreview("Preview stopped: adapter disconnected.");
|
||||
if (wasConnected && !state.adapterConnected && captureBlocking()) {
|
||||
stopCapture("Adapter disconnected; retaining the last received recording.");
|
||||
if (wasConnected && !state.adapterConnected) {
|
||||
stopMacroPreview("Preview stopped: adapter disconnected.");
|
||||
if (captureBlocking()) {
|
||||
stopCapture("Adapter disconnected; retaining the last received recording.");
|
||||
}
|
||||
clearPlaytest("Adapter connection unavailable. Your draft is retained.", "error");
|
||||
}
|
||||
elements.connection.dataset.state = mode;
|
||||
elements.connectionText.textContent = text;
|
||||
elements.copyProfile.disabled = state.busy || !state.adapterConnected;
|
||||
if (state.profile) {
|
||||
elements.save.disabled =
|
||||
!state.adapterConnected || state.busy || !isDirty();
|
||||
elements.activate.disabled =
|
||||
!state.adapterConnected || state.busy || state.active;
|
||||
}
|
||||
updateDeviceActions();
|
||||
renderCapture();
|
||||
}
|
||||
|
||||
function updateDeviceActions() {
|
||||
const unavailable = state.busy || captureBlocking() || !state.adapterConnected;
|
||||
elements.save.disabled = unavailable || !isDirty();
|
||||
elements.activate.disabled = unavailable || !state.profile || state.active;
|
||||
elements.copyProfile.disabled = unavailable || !state.profile;
|
||||
elements.confirmCopy.disabled = unavailable || !state.profile;
|
||||
elements.saveProfileName.disabled = unavailable || !state.profile;
|
||||
elements.saveAlias.disabled = unavailable || currentOwner()?.index === 0;
|
||||
elements.identify.disabled = unavailable || !state.identifyAvailable;
|
||||
renderJoyconMode();
|
||||
renderWiiOrientation();
|
||||
}
|
||||
|
||||
let toastTimer = 0;
|
||||
function toast(message, error = false) {
|
||||
window.clearTimeout(toastTimer);
|
||||
|
|
@ -675,7 +747,16 @@ async function api(path, options = {}) {
|
|||
if (options.method && options.method !== "GET") {
|
||||
headers["X-Switch-Pico-Token"] = state.token;
|
||||
}
|
||||
const response = await fetch(path, { ...options, headers });
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(path, { ...options, headers });
|
||||
} catch (error) {
|
||||
setConnection("error", "Editor server unavailable");
|
||||
throw error;
|
||||
}
|
||||
// USB failures are connection failures, not unsupported playtest features.
|
||||
// Ordinary 400 validation/feature errors must not disconnect a healthy Pico.
|
||||
if (response.status === 503) setConnection("error", "Adapter unavailable");
|
||||
let payload;
|
||||
try {
|
||||
payload = await response.json();
|
||||
|
|
@ -703,7 +784,7 @@ function renderJoyconMode() {
|
|||
let status;
|
||||
let mode;
|
||||
const saved = joyconMode.saved === null ? "" :
|
||||
`Saved default: ${label(joyconMode.saved)} · generation ${joyconMode.generation}.`;
|
||||
`Saved default: ${label(joyconMode.saved)}.`;
|
||||
const selection = joyconModeChanged()
|
||||
? ` Selection: ${label(joyconMode.selected)} — not applied.` : "";
|
||||
if (joyconMode.applying) {
|
||||
|
|
@ -818,7 +899,7 @@ function renderWiiOrientation() {
|
|||
elements.wiiOrientation.disabled = true;
|
||||
elements.applyWiiOrientation.disabled = true;
|
||||
elements.wiiOrientationStatus.dataset.state = "unsupported";
|
||||
elements.wiiOrientationStatus.textContent = "Default profile owner cannot set orientation. Select the connected Wii Remote.";
|
||||
elements.wiiOrientationStatus.textContent = "Select the connected Wii Remote instead of the default profiles to set orientation.";
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -854,29 +935,20 @@ function renderWiiOrientation() {
|
|||
function setBusy(busy) {
|
||||
state.busy = busy;
|
||||
busy ||= captureBlocking();
|
||||
elements.save.disabled =
|
||||
busy || !state.adapterConnected || !isDirty();
|
||||
elements.activate.disabled =
|
||||
busy || !state.adapterConnected || state.active;
|
||||
elements.resetDraft.disabled = busy;
|
||||
elements.refresh.disabled = busy;
|
||||
elements.identity.disabled = busy;
|
||||
elements.identify.disabled = busy || !state.identifyAvailable;
|
||||
elements.profileName.disabled = busy;
|
||||
elements.saveProfileName.disabled = busy || !state.adapterConnected;
|
||||
elements.controllerAlias.disabled =
|
||||
busy || currentOwner()?.index === 0;
|
||||
elements.saveAlias.disabled =
|
||||
busy || !state.adapterConnected || currentOwner()?.index === 0;
|
||||
elements.copyProfile.disabled = busy || !state.adapterConnected;
|
||||
elements.importProfile.disabled = busy;
|
||||
elements.exportProfile.disabled = busy;
|
||||
if (state.schema && state.profile) updateMacroBudgets();
|
||||
document.querySelectorAll(".profile-button").forEach((button) => {
|
||||
button.disabled = busy;
|
||||
});
|
||||
updateDeviceActions();
|
||||
renderCapture();
|
||||
renderWiiOrientation();
|
||||
renderNativeLayout();
|
||||
}
|
||||
|
||||
|
|
@ -927,7 +999,7 @@ function syncLibraryMetadata(identities, restoreStoredOwner = false) {
|
|||
restoreStoredOwner ? storedOwnerKey() : oldOwner?.key
|
||||
) || storedOwnerKey();
|
||||
if (oldOwner && state.profile && !identities.some((entry) => entry.key === oldOwner.key)) {
|
||||
throw new Error("The selected profile owner is unavailable. Your draft is retained; reconnect the owner or export it before choosing another.");
|
||||
throw new Error("The selected controller is unavailable. Your draft is retained; reconnect the controller or export the draft before choosing another.");
|
||||
}
|
||||
state.identities = identities;
|
||||
const nextOwner = (
|
||||
|
|
@ -1004,8 +1076,8 @@ function renderProfileList() {
|
|||
const selected = index === state.profileIndex;
|
||||
const active = owner && owner.active_profile === index + 1;
|
||||
return `
|
||||
<button class="profile-button${selected ? " selected" : ""}" type="button" data-profile-index="${index}">
|
||||
<span class="profile-number"><span>${index + 1}</span>${escapeHtml(state.profileNames[index] || `Profile ${index + 1}`)}</span>
|
||||
<button class="profile-button${selected ? " selected" : ""}" type="button" data-profile-index="${index}" aria-pressed="${selected}" aria-label="Profile ${index + 1}${state.profileNames[index] ? `: ${escapeHtml(state.profileNames[index])}` : ""}${active ? ", active on Pico" : ""}">
|
||||
<span class="profile-number"><span aria-hidden="true">${index + 1}</span>${escapeHtml(state.profileNames[index] || `Profile ${index + 1}`)}</span>
|
||||
${active ? '<span class="mini-active">Active</span>' : ""}
|
||||
</button>`;
|
||||
}).join("");
|
||||
|
|
@ -1145,7 +1217,7 @@ function renderShortcuts() {
|
|||
<div class="control-card">
|
||||
<label for="shortcut-modifier">Shortcut modifier</label>
|
||||
<select class="select" id="shortcut-modifier" data-kind="shortcut-modifier" aria-describedby="shortcut-modifier-help">${modifierOptions(shortcuts.modifier, shortcuts.profiles)}</select>
|
||||
<p class="field-help" id="shortcut-modifier-help">None clears every shortcut. Switch 2 extra inputs can act as modifiers. Trigger modifiers are available for known Xbox, PlayStation and Switch 2 owners.</p>
|
||||
<p class="field-help" id="shortcut-modifier-help">None clears every shortcut. Switch 2 extra inputs can act as modifiers. Trigger modifiers are available for recognized Xbox, PlayStation and Switch 2 controllers.</p>
|
||||
</div>`;
|
||||
elements.shortcuts.innerHTML = shortcuts.profiles.map((selector, index) => `
|
||||
<div class="control-card">
|
||||
|
|
@ -1277,21 +1349,23 @@ function renderButtonMap() {
|
|||
elements.controllerCanvas.dataset.layout = layoutId;
|
||||
elements.controllerLayoutStatus.dataset.state = liveDiagram ? "live" : "preview";
|
||||
elements.controllerLayoutStatus.textContent = preview ? "Manual layout preview" :
|
||||
liveDiagram ? "Live · matching owner" : live ? "Live input · reference diagram" : "Offline · owner reference";
|
||||
liveDiagram ? "Live · selected controller" : live ? "Live input · reference diagram" : "Offline · controller reference";
|
||||
elements.controllerModel.textContent = layout.name;
|
||||
elements.controllerLayoutHelp.textContent = preview
|
||||
? `Manual preview only${live ? `; connected input is ${live.controller.model}` : ""}. No topology or saved mapping is changed. Physical highlighting is off.`
|
||||
? `Layout preview only${live ? `; connected input is ${live.controller.model}` : ""}. The controller setup and saved mappings are unchanged. Physical highlighting is off.`
|
||||
: layout.referenceOnly
|
||||
? "Wii-family reference only: orientation and extensions are unknown. Connect the selected controller for its detected layout. No mappings are changed or hidden."
|
||||
: live && !topologyKnown
|
||||
? layoutId === "wii-remote"
|
||||
? "Legacy firmware does not report Wii orientation. This reference shows the default horizontal mapping; physical highlighting is off. Choose a preview or update firmware."
|
||||
: "Legacy firmware does not report Joy-Con pair/solo topology. This is an owner reference, not detected solo mode. Choose a preview or update firmware."
|
||||
: "Auto uses only the selected owner's metadata. Preview changes this editor's source labels and diagram, never firmware topology or saved mappings.";
|
||||
: "Legacy firmware does not report paired or solo Joy-Con mode. This diagram is a controller reference, not a detected solo layout. Choose a preview or update firmware."
|
||||
: "Auto uses the selected controller's details. Preview changes source labels and the diagram only, never the controller setup or saved mappings.";
|
||||
elements.controllerLayoutNote.textContent =
|
||||
!preview && live?.layout === "joycon2-pair" && !live.identity?.is_joycon_pair
|
||||
? "This older firmware reports paired input through a physical controller's profile bank. There is no independent L+R bank; edits still affect the selected existing owner. Update firmware for separate pair profiles. The grip is illustrative, not detected."
|
||||
? "This older firmware uses an individual controller's profiles for paired input. There are no separate L+R profiles; edits still affect the selected controller. Update firmware for separate pair profiles. The grip is illustrative, not detected."
|
||||
: layout.note ||
|
||||
(layout.generic ? "Generic reference art is not a model identification. Additional reported sources are unlocated, not buttons on this drawing." :
|
||||
"Rear triggers are listed off-art below the front view. Unavailable stored mappings are retained.");
|
||||
(layout.generic ? "This is a generic reference, not an identified controller model. Additional reported inputs are listed separately from the diagram." :
|
||||
"Rear triggers are listed below the front view. Saved mappings for unavailable controls are retained.");
|
||||
const diagramKey = `${layoutId}:${sources.join(",")}`;
|
||||
if (diagramKey !== state.diagramKey) {
|
||||
const focusedSource = elements.controllerCanvas.contains(document.activeElement)
|
||||
|
|
@ -1346,7 +1420,7 @@ function renderButtonMap() {
|
|||
hotspot.classList.toggle("disabled-map", output == null);
|
||||
if (!liveDiagram) hotspot.classList.remove("pressed");
|
||||
hotspot.setAttribute("aria-pressed", String(button === selected));
|
||||
hotspot.title = `${sourceLabel(button)} [${button}] → ${output == null ? "Disabled" : outputLabel(output)}`;
|
||||
hotspot.title = `${sourceLabel(button)} → ${output == null ? "Disabled" : outputLabel(output)}`;
|
||||
hotspot.setAttribute("aria-label", hotspot.title);
|
||||
});
|
||||
elements.selectedSource.innerHTML = sourceOptions(selected);
|
||||
|
|
@ -1354,7 +1428,7 @@ function renderButtonMap() {
|
|||
elements.selectedControlGlyph.textContent = sourceGlyph(selected);
|
||||
elements.selectedControlName.textContent = sourceLabel(selected);
|
||||
elements.selectedControlDescription.textContent =
|
||||
`${sourceAvailable(selected) ? "Source" : "Stored / unavailable source"} ${sourceLabel(selected)} [${selected}] produces ${mappedOutput == null ? "no output" : outputLabel(mappedOutput)}.${sourceAvailable(selected) ? "" : " Its mapping is retained; change the preview to locate it."}`;
|
||||
`${sourceLabel(selected)} produces ${mappedOutput == null ? "no output" : outputLabel(mappedOutput)}.${sourceAvailable(selected) ? "" : " This control is unavailable in the current layout; its saved mapping is retained."}`;
|
||||
elements.selectedMapping.innerHTML = buttonOptions(mappedOutput, true, state.schema.output_controls, style);
|
||||
renderWiiOrientation();
|
||||
}
|
||||
|
|
@ -1613,7 +1687,7 @@ function renderTurbo() {
|
|||
const override = settings.overrides[button];
|
||||
const off = state.profile.turbo[button] === "off";
|
||||
return `<div class="control-card turbo-card" data-turbo-button="${button}" data-source-card="${button}"${sourceAvailable(button) ? "" : " hidden"}>
|
||||
<label for="turbo-${button}"><span data-source-label="${button}">${escapeHtml(sourceLabel(button))}</span> · physical source</label>
|
||||
<label for="turbo-${button}"><span data-source-label="${button}">${escapeHtml(sourceLabel(button))}</span> · controller input</label>
|
||||
<select class="select" id="turbo-${button}" data-kind="turbo" data-name="${button}">
|
||||
${modeOptions(state.schema.turbo_modes, state.profile.turbo[button])}
|
||||
</select>
|
||||
|
|
@ -1745,7 +1819,7 @@ function macroBudgetError(macros) {
|
|||
}
|
||||
const budget = macroBudget(macros);
|
||||
if (budget.steps > MACRO_SHARED_STEP_LIMIT) return "The four macros share a limit of 16 steps.";
|
||||
if (budget.bytes > MACRO_BYTE_LIMIT) return "The four macros share a limit of 136 sparse bytes.";
|
||||
if (budget.bytes > MACRO_BYTE_LIMIT) return "The four macros share 136 bytes of storage.";
|
||||
return "";
|
||||
}
|
||||
|
||||
|
|
@ -1825,7 +1899,7 @@ function renderCapture() {
|
|||
elements.captureProgress.max = Math.max(1, options.max_events);
|
||||
elements.captureProgress.value = count;
|
||||
elements.captureBudget.textContent =
|
||||
`${count}/${options.max_events} recorded states · ${count * options.stepBytes}/${options.max_events * options.stepBytes} reserved sparse bytes · ` +
|
||||
`${count}/${options.max_events} recorded states · ${count * options.stepBytes}/${options.max_events * options.stepBytes} reserved bytes · ` +
|
||||
`other macros: ${options.otherBudget.steps}/16 shared steps, ${options.otherBudget.bytes}/136 bytes. ` +
|
||||
"Initial input counts as a state; held states split at 10 seconds. Capture stops before exceeding capacity.";
|
||||
const terminalNotice = {
|
||||
|
|
@ -1840,7 +1914,7 @@ function renderCapture() {
|
|||
elements.captureNotice.textContent = [
|
||||
session?.error, terminalNotice, page?.conversion_error,
|
||||
session?.message, originWarning,
|
||||
session?.used ? "Recorded steps are now in the unsaved draft. Review the visual preview below; Save remains a separate action." : "",
|
||||
session?.used ? "Recorded steps are in the unsaved draft. Review the preview, then use Save to Pico to store the profile." : "",
|
||||
!session && !sourceReady ? "Connect the selected controller and wait for its live input before recording." : "",
|
||||
!session && !options.channels ? "Select at least one channel." : "",
|
||||
!session && !options.max_events ? "Other macros leave no room for a recorded state. Free shared steps or bytes first." : "",
|
||||
|
|
@ -1917,7 +1991,7 @@ function finishCaptureRequest(session) {
|
|||
async function beginCapture() {
|
||||
if (elements.captureRecord.disabled || state.busy || macroCapture.session ||
|
||||
state.playtestRequestActive || state.libraryRequestActive) return;
|
||||
if (!elements.form.reportValidity() || !macroInputsValid(true)) return;
|
||||
if (!reportProfileValidity() || !macroInputsValid(true)) return;
|
||||
const options = captureOptions();
|
||||
if (!options.channels || !options.max_events) return;
|
||||
const owner = currentOwner();
|
||||
|
|
@ -2013,7 +2087,7 @@ async function stopCapture(message = "") {
|
|||
async function useCapture() {
|
||||
const session = macroCapture.session;
|
||||
if (!session?.terminal || macroCapture.requestActive || !captureOriginMatches(session)) return;
|
||||
if (!session.page?.steps?.length || !elements.form.reportValidity()) return;
|
||||
if (!session.page?.steps?.length || !reportProfileValidity()) return;
|
||||
const candidate = clone(state.profile);
|
||||
candidate.macros[session.macroIndex].steps = clone(session.page.steps);
|
||||
const error = macroBudgetError(candidate.macros);
|
||||
|
|
@ -2086,7 +2160,7 @@ function updateMacroBudgets() {
|
|||
`${budget.steps}/${MACRO_SHARED_STEP_LIMIT} shared steps`
|
||||
);
|
||||
elements.macroControls.querySelector("[data-budget-bytes]")?.replaceChildren(
|
||||
`${budget.bytes}/${MACRO_BYTE_LIMIT} sparse bytes`
|
||||
`${budget.bytes}/${MACRO_BYTE_LIMIT} bytes`
|
||||
);
|
||||
const progress = elements.macroControls.querySelector("[data-budget-progress]");
|
||||
if (progress) progress.value = budget.bytes;
|
||||
|
|
@ -2100,7 +2174,7 @@ function updateMacroBudgets() {
|
|||
elements.macroDuration.textContent =
|
||||
`${macro.steps.length}/8 steps · ${bytes} bytes · ${duration} ms total · ${budget.duration} ms across all macros`;
|
||||
elements.addMacroStep.disabled = state.busy || !canAddMacroStep(DEFAULT_MACRO_STEP_BYTES, budget);
|
||||
elements.addMacroStep.title = "Add a 100 ms button step (5 sparse bytes)";
|
||||
elements.addMacroStep.title = "Add a 100 ms button step (5 bytes)";
|
||||
let startsAt = 0;
|
||||
elements.macroSteps.querySelectorAll("[data-step-index]").forEach((card) => {
|
||||
const index = Number(card.dataset.stepIndex);
|
||||
|
|
@ -2132,7 +2206,10 @@ function updateMacroBudgets() {
|
|||
function macroInputsValid(report = false) {
|
||||
const invalid = elements.macroSteps.querySelector("input:invalid") ||
|
||||
elements.macroControls.querySelector("input:invalid");
|
||||
if (report && invalid) invalid.reportValidity();
|
||||
if (report && invalid) {
|
||||
revealInvalidControl(invalid);
|
||||
invalid.reportValidity();
|
||||
}
|
||||
return !invalid;
|
||||
}
|
||||
|
||||
|
|
@ -2306,7 +2383,7 @@ function renderMacro() {
|
|||
actionChordCard(
|
||||
"profile_switch",
|
||||
"Cycle active profile",
|
||||
`Advance through profile slots 1–${state.schema.profile_capacity}.`,
|
||||
`Cycle through profiles 1–${state.schema.profile_capacity}.`,
|
||||
state.profile.switching_chord,
|
||||
state.schema.default_switching_chord
|
||||
),
|
||||
|
|
@ -2432,8 +2509,8 @@ function renderMacro() {
|
|||
</div>
|
||||
<div class="macro-budget">
|
||||
<strong data-budget-steps>${budget.steps}/16 shared steps</strong>
|
||||
<span data-budget-bytes>${budget.bytes}/136 sparse bytes</span>
|
||||
<progress data-budget-progress max="136" value="${budget.bytes}" aria-label="Shared macro sparse byte budget"></progress>
|
||||
<span data-budget-bytes>${budget.bytes}/136 bytes</span>
|
||||
<progress data-budget-progress max="136" value="${budget.bytes}" aria-label="Shared macro storage"></progress>
|
||||
</div>
|
||||
</div>
|
||||
${actionChordCard(
|
||||
|
|
@ -2485,7 +2562,7 @@ function renderMacroSteps() {
|
|||
const controllerStyle = currentControllerStyle();
|
||||
|
||||
elements.macroSteps.innerHTML = macro.steps.length === 0
|
||||
? '<div class="macro-step end"><p class="field-help">No steps yet. Add a state step to build this macro.</p></div>'
|
||||
? '<div class="macro-step end"><p class="field-help">No steps yet. Add a step or record controller input to build this macro.</p></div>'
|
||||
: macro.steps.map((step, index) => {
|
||||
const overrides = new Set(step.overrides);
|
||||
const outputButtons = new Set(step.output_buttons);
|
||||
|
|
@ -2515,7 +2592,7 @@ function renderMacroSteps() {
|
|||
${macroNumber(index, "triggers.right", step.triggers.right, 0, 65535, "Right trigger", !overrides.has("right_trigger"))}
|
||||
</div>
|
||||
<div class="step-group">
|
||||
<span class="step-group-title">Fields this step overrides</span>
|
||||
<span class="step-group-title">Outputs controlled by this step</span>
|
||||
<div class="check-grid">
|
||||
${state.schema.macro_overrides.map((name) => `
|
||||
<label class="checkbox-pill">
|
||||
|
|
@ -3067,7 +3144,16 @@ elements.captureRecover.addEventListener("click", () => {
|
|||
elements.captureOptions.addEventListener("input", renderCapture);
|
||||
elements.captureOptions.addEventListener("change", renderCapture);
|
||||
window.addEventListener("pagehide", () => stopCapture("Recording stopped because you left the editor."));
|
||||
window.addEventListener("hashchange", () => stopCapture("Recording stopped because you navigated to another section."));
|
||||
window.addEventListener("hashchange", showSectionFromHash);
|
||||
sectionLinks.forEach((link) => {
|
||||
link.addEventListener("click", (event) => {
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
// Reveal the destination before the anchor's native scrolling and focus.
|
||||
// Leave the default action intact for keyboard navigation and history.
|
||||
showSection(link.hash.slice(1));
|
||||
});
|
||||
});
|
||||
sectionPanels.forEach((panel) => { panel.tabIndex = -1; });
|
||||
document.querySelector("#macroCaptureSettings").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
|
@ -3076,6 +3162,11 @@ document.addEventListener("visibilitychange", () => {
|
|||
stopMacroPreview("Preview stopped: tab hidden.");
|
||||
clearMacroDrag();
|
||||
stopCapture("Recording stopped because the tab was hidden.");
|
||||
setConnection("loading", "Connection check paused");
|
||||
} else {
|
||||
setConnection("loading", "Checking adapter…");
|
||||
pollPlaytest();
|
||||
pollLibraryMetadata();
|
||||
}
|
||||
updateMacroPreviewControls();
|
||||
renderCapture();
|
||||
|
|
@ -3091,7 +3182,7 @@ elements.saveProfileName.addEventListener("click", async () => {
|
|||
setBusy(true);
|
||||
try {
|
||||
const value = elements.profileName.value.trim();
|
||||
const result = await api(
|
||||
await api(
|
||||
`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}/name`,
|
||||
{
|
||||
method: "PUT",
|
||||
|
|
@ -3104,7 +3195,7 @@ elements.saveProfileName.addEventListener("click", async () => {
|
|||
elements.profileTitle.textContent = value || `Profile ${state.profileIndex + 1}`;
|
||||
renderProfileList();
|
||||
updateDirtyState();
|
||||
toast(`Profile name saved · generation ${result.stored_generation}`);
|
||||
toast("Profile name saved to Pico.");
|
||||
} catch (error) {
|
||||
toast(error.message, true);
|
||||
} finally {
|
||||
|
|
@ -3126,7 +3217,7 @@ elements.saveAlias.addEventListener("click", async () => {
|
|||
owner.alias = value;
|
||||
owner.label = result.label;
|
||||
renderIdentities();
|
||||
toast(`Controller alias saved · generation ${result.stored_generation}`);
|
||||
toast("Controller alias saved to Pico.");
|
||||
} catch (error) {
|
||||
toast(error.message, true);
|
||||
} finally {
|
||||
|
|
@ -3198,7 +3289,7 @@ elements.importProfileFile.addEventListener("change", async () => {
|
|||
});
|
||||
|
||||
elements.copyProfile.addEventListener("click", () => {
|
||||
if (!elements.form.reportValidity()) return;
|
||||
if (!reportProfileValidity()) return;
|
||||
elements.copyIdentity.innerHTML = state.identities.map((identity) => (
|
||||
`<option value="${identity.index}">${escapeHtml(identity.label)}</option>`
|
||||
)).join("");
|
||||
|
|
@ -3215,10 +3306,10 @@ elements.copyProfile.addEventListener("click", () => {
|
|||
|
||||
elements.confirmCopy.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!elements.form.reportValidity()) return;
|
||||
if (!reportProfileValidity()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await api(
|
||||
await api(
|
||||
`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}/copy`,
|
||||
{
|
||||
method: "POST",
|
||||
|
|
@ -3237,7 +3328,7 @@ elements.confirmCopy.addEventListener("click", async (event) => {
|
|||
renderProfileList();
|
||||
}
|
||||
elements.copyDialog.close();
|
||||
toast(`Profile copied · generation ${result.stored_generation}`);
|
||||
toast(`Draft copied to profile ${elements.copySlot.value} on Pico.`);
|
||||
} catch (error) {
|
||||
toast(error.message, true);
|
||||
} finally {
|
||||
|
|
@ -3285,16 +3376,18 @@ document.querySelectorAll("[data-reset-section]").forEach((button) => {
|
|||
});
|
||||
|
||||
elements.save.addEventListener("click", async () => {
|
||||
if (!elements.form.reportValidity()) return;
|
||||
if (!reportProfileValidity()) return;
|
||||
const error = macroBudgetError(state.profile.macros);
|
||||
if (error) {
|
||||
showSection("macro");
|
||||
window.location.hash = "macro";
|
||||
macroNotice(error, true);
|
||||
toast(error, true);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await api(`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}`, {
|
||||
await api(`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(state.profile),
|
||||
|
|
@ -3314,7 +3407,7 @@ elements.save.addEventListener("click", async () => {
|
|||
}
|
||||
state.original = canonical(state.profile);
|
||||
updateDirtyState();
|
||||
toast(`Saved atomically · generation ${result.stored_generation} · CRC ${result.stored_crc}`);
|
||||
toast("Profile saved to Pico.");
|
||||
} catch (error) {
|
||||
toast(error.message, true);
|
||||
} finally {
|
||||
|
|
@ -3323,17 +3416,18 @@ elements.save.addEventListener("click", async () => {
|
|||
});
|
||||
|
||||
elements.activate.addEventListener("click", async () => {
|
||||
if (!reportProfileValidity()) return;
|
||||
if (isDirty()) {
|
||||
toast("Save or discard the draft before activating this profile.", true);
|
||||
toast("Use Save to Pico or discard the draft before activating this profile.", true);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await api(`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}/activate`, { method: "POST" });
|
||||
await api(`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}/activate`, { method: "POST" });
|
||||
state.identities[state.identityIndex].active_profile = state.profileIndex + 1;
|
||||
state.active = true;
|
||||
renderEditor();
|
||||
toast(`Profile ${state.profileIndex + 1} is active · generation ${result.stored_generation}`);
|
||||
toast(`Profile ${state.profileIndex + 1} is now active.`);
|
||||
} catch (error) {
|
||||
toast(error.message, true);
|
||||
} finally {
|
||||
|
|
@ -3364,4 +3458,5 @@ async function start() {
|
|||
}
|
||||
}
|
||||
|
||||
showSectionFromHash();
|
||||
start();
|
||||
|
|
|
|||
|
|
@ -18,13 +18,16 @@ from tests.test_config_manager import FakeDevice, custom_profile, native_rumble_
|
|||
|
||||
@contextmanager
|
||||
def running_server(
|
||||
monkeypatch: pytest.MonkeyPatch, device: FakeDevice | None
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
device: FakeDevice | None,
|
||||
*,
|
||||
operation_timeout: float = 1.0,
|
||||
) -> Iterator[tuple[str, str]]:
|
||||
server = profile_web.ProfileEditorServer(
|
||||
("127.0.0.1", 0),
|
||||
bus=None,
|
||||
device_address=None,
|
||||
timeout=1.0,
|
||||
timeout=operation_timeout,
|
||||
)
|
||||
if device is not None:
|
||||
monkeypatch.setattr(server, "find_device", lambda: device)
|
||||
|
|
@ -181,6 +184,49 @@ def test_native_hub_editor_discovers_root_and_preserves_saved_state(
|
|||
}.intersection(device.requests)
|
||||
|
||||
|
||||
def test_editor_disconnect_does_not_block_requests_and_rediscovers_adapter(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
attached = True
|
||||
transfer = device.ctrl_transfer
|
||||
|
||||
def find(**arguments: object) -> tuple[FakeDevice, ...]:
|
||||
if attached and (arguments["idVendor"], arguments["idProduct"]) == (
|
||||
0xCAFE, 0x4010
|
||||
):
|
||||
return (device,)
|
||||
return ()
|
||||
|
||||
def ctrl_transfer(*args: Any, **kwargs: Any) -> Any:
|
||||
if not attached:
|
||||
raise usb.core.USBError("device disconnected")
|
||||
return transfer(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(config_manager.usb.core, "find", find)
|
||||
monkeypatch.setattr(device, "ctrl_transfer", ctrl_transfer)
|
||||
with running_server(
|
||||
monkeypatch, None, operation_timeout=15.0
|
||||
) as (base_url, _):
|
||||
status, before = request_json(f"{base_url}/api/profiles")
|
||||
assert status == 200
|
||||
attached = False
|
||||
status, _ = request_json(f"{base_url}/api/profiles/1/1/playtest")
|
||||
assert status == 503
|
||||
# request_json's two-second deadline is independent of the 15-second
|
||||
# transaction timeout. Neither rediscovery nor the next request waits.
|
||||
status, unavailable = request_json(f"{base_url}/api/profiles")
|
||||
assert status == 400
|
||||
assert "error" in unavailable
|
||||
status, _ = request_json(f"{base_url}/api/schema")
|
||||
assert status == 200
|
||||
attached = True
|
||||
device.address += 1
|
||||
status, after = request_json(f"{base_url}/api/profiles")
|
||||
assert status == 200
|
||||
assert after == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("product_id", "extras"),
|
||||
[
|
||||
|
|
@ -607,26 +653,42 @@ def test_live_metadata_cannot_turn_an_unrelated_identity_into_a_pair(
|
|||
assert sample["controller"]["layout"] == "xbox"
|
||||
|
||||
|
||||
def test_wii_pid_does_not_claim_a_remote_or_extension_without_live_metadata(
|
||||
@pytest.mark.parametrize("product_id", [0x0306, 0x0330])
|
||||
def test_wii_family_reference_preserves_sources_until_live_layout_is_known(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
product_id: int,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
device.stable_identity = replace(
|
||||
device.stable_identity, vendor_id=0x057E, product_id=0x0330
|
||||
device.stable_identity, vendor_id=0x057E, product_id=product_id
|
||||
)
|
||||
device.profile_identities = [device.global_identity, device.stable_identity]
|
||||
device.active_profiles[device.stable_identity.to_bytes()] = 0
|
||||
device.profiles[device.stable_identity.to_bytes(), 0] = (
|
||||
config_manager.ControllerProfile.default().to_bytes()
|
||||
)
|
||||
with running_server(monkeypatch, device) as (base_url, _):
|
||||
status, listing = request_json(f"{base_url}/api/profiles")
|
||||
assert status == 200
|
||||
assert listing["identities"][1]["controller"]["layout"] == "generic"
|
||||
owner = listing["identities"][1]
|
||||
assert owner["controller"]["layout"] == "wii-reference"
|
||||
assert owner["controller"]["style"] == "wii"
|
||||
assert owner["source_controls"] == list(config_manager.LOGICAL_CONTROLS)
|
||||
device.playtest_connected = False
|
||||
status, offline = request_json(f"{base_url}/api/profiles/1/1/playtest")
|
||||
assert status == 200
|
||||
assert offline["connected"] is False
|
||||
assert offline["layout"] is None
|
||||
assert offline["controller"] == owner["controller"]
|
||||
assert offline["source_controls"] == owner["source_controls"]
|
||||
device.playtest_connected = True
|
||||
for code, expected in (
|
||||
(0, "generic"),
|
||||
(0, "wii-reference"),
|
||||
(4, "wii-remote"),
|
||||
(5, "wii-nunchuk"),
|
||||
(6, "wii-horizontal"),
|
||||
(7, "wii-vertical"),
|
||||
(0, "generic"),
|
||||
(0, "wii-reference"),
|
||||
):
|
||||
device.playtest_layout = code
|
||||
status, sample = request_json(f"{base_url}/api/profiles/0/1/playtest")
|
||||
|
|
@ -637,6 +699,8 @@ def test_wii_pid_does_not_claim_a_remote_or_extension_without_live_metadata(
|
|||
config_manager.EXTRA_BUTTONS
|
||||
)
|
||||
assert ("left_shoulder" in sample["source_controls"]) == (code == 5)
|
||||
else:
|
||||
assert sample["source_controls"] == owner["source_controls"]
|
||||
|
||||
|
||||
def test_editor_reads_writes_and_activates_profiles_atomically(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue