Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion lib/client.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion src/client/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,19 @@ body[data-ds-dark-theme] .dsh-atb-modal-body select option {
.dsh-atb-ws-group-head {
display: flex; align-items: center; gap: 5px; padding: 3px 6px; border-radius: 6px;
font-size: 11.5px; font-weight: 600; color: var(--dsw-text-secondary, inherit);
background: var(--dsw-bg-hover, rgba(128,128,128,.08)); user-select: none;
/* Sticky project header: the column head above never scrolls (it
lives outside the scroller), but the project row used to ride up with its
cards — on a long column the project name left the screen first. Pin it to
the top of the card scroller so the group you are reading stays labelled.
The fill must be OPAQUE: three stacked layers reproduce exactly what sits
behind the row (board base + column inset + hover tint), so cards passing
underneath never bleed through the label. */
position: sticky; top: 0; z-index: 2;
background:
linear-gradient(var(--dsw-bg-hover, rgba(128,128,128,.08)), var(--dsw-bg-hover, rgba(128,128,128,.08))),
linear-gradient(var(--dsw-bg-inset, rgba(128,128,128,.07)), var(--dsw-bg-inset, rgba(128,128,128,.07))),
var(--dsw-alias-bg-base, #fff);
user-select: none;
}
.dsh-atb-ws-group-icon { display: inline-flex; align-items: center; justify-content: center; opacity: .8; line-height: 1; }
.dsh-atb-ws-group-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
Expand Down
123 changes: 123 additions & 0 deletions tests/sticky-group-head.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// @vitest-environment jsdom
/**
* Sticky project headers inside each column's card scroller.
*
* The board used to lose its project label the moment a column scrolled: the
* column head sits OUTSIDE the scroller (so it never moved), but the project
* row rode up with its cards, leaving a long column unlabelled. The header is
* now pinned with position: sticky.
*
* Two things can silently break that pinning, and both are asserted here:
* 1. the sticky header's ancestor chain must actually contain the scrolling
* box (a sticky element whose ancestor has overflow: hidden / transform,
* or that is not a descendant of the scroller at all, simply stops
* sticking);
* 2. the header fill must be OPAQUE — cards slide underneath it, so a
* transparent background would let their text bleed through the label.
*/
import React from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { BoardController } from '../src/client/controller.ts'
import { TaskBoard } from '../src/client/board/TaskBoard.tsx'
import type { TaskRecord } from '../src/shared/protocol.ts'
import { waitFor } from './wait-for.ts'
import { disposeI18n } from '../src/client/i18n/runtime.ts'

const cleanups: Array<() => void> = []
beforeEach(() => {
localStorage.clear()
document.documentElement.lang = 'zh-CN'
disposeI18n()
})
afterEach(() => {
cleanups.splice(0).reverse().forEach(d => d())
localStorage.clear()
})

/** One task per workspace so each column renders several project groups. */
function makeTask(id: string, workspaceId: string): TaskRecord {
return {
id,
title: `Task ${id}`,
description: '',
prompt: '',
workspaceId,
urgency: 'normal',
status: 'todo',
blocked: false,
execution: { mode: 'claim' },
version: 1,
createdAt: 1,
updatedAt: 1,
createdBy: { kind: 'user' },
updatedBy: { kind: 'user' },
comments: [],
executions: [],
}
}

async function renderBoard(tasks: TaskRecord[]): Promise<HTMLElement> {
const controller = new BoardController({
state: async () => ({ schemaVersion: 1, revision: 1, tasks }),
workspaces: async () => [
{ id: 'ws-a', path: '/p/a', title: 'Alpha Repo', sessionCount: 0 },
{ id: 'ws-b', path: '/p/b', title: 'Beta Repo', sessionCount: 0 },
],
stream: () => () => {},
} as never)
controller.start()
cleanups.push(() => controller.dispose())
await waitFor(() => controller.getSnapshot().workspaces.length === 2)

const host = document.createElement('div')
document.body.append(host)
const root: Root = createRoot(host)
cleanups.push(() => { root.unmount(); host.remove() })
root.render(React.createElement(TaskBoard, { controller }))
return host
}

describe('sticky project group headers', () => {
it('pins .dsh-atb-ws-group-head to the top of its card scroller', async () => {
const host = await renderBoard([makeTask('a-1', 'ws-a'), makeTask('a-2', 'ws-a'), makeTask('b-1', 'ws-b')])
await waitFor(() => host.querySelectorAll('.dsh-atb-column').length === 5)

const heads = Array.from(host.querySelectorAll<HTMLElement>('.dsh-atb-ws-group-head'))
expect(heads.length).toBeGreaterThan(0)

for (const head of heads) {
// Structural precondition: the header must live INSIDE the element that
// actually scrolls, otherwise position: sticky has no scrollport to
// stick to and silently degrades to a normal row.
const scroller = head.closest('.dsh-atb-cards')
expect(scroller, 'group header must be a descendant of the card scroller').not.toBeNull()

// No ancestor between the header and its scroller may clip or transform,
// which would neuter sticky.
for (let node = head.parentElement; node !== null && node !== scroller; node = node.parentElement) {
expect(node.classList.contains('dsh-atb-column'), 'column must not sit between header and scroller').toBe(false)
}
}
})

it('stylesheet carries the sticky rule with an opaque fill', async () => {
const { injectStyles } = await import('../src/client/styles.ts')
document.getElementById('dsh-taskboard-styles')?.remove()
injectStyles()
const css = document.getElementById('dsh-taskboard-styles')!.textContent ?? ''

// Isolate the group-header rule so an unrelated sticky elsewhere in the
// sheet cannot satisfy the assertion.
const rule = /\.dsh-atb-ws-group-head\s*\{([^}]*)\}/.exec(css)
expect(rule, '.dsh-atb-ws-group-head rule must exist').not.toBeNull()
const body = rule![1]!

expect(body).toContain('position: sticky')
expect(body).toContain('top: 0')
// Stacked layer + opaque base: cards sliding under the label must not show
// through. A bare rgba tint (what the row used to carry) would.
expect(body).toContain('var(--dsw-alias-bg-base')
expect(body).not.toMatch(/background:\s*(transparent|none)\s*;/)
})
})