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 @@
@@ -1029,6 +1276,7 @@

Gamepads on this device

+
@@ -1061,7 +1309,11 @@
@@ -1131,9 +1383,16 @@
+
+ + +