- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
559 lines
21 KiB
QML
559 lines
21 KiB
QML
pragma ComponentBehavior: Bound
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import qs.Commons
|
|
import qs.Ui
|
|
|
|
// The Nexthop panel: header verdict, seven tabs, one alive at a time.
|
|
//
|
|
// All data arrives through files the daemon writes, but never through a
|
|
// file handle the shell holds: `nexthop stream` reads live.json (the
|
|
// always-current numbers), apps.json and recent.json (the default graphs)
|
|
// and feeds them here as lines, and `nexthop query` runs on demand when a
|
|
// tab asks for a longer window. The panel never probes anything itself.
|
|
Panel {
|
|
id: root
|
|
moduleName: "io.github.x3me.nexthop"
|
|
ipcTarget: "io.github.x3me.nexthop"
|
|
manageIpc: false
|
|
|
|
property var anchorItem: null
|
|
property var hostWidget: null
|
|
readonly property var barIdentity: hostWidget || root
|
|
|
|
// ---- palette -------------------------------------------------------------
|
|
readonly property color fg: bar ? bar.foreground : Color.popups.text
|
|
readonly property color dim: Color.muted
|
|
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
|
readonly property color okTone: "#9ece6a"
|
|
readonly property color warnTone: "#e0af68"
|
|
|
|
// Right-now congestion, from score.pressure: the fast channel the index
|
|
// cannot be. Empty unless it is worth saying.
|
|
readonly property string pressureSuffix: {
|
|
var p = live && live.pressure ? live.pressure : null
|
|
if (!p || !p.state || p.state === "clear") return ""
|
|
return " \u00b7 " + p.state.toUpperCase()
|
|
}
|
|
|
|
// Disclosure state for the panel's optional detail blocks. Lives here, not
|
|
// on the tab, so moving between tabs does not fold them shut again. Resets
|
|
// with the shell, which is the right lifetime for a view preference.
|
|
property bool instrumentsExpanded: false
|
|
property bool wanDetailOpen: false
|
|
property bool underLoadExpanded: false
|
|
|
|
// A newer version is published. The daemon only ever reports this; the
|
|
// panel only ever mentions it. Updating stays with `omarchy plugin update`,
|
|
// which shows the diff and asks.
|
|
readonly property bool updateAvailable:
|
|
!!(live && live.update && live.update.available)
|
|
|
|
function updateTip() {
|
|
return "A newer version of Nexthop is published.\n\n"
|
|
+ "omarchy plugin update io.github.x3me.nexthop\n\n"
|
|
+ "That shows you what changed before applying it. "
|
|
+ "These checks can be turned off in the panel's Setup tab (the cog)."
|
|
}
|
|
|
|
function bandColor(idx) {
|
|
if (idx === null || idx === undefined) return dim
|
|
if (idx >= 80) return okTone
|
|
if (idx >= 50) return warnTone
|
|
return Color.urgent
|
|
}
|
|
|
|
// ---- state files ---------------------------------------------------------
|
|
readonly property string stateDir: {
|
|
var base = Quickshell.env("XDG_STATE_HOME")
|
|
if (!base || base.length === 0) base = Quickshell.env("HOME") + "/.local/state"
|
|
return base + "/nexthop"
|
|
}
|
|
// A URL, not a path: percent-encoded, so decode before it is used as a
|
|
// filesystem path or a space in the way becomes %20 and cd fails — the
|
|
// same form BarWidget and Service already use.
|
|
readonly property string pluginDir:
|
|
decodeURIComponent(Qt.resolvedUrl(".").toString())
|
|
.replace(/^file:\/\//, "").replace(/\/$/, "")
|
|
|
|
// The panel opens no file of its own. The bar widget that creates it
|
|
// owns the single `nexthop stream` reader — a bounded, non-blocking,
|
|
// no-follow, regular-file-only read out in a small helper — and the
|
|
// panel binds to what it already parsed.
|
|
readonly property var live: hostWidget ? hostWidget.live : null
|
|
readonly property var recent: hostWidget ? hostWidget.recent : null
|
|
readonly property var appsData: hostWidget ? hostWidget.appsData : null
|
|
// The bar widget keeps the clock; a snapshot it calls stale is history,
|
|
// not a verdict, and the header must not present it as one. The tabs
|
|
// keep drawing it — history is what they show anyway.
|
|
readonly property bool stale: hostWidget ? hostWidget.stale === true : false
|
|
readonly property int staleForS: hostWidget ? hostWidget.staleForS : 0
|
|
|
|
// recent.json points, trimmed to the slots that actually have data.
|
|
readonly property var recentPoints: {
|
|
if (!recent || !recent.points) return []
|
|
var pts = recent.points
|
|
var first = -1
|
|
for (var i = 0; i < pts.length; i++) {
|
|
if (pts[i].total !== null) { first = i; break }
|
|
}
|
|
return first < 0 ? [] : pts.slice(first)
|
|
}
|
|
|
|
// ---- daemon config -------------------------------------------------------
|
|
// Settings live in shell.json; the daemon can't read that, so mirror the
|
|
// keys it cares about into its config file whenever they change.
|
|
onSettingsChanged: writeDaemonConfig()
|
|
Component.onCompleted: writeDaemonConfig()
|
|
|
|
function writeDaemonConfig() {
|
|
var cfg = {
|
|
internetAnchor: setting("internetAnchor", "1.1.1.1"),
|
|
probeIntervalMs: setting("probeIntervalMs", 500),
|
|
contentSpeed: setting("contentSpeed", true),
|
|
contentSpeedIntervalMin: setting("contentSpeedIntervalMin", 60),
|
|
peakEngine: setting("peakEngine", "Auto"),
|
|
planDownMbps: setting("planDownMbps", 0),
|
|
planUpMbps: setting("planUpMbps", 0),
|
|
notifyOutage: setting("notifyOutage", true),
|
|
updateCheck: setting("updateCheck", true),
|
|
meteredCare: setting("meteredCare", true),
|
|
historyDays: setting("historyDays", 7),
|
|
throughputWindowS: setting("throughputWindowS", 3),
|
|
}
|
|
// Written atomically via mkstemp + rename — the same discipline the
|
|
// daemon's own writers use. A shell redirection here would be a
|
|
// truncating, symlink-following write at a predictable path: exactly
|
|
// the class the security review flagged on the lock file.
|
|
configWriter.command = ["python3", "-c",
|
|
"import os, sys, tempfile\n" +
|
|
"d = sys.argv[2]\n" +
|
|
"os.makedirs(d, mode=0o700, exist_ok=True)\n" +
|
|
"fd, tmp = tempfile.mkstemp(dir=d)\n" +
|
|
"try:\n" +
|
|
" os.write(fd, sys.argv[1].encode())\n" +
|
|
" os.close(fd)\n" +
|
|
" os.replace(tmp, os.path.join(d, 'config.json'))\n" +
|
|
"except BaseException:\n" +
|
|
" os.unlink(tmp)\n" +
|
|
" raise\n",
|
|
JSON.stringify(cfg), stateDir]
|
|
configWriter.running = true
|
|
}
|
|
|
|
Process { id: configWriter }
|
|
|
|
// ---- history queries -----------------------------------------------------
|
|
// Tabs ask for a window; results land in `history` tagged by the request.
|
|
property var history: null
|
|
property string historyWindow: ""
|
|
property bool historyLoading: false
|
|
|
|
function requestHistory(window) {
|
|
historyWindow = window
|
|
historyLoading = true
|
|
historyProc.command = ["sh", "-c",
|
|
"cd \"$1\" && exec python3 -m nexthopd.cli query --window \"$2\"",
|
|
"sh", pluginDir, window]
|
|
historyProc.running = true
|
|
}
|
|
|
|
Process {
|
|
id: historyProc
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: {
|
|
root.historyLoading = false
|
|
try { root.history = JSON.parse(text) } catch (e) { root.history = null }
|
|
}
|
|
}
|
|
}
|
|
|
|
property var testsData: null
|
|
function requestTests() {
|
|
testsProc.command = ["sh", "-c",
|
|
"cd \"$1\" && exec python3 -m nexthopd.cli tests --limit 24",
|
|
"sh", pluginDir]
|
|
testsProc.running = true
|
|
}
|
|
Process {
|
|
id: testsProc
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: { try { root.testsData = JSON.parse(text) } catch (e) {} }
|
|
}
|
|
}
|
|
|
|
property var eventsData: null
|
|
function requestEvents(window) {
|
|
eventsProc.command = ["sh", "-c",
|
|
"cd \"$1\" && exec python3 -m nexthopd.cli events --window \"$2\"",
|
|
"sh", pluginDir, window || "7d"]
|
|
eventsProc.running = true
|
|
}
|
|
Process {
|
|
id: eventsProc
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: { try { root.eventsData = JSON.parse(text) } catch (e) {} }
|
|
}
|
|
}
|
|
|
|
// A phone sharing its data, and whether we are being careful with it.
|
|
readonly property var metered: live && live.metered ? live.metered : null
|
|
readonly property bool meteredCare: !!(metered && metered.care)
|
|
|
|
// The peak test is sized to saturate the link for a fixed duration, not
|
|
// to a fixed size, so it costs whatever the connection can carry: 285 MB
|
|
// measured on a fibre line, tens of megabytes on a phone. It also fires
|
|
// from a middle-click on the bar, which is easy to hit by accident. So on
|
|
// a metered link the first press arms it and the second runs it — never
|
|
// blocked, because measuring the cellular link is sometimes exactly what
|
|
// you want.
|
|
property bool peakArmed: false
|
|
|
|
Timer {
|
|
id: peakDisarm
|
|
interval: 8000
|
|
onTriggered: root.peakArmed = false
|
|
}
|
|
|
|
function runPeakTest() {
|
|
if (meteredCare && !peakArmed) {
|
|
peakArmed = true
|
|
peakDisarm.restart()
|
|
return
|
|
}
|
|
peakArmed = false
|
|
peakDisarm.stop()
|
|
peakProc.command = ["sh", "-c",
|
|
"cd \"$1\" && exec python3 -m nexthopd.cli peak", "sh", pluginDir]
|
|
peakProc.running = true
|
|
}
|
|
Process { id: peakProc }
|
|
|
|
function copyReport(window) {
|
|
reportProc.command = ["sh", "-c",
|
|
"cd \"$1\" && python3 -m nexthopd.cli report --window \"$2\" | wl-copy",
|
|
"sh", pluginDir, window || "24h"]
|
|
reportProc.running = true
|
|
}
|
|
Process { id: reportProc }
|
|
|
|
// ---- open / close --------------------------------------------------------
|
|
function open() {
|
|
root.controller.show()
|
|
setCenterHoverRevealSuppressed(false)
|
|
}
|
|
|
|
function openFromHotkey() {
|
|
root.controller.show()
|
|
Qt.callLater(function() {
|
|
if (root.opened) setCenterHoverRevealSuppressed(true)
|
|
})
|
|
}
|
|
|
|
function close() {
|
|
// Hide first, and never the other way round. This used to call the bar
|
|
// before hiding, so when 4.0.3 made that call throw, the exception took
|
|
// the hide with it: the panel could not be closed by click, hotkey, IPC
|
|
// or a popout switch, and a KeyboardPanel's dismissal layer covers the
|
|
// whole screen. What the user can see must not depend on a host call we
|
|
// do not own.
|
|
root.controller.hide()
|
|
setCenterHoverRevealSuppressed(false)
|
|
}
|
|
|
|
function toggle() { root.opened ? root.close() : root.openFromHotkey() }
|
|
|
|
function switchPanel(direction) {
|
|
if (root.bar && typeof root.bar.switchPanelFrom === "function")
|
|
return root.bar.switchPanelFrom(root.barIdentity, direction)
|
|
return false
|
|
}
|
|
|
|
// 4.0.3 hands third-party widgets a PluginBarApi facade where this is a
|
|
// readonly property fed by a setter; the property still answers `in`, so
|
|
// asking whether it exists says nothing about whether it can be written.
|
|
function setCenterHoverRevealSuppressed(value) {
|
|
if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function")
|
|
root.bar.setCenterHoverRevealSuppressed(value)
|
|
else if (root.bar && "centerHoverRevealSuppressed" in root.bar)
|
|
root.bar.centerHoverRevealSuppressed = value
|
|
}
|
|
|
|
onOpenedChanged: {
|
|
if (opened) Qt.callLater(function() {
|
|
if (keyCatcher) keyCatcher.forceActiveFocus()
|
|
})
|
|
}
|
|
|
|
// ---- tabs ----------------------------------------------------------------
|
|
readonly property var tabNames: ["Overview", "Latency", "Speed", "Wi-Fi",
|
|
"Apps", "Events", "Setup"]
|
|
// Setup carries a glyph rather than a word: it is a destination you visit
|
|
// rarely, and giving it an equal seventh of the strip would cost the six
|
|
// tabs that are read constantly. Its name stays in tabNames so the IPC
|
|
// route (`showTab Setup`) and the arrow keys treat it like any other.
|
|
readonly property int setupTab: tabNames.length - 1
|
|
property int currentTab: 0
|
|
|
|
IpcHandler {
|
|
target: root.ipcTarget
|
|
function open(): void { root.openFromHotkey() }
|
|
function close(): void { root.close() }
|
|
function show(): void { root.openFromHotkey() }
|
|
function hide(): void { root.close() }
|
|
function toggle(): void { root.toggle() }
|
|
function speedTest(): void { root.runPeakTest() }
|
|
function showTab(name: string): void {
|
|
var i = root.tabNames.indexOf(name)
|
|
if (i >= 0) root.currentTab = i
|
|
root.openFromHotkey()
|
|
}
|
|
}
|
|
|
|
// ---- surface -------------------------------------------------------------
|
|
KeyboardPanel {
|
|
id: panelCard
|
|
anchorItem: root.anchorItem
|
|
owner: root.barIdentity
|
|
bar: root.bar
|
|
open: root.opened
|
|
focusTarget: keyCatcher
|
|
contentWidth: panelCard.fittedContentWidth(Style.space(524))
|
|
contentHeight: panelCard.fittedContentHeight(bodyColumn.implicitHeight)
|
|
|
|
PanelKeyCatcher {
|
|
id: keyCatcher
|
|
anchors.fill: parent
|
|
onCloseRequested: root.close()
|
|
onTabRequested: function(direction) { root.switchPanel(direction) }
|
|
|
|
Keys.onPressed: function(event) {
|
|
// Left/right walk the tab strip; 1-6 jump straight to a tab (Setup,
|
|
// the seventh, has no digit — it is reached by arrow, click or IPC).
|
|
if (event.key === Qt.Key_Left) {
|
|
root.currentTab = (root.currentTab + root.tabNames.length - 1) % root.tabNames.length
|
|
event.accepted = true
|
|
} else if (event.key === Qt.Key_Right) {
|
|
root.currentTab = (root.currentTab + 1) % root.tabNames.length
|
|
event.accepted = true
|
|
} else if (event.key >= Qt.Key_1 && event.key <= Qt.Key_6) {
|
|
root.currentTab = event.key - Qt.Key_1
|
|
event.accepted = true
|
|
}
|
|
}
|
|
|
|
Column {
|
|
id: bodyColumn
|
|
width: parent.width
|
|
spacing: Style.space(12)
|
|
|
|
// ---- header: identity + verdict ---------------------------------
|
|
Item {
|
|
width: parent.width
|
|
height: Math.max(headerLeft.implicitHeight, headerRight.implicitHeight)
|
|
|
|
Row {
|
|
id: headerLeft
|
|
spacing: Style.space(10)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: {
|
|
var s = root.live ? root.live.state : ""
|
|
if (s === "captive") return "" // nf-md-shield_lock: a gate, not a fault
|
|
if (s === "local-down" || s === "wan-down") return ""
|
|
return ""
|
|
}
|
|
color: root.bandColor(root.live && !root.stale ? root.live.index : null)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.fontPx(1.6)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
Column {
|
|
spacing: Style.space(2)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: {
|
|
var l = root.live
|
|
if (!l) return "Nexthop"
|
|
var name = l.link
|
|
? (l.link.ssid || l.link.name || l.link.iface || "") : ""
|
|
if (name) return name
|
|
// The link is gone, so there is no network to name. The
|
|
// app's own name sat here reading like a network called
|
|
// Nexthop; say what is true instead.
|
|
return l.state === "local-down" || l.state === "wan-down"
|
|
? "No network" : "Nexthop"
|
|
}
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.heading
|
|
font.weight: Font.Bold
|
|
}
|
|
Row {
|
|
spacing: Style.space(6)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: {
|
|
var l = root.live
|
|
if (!l) return "WAITING FOR DAEMON"
|
|
if (root.stale) return "NO DATA FOR " + root.staleForS + " S"
|
|
if (l.state === "captive") return "SIGN-IN REQUIRED"
|
|
if (l.state === "local-down") return "ROUTER UNREACHABLE"
|
|
if (l.state === "wan-down") return "NO INTERNET · ROUTER OK"
|
|
// The index is a weakest-link score whose slowest
|
|
// component can pin it, so it answers "how has this
|
|
// connection been" and not "is it bad right now".
|
|
// Queueing answers the second, and only earns a word
|
|
// here when it has one to say — clear adds nothing, so
|
|
// the common case costs no space at all.
|
|
var band = (l.band || "").toUpperCase()
|
|
return band + root.pressureSuffix
|
|
}
|
|
color: root.stale ? root.warnTone
|
|
: (root.live && root.live.state !== "online"
|
|
? Color.urgent : root.dim)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.letterSpacing: 1
|
|
}
|
|
|
|
// A newer version exists. Deliberately the quietest thing
|
|
// that can still be found: one dim glyph beside the verdict,
|
|
// the command on hover, and nothing that acts on its own.
|
|
// Same grammar as the bench glyph on the Overview.
|
|
Text {
|
|
visible: root.updateAvailable
|
|
textFormat: Text.PlainText
|
|
text: "" // nf-md-update
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
HoverHandler { id: updateHover }
|
|
PanelToolTip {
|
|
visible: updateHover.hovered && root.updateAvailable
|
|
text: root.updateTip()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: headerRight
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: indexText.implicitWidth + experienceLabel.implicitWidth
|
|
+ Style.space(8)
|
|
height: indexText.implicitHeight
|
|
|
|
Text {
|
|
id: indexText
|
|
textFormat: Text.PlainText
|
|
anchors.right: parent.right
|
|
text: root.live && !root.stale && root.live.index !== null
|
|
&& root.live.index !== undefined ? String(root.live.index) : "--"
|
|
color: root.bandColor(root.live && !root.stale ? root.live.index : null)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.fontPx(2.4)
|
|
font.weight: Font.Bold
|
|
}
|
|
// On the number's baseline rather than under it — the label is
|
|
// one word, and a whole row of header for it was dead space.
|
|
Text {
|
|
id: experienceLabel
|
|
textFormat: Text.PlainText
|
|
anchors.right: indexText.left
|
|
anchors.rightMargin: Style.space(8)
|
|
anchors.baseline: indexText.baseline
|
|
text: "EXPERIENCE"
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.letterSpacing: 1
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- tab strip ---------------------------------------------------
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
|
|
Repeater {
|
|
model: root.tabNames
|
|
|
|
Rectangle {
|
|
id: tabButton
|
|
required property string modelData
|
|
required property int index
|
|
readonly property bool selected: root.currentTab === index
|
|
|
|
readonly property bool isSetup: index === root.setupTab
|
|
readonly property real setupWidth: Style.space(34)
|
|
|
|
width: isSetup ? setupWidth
|
|
: (parent.width - Style.space(6) * (root.tabNames.length - 1)
|
|
- setupWidth) / (root.tabNames.length - 1)
|
|
height: Style.space(26)
|
|
color: selected
|
|
? Style.selectedFillFor(root.fg, Color.accent)
|
|
: (tabHover.hovered
|
|
? Style.hoverFillFor(root.fg, Color.accent)
|
|
: Style.normalFillFor(root.fg, Color.accent))
|
|
border.width: selected ? 0 : Style.normalBorderWidth
|
|
border.color: Style.normalBorderFor(root.fg, Color.accent)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.centerIn: parent
|
|
text: tabButton.isSetup ? "" : tabButton.modelData // nf-md-cog
|
|
color: tabButton.selected ? root.fg : root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
|
|
HoverHandler { id: tabHover }
|
|
TapHandler { onTapped: root.currentTab = tabButton.index }
|
|
// A glyph does not name itself.
|
|
PanelToolTip {
|
|
visible: tabButton.isSetup && tabHover.hovered
|
|
text: "Settings"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
// ---- the selected tab -------------------------------------------
|
|
Loader {
|
|
width: parent.width
|
|
active: root.opened
|
|
sourceComponent: [overviewTab, latencyTab, speedTab, wifiTab,
|
|
appsTab, eventsTab, settingsTab][root.currentTab]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Component { id: overviewTab; OverviewTab { panel: root } }
|
|
Component { id: latencyTab; LatencyTab { panel: root } }
|
|
Component { id: speedTab; SpeedTab { panel: root } }
|
|
Component { id: wifiTab; WifiTab { panel: root } }
|
|
Component { id: appsTab; AppsTab { panel: root } }
|
|
Component { id: eventsTab; EventsTab { panel: root } }
|
|
Component { id: settingsTab; SettingsTab { panel: root } }
|
|
}
|