diff --git a/crates/beaver_hal/src/brightness/linux.rs b/crates/beaver_hal/src/brightness/linux.rs
index fa7ffc4..97c812e 100644
--- a/crates/beaver_hal/src/brightness/linux.rs
+++ b/crates/beaver_hal/src/brightness/linux.rs
@@ -40,7 +40,7 @@ impl BrightnessDevice for Device {
.method_call(
"org.freedesktop.login1.Session",
"SetBrightness",
- (self.kind.to_string(), self.name.as_str(), value),
+ (self.kind.subsystem(), self.name.as_str(), value),
)
.map_err(Box::new)?)
}
diff --git a/crates/beaver_hal/src/brightness/mod.rs b/crates/beaver_hal/src/brightness/mod.rs
index af8156f..2d73ff8 100644
--- a/crates/beaver_hal/src/brightness/mod.rs
+++ b/crates/beaver_hal/src/brightness/mod.rs
@@ -8,16 +8,30 @@ pub enum DeviceKind {
Backlight,
}
+/// Renders the singular device kind, e.g. "led" - a `DeviceKind` describes one
+/// device. For the logind subsystem name (plural "leds"), which is what
+/// `Session.SetBrightness` expects on the wire, use [`DeviceKind::subsystem`].
impl Display for DeviceKind {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), Error> {
match self {
- DeviceKind::Led => fmt.write_str("leds"),
+ DeviceKind::Led => fmt.write_str("led"),
DeviceKind::Backlight => fmt.write_str("backlight"),
}?;
Ok(())
}
}
+impl DeviceKind {
+ /// The logind/sysfs subsystem this kind lives under ("leds", "backlight").
+ /// Note the plural for LEDs: this is the wire name, not the device kind.
+ pub fn subsystem(&self) -> &'static str {
+ match self {
+ DeviceKind::Led => "leds",
+ DeviceKind::Backlight => "backlight",
+ }
+ }
+}
+
impl Copy for DeviceKind {}
/// Representation of a device for which the brightness can be controlled.
diff --git a/patches/components/script/dom/brightnessdevice.rs.patch b/patches/components/script/dom/brightnessdevice.rs.patch
index d3c6cef..3b69775 100644
--- a/patches/components/script/dom/brightnessdevice.rs.patch
+++ b/patches/components/script/dom/brightnessdevice.rs.patch
@@ -1,6 +1,6 @@
--- original
+++ modified
-@@ -0,0 +1,126 @@
+@@ -0,0 +1,133 @@
+/* SPDX Id: AGPL-3.0-or-later */
+
+//! A single brightness-controllable device (screen backlight, keyboard LED),
@@ -17,7 +17,9 @@
+use script_bindings::reflector::{Reflector, reflect_dom_object_with_cx};
+use servo_constellation_traits::{BrightnessDeviceInfo, ScriptToConstellationMessage};
+
-+use crate::dom::bindings::codegen::Bindings::PowerBinding::BrightnessDeviceMethods;
++use crate::dom::bindings::codegen::Bindings::PowerBinding::{
++ BrightnessDeviceKind, BrightnessDeviceMethods,
++};
+use crate::dom::bindings::reflector::DomGlobal;
+use crate::dom::bindings::root::DomRoot;
+use crate::dom::globalscope::GlobalScope;
@@ -30,7 +32,7 @@
+ /// Index of this device in the constellation's device list.
+ id: u32,
+ name: String,
-+ kind: String,
++ kind: BrightnessDeviceKind,
+ is_on_off: bool,
+ /// Last known brightness in percent: refreshed when the device is enumerated
+ /// and updated optimistically on write, so reads never block.
@@ -43,7 +45,12 @@
+ _reflector: Reflector::new(),
+ id: info.id,
+ name: info.name,
-+ kind: info.kind,
++ // The HAL is the only producer of this string; anything unexpected
++ // is treated as an LED rather than claiming to be the screen.
++ kind: match info.kind.as_str() {
++ "backlight" => BrightnessDeviceKind::Backlight,
++ _ => BrightnessDeviceKind::Led,
++ },
+ is_on_off: info.is_on_off,
+ percent: Cell::new(info.percent),
+ }
@@ -102,8 +109,8 @@
+ DOMString::from(self.name.clone())
+ }
+
-+ fn Kind(&self) -> DOMString {
-+ DOMString::from(self.kind.clone())
++ fn Kind(&self) -> BrightnessDeviceKind {
++ self.kind
+ }
+
+ fn IsOnOff(&self) -> bool {
diff --git a/patches/components/script_bindings/webidls/Power.webidl.patch b/patches/components/script_bindings/webidls/Power.webidl.patch
index abe0d3b..a9d0df0 100644
--- a/patches/components/script_bindings/webidls/Power.webidl.patch
+++ b/patches/components/script_bindings/webidls/Power.webidl.patch
@@ -1,10 +1,13 @@
--- original
+++ modified
-@@ -0,0 +1,38 @@
+@@ -0,0 +1,40 @@
+/* SPDX Id: AGPL-3.0-or-later */
+
+// Control of the device's brightness-capable hardware (screen backlight, keyboard LEDs).
+
++// The kind of hardware a brightness device drives.
++enum BrightnessDeviceKind { "backlight", "led" };
++
+[Exposed=Window,
+Func="Embedder::is_allowed_to_embed"]
+interface BrightnessDevice {
@@ -20,8 +23,7 @@
+ // The platform name of the device, e.g. "intel_backlight".
+ readonly attribute DOMString name;
+
-+ // "backlight" or "led".
-+ readonly attribute DOMString kind;
++ readonly attribute BrightnessDeviceKind kind;
+
+ // True when the device only supports 0% and 100%.
+ readonly attribute boolean isOnOff;
diff --git a/ui/shared/dbus/network_manager.js b/ui/shared/dbus/network_manager.js
index 004b7e0..dd403dd 100644
--- a/ui/shared/dbus/network_manager.js
+++ b/ui/shared/dbus/network_manager.js
@@ -549,6 +549,16 @@ export class WifiDevice extends EventTarget {
return this._activePath ? this._aps.get(this._activePath) || null : null;
}
+ /**
+ * Resolve and return the AP we are associated with, without starting
+ * monitoring. For compact surfaces (a quick-settings tile) that want the
+ * current SSID but must not pay for a GetAll per visible network.
+ */
+ async currentAccessPoint() {
+ await this._resolveActive();
+ return this.activeAccessPoint;
+ }
+
/**
* What a picker renders: strongest AP per SSID, excluding the active one and
* hidden (empty-SSID) networks, strongest first. Recompute on
diff --git a/ui/shared/power/brightness.js b/ui/shared/power/brightness.js
index 79a4aa7..04a4042 100644
--- a/ui/shared/power/brightness.js
+++ b/ui/shared/power/brightness.js
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-// Screen brightness, on top of `navigator.embedder.power`
+// Screen brightness, on top of `navigator.embedder.power`
// The chrome controls the first available backlight, so this
// module resolves that device once and exposes a small surface the UI can bind to.
@@ -69,3 +69,72 @@ class ScreenBrightness {
}
export const screenBrightness = new ScreenBrightness();
+
+// Names that suggest a camera flash / torch LED rather than a notification,
+// charging or keyboard-backlight LED. Matched case-insensitively against the
+// sysfs node name (e.g. "white:flash", "torch-light0").
+const TORCH_HINTS = ["torch", "flash"];
+
+/**
+ * The camera torch, when the device exposes one.
+ *
+ * LEDs come from the same `power.devices()` list as the backlight, under
+ * `kind === "led"`. Several unrelated LEDs live there - charging, notification,
+ * keyboard backlight - so a name hint picks the flash node, and we deliberately
+ * expose nothing when none matches: silently toggling a charging indicator
+ * would be worse than having no torch button.
+ *
+ * Level count is not used to identify one: real torches range from a plain
+ * on/off node to 255 levels. Toggling drives it to full or off.
+ */
+class Torch {
+ #ready = null;
+
+ /** Resolve (once) the torch LED, or null when the device has none. */
+ async device() {
+ if (!this.#ready) {
+ this.#ready = this.#resolve();
+ }
+ return this.#ready;
+ }
+
+ async #resolve() {
+ const api = powerApi();
+ if (!api) {
+ return null;
+ }
+ try {
+ const devices = await api.devices();
+ // The name is what identifies a torch; `isOnOff` is deliberately NOT
+ // required, because plenty of flash LEDs expose several levels rather
+ // than a plain on/off (max_brightness > 1). Toggling drives it to full.
+ return (
+ devices.find((device) => {
+ if (device.kind !== "led") {
+ return false;
+ }
+ const name = device.name.toLowerCase();
+ return TORCH_HINTS.some((hint) => name.includes(hint));
+ }) ?? null
+ );
+ } catch (error) {
+ console.warn("[torch] Failed to list power devices:", error);
+ return null;
+ }
+ }
+
+ /** True when the torch is currently lit, or false when there is none. */
+ async isOn() {
+ const device = await this.device();
+ return device ? device.brightness > 0 : false;
+ }
+
+ async set(on) {
+ const device = await this.device();
+ if (device) {
+ device.brightness = on ? 100 : 0;
+ }
+ }
+}
+
+export const torch = new Torch();
diff --git a/ui/shared/power/brightness_slider.js b/ui/shared/power/brightness_slider.js
index c65f8e1..33eef6d 100644
--- a/ui/shared/power/brightness_slider.js
+++ b/ui/shared/power/brightness_slider.js
@@ -46,19 +46,34 @@ export class BrightnessSlider extends LitElement {
.glyph {
flex: none;
+ display: flex;
+ align-items: center;
color: var(--color-text-tertiary, #999);
- font-size: var(--font-size-sm, 13px);
- line-height: 1;
+ font-size: var(--font-size-lg, 17px);
}
+ /* The track is visually thin but the touch target must not be: the row is
+ padded out to a comfortable finger height and the whole of it is
+ draggable, with the thin bar drawn centred inside it. */
.track {
position: relative;
flex: 1;
+ height: 40px;
+ cursor: pointer;
+ touch-action: none;
+ }
+
+ /* The visible bar, centred in the tall hit area. */
+ .bar {
+ position: absolute;
+ top: 50%;
+ left: 0;
+ right: 0;
height: 6px;
+ transform: translateY(-50%);
border-radius: 3px;
background: var(--color-border, #444);
- cursor: pointer;
- touch-action: none;
+ overflow: hidden;
}
.fill {
@@ -67,11 +82,25 @@ export class BrightnessSlider extends LitElement {
background: var(--color-primary, #4a8);
}
+ /* The thumb travels along a rail inset by half its width at each end, so
+ it stays fully inside the track instead of overhanging (at 100% it used
+ to collide with the value label). Doing this in CSS rather than from a
+ measured width keeps it correct on the very first paint, before any
+ layout has been read. Inset must be half of .thumb's size. */
+ .thumb-rail {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 9px;
+ right: 9px;
+ pointer-events: none;
+ }
+
.thumb {
position: absolute;
top: 50%;
- width: 14px;
- height: 14px;
+ width: 18px;
+ height: 18px;
border-radius: 50%;
background: var(--color-primary, #4a8);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.4);
@@ -200,7 +229,9 @@ export class BrightnessSlider extends LitElement {
render() {
return html`
- ☀
+
+
+
-
-
+
+
+
+
+
+
${this.percent}%
diff --git a/ui/system/mobile/pebble.css b/ui/system/mobile/pebble.css
index 653567a..5f559bf 100644
--- a/ui/system/mobile/pebble.css
+++ b/ui/system/mobile/pebble.css
@@ -16,6 +16,10 @@
--pebble-morph-duration: 0.42s;
--pebble-side-margin: var(--spacing-xl);
--pebble-drag-x: 0px;
+ /* How far the pebble is lifted to make room for the quick-settings tray
+ hanging beneath it. Vertical analogue of --pebble-drag-x; updated live
+ during the pull-down drag. */
+ --pebble-tray-lift: 0px;
/* Touch target for the drag handle at the top of the glance / command
card. Tall enough to hit easily with a real finger; the visible bar
inside is small (see .handle::before). */
@@ -66,7 +70,9 @@
.pebble {
position: absolute;
left: 50%;
- bottom: calc(24px + var(--keyboard-offset, 0px));
+ bottom: calc(
+ 24px + var(--keyboard-offset, 0px) + var(--pebble-tray-lift, 0px)
+ );
width: 92px;
height: 44px;
border-radius: 22px 24px 22px 23px;
@@ -179,6 +185,49 @@
transition: transform 0s;
}
+/* ----- Quick-settings tray -----
+ A sibling of `.pebble` (which is overflow:hidden and would clip it). Its
+ bottom edge keeps the pebble's original 24px margin, so the controls sit in
+ the lowest, most thumb-reachable band while the pebble rides above them on
+ --pebble-tray-lift. Hidden until a pull-down starts. */
+
+pebble-tray {
+ position: absolute;
+ left: var(--pebble-side-margin);
+ right: var(--pebble-side-margin);
+ /* Rests at the pebble's own bottom margin. The gap between the two comes
+ from the pebble lifting by card height + gap, not from moving the tray up
+ as well (that would cancel out and leave them touching). */
+ bottom: calc(24px + var(--keyboard-offset, 0px));
+ visibility: hidden;
+ pointer-events: none;
+ /* Slid down out of view and revealed by translating up as the pull grows.
+ Deliberately NOT clipped with overflow/max-height: that would cut the
+ card's soft shadow off square and read as a hard border.
+ --pebble-tray-reveal runs 0 (hidden) -> tray height (fully out). */
+ transform: translateY(
+ calc(var(--pebble-tray-height, 0px) - var(--pebble-tray-reveal, 0px))
+ );
+ opacity: 0;
+ transition:
+ transform var(--pebble-morph-duration) var(--pebble-easing),
+ opacity var(--pebble-morph-duration) var(--pebble-easing);
+}
+
+pebble-tray.visible {
+ visibility: visible;
+ pointer-events: auto;
+ opacity: 1;
+}
+
+/* While the finger is down the tray tracks it 1:1, so the height transition
+ would only lag behind; it's re-enabled on release so the settle animates.
+ Same trick as `[data-dragging] .pebble` above. */
+:host([data-tray-dragging]) pebble-tray,
+:host([data-tray-dragging]) .pebble {
+ transition: none;
+}
+
.pebble-content {
position: absolute;
inset: 0;
@@ -810,19 +859,13 @@
color: var(--color-text);
}
-/* ----- Command sheet cards ----- */
+/* ----- Content blocker section ----- */
-.brightness-card,
.blocker-card {
background: var(--bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 12px 14px;
-}
-
-/* ----- Content blocker section ----- */
-
-.blocker-card {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
@@ -897,7 +940,8 @@
@media (prefers-reduced-motion: reduce) {
.pebble,
- .pebble-content {
+ .pebble-content,
+ pebble-tray {
transition-duration: 0.01s !important;
animation: none !important;
}
diff --git a/ui/system/mobile/pebble.js b/ui/system/mobile/pebble.js
index d0465f6..f01c4d9 100644
--- a/ui/system/mobile/pebble.js
+++ b/ui/system/mobile/pebble.js
@@ -9,9 +9,9 @@ import {
} from "beaver://shared/third_party/lit/lit-all.min.js";
import { SearchController } from "beaver://shared/search/controller.js";
-import { screenBrightness } from "beaver://shared/power/brightness.js";
import "beaver://shared/power/brightness_slider.js";
import "./pebble_radial.js";
+import "./pebble_tray.js";
import "./pebble_peek.js";
import "./pebble_results.js";
@@ -25,6 +25,15 @@ const T_COMMAND = 80;
const T_TAB_COMMIT = 40;
const T_LONGPRESS_MS = 480;
const T_DOCK_HMOVE = 18;
+// Pull-down tray: distance past which a release commits to open, and how far
+// the drag can run past the tray's height before it stops following 1:1.
+const T_TRAY_COMMIT = 24;
+const TRAY_RUBBER_BAND = 0.35;
+// Breathing room between the bottom of the pebble and the top of the tray, so
+// the two read as separate objects rather than one welded shape. The pebble
+// lifts by the card's height plus this; the card itself rests at the pebble's
+// normal bottom margin, and the difference is the visible gap.
+const TRAY_GAP = 10;
// Radial menu: zone set used on a regular webview
const RADIAL_ZONES_PAGE = [
@@ -77,8 +86,6 @@ export class MobilePebble extends LitElement {
blockerEnabled: { type: Boolean, state: true },
blockerCount: { type: Number, state: true },
blockerAllowed: { type: Number, state: true },
- // Screen brightness (hidden when the device has no controllable backlight)
- hasBrightness: { type: Boolean, state: true },
// Search results
results: { type: Array, state: true },
groups: { type: Array, state: true },
@@ -91,6 +98,17 @@ export class MobilePebble extends LitElement {
peekDirection: { type: Number, state: true },
// Inline pebble offset during dock-drag (px from current dock anchor)
dragOffset: { type: Number, state: true },
+ // Quick-settings tray: open state, and its live height during the pull-down
+ // drag. Orthogonal to STATE (like data-searching), not a fourth rung.
+ trayOpen: { type: Boolean, reflect: true, attribute: "data-tray" },
+ trayDrag: { type: Number, state: true },
+ // True only while the finger is down on a tray pull: suppresses the
+ // height/position transitions so the tray tracks the thumb exactly.
+ trayDragging: {
+ type: Boolean,
+ reflect: true,
+ attribute: "data-tray-dragging",
+ },
};
static styles = css`
@@ -119,7 +137,11 @@ export class MobilePebble extends LitElement {
this.blockerEnabled = true;
this.blockerCount = 0;
this.blockerAllowed = 0;
- this.hasBrightness = false;
+ this.trayOpen = false;
+ this.trayDrag = 0;
+ this.trayDragging = false;
+ // Measured from the tray element the first time it is pulled.
+ this.trayHeight = 0;
this.results = [];
this.groups = [];
@@ -161,10 +183,6 @@ export class MobilePebble extends LitElement {
this._boundViewScrollStart,
);
document.addEventListener("webview-scroll-end", this._boundViewScrollEnd);
- // `device()` resolves to null rather than rejecting when there's no backlight.
- screenBrightness.device().then((device) => {
- this.hasBrightness = !!device;
- });
}
disconnectedCallback() {
@@ -444,7 +462,8 @@ export class MobilePebble extends LitElement {
this.state = next;
if (next !== STATE.AMBIENT) {
this.updateState();
- } else {
+ }
+ if (next === STATE.AMBIENT) {
this.results = [];
this.groups = [];
this.searching = false;
@@ -462,7 +481,7 @@ export class MobilePebble extends LitElement {
return !!el.closest(
"button, input, a, .tab-chip, .nav-link, .tab-card, .notif-item, " +
"pebble-results, .blocker-toggle, .search-submit, label, media-control, " +
- "brightness-slider",
+ "brightness-slider, pebble-tray",
);
}
@@ -497,6 +516,7 @@ export class MobilePebble extends LitElement {
didLongPress: false,
isDocking: false,
isTabSwitching: false,
+ isTraying: false,
peekDirection: 0,
longPressTimer: null,
};
@@ -557,6 +577,32 @@ export class MobilePebble extends LitElement {
return;
}
+ // Vertical pull down in ambient → quick-settings tray, tracking the finger.
+ // Only downward, and only from ambient: down from glance/command already
+ // means "collapse". Skipped when there is no hardware to show, and when the
+ // tray is already out (a second pull-down closes it, handled on release, so
+ // there is nothing to track here).
+ if (
+ this._g.axis === "y" &&
+ this._g.startState === STATE.AMBIENT &&
+ !this._g.didLongPress &&
+ !this.trayOpen &&
+ dy > 0
+ ) {
+ if (!this._g.isTraying) {
+ if (!this._trayHasContent()) {
+ return;
+ }
+ this._g.isTraying = true;
+ this.trayDragging = true;
+ this._measureTray();
+ }
+ const max = this.trayHeight;
+ // Follow 1:1 up to the tray's height, then resist rather than overshoot.
+ this.trayDrag = dy <= max ? dy : max + (dy - max) * TRAY_RUBBER_BAND;
+ return;
+ }
+
// Horizontal pan in ambient → tab switch peek. Once a long-press has
// fired, the gesture is committed to the radial / dock-drag flow, so
// never start a peek from here.
@@ -619,10 +665,28 @@ export class MobilePebble extends LitElement {
return;
}
+ // Tray pull-down finalize: commit past the threshold, else spring back.
+ if (this._g.isTraying) {
+ this.trayDragging = false;
+ if (dy > T_TRAY_COMMIT) {
+ this._openTray();
+ } else {
+ this._closeTray();
+ }
+ this._g = null;
+ return;
+ }
+
// Vertical pan release
if (this._g.axis === "y") {
if (this._g.startState === STATE.AMBIENT) {
- if (dy < -T_COMMAND) this._setState(STATE.COMMAND);
+ // With the tray open the gesture acts on the tray, not the ladder:
+ // it's what's on screen. Either direction puts it away - down because
+ // the tray hangs below the pebble, up because that's the reverse of
+ // the pull that opened it.
+ if (this.trayOpen) {
+ if (Math.abs(dy) > T_TRAY_COMMIT) this._closeTray();
+ } else if (dy < -T_COMMAND) this._setState(STATE.COMMAND);
else if (dy < -T_GLANCE) this._setState(STATE.GLANCE);
} else if (this._g.startState === STATE.GLANCE) {
if (dy < -T_COMMAND) this._setState(STATE.COMMAND);
@@ -637,7 +701,8 @@ export class MobilePebble extends LitElement {
// Tap (no pan, no long-press)
if (!this._g.didPan && !this._g.didLongPress) {
- if (this.state === STATE.AMBIENT) this._setState(STATE.GLANCE);
+ if (this.trayOpen) this._closeTray();
+ else if (this.state === STATE.AMBIENT) this._setState(STATE.GLANCE);
else if (this.state === STATE.GLANCE) this._setState(STATE.AMBIENT);
else if (this.state === STATE.COMMAND) this._setState(STATE.GLANCE);
}
@@ -653,6 +718,11 @@ export class MobilePebble extends LitElement {
this.dragging = false;
this.dragOffset = 0;
if (this._g.longPressTimer) clearTimeout(this._g.longPressTimer);
+ if (this._g.isTraying) {
+ // Abandoned mid-pull: settle to whichever end we were nearest.
+ if (this.trayDrag > T_TRAY_COMMIT) this._openTray();
+ else this._closeTray();
+ }
this._hidePeek();
this._g = null;
}
@@ -660,7 +730,7 @@ export class MobilePebble extends LitElement {
// ----- Document-level handlers -----
_onDocumentPointerDown(e) {
- if (this.state === STATE.AMBIENT) {
+ if (this.state === STATE.AMBIENT && !this.trayOpen) {
return;
}
// Use composedPath so we can detect taps that fall outside the shadow root
@@ -668,7 +738,12 @@ export class MobilePebble extends LitElement {
if (path.includes(this)) {
return;
}
- this._setState(STATE.AMBIENT);
+ if (this.trayOpen) {
+ this._closeTray();
+ }
+ if (this.state !== STATE.AMBIENT) {
+ this._setState(STATE.AMBIENT);
+ }
}
_onDocumentKeyDown(e) {
@@ -676,6 +751,7 @@ export class MobilePebble extends LitElement {
return;
}
if (this.radialOpen) this._closeRadial();
+ else if (this.trayOpen) this._closeTray();
else if (this.state !== STATE.AMBIENT) this._setState(STATE.AMBIENT);
}
@@ -709,6 +785,51 @@ export class MobilePebble extends LitElement {
this._dispatchAction(e.detail.action);
}
+ // ----- Quick-settings tray (pull down from ambient) -----
+
+ _trayElement() {
+ return this.renderRoot?.querySelector("pebble-tray") ?? null;
+ }
+
+ /** True when the tray has at least one control worth showing. */
+ _trayHasContent() {
+ return !!this._trayElement()?.hasContent?.();
+ }
+
+ /**
+ * Natural tray height, measured once it has rendered. Measured lazily rather
+ * than hard-coded because the tray shrinks when hardware is missing.
+ */
+ _measureTray() {
+ const tray = this._trayElement();
+ if (!tray) {
+ return 0;
+ }
+ const height = tray.naturalHeight?.() ?? 0;
+ if (height) {
+ // The pebble has to clear the card plus the gap between them.
+ this.trayHeight = height + TRAY_GAP;
+ }
+ return this.trayHeight;
+ }
+
+ _openTray() {
+ if (!this._trayHasContent()) {
+ return;
+ }
+ this.trayDrag = 0;
+ this.trayOpen = true;
+ // Re-read the hardware: brightness may have changed from the function keys
+ // or another tool since the tray was last shown.
+ this._trayElement()?.refresh?.();
+ }
+
+ _closeTray() {
+ this.trayOpen = false;
+ this.trayDrag = 0;
+ this.trayDragging = false;
+ }
+
// ----- Peek (tab-switch preview) -----
_showPeek(direction) {
@@ -934,10 +1055,17 @@ export class MobilePebble extends LitElement {
// ----- Render -----
render() {
- const dragStyle =
+ // The pebble rises by the tray's live height so the tray can hang beneath
+ // it; --pebble-tray-lift is the vertical analogue of --pebble-drag-x.
+ const lift = this.trayOpen ? this.trayHeight : this.trayDrag;
+ const dragStyle = [
this.dragging && this.dragOffset
? `--pebble-drag-x: ${this.dragOffset}px`
- : "";
+ : "",
+ lift ? `--pebble-tray-lift: ${lift}px` : "",
+ ]
+ .filter(Boolean)
+ .join("; ");
return html`
- ${this.radialOpen ? this._renderRadial() : ""} ${this._renderPeek()}
+ ${this._renderTray()} ${this.radialOpen ? this._renderRadial() : ""}
+ ${this._renderPeek()}
`;
}
+ // The tray is a sibling of `.pebble`, not a child: `.pebble` is
+ // `overflow: hidden`, which would clip it (the radial does the same).
+ // It stays mounted so its hardware probe runs once and `hasContent()` is
+ // ready before the first pull-down; CSS hides it while closed.
+ _renderTray() {
+ const lift = this.trayOpen ? this.trayHeight : this.trayDrag;
+ // The card's own height, i.e. how far it travels to be fully revealed. The
+ // pebble lifts by this plus TRAY_GAP, and that difference is the visible
+ // gap between the two. Clamped so the rubber-banded lift can't slide the
+ // card past its resting place.
+ const card = Math.max(0, this.trayHeight - TRAY_GAP);
+ const reveal = Math.min(card, Math.max(0, lift));
+ const style = [
+ `--pebble-tray-height: ${card}px`,
+ reveal ? `--pebble-tray-reveal: ${reveal}px` : "",
+ ]
+ .filter(Boolean)
+ .join("; ");
+ return html``;
+ }
+
_renderAmbient() {
const active = this.state === STATE.AMBIENT;
const notifCount = this.notifications?.length || 0;
@@ -1181,16 +1334,6 @@ export class MobilePebble extends LitElement {