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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ All changes organized by pull request, newest first. Format is documented under

---

## [PR #123] fix: Discord embed feels like a widget — live resize handles, gesture-frozen resizing, auto zoom, compact CSS

**Branch:** `fix/discord-embed-polish` → `master`
**Date:** 2026-07-14

### Context
The Discord `<webview>` lives in an app-lifetime `position:fixed z-30` host overlaying the tile's rAF-tracked rect (load-bearing: the guest must never unmount/`display:none` or Discord logs out — see `discordStore`). Three defects made it feel like a foreign window instead of a widget: RGL's resize handles render INSIDE each grid item (a transform-created stacking context, so no z-index can lift them above the root-level host) — every handle under the webview (s/se/sw/e/w) was dead; the rAF loop resized the host per mousemove during grid gestures, forcing the guest WebContents into async relayouts (tearing, Discord breakpoint thrash); and the guest rendered desktop-width Discord into narrow tiles with a theme-colored splash flash. The host architecture is unchanged — this is polish on top of it.

### Fixed
- **Dead resize handles — padded frame.** The tile body now wraps a `p-2` gutter and publishes the INNER div's rect, so the fixed host floats ~8px inside the tile: RGL's edge/corner hit areas are exposed all around, and the tile's own border/background visibly frames the embed. Host corners are now `rounded-md` on all four (all are visible inside the gutter).
- **Resize/drag jank — gesture freeze + settle debounce.** New ephemeral `discordStore.interacting` flag, set by `DashboardGrid`'s `onDragStart`/`onResizeStart` and cleared in `onDragStop`/`onResizeStop` (any tile's gesture — harmless when Discord isn't around). While interacting, the host applies rect x/y per frame but FREEZES width/height at the last settled size and goes `pointer-events:none` (grid mousemoves must not hit the guest); the final size applies once on gesture end. Outside gestures, size changes settle via a 120 ms trailing debounce (window resizes stream per-frame rects) — position always tracks live. The style/settle decision is a pure helper `nextHostStyle(rect, interacting, settledSize)` in `widgets/discord/lib.ts`, unit-tested.

### Added
- **Auto zoom.** `zoomForWidth(width)` steps the guest zoom by SETTLED tile width — ≥900px → 1.0, ≥700 → 0.9, ≥550 → 0.85, else 0.75 — applied on webview `dom-ready` and re-applied only when the settled width crosses a breakpoint or after a reload (never per frame). `setZoomFactor`/`insertCSS` added to the `DiscordWebviewElement` interface, signatures pinned against electron@33.4.11 `WebviewTag` typings.
- **Compact CSS.** `DISCORD_COMPACT_CSS` hides the members list (`[class*="membersWrap"]`) via `insertCSS` on every `dom-ready` (inserted CSS doesn't survive reload/navigation). FAIL-SOFT: Discord's hashed class names keep semantic prefixes; if the prefix rots the selector matches nothing and the stock layout shows. Server rail + channel sidebar deliberately kept ("see a server"). No settings toggle yet — Settings files are owned by another in-flight PR; a toggle can follow.

### Changed
- Loading splash background is now Discord's own dark chrome (`#313338`) instead of the app theme surface — no white/theme flash before the guest paints.

### Notes
- Unit tests cover `zoomForWidth` breakpoint boundaries and all `nextHostStyle` branches (hidden, first-show, mid-gesture freeze, hold, debounce) in `lib.test.ts`.
- Not verifiable in unit tests (real `<webview>` needed): handle hit-areas around the gutter, gesture-frozen resizing feel, zoom steps, and the members-list hide — verify in `pnpm dev` / a packaged build.

---

## [PR #122] feat: accordion collapse/expand exchanges space with the widget below (no full-grid reflow)

**Branch:** `feat/accordion-space-steal` → `master`
Expand Down
18 changes: 16 additions & 2 deletions apps/renderer/src/components/DashboardGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ExternalLink } from 'lucide-react';
import ReactGridLayout, { WidthProvider } from 'react-grid-layout';
import type { Layout } from 'react-grid-layout';
import { useLayoutStore, collapsedRowsFor } from '../store/layoutStore';
import { useDiscordStore } from '../store/discordStore';
import { useAppSettingsStore } from '../store/settingsStore';
import { usePopoutStore } from '../store/popoutStore';
import { useWidgetUiStore } from '../store/widgetUiStore';
Expand Down Expand Up @@ -115,6 +116,11 @@ function useRowHeight(layout: Layout[], gap: number): number {

export function DashboardGrid() {
const { layout, syncLayout, markUserEdited, visibleWidgets, setWidgetCollapsed } = useLayoutStore();
// ANY tile's drag/resize gesture flips this: the Discord host freezes its
// size and mutes pointer events for the duration (the webview otherwise
// relayouts per mousemove and eats the grid's drag events). Simplest to set
// it for every gesture — harmless when the Discord tile isn't around.
const setDiscordInteracting = useDiscordStore((s) => s.setInteracting);
const density = useAppSettingsStore((s) => s.density);
const popped = usePopoutStore((s) => s.popped);
const collapsed = useWidgetUiStore((s) => s.collapsed);
Expand Down Expand Up @@ -180,8 +186,16 @@ export function DashboardGrid() {
const hiddenItems = layout.filter((i) => !visibleIds.has(i.i));
syncLayout([...newVisible, ...hiddenItems]);
}}
onDragStop={markUserEdited}
onResizeStop={markUserEdited}
onDragStart={() => setDiscordInteracting(true)}
onResizeStart={() => setDiscordInteracting(true)}
onDragStop={() => {
setDiscordInteracting(false);
markUserEdited();
}}
onResizeStop={() => {
setDiscordInteracting(false);
markUserEdited();
}}
compactType="vertical"
isResizable
isDraggable
Expand Down
8 changes: 8 additions & 0 deletions apps/renderer/src/store/discordStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ interface DiscordState {
/** Live viewport rect of the widget tile's body; null = tile not visible
* (host hides at 0×0 but the guest keeps running). */
hostRect: DiscordHostRect | null;
/** True while ANY grid tile is mid drag/resize gesture (set by
* DashboardGrid). The host freezes its size and mutes pointer events for
* the duration — resizing the guest per mousemove tears, and the webview
* would otherwise eat the grid's drag events. Ephemeral, never persisted. */
interacting: boolean;
/** Mention count parsed from the webview's tab title. */
unread: number;
/** Registered by the host; null when no webview is live. */
Expand All @@ -40,6 +45,7 @@ interface DiscordState {
signOutPrompt: boolean;
activate: () => void;
setHostRect: (rect: DiscordHostRect | null) => void;
setInteracting: (interacting: boolean) => void;
setUnread: (unread: number) => void;
registerControls: (controls: DiscordControls | null) => void;
setSignOutPrompt: (open: boolean) => void;
Expand All @@ -48,11 +54,13 @@ interface DiscordState {
export const useDiscordStore = create<DiscordState>((set) => ({
active: false,
hostRect: null,
interacting: false,
unread: 0,
controls: null,
signOutPrompt: false,
activate: () => set({ active: true }),
setHostRect: (hostRect) => set({ hostRect }),
setInteracting: (interacting) => set({ interacting }),
setUnread: (unread) => set({ unread }),
registerControls: (controls) => set({ controls }),
setSignOutPrompt: (signOutPrompt) => set({ signOutPrompt }),
Expand Down
83 changes: 76 additions & 7 deletions apps/renderer/src/widgets/discord/DiscordHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@ import { useEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import type { DiscordScreenShareRequestData } from '@dash/shared';
import { HeaderAction } from '../../components/HeaderAction';
import { cn } from '../../lib/utils';
import { useDiscordStore } from '../../store/discordStore';
import { DISCORD_APP_URL, DISCORD_HOME_URL, parseDiscordUnreadCount } from './lib';
import {
DISCORD_APP_URL,
DISCORD_COMPACT_CSS,
DISCORD_HOME_URL,
nextHostStyle,
parseDiscordUnreadCount,
zoomForWidth,
} from './lib';
import type {
DiscordHostSize,
DiscordWebviewElement,
WebviewDidFailLoadEvent,
WebviewPageTitleUpdatedEvent,
Expand All @@ -25,12 +34,24 @@ type LoadStatus = 'loading' | 'ready' | 'error';
/** Keep the picker's local lifetime in sync with main's authoritative timeout. */
const SHARE_PICKER_TIMEOUT_MS = 60_000;

/** Out-of-gesture size changes (window resize, density switch) apply after
* the rect has been stable this long — never per rAF frame. */
const SIZE_SETTLE_MS = 120;

export function DiscordHost() {
const webviewRef = useRef<DiscordWebviewElement | null>(null);
const [status, setStatus] = useState<LoadStatus>('loading');
const [shareReq, setShareReq] = useState<DiscordScreenShareRequestData | null>(null);
// Last size actually applied to the webview container — position tracks the
// live rect per frame, size only moves on settle (see nextHostStyle).
const [settledSize, setSettledSize] = useState<DiscordHostSize | null>(null);
// Counts dom-ready fires: 0 = guest methods (setZoomFactor/insertCSS) would
// still throw; bumps on every load so per-load state (zoom) is re-applied.
const [domReadyTick, setDomReadyTick] = useState(0);
const wasInteracting = useRef(false);
const active = useDiscordStore((s) => s.active);
const hostRect = useDiscordStore((s) => s.hostRect);
const interacting = useDiscordStore((s) => s.interacting);
const unread = useDiscordStore((s) => s.unread);
const setUnread = useDiscordStore((s) => s.setUnread);
const registerControls = useDiscordStore((s) => s.registerControls);
Expand All @@ -45,6 +66,13 @@ export function DiscordHost() {
useEffect(() => {
const wv = webviewRef.current;
if (!wv) return;
const onDomReady = () => {
// Fires on every top-level load (reload included) — inserted CSS and
// zoom don't survive navigation. The compact CSS is fail-soft: if its
// hashed-class prefix stops matching, the stock layout just shows.
void wv.insertCSS(DISCORD_COMPACT_CSS);
setDomReadyTick((t) => t + 1); // unlocks + re-triggers the zoom effect
};
const onReady = () => setStatus('ready');
const onFail = (e: Event) => {
const ev = e as WebviewDidFailLoadEvent;
Expand All @@ -55,16 +83,49 @@ export function DiscordHost() {
const onTitle = (e: Event) => {
setUnread(parseDiscordUnreadCount((e as WebviewPageTitleUpdatedEvent).title));
};
wv.addEventListener('dom-ready', onDomReady);
wv.addEventListener('did-finish-load', onReady);
wv.addEventListener('did-fail-load', onFail);
wv.addEventListener('page-title-updated', onTitle);
return () => {
wv.removeEventListener('dom-ready', onDomReady);
wv.removeEventListener('did-finish-load', onReady);
wv.removeEventListener('did-fail-load', onFail);
wv.removeEventListener('page-title-updated', onTitle);
};
}, [active, setUnread]);

// Size settling — the ONLY place settledSize changes. Adopt the live rect's
// size: immediately when the tile hides/first shows or a grid gesture just
// ended (apply the final size once); debounced SIZE_SETTLE_MS otherwise
// (window resizes stream per-frame rects with interacting=false). While a
// gesture is in flight ('hold') the size stays frozen at the last settled
// value — only the host's position tracks the drag.
useEffect(() => {
const justEnded = wasInteracting.current && !interacting;
wasInteracting.current = interacting;
const { settle } = nextHostStyle(hostRect, interacting, settledSize);
if (settle === 'hold') return;
const adopt = () =>
setSettledSize(hostRect ? { width: hostRect.width, height: hostRect.height } : null);
if (settle === 'immediate' || justEnded) {
adopt();
return;
}
const t = setTimeout(adopt, SIZE_SETTLE_MS);
return () => clearTimeout(t);
}, [hostRect, interacting, settledSize]);

// Auto zoom, stepped by the SETTLED width — never per frame. `zoom` is
// derived, so the effect only fires when the width crosses a zoomForWidth
// breakpoint (or when a load/reload bumps domReadyTick and the factor must
// be re-applied).
const zoom = settledSize && settledSize.width > 0 ? zoomForWidth(settledSize.width) : null;
useEffect(() => {
if (domReadyTick === 0 || zoom === null) return; // guest not ready / tile hidden
webviewRef.current?.setZoomFactor(zoom);
}, [domReadyTick, zoom]);

// Screen-share picker — subscribed for the host's whole life (main
// auto-denies getDisplayMedia when nobody is watching).
useEffect(() => {
Expand Down Expand Up @@ -122,12 +183,18 @@ export function DiscordHost() {
: [];

// Tile hidden → 0×0 (guest alive). NEVER display:none — it kills the guest.
const style = hostRect
? { left: hostRect.x, top: hostRect.y, width: hostRect.width, height: hostRect.height }
: { left: 0, top: 0, width: 0, height: 0 };
// Position tracks the live rect per frame; size is settled/frozen (see
// nextHostStyle). rounded-md on ALL corners — the host floats inside the
// tile's padded gutter, so every corner is visible.
const { style } = nextHostStyle(hostRect, interacting, settledSize);

return (
<div className="fixed z-30 overflow-hidden rounded-b-md" style={style}>
<div
// pointer-events-none during grid gestures: RGL's drag/resize mousemoves
// must reach the grid, not the guest.
className={cn('fixed z-30 overflow-hidden rounded-md', interacting && 'pointer-events-none')}
style={style}
>
<div className="relative h-full w-full">
<webview
ref={webviewRef}
Expand All @@ -146,8 +213,10 @@ export function DiscordHost() {
)}

{status === 'loading' && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-th-surface">
<div className="h-4 w-28 rounded bg-th-elevated animate-pulse" />
// Discord's own dark chrome color — the splash must not flash the
// app theme (white in light mode) before the guest paints.
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#313338]">
<div className="h-4 w-28 rounded bg-white/10 animate-pulse" />
</div>
)}

Expand Down
13 changes: 12 additions & 1 deletion apps/renderer/src/widgets/discord/DiscordWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,18 @@ export function DiscordWidget() {
);
}

return <div ref={bodyRef} className="h-full w-full" />;
return (
// Padded frame: the ~8px gutter keeps the fixed z-30 host from covering
// RGL's resize handles — they render INSIDE the grid item (a transformed
// stacking context, so no z-index can lift them above the host), and
// without the gutter every handle except n/ne/nw sits under the webview,
// dead. It also lets the tile's border/background visibly frame the embed.
// The rAF loop publishes the INNER div's rect, so the host floats inside
// the gutter.
<div className="h-full w-full p-2">
<div ref={bodyRef} className="h-full w-full" />
</div>
);
}

export function DiscordActions() {
Expand Down
65 changes: 64 additions & 1 deletion apps/renderer/src/widgets/discord/lib.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { parseDiscordUnreadCount } from './lib';
import { nextHostStyle, parseDiscordUnreadCount, zoomForWidth } from './lib';

describe('parseDiscordUnreadCount', () => {
it('extracts the mention count prefix', () => {
Expand All @@ -16,3 +16,66 @@ describe('parseDiscordUnreadCount', () => {
expect(parseDiscordUnreadCount('Server (3) Discord')).toBe(0);
});
});

describe('zoomForWidth', () => {
it('steps down at each breakpoint (boundaries inclusive)', () => {
expect(zoomForWidth(1400)).toBe(1);
expect(zoomForWidth(900)).toBe(1);
expect(zoomForWidth(899)).toBe(0.9);
expect(zoomForWidth(700)).toBe(0.9);
expect(zoomForWidth(699)).toBe(0.85);
expect(zoomForWidth(550)).toBe(0.85);
expect(zoomForWidth(549)).toBe(0.75);
expect(zoomForWidth(0)).toBe(0.75);
});
});

describe('nextHostStyle', () => {
const rect = { x: 40, y: 60, width: 800, height: 500 };
const settled = { width: 800, height: 500 };

it('hides at 0×0 when the tile is gone, settling immediately', () => {
expect(nextHostStyle(null, false, settled)).toEqual({
style: { left: 0, top: 0, width: 0, height: 0 },
settle: 'immediate',
});
// Mid-gesture unmount still hides — hidden wins over the freeze.
expect(nextHostStyle(null, true, settled).style).toEqual({
left: 0,
top: 0,
width: 0,
height: 0,
});
});

it('adopts the live size in one step when nothing has settled yet', () => {
expect(nextHostStyle(rect, false, null)).toEqual({
style: { left: 40, top: 60, width: 800, height: 500 },
settle: 'immediate',
});
});

it('freezes size but tracks position during a gesture', () => {
const midResize = { x: 200, y: 90, width: 640, height: 380 };
expect(nextHostStyle(midResize, true, settled)).toEqual({
style: { left: 200, top: 90, width: 800, height: 500 },
settle: 'hold',
});
});

it('holds when the settled size already matches the rect', () => {
expect(nextHostStyle(rect, false, settled)).toEqual({
style: { left: 40, top: 60, width: 800, height: 500 },
settle: 'hold',
});
});

it('keeps the settled size and asks for a debounce on out-of-gesture size changes', () => {
// e.g. a window resize streaming per-frame rects with interacting=false
expect(nextHostStyle({ ...rect, width: 820 }, false, settled)).toEqual({
style: { left: 40, top: 60, width: 800, height: 500 },
settle: 'debounce',
});
expect(nextHostStyle({ ...rect, height: 510 }, false, settled).settle).toBe('debounce');
});
});
Loading
Loading