fix(web): window/table polish — opaque windows, sortable columns, sticky-header scrollbar, viewport-clamped windows
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 18:08:40 +02:00
parent 1c12d40712
commit c151a66627
5 changed files with 99 additions and 87 deletions

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -59,11 +59,11 @@
// via $derived runes internally.
const sortBuilders = new Map<string, ReturnType<typeof table.createSort>>()
function getSortBuilder(key: string) {
if (!sortBuilders.has(key)) {
sortBuilders.set(key, table.createSort(key))
function getSortBuilder(col: DataTableColumn<Row>) {
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 @@
</script>
<div class={['flex flex-col h-full min-h-0', className].filter(Boolean).join(' ')}>
<Toolbar {table} {searchable} {paginated} onSearchChange={handleSearch}>
{#if children}
{@render children()}
{/if}
</Toolbar>
<Toolbar {table} {searchable} {paginated} onSearchChange={handleSearch} {children} />
<div class={['min-h-0 flex-1 overflow-auto relative', bordered ? 'rounded-xl border' : ''].filter(Boolean).join(' ')}>
<div class={['flex flex-col min-h-0 flex-1', bordered ? 'rounded-xl border' : ''].filter(Boolean).join(' ')}>
<table class="w-full caption-bottom text-sm table-fixed">
<thead class="[&_tr]:border-b">
<tr>
@@ -131,14 +127,14 @@
<th
class={[
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap',
'sticky top-0 z-10 bg-card/95 backdrop-blur',
'bg-card/95',
col.headerClass,
colAlignClass(col)
].filter(Boolean).join(' ')}
style={colStyle(col)}
>
{#if col.sortable !== false}
{@const sb = getSortBuilder(col.key)}
{@const sb = getSortBuilder(col)}
<SortHeader
label={col.header}
sorted={sb.isActive}
@@ -152,64 +148,68 @@
{/each}
</tr>
</thead>
<tbody class="[&_tr:last-child]:border-0">
{#if loading}
{#each skeletonWidths as w, i}
<tr class="border-b transition-colors hover:bg-transparent">
{#each visibleCols as col (col.key)}
<td class={[col.class, colAlignClass(col), colTruncateClass(col)].filter(Boolean).join(' ')} style={colStyle(col)}>
<Skeleton class="h-4 {skeletonWidths[(i + visibleCols.indexOf(col)) % skeletonWidths.length]}" />
</td>
{/each}
</tr>
{/each}
{:else if rows.length === 0}
<EmptyState message={emptyMessage} colspan={visibleCols.length} />
{:else}
{#each rows as row, idx (row.id ?? row.slug ?? `row-${idx}`)}
<tr
class={[
'border-b transition-colors hover:bg-muted/50',
onRowClick ? 'cursor-pointer' : '',
selected === (row.id ?? row.slug) ? 'bg-muted' : ''
].filter(Boolean).join(' ')}
tabindex={onRowClick ? 0 : undefined}
onclick={onRowClick ? () => 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)}
<td
class={[
'p-2 align-middle whitespace-nowrap',
col.class,
colAlignClass(col),
colTruncateClass(col)
].filter(Boolean).join(' ')}
style={colStyle(col)}
>
{#if typeof col.render === 'string'}
{@const R = renderers[col.render]}
{#if R}
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
<R value={val} {row} {...(col.renderProps ?? {})} />
</table>
<div class="min-h-0 flex-1 overflow-y-auto">
<table class="w-full caption-bottom text-sm table-fixed">
<tbody class="[&_tr:last-child]:border-0">
{#if loading}
{#each skeletonWidths as w, i}
<tr class="border-b transition-colors hover:bg-transparent">
{#each visibleCols as col (col.key)}
<td class={[col.class, colAlignClass(col), colTruncateClass(col)].filter(Boolean).join(' ')} style={colStyle(col)}>
<Skeleton class="h-4 {skeletonWidths[(i + visibleCols.indexOf(col)) % skeletonWidths.length]}" />
</td>
{/each}
</tr>
{/each}
{:else if rows.length === 0}
<EmptyState message={emptyMessage} colspan={visibleCols.length} />
{:else}
{#each rows as row, idx (row.id ?? row.slug ?? `row-${idx}`)}
<tr
class={[
'border-b transition-colors hover:bg-muted/50',
onRowClick ? 'cursor-pointer' : '',
selected === (row.id ?? row.slug) ? 'bg-muted' : ''
].filter(Boolean).join(' ')}
tabindex={onRowClick ? 0 : undefined}
onclick={onRowClick ? () => 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)}
<td
class={[
'p-2 align-middle whitespace-nowrap',
col.class,
colAlignClass(col),
colTruncateClass(col)
].filter(Boolean).join(' ')}
style={colStyle(col)}
>
{#if typeof col.render === 'string'}
{@const R = renderers[col.render]}
{#if R}
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
<R value={val} {row} {...(col.renderProps ?? {})} />
{:else}
{String(val ?? '—')}
{/if}
{:else if typeof col.render === 'function'}
<col.render {row} value={val} {...(col.renderProps ?? {})} />
{:else}
{String(val ?? '—')}
{/if}
{:else if typeof col.render === 'function'}
<col.render {row} value={val} {...(col.renderProps ?? {})} />
{:else}
{String(val ?? '—')}
{/if}
</td>
{/each}
</tr>
{/each}
{/if}
</tbody>
</table>
</td>
{/each}
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
{#if paginated}

View File

@@ -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<T extends { width?: number; height?: number; maxWidth?: number; maxHeight?: number }>(
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 }))
}

View File

@@ -53,7 +53,7 @@
})
const columns: DataTableColumn<Session>[] = [
{ 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 @@
)
</script>
<div class="relative flex h-full flex-col gap-3 overflow-hidden p-4">
<div class="relative flex h-full flex-col gap-3 overflow-hidden p-2">
<div class="relative z-10 flex flex-wrap items-center gap-1.5">
{#each FILTERS as f}