Experiment right side action bar (#7594)

* feat(layout): experimental vertical action strip on right edge

Teleports main-header's action-nav-right to document.body on desktop so
it escapes any ancestor containing-block, then pins it as a vertical strip
at the viewport's right edge via fixed positioning. Reserves the column
with padding-right on .app-container so the right-panel ends to its left.

* feat(layout): right-panel spans full viewport height

Restructure app shell so right-panel is a direct flex child of
.app-container (wrapping main-content via ng-content), letting its
.side extend over the full height of the viewport — including the
header area — rather than starting below the header.

* style(layout): give vertical action strip an elevated surface bg

* style(layout): match vertical action strip styling to left side nav

* fix(layout): stack play/focus buttons vertically in action strip

* style(layout): use solid surface bg for vertical action strip

* fix(layout): keep current-task title visible beside the action strip

In the teleported vertical action strip the current-task title pill was
positioned right:100% of the play button and clipped by the strip's
overflow, and the 48px mini-fab overflowed the 48px rail and got
side-clipped.

Render the title as a position:fixed flyout to the left of the strip
(the strip has no transform/filter so it is not a containing block for
fixed descendants), aligned to the play button across the web /
mac-titlebar / obsidian-header / RTL variants, and drop the play button
wrapper's horizontal margin so it fits the rail.

* fix(layout): stop tooltip overlay from blocking action strip clicks

A tooltip shown 'below' a button in the vertical action strip lands
directly over the next button. The tooltip's cdk-overlay-pane wrapper
(unlike the inner mat-tooltip-component) had no pointer-events:none, so
it intercepted clicks for real users and for Playwright actionability —
breaking ~20 focus-mode/break e2e tests.

* test(focus-mode): use teleport-robust focus-button locator

The action nav is teleported out of <main-header> into a body-level
strip, so 'main-header focus-button button' no longer matches. Drop the
main-header ancestor; 'focus-button button' is unique either way.

* fix(layout): polish vertical action strip (flyout, spacing, bg)

- Current-task title is now a hover-reveal flyout: hidden until the play
  button (or the flyout itself) is hovered. Mobile (< 1080px) unchanged
  (component still display:none's it there).
- Drop the position:fixed + magic top calc; keep the component's own
  absolute + translateY(-50%) so the flyout is pixel-perfectly centred
  on the play button regardless of theme/Material density.
- Strip now overflow: visible so the flyout can extend past the 48px
  rail (overflow-x can't be visible while overflow-y is auto).
- Re-declare --header-nav-button-gap on the strip: it is scoped to
  <main-header>'s :host but the strip is teleported to <body>, so the
  var was undefined => every strip button had gap: 0 (cramped).
- Extra margin between the play button and the focus button.
- Strip background now matches the left sidenav (--sidenav-bg).

* fix(layout): uniform vertical spacing between strip buttons

Drop the extra play-button and counters-group bottom margins; one
--header-nav-button-gap (var(--s)) now drives the spacing between every
button in the strip. Measured: all consecutive gaps = 8px.

* feat(layout): make vertical action bar a configurable opt-in

Adds misc.isVerticalActionBar (default off). The right-edge vertical
action strip was previously an unconditional experiment; it is now an
opt-in toggle in Settings > Misc that switches the layout live without
reload:

- app.component: @if branches the DOM between classic (horizontal
  header) and vertical (right-panel hoisted to full viewport height);
  .app-container gets .has-vertical-action-bar to gate CSS.
- main-header: one-shot ngAfterViewInit teleport replaced with an
  effect() that teleports/restores the action nav reactively to the
  config flag and the desktop/mobile breakpoint.
- app/right-panel SCSS: strip padding and full-height right-panel are
  now scoped to .has-vertical-action-bar; classic layout restored as
  the default.

* fix(layout): even vertical-strip spacing for panel-button wrappers

plugin-header-btns, plugin-side-panel-btns, desktop-panel-buttons and
user-profile-button are zero-height wrapper custom-elements. As direct
flex children of the vertical action strip the column gap landed on the
collapsed wrapper instead of the button(s) inside, so their icons were
unevenly spaced next to real buttons like the add button. Make each
wrapper a centered column flex item that stacks its button(s) with the
same gap, and hide truly-empty wrappers so they don't reserve a phantom
gap slot. Verified: all 7 visible strip buttons now 8px apart.

* fix(right-panel): clip transient content overflow during slide animation

.side animates width 0<->* while .side-inner keeps its min-width, so
content briefly spills past the (intentionally overflow:visible) .side
during open/close. Add an isPanelAnimating host class driven by the
@slideRightPanel start/done callbacks and clip :host for that window
only (overflow: clip — no scroll container). Mirrors the existing
resizing/windowResizing transient-state pattern. Verified: class +
clip present only during the ~200ms animation, visible when idle.

* refactor(layout): drop dual DOM for vertical action bar

Single classic layout for both modes; strip is teleported and offset by
--bar-height so the horizontal header keeps owning the title-bar zone
(native drag region + WCO + Mac traffic lights). Drops the 40px
title-bar-collision padding and the right-panel full-height override.

* feat(layout): right-panel side spans full viewport height

Move the header into right-panel's projected .content slot and make
right-panel host full-height. The panel column now starts at viewport
top with the header band only spanning the .content width, similar to
Obsidian / Linear / VS Code.

* fix(layout): only offset vertical strip below WCO band on Win/Linux

Default top inset is now 0 (web, Mac hiddenInset, native-frame Electron
all let the strip start at viewport top — none of them have a window-
control overlay clashing with the right edge). Push the strip down by
--bar-height only on Win/Linux Electron with custom title bar, where
the WCO buttons would otherwise sit on top of the strip's first row.

* style(electron): use compact 32px WCO band on Win/Linux

Shrinks the Windows Controls Overlay height from 44px to 32px so the
native min/max/close buttons sit in a slimmer band — matches VS Code /
Edge slim title bar conventions and frees more of the header zone for
app content. Width stays OS-controlled (~138px); only height is
configurable.

* style(layout): tighten WCO band to 24px and pull strip up to match

Drops WCO_HEIGHT to 24px on Win/Linux and introduces a --wco-height CSS
var mirroring it. The vertical action strip now clears the WCO band by
exactly --wco-height instead of --bar-height (48px), reclaiming the
newly-freed top zone on the right edge.

* style(layout): add breathing room above strip below WCO band

Strip top inset moves from --wco-height to --wco-height + --s so the
first action button isn't flush against the bottom of the window
controls overlay on Win/Linux.

* fix(styles): scope vertical-action-bar tooltip rule to the experiment

The pane-level `pointer-events: none` was needed only because tooltips
in the teleported vertical strip can land over the next button. Apply
it via a new `body.isVerticalActionBar` body class (toggled from
`misc.isVerticalActionBar` in GlobalThemeService, alongside the existing
`isObsidianStyleHeader` effect) so unrelated app tooltips keep their
default pane behaviour.

* refactor(styles): extract vertical-action-bar CSS to its own partial

Move the 130-line experimental strip block out of `src/styles.scss` and
into `src/styles/components/_vertical-action-bar.scss`. Wired via
`_components.scss`. No CSS rules change — pure code organisation.

* merge(master): resolve _components.scss conflict in right action bar branch

Agent-Logs-Url: https://github.com/super-productivity/super-productivity/sessions/1941dd45-72e1-4a3e-92da-912fcfcb9bed

Co-authored-by: johannesjo <1456265+johannesjo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: johannesjo <1456265+johannesjo@users.noreply.github.com>
This commit is contained in:
Johannes Millan 2026-05-23 16:11:37 +02:00 committed by GitHub
parent 00efe4cacf
commit c1d14d501c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 327 additions and 14 deletions

View file

@ -40,7 +40,7 @@ test.describe('Bug #5117: Flowtime timer stops at Countdown duration', () => {
// Locators
const focusModeOverlay = page.locator('focus-mode-overlay');
const mainFocusButton = page.locator('main-header focus-button button');
const mainFocusButton = page.locator('focus-button button');
// Mode selector buttons - using the segmented button group
const flowtimeButton = page.locator('segmented-button-group button', {
@ -166,7 +166,7 @@ test.describe('Bug #5117: Flowtime timer stops at Countdown duration', () => {
const workViewPage = new WorkViewPage(page, testPrefix);
const focusModeOverlay = page.locator('focus-mode-overlay');
const mainFocusButton = page.locator('main-header focus-button button');
const mainFocusButton = page.locator('focus-button button');
const flowtimeButton = page.locator('segmented-button-group button', {
hasText: 'Flowtime',
});

View file

@ -30,6 +30,13 @@ import { markGpuStartupSuccess } from './gpu-startup-guard';
let mainWin: BrowserWindow;
// Compact WCO band on Win/Linux. Native button width is OS-controlled
// (~138px total); only height is configurable. Lower values may be
// clamped to the OS minimum (~2428px on Win11) — Electron silently
// floors instead of rejecting. Stays well clear of the vertical action
// strip which positions itself --bar-height (48px) down.
const WCO_HEIGHT = 24;
/**
* Returns theme-aware background color for titlebar overlay.
* Semi-transparent to ensure window controls are always visible.
@ -117,7 +124,7 @@ export const createWindow = async ({
? {
color: getTitleBarColor(nativeTheme.shouldUseDarkColors),
symbolColor: initialSymbolColor,
height: 44,
height: WCO_HEIGHT,
}
: undefined;
@ -347,7 +354,7 @@ export const createWindow = async ({
mainWin.setTitleBarOverlay({
color: getTitleBarColor(isDarkMode),
symbolColor,
height: 44,
height: WCO_HEIGHT,
});
} catch (e) {
// setTitleBarOverlay may not be available on all platforms

View file

@ -36,6 +36,7 @@
<div
class="app-container"
[class.has-mobile-bottom-nav]="isShowMobileButtonNav()"
[class.has-vertical-action-bar]="isVerticalActionBar()"
[class.app-entrance]="isAppEntrance()"
[dir]="isRTL ? 'rtl' : 'ltr'"
>
@ -46,16 +47,16 @@
</magic-side-nav>
<main class="main-content">
<header
[class.isNotScrolled]="!layoutService.isWorkViewScrolled()"
class="header-wrapper"
>
<main-header></main-header>
<banner></banner>
</header>
<right-panel>
<header
[class.isNotScrolled]="!layoutService.isWorkViewScrolled()"
class="header-wrapper"
>
<main-header></main-header>
<banner></banner>
</header>
<div
#routeWrapper
[@warpRoute]="getPage(o)"

View file

@ -14,6 +14,23 @@
min-height: -webkit-fill-available;
}
// Vertical action bar (opt-in via misc.isVerticalActionBar): reserve a
// column on the right for the fixed action strip teleported by main-header
// (desktop only). The strip is position: fixed, so this padding keeps the
// main content + right-panel to the left of it.
.app-container.has-vertical-action-bar {
@include mq(xs) {
padding-right: var(--vertical-action-bar-width);
}
}
:host-context([dir='rtl']) .app-container.has-vertical-action-bar {
@include mq(xs) {
padding-right: 0;
padding-left: var(--vertical-action-bar-width);
}
}
// Native mobile: body has safe area padding that pushes content down,
// so the container must be shorter than 100vh to stay within the visible viewport.
// Without this, bottom items (like Settings) in the sidebar are clipped off-screen.

View file

@ -175,6 +175,12 @@ export class AppComponent implements OnDestroy, AfterViewInit {
return misc?.isDisableAnimations ?? false;
});
// Experimental: vertical action strip on the right edge instead of the
// horizontal top header. Switches the DOM layout live (see template).
readonly isVerticalActionBar = computed(
() => this._globalConfigService.misc()?.isVerticalActionBar ?? false,
);
isRTL: boolean = false;
private _isOverlayShownFromStore = toSignal(this._store.select(selectIsOverlayShown), {

View file

@ -42,6 +42,7 @@ export enum BodyClass {
isDisableBackgroundTint = 'isDisableBackgroundTint',
isDisableAnimations = 'isDisableAnimations',
isObsidianStyleHeader = 'isObsidianStyleHeader',
isVerticalActionBar = 'isVerticalActionBar',
isDataImportInProgress = 'isDataImportInProgress',
hasBgImage = 'hasBgImage',
hasMobileBottomNav = 'hasMobileBottomNav',

View file

@ -144,6 +144,10 @@ button.isActive2 {
margin-right: auto;
}
// EXPERIMENT: vertical action strip styles live in src/styles.scss so they
// bypass Angular view encapsulation (and any containing-block surprises
// from this file). See `nav.action-nav-right` rules there.
// Set 20px icon sizing for buttons outside counters-action-group
// (add button, panel buttons, etc.)
> button:not(.sync-btn) .mat-icon:not(.sync-state-ico),

View file

@ -2,6 +2,8 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
inject,
OnDestroy,
signal,
@ -72,6 +74,9 @@ import { FocusModeService } from '../../features/focus-mode/focus-mode.service';
],
})
export class MainHeaderComponent implements OnDestroy {
private readonly _elRef = inject(ElementRef<HTMLElement>);
private _teleportedNav: HTMLElement | null = null;
private _teleportObserver: MutationObserver | null = null;
readonly projectService = inject(ProjectService);
readonly matDialog = inject(MatDialog);
readonly workContextService = inject(WorkContextService);
@ -199,8 +204,76 @@ export class MainHeaderComponent implements OnDestroy {
private _subs: Subscription = new Subscription();
// Vertical action bar is desktop-only and opt-in via misc config.
private readonly _isVerticalActionBar = computed(
() => !this.isXs() && !!this.globalConfigService.misc()?.isVerticalActionBar,
);
constructor() {
// Teleport the action nav to document.body (and back) so the fixed
// vertical strip escapes any ancestor containing-block
// (transform/filter/contain) and reliably anchors to the viewport.
// Reacts live to the config toggle and the desktop/mobile breakpoint;
// also re-runs once the nav enters the DOM (it sits behind
// @if(isDataLoaded())).
effect(() => {
const enabled = this._isVerticalActionBar();
this.isDataLoaded();
this._syncTeleport(enabled);
});
}
private _syncTeleport(enabled: boolean): void {
if (enabled) {
if (this._teleportedNav?.isConnected) return;
if (!this._teleportNav()) {
this._teleportObserver?.disconnect();
this._teleportObserver = new MutationObserver(() => {
if (this._teleportNav()) this._teleportObserver?.disconnect();
});
this._teleportObserver.observe(this._elRef.nativeElement, {
childList: true,
subtree: true,
});
}
} else {
this._teleportObserver?.disconnect();
this._teleportObserver = null;
this._restoreNav();
}
}
private _teleportNav(): boolean {
if (this._teleportedNav?.isConnected) return true;
this._teleportedNav = null;
const nav = (this._elRef.nativeElement as HTMLElement).querySelector(
'nav.action-nav-right',
) as HTMLElement | null;
if (!nav) return false;
nav.classList.add('action-nav-right--teleported');
document.body.appendChild(nav);
this._teleportedNav = nav;
return true;
}
private _restoreNav(): void {
const nav = this._teleportedNav;
if (!nav) return;
this._teleportedNav = null;
nav.classList.remove('action-nav-right--teleported');
const wrapper = (this._elRef.nativeElement as HTMLElement).querySelector('.wrapper');
if (wrapper) {
wrapper.appendChild(nav);
} else {
nav.remove();
}
}
ngOnDestroy(): void {
this._subs.unsubscribe();
this._teleportObserver?.disconnect();
this._teleportedNav?.remove();
this._teleportedNav = null;
}
trackById(i: number, item: SimpleCounter): string {

View file

@ -376,6 +376,15 @@ export class GlobalThemeService {
}
});
effect(() => {
const misc = this._globalConfigService.misc();
if (misc?.isVerticalActionBar) {
this.document.body.classList.add(BodyClass.isVerticalActionBar);
} else {
this.document.body.classList.remove(BodyClass.isVerticalActionBar);
}
});
// Add/remove hasBgImage class to body when background image changes
effect(() => {
if (this.backgroundImg()) {

View file

@ -52,6 +52,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
startOfNextDay: 0,
startOfNextDayTime: '00:00',
isDisableAnimations: false,
isVerticalActionBar: false,
isDisableCelebration: false,
isShowProductivityTipLonger: false,
customTheme: 'default',

View file

@ -70,6 +70,14 @@ export const MISC_SETTINGS_FORM_CFG: ConfigFormSection<MiscConfig> = {
label: T.GCF.MISC.IS_DISABLE_ANIMATIONS,
},
},
{
key: 'isVerticalActionBar',
type: 'checkbox',
templateOptions: {
label: T.GCF.MISC.IS_VERTICAL_ACTION_BAR,
description: T.GCF.MISC.IS_VERTICAL_ACTION_BAR_HINT,
},
},
{
key: 'isDisableCelebration',
type: 'checkbox',

View file

@ -33,6 +33,10 @@ export type MiscConfig = Readonly<{
/** Canonical start-of-next-day value, including minute precision. */
startOfNextDayTime?: string;
isDisableAnimations: boolean;
// Experimental: render the header action buttons as a vertical strip on
// the right edge of the viewport instead of the horizontal top header.
// Desktop only. Optional because it was added later.
isVerticalActionBar?: boolean;
// optional because it was added later
isDisableCelebration?: boolean;
isShowProductivityTipLonger?: boolean;

View file

@ -8,6 +8,8 @@
[style]="sideStyle()"
class="side"
@slideRightPanel
(@slideRightPanel.start)="isPanelAnimating.set(true)"
(@slideRightPanel.done)="isPanelAnimating.set(false)"
>
<div class="right-panel">
<!-- Resize Handle -->

View file

@ -5,7 +5,12 @@
flex-direction: row;
justify-content: stretch;
align-items: stretch;
height: calc(100% - var(--bar-height));
// Right-panel owns the full content area now. The horizontal header is
// projected as the first child of .content, so .side spans top-to-bottom
// of the viewport panels start at the very top, header band only spans
// the width of .content.
height: 100%;
min-width: 0;
&.resizing {
// Disable transitions while resizing
@ -46,11 +51,23 @@
}
}
// While the slide-in/out animation runs, .side width animates 0 <-> *
// but .side-inner keeps its min-width, so the content spills past the
// (intentionally overflow:visible) .side. Clip it at the host for the
// duration only `clip` avoids creating a scroll container.
:host(.isPanelAnimating) {
overflow: clip;
}
.content {
flex-grow: 1;
max-width: 100%;
min-width: 0;
position: relative;
z-index: var(--z-right-panel);
// Column flex: header on top, route fills, progress bar at bottom.
display: flex;
flex-direction: column;
}
.side {

View file

@ -87,6 +87,8 @@ const clampWidth = (width: number, maxWidth: number | string): number => {
'[class.resizing]': 'isResizing()',
// eslint-disable-next-line @typescript-eslint/naming-convention
'[class.windowResizing]': 'isWindowResizing()',
// eslint-disable-next-line @typescript-eslint/naming-convention
'[class.isPanelAnimating]': 'isPanelAnimating()',
},
standalone: true,
})
@ -148,6 +150,9 @@ export class RightPanelComponent implements AfterViewInit, OnDestroy {
readonly currentWidth = signal<number>(RIGHT_PANEL_CONFIG.DEFAULT_WIDTH);
readonly isResizing = signal(false);
readonly isWindowResizing = signal(false);
// True only while the slide-in/out animation runs; clips the transient
// width-vs-min-width overflow of the panel content (see component scss).
readonly isPanelAnimating = signal(false);
private readonly _startX = signal(0);
private readonly _startWidth = signal(0);

View file

@ -2406,6 +2406,8 @@ const T = {
IS_TRAY_SHOW_CURRENT_COUNTDOWN: 'GCF.MISC.IS_TRAY_SHOW_CURRENT_COUNTDOWN',
IS_USE_CUSTOM_WINDOW_TITLE_BAR: 'GCF.MISC.IS_USE_CUSTOM_WINDOW_TITLE_BAR',
IS_USE_CUSTOM_WINDOW_TITLE_BAR_HINT: 'GCF.MISC.IS_USE_CUSTOM_WINDOW_TITLE_BAR_HINT',
IS_VERTICAL_ACTION_BAR: 'GCF.MISC.IS_VERTICAL_ACTION_BAR',
IS_VERTICAL_ACTION_BAR_HINT: 'GCF.MISC.IS_VERTICAL_ACTION_BAR_HINT',
START_OF_NEXT_DAY: 'GCF.MISC.START_OF_NEXT_DAY',
START_OF_NEXT_DAY_HINT: 'GCF.MISC.START_OF_NEXT_DAY_HINT',
THEME: 'GCF.MISC.THEME',

View file

@ -2357,6 +2357,8 @@
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Show current countdown in the tray / status menu (macOS only)",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR": "Use custom title bar (Windows/Linux only)",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR_HINT": "Requires restart to take effect.",
"IS_VERTICAL_ACTION_BAR": "Vertical action bar on the side (experimental)",
"IS_VERTICAL_ACTION_BAR_HINT": "Moves the header action buttons into a vertical strip on the right edge of the window. Desktop only.",
"START_OF_NEXT_DAY": "Start time of the next day",
"START_OF_NEXT_DAY_HINT": "The time when your day starts (Format: HH:mm, e.g. 02:30)",
"THEME": "Theme",

View file

@ -134,6 +134,11 @@
--bar-height-small: 40px;
--mat-mini-fab-size: 48px;
--mac-title-bar-padding: 20px;
--vertical-action-bar-width: 48px;
// Matches WCO_HEIGHT in electron/main-window.ts. Keep in sync so the
// vertical action strip clears the WCO band by exactly this much on
// Win/Linux with custom title bar.
--wco-height: 24px;
// Radius scale use tokens below; legacy aliases kept for existing rules.
--radius-xs: 4px;
@ -160,6 +165,7 @@
--z-right-panel: 44;
--z-right-panel-resize-handle: 65;
--z-right-panel-edge-handle: 66;
--z-vertical-action-bar: 70;
--z-tour: 1001;
--z-onboarding-hint: 1100;
--z-dragged-nav-item: 1002;

View file

@ -20,6 +20,7 @@
@use './number-badge';
@use './bg-overlay';
@use './onboarding-pulse';
@use './vertical-action-bar';
@use './info-panel';
//@import '../../app/ui/custom-datetime-picker/sass/picker';

View file

@ -376,6 +376,16 @@ mat-tooltip-component {
pointer-events: none;
}
// The tooltip's overlay PANE wrapper (not just the inner component) must
// not swallow pointer events. A tooltip shown 'below' a button in the
// vertical action strip lands directly over the next button without
// this it intercepts clicks for both real users and Playwright. Scoped
// to the experimental flag so other tooltips in the app keep their
// pane-level pointer events (interactive hover content, etc.).
body.isVerticalActionBar .cdk-overlay-pane.mat-mdc-tooltip-panel {
pointer-events: none;
}
// SNACK
// Move snack bar above mobile bottom nav for all touch devices
@include media-queries.mq(xs, max) {

View file

@ -0,0 +1,137 @@
// EXPERIMENT: vertical action strip on the right edge of the viewport
// (desktop only). Lives at the top level (not inside main-header.scss) so
// it bypasses Angular view-encapsulation and any containing-block
// surprises (transform/filter/contain on ancestors).
//
// MainHeaderComponent teleports `nav.action-nav-right` to `<body>` when
// `misc.isVerticalActionBar` is enabled and the breakpoint is non-XS,
// adding the `action-nav-right--teleported` marker class. The
// `body.isVerticalActionBar` body class is toggled by GlobalThemeService
// off the same config flag used here only for the tooltip override
// (overlay panes live outside the teleported nav and need a body-level
// anchor).
@media screen and (min-width: 600px) {
// The strip sits BELOW the horizontal header on Win/Linux Electron with
// custom title bar (the header still owns the drag region + native
// window controls); elsewhere it starts at viewport top.
body > nav.action-nav-right.action-nav-right--teleported {
// --header-nav-button-gap is declared on <main-header>'s :host, but the
// strip is teleported to <body> and out of that scope, so the var would
// be undefined here (gap: 0 everything cramped). Re-declare it. One
// value drives the spacing between every button in the strip.
--header-nav-button-gap: var(--s);
display: flex;
position: fixed;
// Default: strip starts at viewport top (web, Mac, native-frame
// Electron none of these have a window-control overlay clashing
// with the right edge). Only Win/Linux Electron with custom title
// bar pushes the strip down by the header height to clear the WCO
// overlay band (rule below).
inset: 0 0 0 auto;
width: var(--vertical-action-bar-width);
margin: 0;
padding: var(--s-half) 0;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: var(--header-nav-button-gap);
// Match the left sidenav (magic-side-nav uses --sidenav-bg).
background: var(--sidenav-bg);
border-left: 1px solid var(--separator-color);
z-index: var(--z-vertical-action-bar);
// overflow: visible so the current-task flyout can extend past the
// 48px rail. (overflow-x can't be visible while overflow-y is auto —
// the two axes are coupled so no inner scroll here; the strip holds
// few small buttons and is far shorter than the viewport.)
overflow: visible;
-webkit-app-region: no-drag;
box-sizing: border-box;
// No extra margin-bottom here: the strip's `gap` already spaces this
// group from the buttons below it, so spacing stays uniform.
> .counters-action-group {
flex-direction: column;
margin-right: 0;
gap: var(--header-nav-button-gap);
}
// These wrapper custom-elements have no box of their own (height 0),
// so as direct flex children the strip's `gap` lands on a collapsed
// wrapper instead of the button(s) inside the visible icons end up
// unevenly spaced next to real buttons (e.g. the add button). Make
// each wrapper a column flex item that stacks its button(s) with the
// same gap, and drop truly-empty wrappers so they don't reserve a
// phantom gap slot.
> plugin-header-btns,
> plugin-side-panel-btns,
> desktop-panel-buttons,
> user-profile-button {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--header-nav-button-gap);
&:empty {
display: none;
}
}
// Center the mini-fab in the rail; no extra margin so the play button
// is spaced from the focus button by the same gap as every other pair.
play-button .play-btn-wrapper {
margin: 0;
}
// Current-task title: hover-reveal flyout to the LEFT of the strip.
// Keep the component's own position:absolute + top:50% +
// translateY(-50%) (relative to .play-btn-wrapper) so it is exactly
// vertically centred on the play button regardless of theme/density.
// Only undo the inline-header horizontal "tuck", and hide it until
// the play button (or the flyout itself) is hovered. Mobile
// (< 1080px) is unchanged the component still display:none's it.
play-button .current-task-title {
right: calc(100% + var(--s-half));
margin: 0;
padding: calc(var(--s-half) * 0.75) var(--s);
max-width: 320px;
z-index: var(--z-vertical-action-bar);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity 0.2s ease-out,
visibility 0.2s ease-out;
}
play-button .play-btn-wrapper:hover .current-task-title,
play-button .current-task-title:hover {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}
// Win/Linux Electron with custom title bar: WCO renders a compact band
// (--wco-height, matches electron WCO_HEIGHT) at top-right. Push the
// strip down by exactly that height so its first button sits flush
// below the WCO buttons and the strip reclaims the newly-freed space.
body.isElectron.isNoMac.isObsidianStyleHeader
> nav.action-nav-right.action-nav-right--teleported {
top: calc(var(--wco-height) + var(--s));
}
[dir='rtl'] > nav.action-nav-right.action-nav-right--teleported {
inset: 0 auto 0 0;
border-left: none;
border-right: 1px solid var(--separator-color);
}
// RTL: strip is on the left edge, so the flyout opens to its right.
[dir='rtl']
> nav.action-nav-right.action-nav-right--teleported
play-button
.current-task-title {
right: auto;
left: calc(100% + var(--s-half));
}
}