Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ export const NotebookItem = memo(function NotebookItem({
dropPosition && `drop-${dropPosition}`,
isDragging && 'dragging'
)}
data-chrome-press=""
style={depth > 0 ? { paddingLeft: `${8 + depth * 16}px` } : undefined}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
Expand Down
12 changes: 10 additions & 2 deletions apps/desktop/src/renderer/components/sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from '../../hooks/useNavigation';
import { EnableSyncModal } from '../sync';
import { useSyncOnboarding } from '../../hooks/useSyncOnboarding';
import { bindChromePress } from '../../motion/chromePress';
import { SidebarHeader } from './SidebarHeader';
import { SidebarBreadcrumb } from './SidebarBreadcrumb';
import { SidebarQuickFilters } from './SidebarQuickFilters';
Expand Down Expand Up @@ -216,6 +217,9 @@ export function Sidebar({ onOpenGraph }: SidebarProps) {
return () => window.removeEventListener('keydown', onKey);
}, [inWorkspace, exitWorkspace]);

const sidebarRef = useRef<HTMLElement>(null);
useEffect(() => bindChromePress(sidebarRef.current), []);

const prevNotebookId = useRef<string | null>(selectedNotebookId);
const [paneDirection, setPaneDirection] = useState<'in' | 'out'>('in');
useEffect(() => {
Expand All @@ -233,7 +237,7 @@ export function Sidebar({ onOpenGraph }: SidebarProps) {
}, [selectedNotebookId, notebookContext.path]);

return (
<aside className={sc('sidebar')} aria-label="Main sidebar">
<aside ref={sidebarRef} className={sc('sidebar')} aria-label="Main sidebar">
<SidebarHeader
onSettingsClick={() => window.dripnex.windows.openSettings()}
onOpenGraph={onOpenGraph}
Expand All @@ -259,7 +263,10 @@ export function Sidebar({ onOpenGraph }: SidebarProps) {

{!isNotebookContext && (
<div className={sc('sidebar-templates')}>
<div className={sc('sidebar-row', selectedNotebookId === 'templates' && 'selected')}>
<div
className={sc('sidebar-row', selectedNotebookId === 'templates' && 'selected')}
data-chrome-press=""
>
<button
type="button"
className={sc('sidebar-row-main')}
Expand Down Expand Up @@ -344,6 +351,7 @@ export function Sidebar({ onOpenGraph }: SidebarProps) {
<button
type="button"
className={sc('sidebar-row', 'sidebar-trash', globalFilter === 'trash' && 'selected')}
data-chrome-press=""
onClick={goToTrash}
aria-pressed={globalFilter === 'trash'}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const QuickFilterItem = memo(function QuickFilterItem({
<button
type="button"
className={sc('sidebar-row', isSelected && 'selected')}
data-chrome-press=""
onClick={onClick}
aria-pressed={isSelected}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const StatusFilters = memo(function StatusFilters({
key={status}
type="button"
className={sc('sidebar-row', selectedStatus === status && 'selected')}
data-chrome-press=""
onClick={() => onSelectStatus(selectedStatus === status ? null : status)}
aria-pressed={selectedStatus === status}
data-status={status}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ export function TagsList({ selectedTag, onSelectTag, counts, filterQuery = '' }:
<button
type="button"
className={sc('sidebar-row', 'tags-list-item', tag === selectedTag && 'selected')}
data-chrome-press=""
onClick={() => handleTagClick(tag)}
>
<span
Expand Down
88 changes: 88 additions & 0 deletions apps/desktop/src/renderer/motion/__tests__/chromePress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import {
CHROME_HOVER_PX,
CHROME_PRESS_ATTR,
CHROME_PRESS_PX,
CHROME_PRESS_SELECTOR,
chromeMotionName,
chromePressPhase,
} from '../chromePress';
import { playMotion, setMotionScale, setPerformanceLow } from '../gsapRuntime';

const here = dirname(fileURLToPath(import.meta.url));
const sidebarSrc = readFileSync(join(here, '../../components/sidebar/Sidebar.tsx'), 'utf8');
const filtersSrc = readFileSync(
join(here, '../../components/sidebar/SidebarQuickFilters.tsx'),
'utf8'
);
const statusSrc = readFileSync(join(here, '../../components/sidebar/StatusFilters.tsx'), 'utf8');
const tagsSrc = readFileSync(join(here, '../../components/sidebar/TagsList.tsx'), 'utf8');
const notebookSrc = readFileSync(join(here, '../../components/sidebar/NotebookItem.tsx'), 'utf8');

describe('chromePressPhase', () => {
it('prefers press over hover', () => {
expect(chromePressPhase(false, false)).toBe('rest');
expect(chromePressPhase(true, false)).toBe('hover');
expect(chromePressPhase(true, true)).toBe('press');
expect(chromePressPhase(false, true)).toBe('press');
});

it('maps phases to motion names', () => {
expect(chromeMotionName('hover')).toBe('chrome-hover');
expect(chromeMotionName('press')).toBe('chrome-press');
expect(chromeMotionName('rest')).toBe('chrome-rest');
});

it('targets data-chrome-press so CSS modules do not hide rows', () => {
expect(CHROME_PRESS_ATTR).toBe('data-chrome-press');
expect(CHROME_PRESS_SELECTOR).toBe('[data-chrome-press]');
expect(CHROME_HOVER_PX).toBe(6);
expect(CHROME_PRESS_PX).toBe(8);
});
});

describe('chrome-press runtime', () => {
afterEach(() => {
setMotionScale(1);
setPerformanceLow(false);
});

it('hovers 6px and presses 8px in 120–140ms', () => {
const row = { opacity: 1, x: 0, y: 0, scale: 1 };
const target = row as unknown as Element;

const hover = playMotion('chrome-hover', target);
expect(hover?.vars.duration).toBeCloseTo(0.14);
expect(hover?.vars.x).toBe(CHROME_HOVER_PX);
expect(hover?.vars.y).toBe(0);

const press = playMotion('chrome-press', target);
expect(press?.vars.duration).toBeCloseTo(0.12);
expect(press?.vars.x).toBe(CHROME_PRESS_PX);

const rest = playMotion('chrome-rest', target);
expect(rest?.vars.duration).toBeCloseTo(0.14);
expect(rest?.vars.x).toBe(0);
});

it('snaps when Performance is Low', () => {
setPerformanceLow(true);
const row = { opacity: 1, x: 6, y: 0, scale: 1 };
expect(playMotion('chrome-hover', row as unknown as Element)).toBeNull();
expect(row.x).toBe(0);
});
});

describe('sidebar chrome wiring', () => {
it('delegates GSAP chrome-press from the sidebar root onto marked rows', () => {
expect(sidebarSrc).toContain('bindChromePress');
expect(sidebarSrc).toContain('data-chrome-press');
expect(filtersSrc).toContain('data-chrome-press');
expect(statusSrc).toContain('data-chrome-press');
expect(tagsSrc).toContain('data-chrome-press');
expect(notebookSrc).toContain('data-chrome-press');
});
});
10 changes: 10 additions & 0 deletions apps/desktop/src/renderer/motion/__tests__/gsapRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ describe('gsapRuntime', () => {
expect(panel?.vars.duration).toBeCloseTo(0.18);
expect(panel?.vars.x).toBe(0);
gsap.killTweensOf(row);

const hover = playMotion('chrome-hover', target);
expect(hover?.vars.duration).toBeCloseTo(0.14);
expect(hover?.vars.x).toBe(6);
gsap.killTweensOf(row);

const press = playMotion('chrome-press', target);
expect(press?.vars.duration).toBeCloseTo(0.12);
expect(press?.vars.x).toBe(8);
gsap.killTweensOf(row);
});

it('snaps gate-in when Performance is Low', () => {
Expand Down
94 changes: 94 additions & 0 deletions apps/desktop/src/renderer/motion/chromePress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { playMotion, type MotionEventName } from './gsapRuntime';

/** Rows that play chrome-hover / chrome-press. CSS modules hash class names. */
export const CHROME_PRESS_ATTR = 'data-chrome-press';
export const CHROME_PRESS_SELECTOR = `[${CHROME_PRESS_ATTR}]`;

export const CHROME_HOVER_PX = 6;
export const CHROME_PRESS_PX = 8;

export type ChromePressPhase = 'hover' | 'press' | 'rest';

export function chromePressPhase(hovering: boolean, pressing: boolean): ChromePressPhase {
if (pressing) return 'press';
if (hovering) return 'hover';
return 'rest';
}

export function chromeMotionName(phase: ChromePressPhase): MotionEventName {
if (phase === 'hover') return 'chrome-hover';
if (phase === 'press') return 'chrome-press';
return 'chrome-rest';
}

export function chromeRowFromTarget(
target: EventTarget | null,
selector = CHROME_PRESS_SELECTOR
): Element | null {
if (!(target instanceof Element)) return null;
return target.closest(selector);
}

/** Delegate hover/press on `[data-chrome-press]` descendants. */
export function bindChromePress(
root: Element | null,
selector = CHROME_PRESS_SELECTOR
): () => void {
if (!root) return () => undefined;

let hoverRow: Element | null = null;
let pressRow: Element | null = null;

const play = (phase: ChromePressPhase, el: Element | null) => {
if (el) playMotion(chromeMotionName(phase), el);
};

const onOver = (event: Event) => {
const row = chromeRowFromTarget(event.target, selector);
if (!row || row === hoverRow) return;
if (hoverRow && hoverRow !== pressRow) play('rest', hoverRow);
hoverRow = row;
if (pressRow !== row) play('hover', row);
};

const onOut = (event: Event) => {
const row = hoverRow;
if (!row) return;
const related = 'relatedTarget' in event ? event.relatedTarget : null;
if (related instanceof Node && row.contains(related)) return;
hoverRow = null;
if (pressRow === row) return;
play('rest', row);
};

const onDown = (event: Event) => {
if ('button' in event && event.button !== 0) return;
const row = chromeRowFromTarget(event.target, selector);
if (!row) return;
pressRow = row;
play('press', row);
};

const onUp = () => {
const row = pressRow;
pressRow = null;
if (!row) return;
play(hoverRow === row ? 'hover' : 'rest', row);
};

root.addEventListener('pointerover', onOver);
root.addEventListener('pointerout', onOut);
root.addEventListener('pointerdown', onDown);
root.addEventListener('pointerup', onUp);
root.addEventListener('pointercancel', onUp);

return () => {
root.removeEventListener('pointerover', onOver);
root.removeEventListener('pointerout', onOut);
root.removeEventListener('pointerdown', onDown);
root.removeEventListener('pointerup', onUp);
root.removeEventListener('pointercancel', onUp);
if (pressRow) play('rest', pressRow);
else if (hoverRow) play('rest', hoverRow);
};
}
32 changes: 31 additions & 1 deletion apps/desktop/src/renderer/motion/gsapRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ export type MotionEventName =
| 'list-select'
| 'welcome-in'
| 'gate-in'
| 'sidebar-in';
| 'sidebar-in'
| 'chrome-hover'
| 'chrome-press'
| 'chrome-rest';

export interface MotionPlayOptions {
onComplete?: () => void;
Expand Down Expand Up @@ -180,6 +183,33 @@ export function playMotion(
}
);
}
if (name === 'chrome-hover') {
return gsap.to(target, {
x: 6,
y: 0,
duration: scaledDuration(140),
ease,
onComplete: options.onComplete,
});
}
if (name === 'chrome-press') {
return gsap.to(target, {
x: 8,
y: 0,
duration: scaledDuration(120),
ease,
onComplete: options.onComplete,
});
}
if (name === 'chrome-rest') {
return gsap.to(target, {
x: 0,
y: 0,
duration: scaledDuration(140),
ease,
onComplete: options.onComplete,
});
}
if (name === 'welcome-in' || name === 'gate-in') {
return gsap.fromTo(
target,
Expand Down
1 change: 1 addition & 0 deletions docs/research/2026-08-28-electron-ux-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,4 @@ Every 10 minutes: one significant UX/UI change, a plugin, or a palette — never
- 2026-08-28 — Source-mode Wrap as Strikethrough / Unwrap Strikethrough: wraps the selection as `~~text~~`; peels the mark under the cursor. Fences, tilde runs, and existing marks skipped. Does not rewrite the rest of the note. Files: `apps/desktop/src/renderer/plugins/wrapStrike.ts`, `apps/desktop/src/renderer/plugins/index.ts`, `apps/desktop/src/renderer/plugins/__tests__/editorPacks.test.ts`.
- 2026-08-28 — Source-mode Jump to Next/Previous Strikethrough: next `~~text~~`, wraps, fences and tilde runs skipped. Does not rewrite. Files: `apps/desktop/src/renderer/plugins/jumpStrike.ts`, `apps/desktop/src/renderer/plugins/wrapStrike.ts`, `apps/desktop/src/renderer/plugins/index.ts`, `apps/desktop/src/renderer/plugins/__tests__/editorPacks.test.ts`.
- 2026-08-28 — GSAP `panel-in` on the AI aside (8px from the right). Width snaps; inner `.ai-panel` no longer owns the tween so overflow does not clip it. Reduced-motion / Low skip the enter. Files: `apps/desktop/src/renderer/motion/gsapRuntime.ts`, `apps/desktop/src/renderer/motion/panelIn.ts`, `apps/desktop/src/renderer/App.tsx`, `apps/desktop/src/renderer/components/ai/AiPanel.tsx`, `apps/desktop/src/renderer/styles/global.css`, `apps/desktop/src/renderer/motion/__tests__/panelIn.test.ts`, `apps/desktop/src/renderer/motion/__tests__/gsapRuntime.test.ts`.
- 2026-08-28 — GSAP `chrome-hover` / `chrome-press` on sidebar rows (6px hover, 8px press). Delegated from the sidebar root onto `[data-chrome-press]`. Reduced-motion / Low snap to rest. Files: `apps/desktop/src/renderer/motion/gsapRuntime.ts`, `apps/desktop/src/renderer/motion/chromePress.ts`, `apps/desktop/src/renderer/components/sidebar/Sidebar.tsx`, `apps/desktop/src/renderer/components/sidebar/SidebarQuickFilters.tsx`, `apps/desktop/src/renderer/components/sidebar/StatusFilters.tsx`, `apps/desktop/src/renderer/components/sidebar/TagsList.tsx`, `apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx`, `apps/desktop/src/renderer/motion/__tests__/chromePress.test.ts`, `apps/desktop/src/renderer/motion/__tests__/gsapRuntime.test.ts`.
Loading