= {
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',
diff --git a/src/app/features/config/global-config.model.ts b/src/app/features/config/global-config.model.ts
index 70a8a1020b..5edbd67e78 100644
--- a/src/app/features/config/global-config.model.ts
+++ b/src/app/features/config/global-config.model.ts
@@ -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;
diff --git a/src/app/features/right-panel/right-panel.component.html b/src/app/features/right-panel/right-panel.component.html
index e195e8cb27..2191a17d0c 100644
--- a/src/app/features/right-panel/right-panel.component.html
+++ b/src/app/features/right-panel/right-panel.component.html
@@ -8,6 +8,8 @@
[style]="sideStyle()"
class="side"
@slideRightPanel
+ (@slideRightPanel.start)="isPanelAnimating.set(true)"
+ (@slideRightPanel.done)="isPanelAnimating.set(false)"
>
diff --git a/src/app/features/right-panel/right-panel.component.scss b/src/app/features/right-panel/right-panel.component.scss
index 1d63946d37..c342ee2639 100644
--- a/src/app/features/right-panel/right-panel.component.scss
+++ b/src/app/features/right-panel/right-panel.component.scss
@@ -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 {
diff --git a/src/app/features/right-panel/right-panel.component.ts b/src/app/features/right-panel/right-panel.component.ts
index 78e5d8a3a5..b8e9d3aca0 100644
--- a/src/app/features/right-panel/right-panel.component.ts
+++ b/src/app/features/right-panel/right-panel.component.ts
@@ -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(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);
diff --git a/src/app/t.const.ts b/src/app/t.const.ts
index 9b42714b3d..97dc458b04 100644
--- a/src/app/t.const.ts
+++ b/src/app/t.const.ts
@@ -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',
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index c4179ef8e4..dcff62767a 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -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",
diff --git a/src/styles/_css-variables.scss b/src/styles/_css-variables.scss
index 63a133bf1f..4bfa436021 100644
--- a/src/styles/_css-variables.scss
+++ b/src/styles/_css-variables.scss
@@ -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;
diff --git a/src/styles/components/_components.scss b/src/styles/components/_components.scss
index 587069cdb6..73e6a8d8a4 100644
--- a/src/styles/components/_components.scss
+++ b/src/styles/components/_components.scss
@@ -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';
diff --git a/src/styles/components/_overwrite-material.scss b/src/styles/components/_overwrite-material.scss
index 21d6183f4b..6757e75527 100644
--- a/src/styles/components/_overwrite-material.scss
+++ b/src/styles/components/_overwrite-material.scss
@@ -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) {
diff --git a/src/styles/components/_vertical-action-bar.scss b/src/styles/components/_vertical-action-bar.scss
new file mode 100644
index 0000000000..609938351e
--- /dev/null
+++ b/src/styles/components/_vertical-action-bar.scss
@@ -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 `` 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 's :host, but the
+ // strip is teleported to 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));
+ }
+}