-
- - - -
- +
+ + + +
+
this._globalConfigService.misc()?.isVerticalActionBar ?? false, + ); + isRTL: boolean = false; private _isOverlayShownFromStore = toSignal(this._store.select(selectIsOverlayShown), { diff --git a/src/app/app.constants.ts b/src/app/app.constants.ts index 6c10ff45ca..584909f400 100644 --- a/src/app/app.constants.ts +++ b/src/app/app.constants.ts @@ -42,6 +42,7 @@ export enum BodyClass { isDisableBackgroundTint = 'isDisableBackgroundTint', isDisableAnimations = 'isDisableAnimations', isObsidianStyleHeader = 'isObsidianStyleHeader', + isVerticalActionBar = 'isVerticalActionBar', isDataImportInProgress = 'isDataImportInProgress', hasBgImage = 'hasBgImage', hasMobileBottomNav = 'hasMobileBottomNav', diff --git a/src/app/core-ui/main-header/main-header.component.scss b/src/app/core-ui/main-header/main-header.component.scss index 6a286a1a65..3c4e1ab0a0 100644 --- a/src/app/core-ui/main-header/main-header.component.scss +++ b/src/app/core-ui/main-header/main-header.component.scss @@ -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), diff --git a/src/app/core-ui/main-header/main-header.component.ts b/src/app/core-ui/main-header/main-header.component.ts index ab42fec8db..93fdd5aed8 100644 --- a/src/app/core-ui/main-header/main-header.component.ts +++ b/src/app/core-ui/main-header/main-header.component.ts @@ -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); + 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 { diff --git a/src/app/core/theme/global-theme.service.ts b/src/app/core/theme/global-theme.service.ts index b4c50469ba..bcf25c2928 100644 --- a/src/app/core/theme/global-theme.service.ts +++ b/src/app/core/theme/global-theme.service.ts @@ -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()) { diff --git a/src/app/features/config/default-global-config.const.ts b/src/app/features/config/default-global-config.const.ts index 297dc1f9a3..815a144017 100644 --- a/src/app/features/config/default-global-config.const.ts +++ b/src/app/features/config/default-global-config.const.ts @@ -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', diff --git a/src/app/features/config/form-cfgs/misc-settings-form.const.ts b/src/app/features/config/form-cfgs/misc-settings-form.const.ts index 62928cbec5..c4b0d85aee 100644 --- a/src/app/features/config/form-cfgs/misc-settings-form.const.ts +++ b/src/app/features/config/form-cfgs/misc-settings-form.const.ts @@ -70,6 +70,14 @@ export const MISC_SETTINGS_FORM_CFG: ConfigFormSection = { 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)); + } +}