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
14 changes: 14 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,17 @@ agent integrations, public sharing, other native platforms and billing. The
local module slices above do not qualify every approved design frame. Unsupported
integration states must remain explicit; fixtures and generated API types do not
count as working services.


## Sidebar width

Wide browser and native layouts (at least 900px) share a live sidebar resize
separator. Width is 180–420px, default 224px, stored under `notes.sidebar-width`
in device-local storage; storage denial keeps an in-memory preference usable.
Narrow drawers remain 224px and have no resize rail. Hiding/showing the wide
sidebar retains its width. Pointer capture is retired on cancellation, lost
capture, blur, disable and unmount without changing document/body styles.
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`.
75 changes: 74 additions & 1 deletion web/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,9 @@ h3 {
font-size: 14px;
}
.app {
position: relative;
display: grid;
grid-template-columns: 224px minmax(0, 1fr);
grid-template-columns: var(--sidebar-width, 224px) minmax(0, 1fr);
height: 100dvh;
min-height: 400px;
font-size: 13px;
Expand Down Expand Up @@ -972,3 +973,75 @@ dialog p {

.link-popover-lock { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line2); }
.link-popover-lock small { flex: 1; }

/* orbyn-designs 95bd47e, sidebar-dragging / window-title-row. */
.sidebar-resize {
position: absolute;
top: 0;
bottom: 0;
width: 16px;
z-index: 7;
cursor: col-resize;
touch-action: none;
user-select: none;
outline: none;
}
.sidebar-resize::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 7px;
width: 2px;
border-radius: 1px;
background: color-mix(in srgb, var(--t1) 60%, transparent);
opacity: 0;
transition: opacity 150ms;
}
.sidebar-resize:hover::after,
.sidebar-resize:focus-visible::after,
.sidebar-resize.dragging::after {
opacity: 1;
}
.sidebar-width-pill {
position: absolute;
top: 50%;
left: 8px;
transform: translate(-50%, -50%);
z-index: 8;
pointer-events: none;
font:
11px/1 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
padding: 4px 7px;
border-radius: 7px;
background: var(--t1);
color: var(--main);
white-space: nowrap;
}
.sidebar .nav > svg,
.sidebar .nav > span:not(.nav-label),
.sidebar .search > svg,
.sidebar .search > kbd {
flex-shrink: 0;
}
.sidebar .nav .nav-label {
flex: 1;
min-width: 0;
margin-left: 0;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: inherit;
font-size: inherit;
}
.sidebar .group,
.sidebar .search > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
173 changes: 173 additions & 0 deletions web/src/ui/SidebarResize.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";

export const SIDEBAR_MIN = 180;
export const SIDEBAR_MAX = 420;
export const SIDEBAR_DEFAULT = 224;
const WIDTH_KEY = "notes.sidebar-width";

function validWidth(value: number) {
return Number.isFinite(value) && value >= SIDEBAR_MIN && value <= SIDEBAR_MAX;
}
function clamp(value: number) {
return Math.round(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, value)));
}

/** Device-local presentation preference; unavailable storage leaves resizing usable. */
export function useSidebarWidth() {
const [width, setWidth] = useState(() => {
try {
const raw = localStorage.getItem(WIDTH_KEY);
const value = raw === null || raw.trim() === "" ? NaN : Number(raw);
return validWidth(value) ? clamp(value) : SIDEBAR_DEFAULT;
} catch {
/* Optional storage cannot prevent rendering. */
}
return SIDEBAR_DEFAULT;
});
const changeWidth = useCallback((value: number) => {
if (!Number.isFinite(value)) return;
const next = clamp(value);
setWidth(next);
try {
localStorage.setItem(WIDTH_KEY, String(next));
} catch {
/* Optional preference. */
}
}, []);
return [width, changeWidth] as const;
}

export function SidebarResize({
value,
onChange,
controls = "notes-sidebar",
disabled = false,
}: {
value: number;
onChange: (value: number) => void;
controls?: string;
disabled?: boolean;
}) {
const rail = useRef<HTMLDivElement>(null);
const active = useRef<{
id: number;
x: number;
width: number;
element: HTMLDivElement;
} | null>(null);
const [dragging, setDragging] = useState(false);
const current = useRef({ disabled, onChange });
current.current = { disabled, onChange };
const width = validWidth(value) ? clamp(value) : SIDEBAR_DEFAULT;

const release = useCallback(() => {
const pointer = active.current;
active.current = null;
if (pointer?.element.hasPointerCapture?.(pointer.id)) {
pointer.element.releasePointerCapture(pointer.id);
}
}, []);
const stop = useCallback(() => {
release();
setDragging(false);
}, [release]);
useLayoutEffect(() => {
if (disabled) stop();
}, [disabled, stop]);
useEffect(() => {
window.addEventListener("blur", stop);
return () => {
window.removeEventListener("blur", stop);
release();
};
}, [stop, release]);
if (disabled) return null;
return (
<div
ref={rail}
className={`sidebar-resize${dragging ? " dragging" : ""}`}
style={{ left: width - 8 }}
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
aria-controls={controls}
aria-valuemin={SIDEBAR_MIN}
aria-valuemax={SIDEBAR_MAX}
aria-valuenow={width}
aria-valuetext={`${width} pixels`}
tabIndex={0}
onPointerDown={(event) => {
if (event.button !== 0 || event.isPrimary === false || active.current)
return;
event.preventDefault();
event.currentTarget.focus();
active.current = {
id: event.pointerId,
x: event.clientX,
width,
element: event.currentTarget,
};
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
}}
onPointerMove={(event) => {
const pointer = active.current;
if (
!pointer ||
event.pointerId !== pointer.id ||
current.current.disabled
)
return;
current.current.onChange(
clamp(pointer.width + event.clientX - pointer.x),
);
}}
onPointerUp={(event) => {
if (event.pointerId === active.current?.id) stop();
}}
onPointerCancel={(event) => {
if (event.pointerId === active.current?.id) stop();
}}
onLostPointerCapture={(event) => {
if (event.pointerId === active.current?.id) stop();
}}
onBlur={stop}
onDoubleClick={() => {
stop();
onChange(SIDEBAR_DEFAULT);
}}
onKeyDown={(event) => {
if (
event.key !== "ArrowLeft" &&
event.key !== "ArrowRight" &&
event.key !== "Enter"
)
return;
event.preventDefault();
stop();
onChange(
event.key === "Enter"
? SIDEBAR_DEFAULT
: clamp(width + (event.key === "ArrowLeft" ? -16 : 16)),
);
}}
>
{dragging && (
<span className="sidebar-width-pill" aria-hidden="true">
{width}
{width === SIDEBAR_MIN
? " · minimum"
: width === SIDEBAR_MAX
? " · maximum"
: ""}
</span>
)}
</div>
);
}
Loading
Loading