From 478853bd4a776d11f7ea44934b1c2c47a5b06ae0 Mon Sep 17 00:00:00 2001 From: Nishant Arora <15331971+nishant@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:10:31 -0500 Subject: [PATCH 1/3] fix: Discord embed polish - resize handles, gesture freeze, auto zoom, compact mode - Padded frame: tile body gets a p-2 gutter and publishes the INNER rect, exposing RGL's edge/corner resize handles (previously covered by the fixed z-30 host - only n/ne/nw worked) and framing the embed with the tile chrome; host rounds all four corners now. - Gesture freeze: discordStore.interacting (set by any grid drag/resize via DashboardGrid gesture callbacks) freezes the host's size at the last settled value and mutes its pointer events while position keeps tracking; final size applies once on gesture end. Out-of-gesture size changes settle via a 120ms debounce. Decision logic is a pure helper (nextHostStyle) with unit tests. - Auto zoom: zoomForWidth steps the guest zoom (>=900 1.0 / >=700 0.9 / >=550 0.85 / else 0.75) on dom-ready and on settled-width breakpoint crossings; webview-tag setZoomFactor/insertCSS signatures pinned against electron@33.4.11 typings. - Compact CSS: fail-soft [class*=membersWrap] hide injected on every dom-ready (inserted CSS doesn't survive reloads); server rail and channel sidebar deliberately kept. - Loading splash now Discord dark (#313338) instead of theme surface. Co-Authored-By: Claude Opus 4.8 --- .../renderer/src/components/DashboardGrid.tsx | 18 +++- apps/renderer/src/store/discordStore.ts | 8 ++ .../src/widgets/discord/DiscordHost.tsx | 83 +++++++++++++++++-- .../src/widgets/discord/DiscordWidget.tsx | 13 ++- apps/renderer/src/widgets/discord/lib.test.ts | 65 ++++++++++++++- apps/renderer/src/widgets/discord/lib.ts | 75 ++++++++++++++++- 6 files changed, 250 insertions(+), 12 deletions(-) diff --git a/apps/renderer/src/components/DashboardGrid.tsx b/apps/renderer/src/components/DashboardGrid.tsx index 5080560..bfc8919 100644 --- a/apps/renderer/src/components/DashboardGrid.tsx +++ b/apps/renderer/src/components/DashboardGrid.tsx @@ -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'; @@ -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); @@ -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 diff --git a/apps/renderer/src/store/discordStore.ts b/apps/renderer/src/store/discordStore.ts index 9932dd7..1eb1ab0 100644 --- a/apps/renderer/src/store/discordStore.ts +++ b/apps/renderer/src/store/discordStore.ts @@ -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. */ @@ -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; @@ -48,11 +54,13 @@ interface DiscordState { export const useDiscordStore = create((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 }), diff --git a/apps/renderer/src/widgets/discord/DiscordHost.tsx b/apps/renderer/src/widgets/discord/DiscordHost.tsx index 5836874..c510bdf 100644 --- a/apps/renderer/src/widgets/discord/DiscordHost.tsx +++ b/apps/renderer/src/widgets/discord/DiscordHost.tsx @@ -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, @@ -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(null); const [status, setStatus] = useState('loading'); const [shareReq, setShareReq] = useState(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(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); @@ -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; @@ -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(() => { @@ -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 ( -
+
-
+ // Discord's own dark chrome color — the splash must not flash the + // app theme (white in light mode) before the guest paints. +
+
)} diff --git a/apps/renderer/src/widgets/discord/DiscordWidget.tsx b/apps/renderer/src/widgets/discord/DiscordWidget.tsx index 53d9a0c..a1ae979 100644 --- a/apps/renderer/src/widgets/discord/DiscordWidget.tsx +++ b/apps/renderer/src/widgets/discord/DiscordWidget.tsx @@ -53,7 +53,18 @@ export function DiscordWidget() { ); } - return
; + 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. +
+
+
+ ); } export function DiscordActions() { diff --git a/apps/renderer/src/widgets/discord/lib.test.ts b/apps/renderer/src/widgets/discord/lib.test.ts index 64f7f75..2a32d9d 100644 --- a/apps/renderer/src/widgets/discord/lib.test.ts +++ b/apps/renderer/src/widgets/discord/lib.test.ts @@ -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', () => { @@ -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'); + }); +}); diff --git a/apps/renderer/src/widgets/discord/lib.ts b/apps/renderer/src/widgets/discord/lib.ts index 29b83ff..ad1d8e6 100644 --- a/apps/renderer/src/widgets/discord/lib.ts +++ b/apps/renderer/src/widgets/discord/lib.ts @@ -3,11 +3,18 @@ // typed there); only the runtime method surface + event payloads we drive are // declared here. The renderer deliberately has no electron types dependency. -/** The subset of Electron's WebviewTag API the Discord widget uses. */ +import type { DiscordHostRect } from '../../store/discordStore'; + +/** The subset of Electron's WebviewTag API the Discord widget uses. + * Signatures pinned against electron@33.4.11 electron.d.ts (WebviewTag). */ export interface DiscordWebviewElement extends HTMLElement { reload(): void; loadURL(url: string): Promise; getURL(): string; + /** Guest zoom. Throws if called before the webview's first dom-ready. */ + setZoomFactor(factor: number): void; + /** Resolves with a removal key. Inserted CSS does NOT survive navigation. */ + insertCSS(css: string): Promise; } /** `did-fail-load` webview DOM event payload (subset). */ @@ -32,3 +39,69 @@ export function parseDiscordUnreadCount(title: string): number { const m = /^\((\d+)\)/.exec(title.trim()); return m ? parseInt(m[1], 10) : 0; } + +/** Guest zoom factor for a settled tile width. Discord's desktop layout is + * designed for ~1000px+ windows; stepping the zoom down keeps the channel + * sidebar, chat column, and composer usable inside a dashboard tile. */ +export function zoomForWidth(width: number): number { + if (width >= 900) return 1; + if (width >= 700) return 0.9; + if (width >= 550) return 0.85; + return 0.75; +} + +/** Compact-mode CSS injected on every dom-ready (inserted CSS doesn't survive + * reload/navigation). Discord's class names are build-hashed but keep their + * semantic prefix (e.g. `membersWrap_c8ffbb`), so match on the prefix. + * FAIL-SOFT by design: if Discord renames the prefix the selector matches + * nothing and the stock layout shows — nothing breaks. Selector verified + * against discord.com 2026-07; may rot with a Discord redesign. + * Deliberately does NOT hide the server rail or channel sidebar. */ +export const DISCORD_COMPACT_CSS = '[class*="membersWrap"] { display: none !important; }'; + +/** Last width/height the host actually applied to the webview container. */ +export interface DiscordHostSize { + width: number; + height: number; +} + +/** How the host should adopt the live rect's size as the new settled size: + * 'immediate' — right now; 'debounce' — after the ~120ms settle delay; + * 'hold' — not at all (mid-gesture, or nothing changed). Position never + * settles — it is always applied live. */ +export type DiscordSizeSettle = 'immediate' | 'debounce' | 'hold'; + +export interface DiscordHostStyleDecision { + style: { left: number; top: number; width: number; height: number }; + settle: DiscordSizeSettle; +} + +/** Host geometry for one frame. Position always tracks the live rect (moving + * a fixed div is cheap); width/height only ever take the SETTLED size — + * resizing the guest WebContents forces an async relayout per change, so + * per-mousemove sizes tear and thrash Discord's breakpoints. During grid + * gestures (`interacting`) the size is frozen outright; outside them, size + * changes settle via the caller's debounce. + * Exported for tests. */ +export function nextHostStyle( + rect: DiscordHostRect | null, + interacting: boolean, + settledSize: DiscordHostSize | null, +): DiscordHostStyleDecision { + // Tile hidden → 0×0 instantly (NEVER display:none/unmount — guest dies). + if (!rect) { + return { style: { left: 0, top: 0, width: 0, height: 0 }, settle: 'immediate' }; + } + // Nothing settled yet (first show, or re-show after hiding) — nothing to + // freeze to, so take the live size in one step. + if (!settledSize) { + return { + style: { left: rect.x, top: rect.y, width: rect.width, height: rect.height }, + settle: 'immediate', + }; + } + const style = { left: rect.x, top: rect.y, width: settledSize.width, height: settledSize.height }; + if (interacting) return { style, settle: 'hold' }; + const sizeChanged = rect.width !== settledSize.width || rect.height !== settledSize.height; + return { style, settle: sizeChanged ? 'debounce' : 'hold' }; +} From c2cadcfa2aa581b71746d909dd991c0e0231dc62 Mon Sep 17 00:00:00 2001 From: Nishant Arora <15331971+nishant@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:12:18 -0500 Subject: [PATCH 2/3] docs: changelog entry for Discord embed polish Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc30bb4..d2cfad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ All changes organized by pull request, newest first. Format is documented under --- +## [PR #TBD] 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 `` 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 `` 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` From 71735c21992a99eeabe5ae67c7c5212fbd5a3149 Mon Sep 17 00:00:00 2001 From: Nishant Arora <15331971+nishant@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:13:48 -0500 Subject: [PATCH 3/3] docs: backfill PR #123 into changelog entry Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2cfad4..14a5ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All changes organized by pull request, newest first. Format is documented under --- -## [PR #TBD] fix: Discord embed feels like a widget — live resize handles, gesture-frozen resizing, auto zoom, compact CSS +## [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