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; flex-direction: column;
box-sizing: border-box; box-sizing: border-box;
pointer-events: auto; pointer-events: auto;
/* Frosted glass — same idea as the desktop's "What should Nomos do?" background: var(--card);
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);
color: var(--card-foreground); color: var(--card-foreground);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -273,6 +263,7 @@
outline: none; outline: none;
} }
[data-wm-window][data-wm-focused] { [data-wm-window][data-wm-focused] {
border-color: var(--ring); border-color: var(--ring);
box-shadow: 0 12px 32px oklch(0 0 0 / 0.28); box-shadow: 0 12px 32px oklch(0 0 0 / 0.28);
@@ -287,6 +278,7 @@
display: none; display: none;
} }
[data-wm-resize] { [data-wm-resize] {
position: absolute; position: absolute;
} }

View File

@@ -43,12 +43,12 @@
// last size so reopening restores it. // last size so reopening restores it.
const COLLAPSED_SIZE = 6 const COLLAPSED_SIZE = 6
const OPEN_MIN_SIZE = 12 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 // Reopening must restore a concrete number, never `undefined` — the pane
// only re-triggers the library's resize/equalize pass when `size` changes // only re-triggers the library's resize/equalize pass when `size` changes
// to a different *number*, so setting it back to `undefined` silently // to a different *number*, so setting it back to `undefined` silently
// no-ops and leaves the section stuck at its collapsed height. // 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) { function toggleSection(i: number, isOpen: boolean) {
if (isOpen) { if (isOpen) {

View File

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

View File

@@ -18,6 +18,26 @@ import { heading } from '$lib/tasks'
export const SESSION_PREFIX = 'session:' export const SESSION_PREFIX = 'session:'
export const wm = createManager({ defaultSize: { width: 480, height: 560 } }) 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, { export const dk = createDesktop(wm, {
// topEdge:'maximize' + preview gives the classic drag-to-top-maximizes // topEdge:'maximize' + preview gives the classic drag-to-top-maximizes
// affordance; magnetism/keyboard are wmkit defaults worth turning on now // affordance; magnetism/keyboard are wmkit defaults worth turning on now
@@ -94,14 +114,14 @@ export function openAppWindow(appId: string): void {
wm.focus(id) wm.focus(id)
return return
} }
wm.open({ wm.open(clampToDesktop({
id, id,
title: app.title, title: app.title,
width: app.width, width: app.width,
height: app.height, height: app.height,
minWidth: app.minWidth, minWidth: app.minWidth,
minHeight: app.minHeight minHeight: app.minHeight
}) }))
} }
// Opens a window for the entity, or focuses (and restores, if minimized) the // 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) wm.focus(slug)
return 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 // 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) wm.focus(NEW_TASK_WINDOW_ID)
return 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 // 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) wm.focus(id)
return 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>[] = [ 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: '_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: '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' }, { key: 'last_active_at', header: 'Last active', render: 'relative-time', sortable: true, width: '112px', align: 'right' },
@@ -66,7 +66,7 @@
) )
</script> </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"> <div class="relative z-10 flex flex-wrap items-center gap-1.5">
{#each FILTERS as f} {#each FILTERS as f}