Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,23 @@ The separator supports 16px Left/Right steps; double-click or Enter resets 224px
This is a layout preference and does not navigate, save content or remount actors.
The design source is revision 95bd47e899600c0f038a7d4622a40cfc61d063e2,
Notes `sidebar-dragging` and Components `window-title-row`.


## Native title-row surfaces

The native 44px caption row is transparent over full-height sidebar/content
surfaces and carries no centered window title. Windows caption buttons retain
46x44px hit areas and the existing readiness, drag, system-menu and close guards.
The first sidebar control starts 56px from the window top; sheet surface reaches
the top while interactive sheet content begins below the 44px caption area.
Workspace publishes only `DesktopLayout {sidebarWidth, sidebarVisible}` through
the shell context in a layout effect after its initial load. A null layout is a
single-pane account/loading window with no sidebar toggle. Width metadata has
no account identity or content and does not remount editors or module actors.
The existing editor action portal remains mounted in the row. Its bounded area
scrolls horizontally when actions cannot fit, keeping the protected caption
buttons outside that scroll area. Buttons and scrollbar interaction do not drag
the window. Back/Forward history and the account-avatar sidebar header remain
separate work; no decorative history controls are added.
Reference: design revision 95bd47e899600c0f038a7d4622a40cfc61d063e2,
Components `window-title-row` and Notes `layout-desktop`/`layout-narrow`.
46 changes: 37 additions & 9 deletions web/src/desktop/DesktopShell.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject, type CSSProperties } from "react";
import { PanelLeft, Minus, Square, Copy, X } from "lucide-react";
import { checkClose, lockClosing } from "./closeGuard";
import { freezeWindow } from "./freeze";
import { connectWindow, isDesktop, type DesktopWindow } from "./window";
import "./desktop.css";

export type DesktopLayout = { sidebarWidth: number; sidebarVisible: boolean };

type Navigation = { expanded: boolean; toggle: () => void };
type DesktopContextValue = {
toolbar: HTMLElement | null;
navigationRef: RefObject<HTMLButtonElement | null>;
setNavigation: (navigation: Navigation | null) => void;
setLayout: (layout: DesktopLayout | null) => void;
};
const DesktopContext = createContext<DesktopContextValue | null>(null);
export const useDesktop = () => useContext(DesktopContext);
Expand All @@ -19,13 +22,31 @@ export function DesktopShell({ children }: { children: ReactNode }) {
return <NativeShell>{children}</NativeShell>;
}

// Preserve an existing focused action when its scrollport shrinks. Fixed menu
// items are outside the strip and must not change the trigger strip's position.
function revealToolbarFocus(toolbar: HTMLElement | null) {
const focused = document.activeElement;
if (!toolbar || !(focused instanceof HTMLElement) ||
!toolbar.contains(focused) || focused.closest('[role="menu"]')) return;
const viewport = toolbar.getBoundingClientRect();
const control = focused.getBoundingClientRect();
const left = viewport.left + toolbar.clientLeft;
const right = left + toolbar.clientWidth;
const delta = control.left < left ? control.left - left
: control.right > right ? control.right - right : 0;
if (delta) toolbar.scrollLeft = Math.max(0, Math.min(
toolbar.scrollWidth - toolbar.clientWidth, toolbar.scrollLeft + delta,
));
}

function NativeShell({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [started, setStarted] = useState(false);
const [attempt, setAttempt] = useState(0);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [maximized, setMaximized] = useState(false);
const [layout, setLayout] = useState<DesktopLayout | null>(null);
const [navigation, setNavigation] = useState<Navigation | null>(null);
const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
const navigationRef = useRef<HTMLButtonElement>(null);
Expand All @@ -35,6 +56,12 @@ function NativeShell({ children }: { children: ReactNode }) {
const bridge = useRef<DesktopWindow | null>(null);
const closing = useRef(false);
const failure = () => setError("Window controls aren’t available. Try again.");
useLayoutEffect(() => {
if (!toolbar || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => revealToolbarFocus(toolbar));
observer.observe(toolbar);
return () => observer.disconnect();
}, [toolbar]);
useLayoutEffect(() => {
document.documentElement.dataset.desktop = "true";
return () => { delete document.documentElement.dataset.desktop; };
Expand Down Expand Up @@ -87,23 +114,24 @@ function NativeShell({ children }: { children: ReactNode }) {
function run(action: (window: DesktopWindow) => Promise<void>) {
if (bridge.current) void action(bridge.current).catch(failure);
}
return <DesktopContext.Provider value={{ toolbar, navigationRef, setNavigation }}>
<div className="desktop-shell" ref={root}>
return <DesktopContext.Provider value={{ toolbar, navigationRef, setNavigation, setLayout }}>
<div className="desktop-shell" ref={root} data-layout={layout ? "workspace" : "single-pane"}
data-sidebar-visible={!!layout?.sidebarVisible}
style={{ "--desktop-sidebar-width": `${layout?.sidebarWidth ?? 224}px` } as CSSProperties}>
<header className="desktop-titlebar" aria-label="Window toolbar" onMouseDown={event => {
if (event.button !== 0 || !ready || (event.target as HTMLElement).closest("button, a, input, textarea, select, [role='menu']")) return;
if (event.button !== 0 || !ready || (event.target as HTMLElement).closest("button, a, input, textarea, select, [role='menu'], [data-window-interactive]")) return;
if (event.detail === 2) run(window => window.toggleMaximize());
else run(window => window.startDragging());
}} onContextMenu={event => {
if ((event.target as HTMLElement).closest("button, a, input, textarea, select, [role='menu']")) return;
if ((event.target as HTMLElement).closest("button, a, input, textarea, select, [role='menu'], [data-window-interactive]")) return;
event.preventDefault(); run(window => window.showSystemMenu());
}}>
{navigation && <button ref={navigationRef} className="desktop-sidebar-toggle" disabled={!ready}
aria-label={navigation.expanded ? "Hide sidebar" : "Show sidebar"} aria-expanded={navigation.expanded}
aria-controls="notes-sidebar" onClick={navigation.toggle}><PanelLeft size={16} strokeWidth={1.6} /></button>}
<div className="desktop-drag-area">
<span className="desktop-window-title">Orbyn Notes</span>
</div>
<div className="desktop-editor-toolbar" ref={setToolbar} />
<div className="desktop-drag-area" />
<div className="desktop-editor-toolbar" data-window-interactive="true" ref={setToolbar}
onFocusCapture={() => revealToolbarFocus(toolbar)} />
<div className="desktop-caption-controls">
<button data-preserves-editor-draft="true" aria-label="Minimize window" title="Minimize" disabled={!ready} onClick={() => run(window => window.minimize())}><Minus size={12} /></button>
<button data-preserves-editor-draft="true" aria-label={maximized ? "Restore window" : "Maximize window"} title={maximized ? "Restore" : "Maximize"} disabled={!ready} onClick={() => run(window => window.toggleMaximize())}>{maximized ? <Copy size={11} /> : <Square size={10} />}</button>
Expand Down
41 changes: 21 additions & 20 deletions web/src/desktop/desktop.css
Original file line number Diff line number Diff line change
@@ -1,33 +1,26 @@
.desktop-shell {
position: relative;
height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--main);
}
.desktop-titlebar {
position: relative;
position: absolute;
top: 0;
left: 0;
right: 0;
height: 44px;
flex: 0 0 44px;
display: flex;
align-items: center;
user-select: none;
background: var(--main);
background: transparent;
z-index: 20;
}
.desktop-drag-area { flex: 1; height: 100%; min-width: 28px; }
.desktop-window-title {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 12.5px;
font-weight: 500;
pointer-events: none;
white-space: nowrap;
}
.desktop-titlebar:has(.desktop-editor-actions) .desktop-window-title { display: none; }
.desktop-titlebar:has(.desktop-editor-actions) .desktop-drag-area { flex: 0 1 24px; }
.desktop-titlebar:has(.desktop-editor-actions) .desktop-drag-area { flex: 0 0 28px; }
.desktop-sidebar-toggle {
width: 28px;
height: 28px;
Expand All @@ -52,20 +45,28 @@
.desktop-caption-controls .desktop-close:hover { background: #c42b1c; color: #fff; }
.desktop-caption-controls .desktop-close:active { background: #a5261a; color: #fff; }
.desktop-caption-controls button:focus-visible { outline-offset: -4px; }
.desktop-editor-toolbar { min-width: 0; flex: 1; }
.desktop-editor-toolbar { min-width: 0; flex: 1; height: 44px; overflow-x: auto; scrollbar-width: thin; }
.desktop-editor-toolbar:empty { display: none; }
.desktop-editor-actions { display: flex; align-items: center; gap: 12px; padding-right: 8px; min-width: 0; }
.desktop-editor-actions { display: flex; align-items: center; gap: 12px; padding-right: 8px; min-width: max-content; min-height: 36px; }
.desktop-editor-actions > span { margin-left: auto; color: var(--t3); font-size: 11px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.desktop-editor-actions > button { white-space: nowrap; background: transparent; }
.desktop-editor-actions > button { white-space: nowrap; background: transparent; flex-shrink: 0; }
.desktop-editor-actions > .menu-anchor { flex-shrink: 0; }
.desktop-content { flex: 1; min-height: 0; overflow: hidden; }
.desktop-content > .app { height: 100%; min-height: 0; }
.desktop-content .mobile-header { display: none; }
.desktop-content .sidebar { padding-top: 2px; }
.desktop-content .sheet { margin: 0; border: 0; border-radius: 0; }
.desktop-content .sidebar { padding-top: 56px; }
.desktop-sheet-content { display: contents; }
.desktop-content .sheet { margin: 0; border: 0; border-radius: 0; overflow: hidden; }
.desktop-content .desktop-sheet-content { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; margin-top: 44px; overflow: auto; }
.desktop-shell[data-layout="single-pane"] .desktop-content { padding-top: 44px; }
.desktop-shell[data-sidebar-visible="true"] { background: linear-gradient(to right, var(--bg) 0 var(--desktop-sidebar-width), var(--main) var(--desktop-sidebar-width)); }
.desktop-shell > .loading { flex: 1; min-height: 0; padding-top: 44px; }
.desktop-content .sidebar-resize { z-index: 21; }
.desktop-titlebar:has([role="menu"]) { z-index: 22; }
.desktop-content .account-page, .desktop-content .loading { min-height: 100%; height: 100%; overflow: auto; }
.desktop-content .desktop-sidebar-hidden { grid-template-columns: minmax(0, 1fr); }
.desktop-sidebar-hidden > .sidebar { display: none; }
.desktop-window-notice { display: flex; align-items: center; gap: 12px; padding: 6px 16px; font-size: 12px; background: var(--amber-bg); color: var(--t2); }
.desktop-window-notice { position: relative; z-index: 23; margin-top: 44px; display: flex; align-items: center; gap: 12px; padding: 6px 16px; font-size: 12px; background: var(--amber-bg); color: var(--t2); }
.desktop-window-notice button { margin-left: auto; }
@media (max-width: 899px) {
.desktop-content .sidebar.open { top: 44px; padding-top: 12px; }
Expand Down
16 changes: 13 additions & 3 deletions web/src/ui/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -486,8 +486,16 @@ export function Workspace({
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
useEffect(() => {
if (!desktop) return;
useLayoutEffect(() => {
if (!desktop || !loaded) return;
desktop.setLayout({
sidebarWidth,
sidebarVisible: wide && !sidebarHidden,
});
return () => desktop.setLayout(null);
}, [desktop?.setLayout, loaded, sidebarWidth, wide, sidebarHidden]);
useLayoutEffect(() => {
if (!desktop || !loaded) return;
desktop.setNavigation({
expanded: wide ? !sidebarHidden : drawer,
toggle: () => {
Expand All @@ -496,7 +504,7 @@ export function Workspace({
},
});
return () => desktop.setNavigation(null);
}, [desktop?.setNavigation, wide, sidebarHidden, drawer]);
}, [desktop?.setNavigation, loaded, wide, sidebarHidden, drawer]);
useEffect(() => {
if (loaded) return;
const timer = setTimeout(() => setShowLoadingSkeleton(true), 400);
Expand Down Expand Up @@ -1801,6 +1809,7 @@ export function Workspace({
disabled={!wide || !!(desktop && sidebarHidden)}
/>
<main id="main" className="sheet" inert={drawer}>
<div className="desktop-sheet-content">
<SearchSurface
active={searchActive}
privacyVersion={searchPrivacyVersion}
Expand Down Expand Up @@ -2506,6 +2515,7 @@ export function Workspace({
accept=".md,.markdown,.mdown,text/markdown"
onChange={(e) => void readFiles(e.target.files)}
/>
</div>
</main>
{linkRecoveryOpen && links.service && (
<Dialog
Expand Down
Loading
Loading