From c151a66627b6ba1d2c0a4342155c3b4016e2c9b0 Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 21 Jul 2026 18:08:40 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20window/table=20polish=20=E2=80=94?= =?UTF-8?q?=20opaque=20windows,=20sortable=20columns,=20sticky-header=20sc?= =?UTF-8?q?rollbar,=20viewport-clamped=20windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Windows: make floating windows fully opaque (drop backdrop-blur/color-mix transparency), add margin around windows, reduce Overview padding to p-2. - DataTable: fix Toolbar always rendering an empty padded bar (children slot was always truthy regardless of actual content); split header/body into separate tables so the scrollbar no longer overlaps the sticky header; make sort work for derived/synthetic columns by sorting on the column's accessor instead of a nonexistent row key. - Overview: enable sorting on Status and Task columns via accessors. - TaskContextPanel: give the Activity pane more height by default (Scope 30% / Activity 70%), fixing that the saved split sizes were never actually applied to the bound Pane sizes. - windows.ts: clamp new/resized windows to the desktop viewport so content-heavy entity windows can't grow taller than the visible screen; fixes a bad defaultSize.height ('30vh', an invalid non-numeric value) that had silently left window height unconstrained. Co-Authored-By: Claude Sonnet 5 --- web/src/app.css | 14 +- .../lib/components/TaskContextPanel.svelte | 4 +- .../components/data-table/DataTable.svelte | 134 +++++++++--------- web/src/lib/stores/windows.ts | 30 +++- web/src/pages/Overview.svelte | 4 +- 5 files changed, 99 insertions(+), 87 deletions(-) diff --git a/web/src/app.css b/web/src/app.css index 290f9a6..04c50cc 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -254,17 +254,7 @@ flex-direction: column; box-sizing: border-box; pointer-events: auto; - /* Frosted glass — same idea as the desktop's "What should Nomos do?" - launcher card (bg-card/70 backdrop-blur), tuned less transparent - (85%, not 70%) because backdrop-filter's blur strength isn't - consistent across engines — Firefox blurs noticeably less than - Chromium at the same radius, so a Chromium-tuned opacity reads as - "way too see-through" there (2026-07-21). Leaning on a higher base - opacity keeps windows legible everywhere; the blur is a bonus on - top, not what's carrying the effect. */ - background: color-mix(in oklab, var(--card) 85%, transparent); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); + background: var(--card); color: var(--card-foreground); border: 1px solid var(--border); border-radius: var(--radius-lg); @@ -273,6 +263,7 @@ outline: none; } + [data-wm-window][data-wm-focused] { border-color: var(--ring); box-shadow: 0 12px 32px oklch(0 0 0 / 0.28); @@ -287,6 +278,7 @@ display: none; } + [data-wm-resize] { position: absolute; } diff --git a/web/src/lib/components/TaskContextPanel.svelte b/web/src/lib/components/TaskContextPanel.svelte index 163b81b..e9e6472 100644 --- a/web/src/lib/components/TaskContextPanel.svelte +++ b/web/src/lib/components/TaskContextPanel.svelte @@ -43,12 +43,12 @@ // last size so reopening restores it. const COLLAPSED_SIZE = 6 const OPEN_MIN_SIZE = 12 - let sizes = $state<(number | undefined)[]>([undefined, undefined]) + let sizes = $state<(number | undefined)[]>([30, 70]) // Reopening must restore a concrete number, never `undefined` — the pane // only re-triggers the library's resize/equalize pass when `size` changes // to a different *number*, so setting it back to `undefined` silently // no-ops and leaves the section stuck at its collapsed height. - let savedSizes: number[] = [34, 66] + let savedSizes: number[] = [30, 70] function toggleSection(i: number, isOpen: boolean) { if (isOpen) { diff --git a/web/src/lib/components/data-table/DataTable.svelte b/web/src/lib/components/data-table/DataTable.svelte index 5ec6fe2..0a124aa 100644 --- a/web/src/lib/components/data-table/DataTable.svelte +++ b/web/src/lib/components/data-table/DataTable.svelte @@ -59,11 +59,11 @@ // via $derived runes internally. const sortBuilders = new Map>() - function getSortBuilder(key: string) { - if (!sortBuilders.has(key)) { - sortBuilders.set(key, table.createSort(key)) + function getSortBuilder(col: DataTableColumn) { + if (!sortBuilders.has(col.key)) { + sortBuilders.set(col.key, table.createSort(col.accessor ?? col.key)) } - return sortBuilders.get(key)! + return sortBuilders.get(col.key)! } let search = $state.raw( @@ -117,13 +117,9 @@
- - {#if children} - {@render children()} - {/if} - + -
+
@@ -131,14 +127,14 @@ - - {#if loading} - {#each skeletonWidths as w, i} - - {#each visibleCols as col (col.key)} - - {/each} - - {/each} - {:else if rows.length === 0} - - {:else} - {#each rows as row, idx (row.id ?? row.slug ?? `row-${idx}`)} - onRowClick(row) : undefined} - onkeydown={onRowClick - ? (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRowClick(row) } } - : undefined} - > - {#each visibleCols as col (col.key)} - {@const val = resolveCellValue(row, col)} -
{#if col.sortable !== false} - {@const sb = getSortBuilder(col.key)} + {@const sb = getSortBuilder(col)}
- -
- {#if typeof col.render === 'string'} - {@const R = renderers[col.render]} - {#if R} - - +
+
+ + + {#if loading} + {#each skeletonWidths as w, i} + + {#each visibleCols as col (col.key)} + + {/each} + + {/each} + {:else if rows.length === 0} + + {:else} + {#each rows as row, idx (row.id ?? row.slug ?? `row-${idx}`)} + onRowClick(row) : undefined} + onkeydown={onRowClick + ? (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRowClick(row) } } + : undefined} + > + {#each visibleCols as col (col.key)} + {@const val = resolveCellValue(row, col)} + - {/each} - - {/each} - {/if} - -
+ +
+ {#if typeof col.render === 'string'} + {@const R = renderers[col.render]} + {#if R} + + + {:else} + {String(val ?? '—')} + {/if} + {:else if typeof col.render === 'function'} + {:else} {String(val ?? '—')} {/if} - {:else if typeof col.render === 'function'} - - {:else} - {String(val ?? '—')} - {/if} -
+ + {/each} + + {/each} + {/if} + + +
{#if paginated} diff --git a/web/src/lib/stores/windows.ts b/web/src/lib/stores/windows.ts index 737872d..583ab3d 100644 --- a/web/src/lib/stores/windows.ts +++ b/web/src/lib/stores/windows.ts @@ -18,6 +18,26 @@ import { heading } from '$lib/tasks' export const SESSION_PREFIX = 'session:' export const wm = createManager({ defaultSize: { width: 480, height: 560 } }) + +// New windows (and manual resizing) must never exceed the visible desktop +// area — without this, a content-heavy entity window (many Details/ +// Relations/Tasks sections) grows taller than the viewport with no way to +// reach its own titlebar controls. Clamps requested width/height down to +// the current viewport and caps maxWidth/maxHeight the same way, so +// dragging a resize handle can't push it past the edge either. +function clampToDesktop( + init: T +): T { + const { viewport } = wm.getState() + if (viewport.width <= 0 || viewport.height <= 0) return init + return { + ...init, + width: init.width !== undefined ? Math.min(init.width, viewport.width) : undefined, + height: init.height !== undefined ? Math.min(init.height, viewport.height) : undefined, + maxWidth: Math.min(init.maxWidth ?? viewport.width, viewport.width), + maxHeight: Math.min(init.maxHeight ?? viewport.height, viewport.height) + } +} export const dk = createDesktop(wm, { // topEdge:'maximize' + preview gives the classic drag-to-top-maximizes // affordance; magnetism/keyboard are wmkit defaults worth turning on now @@ -94,14 +114,14 @@ export function openAppWindow(appId: string): void { wm.focus(id) return } - wm.open({ + wm.open(clampToDesktop({ id, title: app.title, width: app.width, height: app.height, minWidth: app.minWidth, minHeight: app.minHeight - }) + })) } // Opens a window for the entity, or focuses (and restores, if minimized) the @@ -115,7 +135,7 @@ export function openEntityWindow(slug: string | null): void { wm.focus(slug) return } - wm.open({ id: slug, title: slug }) + wm.open(clampToDesktop({ id: slug, title: slug })) } // Singleton "compose a new task" window — the Tasks app's New Task button @@ -132,7 +152,7 @@ export function openNewTaskWindow(): void { wm.focus(NEW_TASK_WINDOW_ID) return } - wm.open({ id: NEW_TASK_WINDOW_ID, title: 'New task', width: 900, height: 640, minWidth: 600, minHeight: 400 }) + wm.open(clampToDesktop({ id: NEW_TASK_WINDOW_ID, title: 'New task', width: 900, height: 640, minWidth: 600, minHeight: 400 })) } // Same dedupe/restore/focus pattern as openEntityWindow, for a task/session's @@ -149,5 +169,5 @@ export function openTaskWindow(sessionId: string | null, title: string): void { wm.focus(id) return } - wm.open({ id, title, width: 900, height: 640, minWidth: 600, minHeight: 400 }) + wm.open(clampToDesktop({ id, title, width: 900, height: 640, minWidth: 600, minHeight: 400 })) } diff --git a/web/src/pages/Overview.svelte b/web/src/pages/Overview.svelte index e9a3155..35f0ce0 100644 --- a/web/src/pages/Overview.svelte +++ b/web/src/pages/Overview.svelte @@ -53,7 +53,7 @@ }) const columns: DataTableColumn[] = [ - { key: '_status', header: 'Status', render: StatusDotRenderer, width: '140px' }, + { key: '_status', header: 'Status', render: StatusDotRenderer, sortable: true, accessor: (s) => bucket(s), width: '140px' }, { key: '_heading', header: 'Task', sortable: true, accessor: heading, truncate: true }, { key: 'summary', header: 'Summary', accessor: (s) => s.summary || '—', truncate: true, headerClass: 'hidden md:table-cell', class: 'hidden md:table-cell' }, { key: 'last_active_at', header: 'Last active', render: 'relative-time', sortable: true, width: '112px', align: 'right' }, @@ -66,7 +66,7 @@ ) -
+
{#each FILTERS as f}