Gamepads on this device
Several gamepads can share one controller. Keep this window active while playing.
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1b2a5f9..53d71d2 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -34,6 +34,16 @@ jobs:
docker run --rm --network none -v "$PWD:/src" -w /src \
control4free-launcher-build python3 -B tests/run.py
+ - name: Browser editor checks
+ run: |
+ docker run --rm -v "$PWD:/src" -w /src mcr.microsoft.com/playwright:v1.63.0-noble \
+ bash -lc 'mkdir -p /tmp/c4f-browser; cd /tmp/c4f-browser; npm init -y >/dev/null; npm install playwright@1.63.0; cd /src; NODE_PATH=/tmp/c4f-browser/node_modules node tests/browser_test.cjs'
+
+ - name: Emulated gamepad checks
+ run: |
+ docker run --rm -v "$PWD:/src" -w /src mcr.microsoft.com/playwright:v1.63.0-noble \
+ bash -lc 'mkdir -p /tmp/c4f-browser; cd /tmp/c4f-browser; npm init -y >/dev/null; npm install playwright@1.63.0; cd /src; NODE_PATH=/tmp/c4f-browser/node_modules node tests/fake_gamepads_test.cjs'
+
- name: Collect what was built
run: |
version=$(cat VERSION)
@@ -60,6 +70,6 @@ jobs:
run: |
# The release notes are this version's section of the changelog.
version=$(cat VERSION)
- awk -v head="## $version " 'index($0, head) == 1 { keep = 1; next } /^## / { keep = 0 } keep' CHANGELOG.md > notes.md
+ python3 tools/release_notes.py CHANGELOG.md "$version" "$GITHUB_REPOSITORY" > notes.md
test -s notes.md || { echo "CHANGELOG.md has no section for $version"; exit 1; }
gh release create "${GITHUB_REF#refs/tags/}" artifacts/* --verify-tag --title "Control4Free $version" --notes-file notes.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 873753a..ab35a87 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,35 @@
Versions follow [Semantic Versioning](https://semver.org): PATCH for fixes, MINOR for features that break
nothing, MAJOR for anything people rely on that changes.
+## 1.2.0 — 2026-10-08
+
+Gamepad mapping.
+
+- Map a gamepad on a picture of a DualShock 4: each control shows what drives it and lights up when pressed.
+ Click a control, then press the gamepad button for it. It works for gamepads the browser doesn't know, and
+ for wheels, guitars, pedals and arcade sticks, as DualShock 4 buttons, sticks and triggers. Sticks and
+ triggers show their position with a dead zone and a curve. **Everything it sends** shows every button and
+ axis live, and sets an axis's range a step at a time. See [Gamepad mapping](docs/gamepad-mapping.md).
+- Keyboard keys are chosen on the same picture.
+- Mappings are saved as profiles in the browser, used for every gamepad of the same kind, and can be exported
+ to a file and imported on another device.
+- Several gamepads can play as the same controller, for example a wheel and its pedals.
+- A button still held when a gamepad starts playing does nothing until it's let go.
+- A phone that drops off the network no longer keeps a connection to the PS4 open for ever. Before, enough of
+ those could stop new phones and computers from opening the page.
+- When two people pick controllers at the same moment, one is asked to try again, instead of one taking the
+ other's controller.
+- A controller the console refused input for recovers once it accepts input again.
+- The page only asks for the PS4's address when it was saved to the device and opened from there.
+- The address box takes a full address, such as `http://192.168.1.20:4264/`, and adds `:4264` when it's
+ missing.
+- A touch on the touchpad that the browser cancels no longer clicks it.
+- Keys with Ctrl, Alt or the Windows key can't be chosen any more: they never worked in play.
+- The app on the PS4 says when the Control4Free running is a different version from the app, and how to switch.
+ It tells a missing auto-start copy apart from auto-start being off.
+- The service's log stays under 2 MB, however long it runs.
+- The diagnostic app says whether it managed to save its report.
+
## 1.1.1 — 2026-10-07
Fixes.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c5092a1..aefc964 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -54,6 +54,8 @@ The suites build the real sources for the host with only the PS4 calls stubbed,
and checks the package's `param.sfo`;
- `test_logging` covers the log, the kernel-log opener and DeviceId matching.
+`tests/fake_gamepads_test.cjs` runs researched gamepad emulations against the controller page in the Playwright Docker image, without console access or hardware compatibility claims.
+
Add a test with your fix: one that fails without it. Then test on a real console if you can, and say in your
pull request which firmware, GoldHEN version and games you tried.
diff --git a/README.md b/README.md
index 2fba6ad..a29db17 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,10 @@ sees a real controller, so it works wherever a DualShock does:
Move, resize or hide any button with the layout editor; each device keeps its own layout.
- **The gamepads you already have.** Connect one to the phone or PC and give it a controller. One computer
can run all four.
-- **A keyboard**, with keys you can change.
+- **A keyboard**, with keys you choose on the same picture.
+- **Your own gamepad mapping.** Map any gamepad on a picture of a DualShock 4: click a control, then press the
+ button you want. Works for gamepads the browser doesn't know, and for wheels, guitars and pedals too. See
+ [Gamepad mapping](docs/gamepad-mapping.md).
- **Rumble and light bar.** When a game rumbles, your phone buzzes (Android), and so does your gamepad,
though not every controller supports vibration in the browser. The page glows in the controller's
light-bar colour, the one the PS4 or the game sets.
@@ -92,6 +95,9 @@ New to GoldHEN packages? The **[installation guide](docs/installation.md)** walk
## Limitations
+- **The PS4 always sees a DualShock 4.** A wheel plays as sticks and triggers, so games that need a real
+ wheel or guitar don't accept it, and there's no force feedback.
+- **Six pages at a time.** Up to six phones or computers can have the page open at once.
- **No motion controls.** Browsers only give phone motion sensors to secure (`https`) pages, and the
console's page is plain `http`.
- **iPhones don't vibrate.** Safari doesn't let pages use the phone's vibration. A gamepad connected to the
diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md
index 21ba975..2492308 100644
--- a/THIRD_PARTY.md
+++ b/THIRD_PARTY.md
@@ -34,7 +34,9 @@
- The on-screen DualShock 4 buttons in `client/index.html` are drawn after the
[DualShock 4 layout diagram](https://commons.wikimedia.org/wiki/File:Dualshock_4_Layout.svg)
by Tokyoship, licensed under CC BY 3.0
- (https://creativecommons.org/licenses/by/3.0/).
+ (https://creativecommons.org/licenses/by/3.0/). The PS button's mark uses the
+ diagram's paths, resized and recolored; the surrounding buttons are adapted
+ to the interactive page.
These license notices are also included inside the package.
diff --git a/VERSION b/VERSION
index 524cb55..26aaba0 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.1
+1.2.0
diff --git a/client/index.html b/client/index.html
index 49020c2..ae368be 100644
--- a/client/index.html
+++ b/client/index.html
@@ -338,10 +338,10 @@
.gp-row {
display: grid;
- grid-template-columns: auto 1fr auto;
+ grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
- gap: 14px;
- padding: 12px 14px;
+ gap: 16px;
+ padding: 16px;
border-radius: 6px;
background: rgba(255, 255, 255, .05);
box-shadow: inset 0 0 0 1px var(--line-soft);
@@ -349,9 +349,26 @@
}
.gp-row.active { box-shadow: var(--glow); }
- .gp-row svg { width: 30px; height: 30px; color: var(--muted); }
- .gp-name { font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .gp-sub { font-size: 12.5px; color: var(--muted); font-weight: 300; }
+ .gp-device { display: flex; align-items: center; gap: 12px; min-width: 0; }
+ .gp-device-icon { display: grid; place-items: center; flex: 0 0 40px; height: 40px; border-radius: 6px; background: rgba(255, 255, 255, .06); color: var(--muted); }
+ .gp-device-icon svg { width: 26px; height: 26px; }
+ .gp-details { display: grid; gap: 3px; min-width: 0; }
+ .gp-name { font-size: 15px; font-weight: 500; line-height: 1.4; overflow-wrap: anywhere; }
+ .gp-sub { font-size: 12.5px; line-height: 1.45; color: var(--muted); font-weight: 300; }
+ .gp-meta { display: flex; flex-wrap: wrap; gap: 2px 10px; }
+ .gp-meta > span { max-width: 100%; overflow-wrap: anywhere; }
+ .gp-profile::before { content: '·'; margin-right: 10px; }
+ .gp-note { font-size: 12.5px; line-height: 1.45; color: var(--muted); }
+ .gp-note.warning { color: var(--warn); }
+ .gp-assignment { display: grid; gap: 6px; }
+ .gp-assignment-label { font-size: 12px; color: var(--muted); }
+ .gp-actions { display: flex; align-items: flex-end; gap: 16px; }
+ .gp-row .seg { flex-wrap: nowrap; gap: 4px; }
+ .gp-row .seg button { height: 40px; font-variant-numeric: tabular-nums; }
+ .gp-row .gp-mapping { height: 40px; min-width: 96px; padding: 0 14px; font-size: 14px; }
+ .gp-row--settings { grid-template-columns: minmax(0, 1fr) auto; }
+ .gp-row--settings .gp-mapping { align-self: center; }
+ .gp-help { margin: 12px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--muted); text-wrap: pretty; }
.seg { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
@@ -372,9 +389,18 @@
.seg button:active { transform: scale(.95); }
.seg button[aria-checked="true"] { background: #fff; color: #06215a; box-shadow: 0 0 0 2px var(--pc), 0 0 14px rgba(255, 255, 255, .45); }
+ @media (max-width: 760px) {
+ .gp-row { grid-template-columns: minmax(0, 1fr) auto; gap: 14px 10px; padding: 14px; }
+ .gp-device { align-items: flex-start; gap: 10px; }
+ .gp-profile::before { content: none; }
+ .gp-actions { display: contents; }
+ .gp-assignment { grid-column: 1 / -1; grid-row: 2; }
+ .gp-row .seg { justify-content: flex-start; }
+ .gp-row .seg button { flex: 1; }
+ .gp-row .gp-mapping { grid-column: 2; grid-row: 1; align-self: start; min-width: 0; padding: 0 12px; }
+ }
+
@media (max-width: 560px) {
- .gp-row { grid-template-columns: auto 1fr; }
- .gp-row .seg { grid-column: 1 / -1; justify-content: flex-start; }
/* A phone shows its own clock: give the connection status the room */
.home .clock { display: none; }
}
@@ -606,7 +632,7 @@
.finger { position: absolute; width: calc(var(--u) * 7); height: calc(var(--u) * 7); margin: calc(var(--u) * -3.5) 0 0 calc(var(--u) * -3.5); border-radius: 50%; background: color-mix(in srgb, var(--accent) 45%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 85%, transparent), 0 0 20px var(--accent); pointer-events: none; }
- /* PS button: opens the menu */
+ /* PS button: round inset cap with the PlayStation mark (Tokyoship, CC BY 3.0). */
.ps-btn {
border-radius: 50%;
display: grid;
@@ -614,11 +640,16 @@
color: rgba(236, 240, 248, .75);
background: radial-gradient(circle at 50% 30%, #414a5f 0%, #1a1f2b 72%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .2), 0 0 0 calc(var(--u) * .6) #121620, 0 0 0 calc(var(--u) * .6 + 1px) rgba(255, 255, 255, .08), 0 6px 16px -4px rgba(0, 0, 0, .8);
- transition: transform .08s var(--ease);
+ transition: transform .08s var(--ease), filter .08s, box-shadow .12s, background .12s;
}
- .ps-btn:active { transform: scale(.92); }
- .ps-btn svg { width: 46%; height: 46%; }
+ .ps-btn.on, .ps-btn:active {
+ transform: scale(.92);
+ color: #fff;
+ background: radial-gradient(circle at 50% 30%, color-mix(in srgb, var(--accent) 40%, #414a5f), color-mix(in srgb, var(--accent) 22%, #1a1f2b) 72%);
+ box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent) 75%, transparent), 0 0 0 calc(var(--u) * .6) #121620, 0 0 calc(var(--u) * 3) -1px var(--accent);
+ }
+ .ps-btn svg { width: 58%; height: 58%; pointer-events: none; }
/* Sticks: DualShock tops (hollow, with a ridged rim) in a sunken well that shows how far they move */
.stick {
@@ -881,11 +912,227 @@
.save-bar .save-status { flex: 1; min-width: 180px; font-size: 13.5px; color: var(--muted); font-weight: 300; }
.save-bar .field { width: auto; }
- .key-row { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; min-height: 52px; padding: 8px 18px; border-top: 1px solid var(--line-soft); }
- .key-row:first-child { border-top: 0; }
.key-btn { min-width: 110px; height: 36px; padding: 0 14px; border: 0; border-radius: 4px; background: rgba(255, 255, 255, .1); box-shadow: inset 0 0 0 1px var(--line); font-size: 14px; }
.key-btn:hover { box-shadow: var(--glow); }
.key-btn.listening { background: #fff; color: #06215a; animation: pulse 1s ease-in-out infinite; }
+ /* ---------- Mapping: a DualShock 4 to map a gamepad or the keyboard onto ---------- */
+
+ .map-view { position: fixed; inset: 0; z-index: 4; display: flex; flex-direction: column; overflow-y: auto; overscroll-behavior: contain; }
+ .map-view .backdrop { z-index: -1; position: fixed; }
+
+ .map-top {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ gap: 12px 16px;
+ padding: max(10px, env(safe-area-inset-top)) max(22px, env(safe-area-inset-right)) 12px max(22px, env(safe-area-inset-left));
+ }
+
+ .map-what { display: grid; gap: 4px; min-width: 0; flex: 1 1 280px; }
+ .map-what .field { width: 100%; }
+ .map-source { height: 42px; max-width: 440px; font-size: 16px; }
+ .map-sub { font-size: 12.5px; color: var(--muted); font-weight: 300; }
+ .map-profile { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 10px; }
+ .map-label { display: grid; gap: 4px; font-size: 12px; line-height: 16px; color: var(--muted); }
+ .map-profile .map-label .field { width: 200px; height: 42px; font-size: 14px; }
+ .map-top > .icon-btn { margin-top: 20px; }
+ .map-top .icon-btn { width: 42px; height: 42px; }
+ .map-menu-wrap { position: relative; }
+
+ .map-menu {
+ position: absolute;
+ right: 0;
+ top: calc(100% + 6px);
+ z-index: 6;
+ display: grid;
+ min-width: 240px;
+ padding: 6px;
+ border-radius: 8px;
+ background: var(--panel-strong);
+ box-shadow: inset 0 0 0 1px var(--line), 0 18px 40px -12px rgba(0, 0, 0, .9);
+ }
+
+ .map-menu button, .map-file { display: block; padding: 10px 12px; border: 0; border-radius: 5px; background: none; color: var(--text); font-size: 14px; text-align: left; cursor: pointer; transition: background .15s; }
+ .map-menu button:hover, .map-file:hover { background: rgba(255, 255, 255, .1); }
+ .map-menu button:disabled { opacity: .4; pointer-events: none; }
+ .map-menu .danger { color: var(--bad); }
+ .map-file input { display: none; }
+ .map-save { display: flex; align-items: center; gap: 10px; margin-left: auto; padding-top: 20px; }
+ .map-save .btn { height: 42px; }
+ .map-dirty { font-size: 13px; color: var(--warn); }
+
+ .map-banner {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px 14px;
+ margin: 0 max(22px, env(safe-area-inset-right)) 10px max(22px, env(safe-area-inset-left));
+ padding: 10px 14px;
+ border-radius: 6px;
+ background: rgba(255, 208, 77, .1);
+ box-shadow: inset 0 0 0 1px rgba(255, 208, 77, .4);
+ font-size: 14px;
+ line-height: 1.5;
+ }
+
+ .map-tabs { display: flex; gap: 6px; padding: 0 max(22px, env(safe-area-inset-right)) 12px max(22px, env(safe-area-inset-left)); }
+ .map-tabs button { height: 36px; padding: 0 16px; border: 0; border-radius: 4px; background: transparent; color: var(--muted); font-size: 14px; transition: background .2s, color .2s, box-shadow .2s; }
+ .map-tabs button:hover { color: #fff; background: rgba(255, 255, 255, .08); }
+ .map-tabs button[aria-selected="true"] { color: #06215a; background: #fff; }
+
+ .map-body {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 360px;
+ align-items: start;
+ gap: 18px;
+ width: 100%;
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 0 max(22px, env(safe-area-inset-right)) 8px max(22px, env(safe-area-inset-left));
+ }
+
+ @media (max-width: 900px) { .map-body { grid-template-columns: 1fr; } }
+
+ .map-stage {
+ padding: 10px;
+ border-radius: 10px;
+ background:
+ radial-gradient(75% 95% at 10% -15%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 62%),
+ #0b0e14;
+ box-shadow: inset 0 0 0 1px var(--line-soft), 0 16px 34px -18px rgba(0, 0, 0, .9);
+ }
+
+ .map-pad { position: relative; width: 100%; max-width: calc(64dvh * 2); aspect-ratio: 2 / 1; margin: 0 auto; }
+ .map-pad .pad-area { inset: 0; }
+ .map-area .ctl { cursor: pointer; }
+ .map-area .map-unset { opacity: .4; }
+ .map-area .map-sel:not(.arm) { outline: 2px solid var(--accent); outline-offset: 5px; }
+ .map-area .arm.map-sel { stroke: #fff; stroke-width: 3; }
+ .map-chips { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
+
+ .map-chip {
+ position: absolute;
+ max-width: 128px;
+ padding: 2px 7px;
+ border: 0;
+ border-radius: 5px;
+ background: rgba(2, 11, 31, .9);
+ box-shadow: inset 0 0 0 1px var(--line);
+ color: #fff;
+ font-size: 11.5px;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.4;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ transform: translate(-50%, -50%);
+ pointer-events: auto;
+ transition: box-shadow .15s, background .15s;
+ }
+
+ .map-chip:hover { box-shadow: var(--glow); }
+ .map-chip.unset { color: var(--faint); box-shadow: inset 0 0 0 1px var(--line-soft); }
+ .map-chip.sel { background: #fff; color: #06215a; }
+ .map-chip.listening { background: var(--accent); animation: pulse 1s ease-in-out infinite; }
+
+ .map-panel {
+ display: grid;
+ align-content: start;
+ gap: 12px;
+ padding: 18px;
+ border-radius: 8px;
+ background: var(--panel);
+ box-shadow: inset 0 0 0 1px var(--line-soft);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ }
+
+ .map-panel-title { margin: 0; font-size: 22px; font-weight: 300; letter-spacing: .01em; }
+ .map-panel .hint { margin: 0; }
+ .map-rows { display: grid; gap: 8px; }
+ .map-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 44px; padding: 6px 10px; border-radius: 5px; background: rgba(255, 255, 255, .05); font-size: 14px; }
+ .map-row-label { flex: 1; min-width: 0; }
+ .map-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+ .map-actions .btn { height: 40px; padding: 0 16px; font-size: 14px; }
+ .link-btn:disabled { opacity: .4; pointer-events: none; }
+
+ @keyframes listen { 50% { box-shadow: inset 0 0 0 1px var(--accent), 0 0 22px -4px var(--accent); } }
+ .map-listen { margin: 0; padding: 14px 16px; border-radius: 6px; background: color-mix(in srgb, var(--accent) 20%, transparent); box-shadow: inset 0 0 0 1px var(--accent); font-size: 15px; line-height: 1.5; animation: listen 1.4s ease-in-out infinite; }
+ .map-step { margin: 0; font-size: 12.5px; color: var(--muted); }
+
+ .map-section { display: grid; gap: 10px; padding-top: 12px; border-top: 1px solid var(--line-soft); }
+ .map-section h4 { margin: 0; font-size: 13px; font-weight: 400; color: var(--muted); letter-spacing: .03em; }
+ .map-section .hint { font-size: 12.5px; }
+ .map-slider { display: grid; grid-template-columns: 90px minmax(0, 1fr) 62px; align-items: center; gap: 10px; font-size: 14px; }
+ .map-slider input { width: 100%; accent-color: var(--accent); }
+ .map-slider output { text-align: right; font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; }
+ .map-toggle { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 14px; cursor: pointer; }
+ .map-toggle .check { grid-column: auto; grid-row: auto; }
+
+ .map-stick { justify-self: center; width: 150px; height: 150px; overflow: visible; }
+ .map-stick .well { fill: rgba(0, 0, 0, .35); stroke: var(--line); }
+ .map-stick .dead { fill: rgba(255, 208, 77, .1); stroke: rgba(255, 208, 77, .6); stroke-dasharray: 3 3; }
+ .map-stick .cross { stroke: var(--line-soft); }
+ .map-stick .raw { fill: var(--faint); }
+ .map-stick .out { fill: var(--accent); stroke: #fff; stroke-width: 1.5; }
+ .map-curve { justify-self: start; width: 110px; height: 110px; padding: 6px; border-radius: 6px; background: rgba(0, 0, 0, .3); overflow: visible; }
+ .map-curve .grid { fill: none; stroke: var(--line); stroke-dasharray: 3 3; }
+ .map-curve .line { fill: none; stroke: var(--accent); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
+
+ .map-bar { position: relative; height: 14px; border-radius: 7px; background: rgba(0, 0, 0, .35); box-shadow: inset 0 0 0 1px var(--line-soft); overflow: hidden; }
+ .map-bar.wide { height: 18px; border-radius: 9px; overflow: visible; }
+ .map-bar.wide::after { content: ""; position: absolute; left: 50%; top: -3px; bottom: -3px; width: 1px; background: var(--line); }
+ .map-bar-fill { position: absolute; inset: 0 auto 0 0; width: 0; background: var(--accent); }
+ .map-bar-dot { position: absolute; top: 50%; left: 50%; width: 16px; height: 16px; margin: -8px 0 0 -8px; border-radius: 50%; background: #fff; box-shadow: 0 0 12px var(--accent); }
+
+ .map-inputs { display: grid; gap: 10px; }
+ .in-head { margin: 6px 0 0; font-size: 13px; font-weight: 400; color: var(--muted); letter-spacing: .03em; }
+ .in-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; }
+
+ .in-tile, .in-axis {
+ position: relative;
+ border: 0;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, .06);
+ box-shadow: inset 0 0 0 1px var(--line-soft);
+ text-align: left;
+ overflow: hidden;
+ transition: box-shadow .15s, transform .1s;
+ }
+
+ .in-tile:hover, .in-axis:hover { box-shadow: var(--glow); }
+ .in-tile:active, .in-axis:active { transform: scale(.98); }
+ .in-tile { display: grid; align-content: end; gap: 2px; min-height: 66px; padding: 8px; }
+ .in-tile.on { box-shadow: inset 0 0 0 2px var(--accent), 0 0 16px -2px var(--accent); }
+ .in-fill { position: absolute; left: 0; right: 0; bottom: 0; height: 0; background: color-mix(in srgb, var(--accent) 35%, transparent); }
+ .in-name { position: relative; font-size: 15px; }
+ .in-use { position: relative; font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+ .in-axes { display: grid; gap: 8px; }
+ .in-axis { display: grid; grid-template-columns: minmax(120px, 190px) minmax(0, 1fr) 52px; grid-template-areas: "name bar value" "use use use"; align-items: center; gap: 4px 12px; padding: 10px 12px; }
+ .in-axis .in-name { grid-area: name; font-size: 14px; }
+ .in-axis .in-use { grid-area: use; }
+ .in-bar { grid-area: bar; position: relative; height: 10px; border-radius: 5px; background: rgba(0, 0, 0, .35); }
+ .in-bar::after { content: ""; position: absolute; left: 50%; top: -3px; bottom: -3px; width: 1px; background: var(--line); }
+ .in-dot { position: absolute; top: 50%; left: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 8px var(--accent); }
+ .in-value { grid-area: value; text-align: right; font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; }
+ .in-axis.outside .in-dot { background: var(--faint); box-shadow: none; }
+ .in-tile.missing, .in-axis.missing { opacity: .4; }
+
+ .map-list { display: none; gap: 6px; margin-top: 10px; }
+ .map-list-item { display: flex; justify-content: space-between; gap: 10px; padding: 10px 12px; border: 0; border-radius: 5px; background: rgba(255, 255, 255, .06); color: var(--text); font-size: 14px; text-align: left; }
+ .map-list-item.sel { box-shadow: var(--glow); background: rgba(255, 255, 255, .14); }
+ .map-list-tag { color: var(--muted); font-variant-numeric: tabular-nums; }
+ @media (max-width: 700px) { .map-chips { display: none; } .map-list { display: grid; } }
+
+ .map-status { margin: 0; padding: 6px max(22px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(22px, env(safe-area-inset-left)); min-height: 2.8em; font-size: 14px; color: var(--muted); }
+
+ @media (max-width: 640px) {
+ .map-profile { width: 100%; }
+ .map-label { flex: 1 1 140px; }
+ .map-profile .map-label .field { width: 100%; }
+ .map-save { width: 100%; justify-content: flex-end; padding-top: 0; }
+ .in-axis { grid-template-columns: minmax(0, 1fr) 52px; grid-template-areas: "name value" "bar bar" "use use"; }
+ }
@media (max-width: 760px) {
.settings-body { grid-template-columns: 1fr; gap: 10px; }
@@ -1011,10 +1258,10 @@
Several gamepads can share one controller. Keep this window active while playing.Gamepads on this device
+
Choose what each button and stick on a gamepad does. Mappings are saved in this browser, for every gamepad of the same kind.
+Connect a gamepad to this device and press a button on it. If it never shows up, this browser only allows gamepads on secure pages: save this page, open the saved file, and enter the console's address.
+Saved on this phone or computer only.
@@ -1142,15 +1401,56 @@Click a key to change it, then press the new key. Keys drive the controller opened on this page.
- - +Keys drive the controller open on this page. Choose them on a picture of the controller: click a control, then press its key.
+