fix(web): window/table polish — opaque windows, sortable columns, sticky-header scrollbar, viewport-clamped windows
- 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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user