diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index 300bc243..b6b09ab0 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -53,26 +53,24 @@ struct GamepadAddHostView: View { } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { - VStack(spacing: gamepadHeaderSpacing(compact: compact)) { + VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) { + // Leading, like every gamepad heading — and no close chrome (B is the exit). Text("Add Host") .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(ink.fg) - .frame(maxWidth: .infinity) - // On the title row itself (not the header block) so it rides the title's own - // top padding — the same anchoring the settings screen's close button uses. - .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } if !compact { Text("Hosts on this network appear automatically — add one by address " + "for everything else.") .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption)) .foregroundStyle(ink.fg(0.55)) - .multilineTextAlignment(.center) - .frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72) + .multilineTextAlignment(.leading) + .frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading) } } + .padding(.horizontal, 24) .padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, alignment: .leading) .background { GamepadTrayScrim(edge: .top) } } .safeAreaInset(edge: .bottom, spacing: 0) { @@ -95,6 +93,18 @@ struct GamepadAddHostView: View { .onChange(of: port) { _, value in if value.count > 5 { port = String(value.prefix(5)) } } + #if !os(tvOS) + // The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware + // keyboard's Esc and the macOS sheet's cancel working without chrome. + .background { + Button("Cancel") { performClose() } + .keyboardShortcut(.cancelAction) + .buttonStyle(.plain) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + #endif #if os(tvOS) // tvOS types with the SYSTEM fullscreen keyboard (TVTextEntry) instead of the custom // tray — the remote and the pad both drive it natively. Same `editing` state as the @@ -161,24 +171,6 @@ struct GamepadAddHostView: View { if let close { close() } else { dismiss() } } - /// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc - /// rides the cancel action. - private var closeButton: some View { - Button { performClose() } label: { - Image(systemName: "xmark") - .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) - .foregroundStyle(ink.fg) - .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) - .consoleGlassBackground(Circle(), interactive: true) - .contentShape(Circle()) - } - .buttonStyle(.plain) - #if !os(tvOS) - .keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there) - #endif - .accessibilityLabel("Cancel") - } - // MARK: - Rows private struct Row: Identifiable { diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index 995abd16..8391a3b1 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -32,7 +32,7 @@ func gamepadTitleTopPadding(compact: Bool) -> CGFloat { #elseif os(tvOS) 24 #else - compact ? 10 : 18 + compact ? 18 : 28 #endif } @@ -57,12 +57,13 @@ func gamepadHeaderSpacing(compact: Bool) -> CGFloat { } /// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the -/// in-hand compact-aware sizes elsewhere. +/// in-hand compact-aware sizes elsewhere. Sized as a proper screen heading — the field verdict +/// on the smaller first cut was "way too small" once the title moved off-centre. func gamepadTitleSize(compact: Bool) -> CGFloat { #if os(tvOS) 44 #else - compact ? 20 : 30 + compact ? 24 : 34 #endif } @@ -82,8 +83,6 @@ enum GamepadFormMetrics { static let rowCorner: CGFloat = 18 static let rowMaxWidth: CGFloat = 920 static let detailFont: CGFloat = 19 - static let closeFont: CGFloat = 20 - static let closeSide: CGFloat = 48 static let bandWidth: CGFloat = 380 #else static let headerFont: CGFloat = 12 @@ -97,8 +96,6 @@ enum GamepadFormMetrics { static let rowCorner: CGFloat = 14 static let rowMaxWidth: CGFloat = 620 static let detailFont: CGFloat = 13 - static let closeFont: CGFloat = 14 - static let closeSide: CGFloat = 34 /// The option band's (GamepadOptionBand) fixed stage inside a choice row. static let bandWidth: CGFloat = 240 #endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 370d20d9..8c7a6eef 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -124,6 +124,11 @@ struct GamepadHomeView: View { #if os(iOS) if let screen = topScreen { screenLayer(screen) + // Resolve the screen's internal layout (safe-area trays, the scrims' + // `.ignoresSafeArea()` full-bleed) BEFORE the insertion animates: without + // this the tray blurs entered at their content bounds and visibly grew to + // the screen edges mid-push. + .geometryGroup() .zIndex(1) .id(screen.id) .transition(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact))) @@ -322,32 +327,27 @@ struct GamepadHomeView: View { // MARK: - Chrome private var titleBar: some View { - // The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone - // it sat directly on top of the centred title ("Select a Host" ran straight into the pad - // name). Laying it out as a row with a hidden mirror on the leading side keeps the title - // optically centred AND clear of the chip at every width; the title shrinks a little - // before it would ever truncate. + // Leading title (a console heading, not a floating label — field ask), chip trailing. + // The old hidden-mirror trick existed only to keep a CENTRED title clear of the chip; + // a leading title needs none of it — the flexible frame keeps the two apart, and the + // title shrinks a little before it would ever truncate. HStack(spacing: 12) { - statusChip(hidden: true) Text("Select a Host") .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(ink.fg) .lineLimit(1) .minimumScaleFactor(0.75) - .frame(maxWidth: .infinity) - statusChip(hidden: false) + .frame(maxWidth: .infinity, alignment: .leading) + statusChip } - .padding(.horizontal, 20) + .padding(.horizontal, 24) } /// Which pad is driving this UI (name + battery) — quiet, and only where there's room; a - /// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same - /// chip purely as a width reserve. - @ViewBuilder private func statusChip(hidden: Bool) -> some View { + /// compact-height phone gives the pixels to the carousel instead. + @ViewBuilder private var statusChip: some View { if !compact, let active = gamepads.active { ControllerStatusChip(controller: active) - .opacity(hidden ? 0 : 1) - .accessibilityHidden(hidden) } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift index 67ba4a44..8402e890 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift @@ -27,35 +27,29 @@ struct GamepadLibraryScreen: View { store: store, host: host, onLaunch: onLaunch, onClose: close, controllerActive: controllerActive) .safeAreaInset(edge: .top, spacing: 0) { + // Leading, like every gamepad heading — no close chrome, B is the exit (the + // coverflow's, or LibraryView's own back-catcher before the coverflow exists). Text("\(host.displayName) — Library") .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(ink.fg) .lineLimit(1) .minimumScaleFactor(0.75) - .frame(maxWidth: .infinity) - .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) .padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) .background { GamepadTrayScrim(edge: .top) } } + // A hardware keyboard's Esc still closes, without chrome. + .background { + Button("Close") { close() } + .keyboardShortcut(.cancelAction) + .buttonStyle(.plain) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } .gamepadPaletteInk() } - - /// Touch/click fallback for closing — the controller path is B (the coverflow's onDismiss), - /// and it also covers the loading/error/empty states, which the coverflow (and its B) never - /// mounts under. A hardware keyboard's Esc rides the cancel action. - private var closeButton: some View { - Button { close() } label: { - Image(systemName: "xmark") - .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) - .foregroundStyle(ink.fg) - .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) - .consoleGlassBackground(Circle(), interactive: true) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .keyboardShortcut(.cancelAction) - .accessibilityLabel("Close library") - } } #endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index f02b2544..13efdf4e 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -65,6 +65,17 @@ struct LibraryView: View { imageSession?.finishTasksAndInvalidate() imageSession = nil } + #if os(iOS) || os(macOS) + // B closes the library even before the coverflow exists (loading / error / empty): + // the coverflow's carousel owns B once games render; until then this zero-size + // listener does — without it a controller-only user is trapped on an error screen + // (the gamepad screens carry no close chrome). + .background { + if gamepadUIActive && games.isEmpty { + LibraryBackCatcher(active: controllerActive) { (onClose ?? { dismiss() })() } + } + } + #endif } @ViewBuilder private var content: some View { @@ -210,6 +221,30 @@ struct LibraryView: View { } } +#if os(iOS) || os(macOS) +/// Zero-size controller listener for the library's pre-coverflow states — B backs out. The same +/// shape as ConnectOverlay's `ConnectControllerInput`; `GamepadMenuInput.needsSnapshot` swallows +/// the held press that opened the screen. Unmounts the moment the coverflow (and its own B) is up. +private struct LibraryBackCatcher: View { + let active: Bool + let onBack: () -> Void + @State private var input = GamepadMenuInput(manager: .shared) + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .onAppear { + input.onBack = onBack + if active { input.start() } + } + .onChange(of: active) { _, nowActive in + if nowActive { input.start() } else { input.stop() } + } + .onDisappear { input.stop() } + } +} +#endif + /// One poster tile. Steam vs custom is marked with a badge; the art walks the candidate URLs /// (portrait → header → hero) and finally a text placeholder. private struct GameCard: View { diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift index b4397d32..ad43ad23 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift @@ -1,10 +1,19 @@ -// The gamepad settings' "select" value as a REAL band: every option sits on a drum rotating -// about a vertical axis — the current one faces you flat, its neighbours curve away with -// perspective, shrinking and fading toward the edges. The old presentation animated a single -// Text keyed by its value (an old-out/new-in crossfade that merely implied motion), which fell -// apart under fast repeated steps: each press restarted the fade. Here the drum's position is -// one continuous value driven by a spring, and SwiftUI's spring retargeting preserves velocity — -// rapid presses accumulate into one accelerating spin instead of five restarted crossfades. +// The gamepad settings' "select" value as a REAL band: the options sit side by side on a drum +// segment curving about a vertical axis — the current one faces you flat, and a step rotates the +// next one in with perspective. The old presentation animated a single Text keyed by its value +// (an old-out/new-in crossfade that merely implied motion), which fell apart under fast repeated +// steps: each press restarted the fade. Here the drum's position is one continuous value driven +// by a spring, and SwiftUI's spring retargeting preserves velocity — rapid presses accumulate +// into one accelerating travel instead of five restarted crossfades. +// +// The band is LINEAR, not a ring (field verdict on the first cut): a ring showed the first +// option waiting to the right of the last one, which left/right can't reach (adjust clamps) — +// a promise the navigation doesn't keep. And on a 2-option ring the unselected option flipped +// sides with every step. So positions are fixed: option i sits i steps from the start, the ends +// are the ends, and A's wrap from the last option travels BACK across the list to the first. +// Options other than the facing one exist only while the drum is actually moving — at rest a row +// shows exactly its value (a resting neighbour under a long label rendered as overlapping, +// unreadable text). // // The band is purely presentational: stepping semantics (left/right clamps with a boundary thud, // A cycles forward wrapping, disabled rows refuse input) stay in GamepadSettingsView's row @@ -28,9 +37,8 @@ struct GamepadOptionBand: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion - /// Where the drum rests, in option steps — UNBOUNDED: a forward wrap keeps adding 1, never - /// modded back, so the ring distance below is what brings option 0 around from the right. - /// Rendering only ever reads it modulo `options.count`. + /// Where the drum rests, in option steps — always chasing `Double(selection)`; only the + /// spring's interpolation ever puts it between integers. @State private var drumPosition: Double init(options: [String], selection: Int, focused: Bool, width: CGFloat) { @@ -56,7 +64,6 @@ struct GamepadOptionBand: View { Drum( options: options, rotation: drumPosition, - neighborGate: focused ? 1 : 0, target: drumPosition, // Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge. radius: width * 0.72) @@ -77,8 +84,8 @@ struct GamepadOptionBand: View { } .onChange(of: selection) { old, new in step(from: old, to: new) } // The options list itself can mutate under the drum (a custom resolution appears, a - // controller connects, the buffer options re-derive from a new refresh rate) — the ring - // math is only valid while drumPosition ≡ selection (mod count), so re-seat without a spin. + // controller connects, the buffer options re-derive from a new refresh rate) — re-seat + // without a travel. .onChange(of: options.count) { _, _ in snap() } // One element to VoiceOver — the neighbour texts are rendering, not content. .accessibilityElement(children: .ignore) @@ -89,22 +96,15 @@ struct GamepadOptionBand: View { options.indices.contains(selection) ? options[selection] : "" } - /// One step spins the drum; anything else (an external write from the touch settings, a - /// re-derived options list) re-seats it — a spin to a value the user didn't step to would - /// read as the UI acting on its own. + /// A step (or A's wrap — which on a linear band is a fast travel back to the start) springs + /// the drum; anything else (an external write from the touch settings, a re-derived options + /// list) re-seats it — a travel to a value the user didn't step to would read as the UI + /// acting on its own. private func step(from old: Int, to new: Int) { - let n = options.count - let raw = new - old - let delta: Int? = if n > 1 && old == n - 1 && new == 0 { - 1 // A's wrap from the last option: keep spinning FORWARD, the way the thumb pressed. - } else if abs(raw) == 1 { - raw - } else { - nil - } - guard let delta, !reduceMotion else { return snap() } + let wrapped = options.count > 1 && old == options.count - 1 && new == 0 + guard (abs(new - old) == 1 || wrapped), !reduceMotion else { return snap() } withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) { - drumPosition += Double(delta) + drumPosition = Double(new) } } @@ -117,51 +117,44 @@ struct GamepadOptionBand: View { /// The rotating drum itself. `Animatable` so SwiftUI re-evaluates the body with the INTERPOLATED /// rotation every frame of the spring — each option's offset/scale/opacity follows the real arc, -/// and options more than one step away genuinely enter and leave mid-spin. (A plain `.animation` -/// on independent modifiers can't do that: each modifier would lerp its own endpoints and the +/// and options along the travel genuinely enter and leave mid-flight. (A plain `.animation` on +/// independent modifiers can't do that: each modifier would lerp its own endpoints and the /// in-between options would never appear.) private struct Drum: View, Animatable { let options: [String] /// The interpolated drum position, in option steps. var rotation: Double - /// 1 while the row is focused — the resting drum shows its neighbours only under focus (an - /// unfocused row is one flat Text, visually and costwise what it was before the band). - var neighborGate: Double /// Where the spring is headed (jumps instantly on a step; only `rotation` chases it). The - /// distance between them is "how mid-flight are we" — it keeps the neighbours visible while - /// an unfocused drum finishes settling, fading them continuously as it lands. + /// distance between them is "how mid-flight are we" — the neighbours exist exactly as long + /// as the drum is moving, fading continuously as it lands, so a resting row is one flat + /// Text and a long label never sits under a resting neighbour. let target: Double /// Drum radius in points (from the band width — see the caller). let radius: Double - var animatableData: AnimatablePair { - get { AnimatablePair(rotation, neighborGate) } - set { - rotation = newValue.first - neighborGate = newValue.second - } + var animatableData: Double { + get { rotation } + set { rotation = newValue } } /// Angular pitch between adjacent options on the drum. private static let stepAngle = 34.0 * .pi / 180.0 var body: some View { - let n = options.count let flight = min(1, abs(rotation - target) * 3) let content = ZStack { - ForEach(0.. 0.001 && abs(d) <= 2.5) { + option(i, distance: d, gate: flight) } } } #if os(tvOS) - // Flatten the transform stack while spinning — the 10-foot GPU already made these rows - // drop Liquid Glass, and five projected texts per step is the same class of cost. + // Flatten the transform stack while travelling — the 10-foot GPU already made these + // rows drop Liquid Glass, and five projected texts per step is the same class of cost. content.drawingGroup() #else content @@ -171,7 +164,7 @@ private struct Drum: View, Animatable { @ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View { let angle = d * Self.stepAngle let depth = cos(angle) - // The facing option never gates: an unfocused row still shows its value. + // The facing option never gates: a resting row still shows its value. let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate) Text(options[i]) .lineLimit(1) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 6727577e..b044af86 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -144,12 +144,14 @@ struct GamepadSettingsView: View { } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { - VStack(spacing: gamepadHeaderSpacing(compact: compact)) { + VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) { + // Leading, like a console section heading — centred read as a floating label, + // and a gamepad UI needs no close chrome next to it (B is the exit). Text(title) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(ink.fg) - .frame(maxWidth: .infinity) - .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) // The picker is one layer deeper — its rows aren't sections of anything, so the // strip would be a control that does nothing while it's up. if pinTarget == nil { tabStrip } @@ -191,6 +193,18 @@ struct GamepadSettingsView: View { gamepads.startDiscovery() } .onDisappear { gamepads.stopDiscovery() } + #if !os(tvOS) + // The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware + // keyboard's Esc and the macOS sheet's cancel working without chrome. + .background { + Button("Close") { performClose() } + .keyboardShortcut(.cancelAction) + .buttonStyle(.plain) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + #endif } /// The section switcher. Horizontally scrollable so a narrow phone in landscape never has to @@ -294,24 +308,6 @@ struct GamepadSettingsView: View { if let close { close() } else { dismiss() } } - /// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc - /// rides the cancel action. - private var closeButton: some View { - Button { performClose() } label: { - Image(systemName: "xmark") - .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) - .foregroundStyle(ink.fg) - .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) - .consoleGlassBackground(Circle(), interactive: true) - .contentShape(Circle()) - } - .buttonStyle(.plain) - #if !os(tvOS) - .keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there) - #endif - .accessibilityLabel("Close settings") - } - /// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which /// layer the row list currently is. private var title: String { @@ -391,12 +387,10 @@ struct GamepadSettingsView: View { .font(.geist(m.valueFont, .medium, relativeTo: .callout)) .foregroundStyle(focused ? ink.fg : ink.fg(0.6)) } else { - // Toggles and the flat rows keep the quiet slip: keyed by the value so - // a change slides the new string in following the user's motion (a - // right-step enters from the right), crossfading over ~14 pt. The - // ZStack is the stable home the removed/inserted texts transition - // within. (A two-position switch on a drum would read as a coin flip — - // both sibling clients keep toggles a different control, too.) + // The flat rows (profile pin counts, placeholders) keep the quiet slip: + // keyed by the value so a change slides the new string in following the + // user's motion, crossfading over ~14 pt. The ZStack is the stable home + // the removed/inserted texts transition within. let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14 ZStack { Text(row.value) @@ -771,6 +765,8 @@ struct GamepadSettingsView: View { value: pinned ? "Pinned" : "Off", detail: "A pinned profile appears as its own card on the host — one press " + "connects with it.", + optionLabels: ["Off", "Pinned"], + selectedIndex: pinned ? 1 : 0, adjust: { delta in let target = delta > 0 guard pinned != target else { return false } @@ -868,6 +864,10 @@ struct GamepadSettingsView: View { id: id, tab: tab, icon: icon, label: label, value: value.wrappedValue ? "On" : "Off", detail: detail, + // Toggles ride the band too (field ask): Off sits left of On, matching the + // directional semantics below, so a right-step slides On in from the right. + optionLabels: ["Off", "On"], + selectedIndex: value.wrappedValue ? 1 : 0, enabled: enabled, adjust: { delta in // Directional semantics: left = off, right = on; a no-op reads as a boundary.