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

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