Brand the panel: yellow IP/DNS line, white toggle

- Colour the IP / DNS subtitle under each machine with the logo yellow
  (#FAE204, sampled as the dominant opaque colour of offscale-logo.png).
  Stock draws that line through `dim` = Qt.darker(foreground, 1.55), which
  on yellow shifts the hue to olive (#A19203) rather than quietening it, so
  it takes the flat accent instead. Everything else keeps the themed
  foreground: section headers, machine names, account names and all the
  iconography.

- Add OffscaleToggle.qml so the switch reads white when on. The stock
  ToggleSwitch resolves its checked colours through
  Style.selectedStateColor / selectedFillFor, which read [controls]
  selected-color; Ethereal sets that to a literal "#ffcead" and
  Style.resolveStateColor returns literals verbatim, ignoring the
  foreground and accent properties entirely. No property on the stock
  switch can reach it, and overriding the token globally would recolour
  every selected state in the shell. The copy differs only in the checked
  state and needs an explicit `import qs.Ui`, which the original gets
  implicitly by living inside that module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 17:31:30 +01:00
co-authored by Claude Opus 5
parent b9bb75e8f5
commit ad997bd6b1
2 changed files with 142 additions and 2 deletions
+125
View File
@@ -0,0 +1,125 @@
// Local copy of qs.Ui.ToggleSwitch, changed in exactly one respect: the
// "on" colours come from `foreground` instead of the theme's selected-state
// tokens.
//
// Stock resolves them through Style.selectedStateColor / selectedFillFor,
// which read [controls] selected-color from the active theme. Ethereal sets
// that to a literal "#ffcead", and Style.resolveStateColor returns a literal
// verbatim — the `foreground` and `accent` properties are ignored entirely.
// So no property on the stock switch can make the checked knob white; only
// overriding [controls] selected-color globally would, and that would recolour
// every selected state across the shell (buttons, sliders, and the audio,
// bluetooth, network and clock panels).
//
import QtQuick
import qs.Commons
import qs.Ui // BorderSurface: implicit for the original inside qs.Ui, explicit here
// Bare on/off switch: a track with a sliding knob and no label. This is the
// switch `Toggle` parks at the end of its labeled row, factored out so panel
// headers and other compact controls render the identical thing.
//
// The caller owns the value: bind `checked` to real state and flip it in
// response to `toggled()`. Services that already track a desired state
// optimistically (see the Tailscale service's `_desired`) get an instant knob
// throw for free, because `checked` is already the optimistic value.
//
// `busy` marks an operation in flight and swallows further clicks, but leaves
// hover, cursor, and tooltips alone so the control does not flicker every time
// a background refresh runs.
//
// The cursor is a ring drawn outside the track rather than a state on the
// track itself: themes give normal chrome a stronger border than hover-cursor
// (0.4 vs 0.25 by default), which is right for controls that are borderless at
// rest but would make a bordered track go *fainter* under the cursor. On the
// panel background the ring reads immediately. `cursorRing` follows
// `interactive` — a switch whose surrounding row owns the click owns the
// cursor too.
//
// `rounded` auto-detects from Style.cornerRadius so the switch follows the
// theme: pill shape when Hyprland corners are rounded, square on sharp.
Item {
id: root
property bool checked: false
property bool busy: false
// Off when the surrounding row owns the click, as in `Toggle`.
property bool interactive: true
// Panel-cursor flag. Same role as Button.hasCursor: panels with their own
// keyboard cursor bind this to drive the highlight separately from hover.
property bool hasCursor: false
property bool cursorRing: interactive
property int cursorPad: Style.space(6)
property bool rounded: Style.cornerRadius > 0
property color foreground: Color.foreground
property color accent: Color.accent
signal toggled()
signal hovered(bool isHovered)
readonly property alias containsMouse: mouse.containsMouse
readonly property bool hot: hasCursor || mouse.containsMouse
// `trackHeight` is settable so a compact placement — a switch riding a panel
// section header, say — can ask for a genuinely smaller control instead of
// scaling a big one down, which lands the track and knob on fractional pixels
// and blurs their edges. The derived sizes only carry floors low enough to
// stay out of an override's way; at the default track height each one is
// already above its floor, so nothing about the normal switch changes.
property int trackHeight: Math.max(22, Math.round(Style.spacing.controlHeight * 0.55))
property int trackWidth: Math.round(trackHeight * 1.9)
property int knobSize: Math.max(6, Math.round(trackHeight * 0.72))
property int knobInset: Math.max(1, Math.round((trackHeight - knobSize) / 2))
readonly property int _pad: cursorRing ? cursorPad : 0
implicitWidth: trackWidth + _pad * 2
implicitHeight: trackHeight + _pad * 2
BorderSurface {
anchors.fill: parent
visible: root.cursorRing && root.hot
color: "transparent"
radius: Style.cornerRadius
borderSpec: Border.controlSpec("hover-cursor", root.foreground, root.accent)
}
BorderSurface {
id: track
width: root.trackWidth
height: root.trackHeight
anchors.centerIn: parent
radius: root.rounded ? height / 2 : 0
color: root.checked
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18)
: Style.normalFillFor(root.foreground, root.accent)
borderSpec: Border.controlSpec("normal", root.foreground, root.accent)
Behavior on color { ColorAnimation { duration: 120 } }
Rectangle {
width: root.knobSize
height: root.knobSize
radius: root.rounded ? height / 2 : 0
x: root.checked ? track.width - width - root.knobInset : root.knobInset
anchors.verticalCenter: parent.verticalCenter
color: root.checked ? root.foreground : Qt.darker(root.foreground, 1.25)
Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 120 } }
}
}
MouseArea {
id: mouse
anchors.fill: parent
enabled: root.interactive
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: root.hovered(containsMouse)
onClicked: if (!root.busy) root.toggled()
}
}
+17 -2
View File
@@ -23,9 +23,18 @@ Panel {
property bool mullvadPickerOpen: false property bool mullvadPickerOpen: false
property string mullvadQuery: "" property string mullvadQuery: ""
// Text stays on the bar's themed colour: section headers, machine names and
// account names read white.
readonly property color foreground: bar ? bar.foreground : Color.foreground readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property color urgent: bar ? bar.urgent : Color.urgent readonly property color urgent: bar ? bar.urgent : Color.urgent
readonly property color dim: Qt.darker(foreground, 1.55) readonly property color dim: Qt.darker(foreground, 1.55)
// Brand yellow, sampled as the dominant opaque colour of offscale-logo.png.
// Used for exactly one thing: the IP / DNS subtitle under each machine, at
// full strength. Stock renders that line through `dim`, but darkening yellow
// shifts its hue to olive (#A19203) rather than merely quietening it, so it
// gets the flat accent instead. Icons stay on the themed foreground.
readonly property color accent: "#FAE204"
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
readonly property bool showConnections: tailscale.accounts.length > 1 || tailscale.accountsAccessDenied readonly property bool showConnections: tailscale.accounts.length > 1 || tailscale.accountsAccessDenied
readonly property bool showPeers: tailscale.active && tailscale.peers.length > 0 readonly property bool showPeers: tailscale.active && tailscale.peers.length > 0
@@ -499,13 +508,19 @@ Panel {
// header's only cursor target. The service already flips `active` // header's only cursor target. The service already flips `active`
// optimistically, so the knob throws the instant you click it. // optimistically, so the knob throws the instant you click it.
trailingControl: Component { trailingControl: Component {
ToggleSwitch { OffscaleToggle {
id: powerSwitch id: powerSwitch
visible: tailscale.installed visible: tailscale.installed
checked: tailscale.active checked: tailscale.active
busy: tailscale.busy busy: tailscale.busy
hasCursor: header.ringVisible hasCursor: header.ringVisible
foreground: hero.foreground foreground: hero.foreground
// ToggleSwitch defaults `accent` to Color.accent — the theme
// accent, which is where the off-white/peach knob and border
// came from. The hero only forwards `foreground`, so pin
// accent to it as well and the switch reads white in both the
// connected and disconnected states.
accent: hero.foreground
onHovered: function(on) { if (on) header.focusHero() } onHovered: function(on) { if (on) header.focusHero() }
onToggled: tailscale.toggleOffscale() onToggled: tailscale.toggleOffscale()
@@ -998,7 +1013,7 @@ Panel {
if (peerRow.peerDns !== "") parts.push(peerRow.peerDns) if (peerRow.peerDns !== "") parts.push(peerRow.peerDns)
return parts.join(" · ") return parts.join(" · ")
} }
color: root.dim color: root.accent
font.family: root.fontFamily font.family: root.fontFamily
font.pixelSize: Style.font.caption font.pixelSize: Style.font.caption
elide: Text.ElideRight elide: Text.ElideRight