diff --git a/src/app/composer-zoom-smoke.test.ts b/src/app/composer-zoom-smoke.test.ts index 83851574c..800803a91 100644 --- a/src/app/composer-zoom-smoke.test.ts +++ b/src/app/composer-zoom-smoke.test.ts @@ -30,7 +30,7 @@ const INPUT_SELECTORS = [ "input", "textarea", "select", - ".board-search-input", + ".board-token-input", ".board-drawer-field-input", ".board-drawer-field-textarea", ".board-drawer-field-select", diff --git a/src/components/a11y-audit-fixes.test.ts b/src/components/a11y-audit-fixes.test.ts index 06700d8ce..a9200ae83 100644 --- a/src/components/a11y-audit-fixes.test.ts +++ b/src/components/a11y-audit-fixes.test.ts @@ -44,17 +44,20 @@ test("nav count badge uses a solid accent fill (WCAG contrast)", async () => { ); }); -test("accent-filled buttons pair the accent with its semantic foreground", async () => { - // White / --text-primary on --accent-presence failed AA (~2.8:1 dark). The - // accent's paired --accent-presence-foreground adapts per mode, so route to it. +test("filled action buttons pair the fill with its semantic foreground", async () => { + // White / --text-primary on --accent-presence failed AA (~2.8:1 dark), so + // filled buttons must route to the fill's paired foreground token, which + // adapts per mode. The Tasks redesign makes New task a --primary CTA (Coven + // design language: accent is presence, not the CTA colour) — --primary- + // foreground is its designed contrast pair, so the AA guarantee is preserved. const board = await readFile(new URL("../styles/board.css", import.meta.url), "utf8"); assert.match( board, - /\.board-new-card-btn\s*\{[^}]*background:var\(--accent-presence\)[^}]*color:var\(--accent-presence-foreground\)/, + /\.board-new-card-btn\s*\{[^}]*background:var\(--primary\)[^}]*color:var\(--primary-foreground\)/, ); assert.doesNotMatch( board, - /\.board-new-card-btn\s*\{[^}]*background:var\(--accent-presence\)[^}]*color:var\(--text-primary\)/, + /\.board-new-card-btn\s*\{[^}]*color:var\(--text-primary\)/, ); const css = await readFile(new URL("../app/globals.css", import.meta.url), "utf8"); // Both salem accent action states use the paired foreground, not hardcoded #fff. diff --git a/src/components/board-bulk-select.test.ts b/src/components/board-bulk-select.test.ts index 8a5fc5fd2..4a1764ad7 100644 --- a/src/components/board-bulk-select.test.ts +++ b/src/components/board-bulk-select.test.ts @@ -23,8 +23,9 @@ assert.match(view, /[\s\S]*?onChange=\{\(next assert.match(view, /list="board-bulk-label-options"/, "label input is backed by a datalist of existing labels"); assert.match(view, /void bulkAddLabel\(labelDraft\)/, "label form submits bulkAddLabel"); -// §8 chrome budget: entering select mode is an occasional verb — it lives in -// the shared overflow menu rather than as an always-visible toolbar button. +// Redesign: Select-multiple and Delete-selected are first-class toolbar verbs +// (visible icon buttons), while the overflow keeps the occasional Clear-done. assert.match( view, //, @@ -53,8 +54,13 @@ assert.match( ); assert.match( view, - / cardSelect\.setSelectMode\(true\)\}/, - "Select multiple is an overflow-menu item", + /className=\{`board-icon-btn\$\{cardSelect\.selectMode \? " board-icon-btn--active" : ""\}`\}/, + "Select multiple is a visible toolbar icon button", +); +assert.match( + view, + /icon="ph:trash"\s*\n\s*danger\s*\n\s*disabled=\{doneCards\.length === 0\}/, + "Clear done remains the overflow-menu item", ); console.log("board-bulk-select.test.ts: ok"); diff --git a/src/components/board-filter-menu.tsx b/src/components/board-filter-menu.tsx new file mode 100644 index 000000000..53447116c --- /dev/null +++ b/src/components/board-filter-menu.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState } from "react"; +import { Icon } from "@/lib/icon"; +import type { CardStatus } from "@/lib/cave-board-types"; + +export type FilterOption = { id: string; label: string; checked: boolean }; + +type Props = { + /** Which dimension the menu filters — mirrors the active group tab. */ + dimension: "status" | "project"; + statusOptions: { id: CardStatus; label: string; checked: boolean }[]; + projectOptions: FilterOption[]; + onToggleStatus: (id: CardStatus) => void; + onToggleProject: (id: string) => void; + onSelectAll: () => void; + /** Count of active (checked) options and the total, for the badge + "N/M". */ + activeCount: number; + totalCount: number; +}; + +export function BoardFilterMenu({ + dimension, + statusOptions, + projectOptions, + onToggleStatus, + onToggleProject, + onSelectAll, + activeCount, + totalCount, +}: Props) { + const [open, setOpen] = useState(false); + const hasFilters = activeCount < totalCount; + + return ( +
+ {open ?
setOpen(false)} /> : null} + + {open ? ( +
+
+ + Filter by {dimension === "project" ? "project" : "status"} + + {hasFilters ? ( + + ) : null} +
+ {dimension === "status" + ? statusOptions.map((o) => ( + + )) + : projectOptions.map((o) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/board-token-search.tsx b/src/components/board-token-search.tsx new file mode 100644 index 000000000..500c592cf --- /dev/null +++ b/src/components/board-token-search.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Icon } from "@/lib/icon"; +import type { Card } from "@/lib/cave-board-types"; +import type { Familiar } from "@/lib/types"; +import { parseBoardSearchQuery } from "@/lib/board-search"; + +/** A committed filter chip (key:value) shown inside the search field. */ +type Chip = { key: string; value: string }; + +/** Search keys the token composer offers as typeahead. Mirrors the vocabulary + * `cardMatchesBoardSearch` understands, so every suggested chip is a real, + * applied filter — not decoration. Value lists are derived from live board + * data where the set is dynamic (familiar, cwd) and pinned where it's a fixed + * enum (is, priority, status). */ +const SEARCH_KEYS: { key: string; label: string; staticOpts?: string[] }[] = [ + { key: "is", label: "Task state", staticOpts: ["open", "closed"] }, + { key: "priority", label: "Priority", staticOpts: ["urgent", "high", "medium", "low"] }, + { key: "status", label: "Bucket", staticOpts: ["backlog", "inbox", "running", "review", "blocked", "done"] }, + { key: "familiar", label: "Familiar" }, + { key: "cwd", label: "Working dir" }, + { key: "url", label: "Source", staticOpts: ["github", "linear", "portal"] }, +]; +const KNOWN_KEYS = new Set(SEARCH_KEYS.map((k) => k.key)); + +/** Rebuild the flat query string the board filter consumes from the chips plus + * whatever free text is still in the input. Chips carry a quoted value when it + * contains whitespace so the tokenizer keeps them whole. */ +function composeQuery(chips: Chip[], text: string): string { + const chipStr = chips + .map((c) => `${c.key}:${/\s/.test(c.value) ? `"${c.value}"` : c.value}`) + .join(" "); + return `${chipStr} ${text}`.trim(); +} + +/** Decompose an incoming query string into committed chips (known keys) and the + * leftover free text. Unknown keys stay in the free text so nothing is lost. */ +function decompose(query: string): { chips: Chip[]; text: string } { + const chips: Chip[] = []; + const free: string[] = []; + for (const tok of parseBoardSearchQuery(query)) { + if (tok.key && KNOWN_KEYS.has(tok.key) && !tok.negated) { + chips.push({ key: tok.key, value: tok.value }); + } else { + free.push(tok.negated ? `-${tok.key ? `${tok.key}:` : ""}${tok.value}` : `${tok.key ? `${tok.key}:` : ""}${tok.value}`); + } + } + return { chips, text: free.join(" ") }; +} + +type Props = { + value: string; + onChange: (query: string) => void; + familiars: Familiar[]; + cards: Card[]; + inputRef?: React.RefObject; +}; + +export function BoardTokenSearch({ value, onChange, familiars, cards, inputRef }: Props) { + const localRef = useRef(null); + const ref = inputRef ?? localRef; + const [inputText, setInputText] = useState(""); + const [open, setOpen] = useState(false); + + // The parent's query string is the single source of truth; chips are derived + // from it so an external clear (Escape / clear button) collapses them too. + const { chips } = useMemo(() => decompose(value), [value]); + + // When the query is cleared from outside, drop any half-typed free text too. + useEffect(() => { + if (value === "") setInputText(""); + }, [value]); + + const commit = useCallback( + (nextChips: Chip[], nextText: string) => { + onChange(composeQuery(nextChips, nextText)); + }, + [onChange], + ); + + const addChip = useCallback( + (key: string, val: string) => { + const exists = chips.some((c) => c.key === key && c.value === val.toLowerCase()); + const next = exists ? chips : [...chips, { key, value: val.toLowerCase() }]; + setInputText(""); + commit(next, ""); + setOpen(false); + requestAnimationFrame(() => ref.current?.focus()); + }, + [chips, commit, ref], + ); + + const removeChip = useCallback( + (idx: number) => { + commit(chips.filter((_, i) => i !== idx), inputText); + }, + [chips, inputText, commit], + ); + + const onInput = (e: React.ChangeEvent) => { + const text = e.target.value; + setInputText(text); + setOpen(true); + commit(chips, text); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (open) { setOpen(false); return; } + if (inputText || chips.length) { e.preventDefault(); setInputText(""); onChange(""); } + } else if (e.key === "Backspace" && inputText === "" && chips.length) { + e.preventDefault(); + removeChip(chips.length - 1); + } + }; + + // Suggestions: when the input looks like `key:partial`, offer that key's + // values; otherwise offer the keys themselves (filtered by the typed prefix). + const suggestions = useMemo(() => { + const match = /^\s*([a-zA-Z]+):(.*)$/.exec(inputText); + if (match && KNOWN_KEYS.has(match[1].toLowerCase())) { + const key = match[1].toLowerCase(); + const part = match[2].trim().toLowerCase(); + const cfg = SEARCH_KEYS.find((k) => k.key === key)!; + let opts = cfg.staticOpts ?? []; + if (key === "familiar") { + opts = familiars.map((f) => f.display_name || f.name || f.id).filter(Boolean) as string[]; + } else if (key === "cwd") { + opts = [...new Set(cards.map((c) => c.cwd).filter(Boolean) as string[])].map((p) => { + const seg = p.split("/").filter(Boolean).pop(); + return seg ?? p; + }); + opts = [...new Set(opts)]; + } + return { + label: cfg.label, + items: opts + .filter((o) => o.toLowerCase().includes(part)) + .slice(0, 8) + .map((o) => ({ tag: `${key}:${o}`, label: cfg.label, onPick: () => addChip(key, o) })), + }; + } + const word = inputText.trim().toLowerCase(); + return { + label: "Filter by", + items: SEARCH_KEYS.filter((k) => !word || k.key.startsWith(word)).map((k) => ({ + tag: `${k.key}:`, + label: k.label, + onPick: () => { setInputText(`${k.key}:`); setOpen(true); requestAnimationFrame(() => ref.current?.focus()); }, + })), + }; + }, [inputText, familiars, cards, addChip, ref]); + + const showSuggest = open && suggestions.items.length > 0; + + return ( +
+ {showSuggest ?
setOpen(false)} /> : null} +
+ + + {chips.map((chip, i) => ( + + {chip.key}: + {chip.value} + + + ))} + setOpen(true)} + placeholder={chips.length ? "Add filter…" : "Search tasks or type is:open cwd:coven-cave url:github"} + /> + / +
+ {showSuggest ? ( +
+
{suggestions.label}
+ {suggestions.items.map((opt) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/board-ux-polish.test.ts b/src/components/board-ux-polish.test.ts index e200637f7..041d9f446 100644 --- a/src/components/board-ux-polish.test.ts +++ b/src/components/board-ux-polish.test.ts @@ -44,7 +44,7 @@ assert.match( ); assert.match( styles, - /\.board-search-wrap\s*\{[\s\S]*min-width:\s*0/, + /\.board-token-search\s*\{[\s\S]*min-width:\s*0/, "Desktop board search should be allowed to shrink before forcing toolbar wrapping", ); // The stacked layout is keyed to the surface's own width (@container board) so @@ -79,7 +79,7 @@ assert.match(kanban, /board-kanban-card--grabbed/, "Grabbed visual affordance pr // ───────── Mobile board chrome ───────── assert.match( styles, - /@container board \(max-width: 767px\) \{[\s\S]*\.board-search-input\s*\{[\s\S]*height:\s*44px[\s\S]*\.board-view-toggle\s*\{[\s\S]*display:\s*none[\s\S]*\.board-toolbar-btn,\s*\n\s*\.board-new-card-btn\s*\{[\s\S]*min-height:\s*var\(--touch-target\)/, + /@container board \(max-width: 767px\) \{[\s\S]*\.board-token-field\s*\{[\s\S]*min-height:\s*44px[\s\S]*\.board-view-toggle\s*\{[\s\S]*display:\s*none[\s\S]*\.board-toolbar-btn,\s*\n\s*\.board-new-card-btn\s*\{[\s\S]*min-height:\s*var\(--touch-target\)/, "Narrow board search and toolbar controls should meet thumb-sized touch targets", ); diff --git a/src/components/board-view-familiar-scope.test.ts b/src/components/board-view-familiar-scope.test.ts index 12746f065..f4b2c2edb 100644 --- a/src/components/board-view-familiar-scope.test.ts +++ b/src/components/board-view-familiar-scope.test.ts @@ -14,8 +14,8 @@ assert.match( assert.match( source, - /\[cards, familiarsById, searchQuery, activeFamiliarId, scopeFamiliarIds, deletePending\]/, - "BoardView filtered memo deps include activeFamiliarId + scopeFamiliarIds (and deletePending for the undo-window hide) so re-filter triggers on familiar switch / multiselect change", + /\[cards, familiarsById, searchQuery, activeFamiliarId, scopeFamiliarIds, deletePending, excludedStatus, excludedProject, projectLabelOf\]/, + "BoardView filtered memo deps include activeFamiliarId + scopeFamiliarIds (and deletePending for the undo-window hide, plus the redesign's excludedStatus/excludedProject/projectLabelOf filter deps) so re-filter triggers on familiar switch / multiselect change / filter toggle", ); // Multiselect: when a scope set is supplied, the board filters to the union via diff --git a/src/components/board-view.tsx b/src/components/board-view.tsx index 19fb76465..29d3d804f 100644 --- a/src/components/board-view.tsx +++ b/src/components/board-view.tsx @@ -30,6 +30,8 @@ import { Tabs } from "@/components/ui/tabs"; import { StandardSelect } from "@/components/ui/select"; import { SkeletonRows } from "@/components/ui/skeleton"; import { BoardCardStack } from "@/components/board-card-stack"; +import { BoardTokenSearch } from "@/components/board-token-search"; +import { BoardFilterMenu } from "@/components/board-filter-menu"; import { BoardInspector } from "@/components/board-inspector"; import { TaskWorkCockpit } from "@/components/task-work-cockpit"; import { useIsMobile } from "@/lib/use-viewport"; @@ -126,6 +128,13 @@ export function BoardView({ const [tableSortDir, setTableSortDir] = useSurfacePreference(surfacePreferenceSpecs.board.tableSortDir); const [searchQuery, setSearchQuery] = useState(""); const searchRef = useRef(null); + // Toolbar filter dropdown (redesign): status/project narrowing on top of the + // search + grouping. Modelled as EXCLUDED sets so newly-appearing statuses or + // projects default to visible; "Select all" clears the exclusions. + const [excludedStatus, setExcludedStatus] = useState>(new Set()); + const [excludedProject, setExcludedProject] = useState>(new Set()); + // Inline confirm for the toolbar's Delete-selected button (redesign). + const [toolbarDeleteConfirm, setToolbarDeleteConfirm] = useState(false); const [selectedCardId, setSelectedCardId] = useState(null); const [workCardId, setWorkCardId] = useState(null); const [pendingBridgeStart, setPendingBridgeStart] = useState<{ @@ -313,6 +322,17 @@ export function BoardView({ }, [cards]); const familiarsById = useMemo(() => new Map(familiars.map((f) => [f.id, f])), [familiars]); + // Stable project label for a card — the key the project filter/grouping keys + // on. Mirrors the board's project grouping: named project → its name, else a + // cwd basename, else the "No project" bucket. + const projectLabelOf = useCallback((c: Card): string => { + if (c.projectId) { + const p = chatProjectById(c.projectId, projects); + if (p?.name) return p.name; + } + if (c.cwd) return c.cwd.split("/").filter(Boolean).pop() ?? c.cwd; + return "No project"; + }, [projects]); const filtered = useMemo(() => { // Hide cards whose delete is pending in the undo window (restored on Undo). const hidden = new Set((deletePending?.item ?? []).map((c) => c.id)); @@ -322,14 +342,51 @@ export function BoardView({ (scopeFamiliarIds ? familiarInScope(scopeFamiliarIds, c.familiarId) : activeFamiliarId === null || c.familiarId === activeFamiliarId) && + !excludedStatus.has(c.status) && + !excludedProject.has(projectLabelOf(c)) && cardMatchesBoardSearch(c, searchQuery, familiarsById), ); - }, [cards, familiarsById, searchQuery, activeFamiliarId, scopeFamiliarIds, deletePending]); + }, [cards, familiarsById, searchQuery, activeFamiliarId, scopeFamiliarIds, deletePending, excludedStatus, excludedProject, projectLabelOf]); // Done cards in the CURRENT scope (the filtered set the user is viewing) — // the exact set "Clear done" operates on. const doneCards = useMemo(() => filtered.filter((c) => c.status === "done"), [filtered]); + // ── Toolbar filter dropdown options ───────────────────────────────────────── + // The menu narrows by the dimension the board is grouped by (status by + // default, project when grouping by project). Project options are the distinct + // labels present in the current (familiar-scoped, pre-filter) card set. + const STATUS_TOOLBAR_LABELS: Record = { + backlog: "Backlog", inbox: "Inbox", running: "Running", + review: "Review", blocked: "Blocked", done: "Done", + }; + const scopedCards = useMemo( + () => cards.filter((c) => + scopeFamiliarIds + ? familiarInScope(scopeFamiliarIds, c.familiarId) + : activeFamiliarId === null || c.familiarId === activeFamiliarId), + [cards, scopeFamiliarIds, activeFamiliarId], + ); + const projectFilterLabels = useMemo( + () => [...new Set(scopedCards.map(projectLabelOf))].sort((a, b) => + a === "No project" ? -1 : b === "No project" ? 1 : a.localeCompare(b)), + [scopedCards, projectLabelOf], + ); + const toggleStatusFilter = useCallback((id: CardStatus) => { + setExcludedStatus((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }, []); + const toggleProjectFilter = useCallback((id: string) => { + setExcludedProject((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }, []); + // The undo banner is transient — auto-dismiss ~5s after a clear. useEffect(() => { if (!clearedBanner) return; @@ -603,6 +660,14 @@ export function BoardView({ const [bulkBusy, setBulkBusy] = useState(false); const [labelDraft, setLabelDraft] = useState(""); const selectedCards = () => cardSelect.selectedFrom(filtered); + // Live selection count for the toolbar Select/Delete buttons (redesign). + const selectionCount = selectedCards().length; + const hasSelection = selectionCount > 0; + // Drop the delete-confirm popover whenever the selection empties or select + // mode exits, so it can't linger with nothing to act on. + useEffect(() => { + if (!hasSelection || !cardSelect.selectMode) setToolbarDeleteConfirm(false); + }, [hasSelection, cardSelect.selectMode]); // Existing labels across the board → datalist autocomplete for the bulk // add-label control (NOT a filter row — label filtering is search syntax). const bulkLabelOptions = useMemo( @@ -962,37 +1027,13 @@ export function BoardView({ ) : null} {activeTab === "tasks" ? ( <> -
- - - setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape" && searchQuery) { - e.preventDefault(); - setSearchQuery(""); - } - }} - placeholder='Search tasks or type is:open cwd:coven-cave url:github' - /> - {!searchQuery ? ( - / - ) : null} - {searchQuery ? ( - - ) : null} -
+
{/* Grouping drives status columns (kanban) / status rows (table) when "Status", and swimlanes / grouped rows when "Familiar" or @@ -1082,10 +1123,69 @@ export function BoardView({
- {/* Chrome budget (§8): Select-multiple and Clear-done are occasional - verbs — they live in the overflow menu. The destructive clear - still routes through the inline confirm group, which temporarily - replaces the menu while deciding. */} + {/* Redesign: Select-multiple and Delete-selected are first-class + toolbar verbs (visible icon buttons), not overflow items. Delete + routes through an inline confirm popover. Select/delete apply to + the kanban + table surfaces (the gantt has no row selection). */} + {!isMobile && (viewMode === "kanban" || viewMode === "table") ? ( + <> + +
+ + {toolbarDeleteConfirm && hasSelection ? ( + <> +
setToolbarDeleteConfirm(false)} /> +
+
Delete {selectionCount} {selectionCount === 1 ? "task" : "tasks"}?
+
This removes them from the queue. Undo is available briefly.
+
+ + +
+
+ + ) : null} +
+ + ) : null} + + {/* Redesign: Filter dropdown narrows by the grouped dimension. */} + {showGroupToggle ? ( + ({ id: st, label: STATUS_TOOLBAR_LABELS[st], checked: !excludedStatus.has(st) }))} + projectOptions={projectFilterLabels.map((p) => ({ id: p, label: p, checked: !excludedProject.has(p) }))} + onToggleStatus={toggleStatusFilter} + onToggleProject={toggleProjectFilter} + onSelectAll={() => { setExcludedStatus(new Set()); setExcludedProject(new Set()); }} + activeCount={effectiveGroupBy === "project" + ? projectFilterLabels.filter((p) => !excludedProject.has(p)).length + : STATUSES.length - excludedStatus.size} + totalCount={effectiveGroupBy === "project" ? projectFilterLabels.length : STATUSES.length} + /> + ) : null} + + {/* Clear-done stays reachable for table/gantt (the kanban Done column + also exposes it inline). Occasional verb → compact overflow. */} {clearConfirm ? (
diff --git a/src/lib/board-search.test.ts b/src/lib/board-search.test.ts index 0db5c9153..633d9c4a4 100644 --- a/src/lib/board-search.test.ts +++ b/src/lib/board-search.test.ts @@ -89,7 +89,11 @@ assert.match( ); const boardView = await readFile(new URL("../components/board-view.tsx", import.meta.url), "utf8"); -assert.match(boardView, /board-search-input/, "Tasks header should expose one search input"); +// The Tasks header search affordance is the tokenized +// (redesign) — the single search input now lives in that component. +assert.match(boardView, / --color-warning/--color-danger); mapped accent-filled controls to --accent-presence-foreground and danger/success fills to --color-danger-foreground/--color-success-foreground (promoted the success sibling in foundations.css); promoted the codex module's stray #0f1012/#061312 into --cv-* definitions. -1 banked in the design-doc reconcile PR (cave-kf3x). Remaining 51 need design decisions (canvas/QR grounds, GitHub brand colors, profile-card palette, decorative art, #000 shade mixes). inlineTsxStyles: 227, // style={{…}} in TSX; many are legit dynamic values (banked from 500 — the count had drifted far below the old ceiling; -2: projects access page rebuild dropped the hub's inline styles) diff --git a/src/styles/board/chrome-table.css b/src/styles/board/chrome-table.css index 13c09dbfc..8c58b0347 100644 --- a/src/styles/board/chrome-table.css +++ b/src/styles/board/chrome-table.css @@ -12,58 +12,116 @@ same rules fire there too. */ .board-shell { display:flex; flex-direction:column; height:100%; overflow:hidden; background:var(--bg-base); color:var(--text-primary); container:board / inline-size; } -/* Compact chrome (matches .surface-compact-header / .gh-compact-header): one - slim 40px band; --board-control-h shrinks header controls to the shared 26px - without touching the same classes reused outside the band (inspector, gantt). - The mobile container query below re-inflates everything to touch targets. */ -.board-header { display:flex; align-items:center; gap:8px; padding:5px 12px; min-height:40px; --board-control-h:26px; border-bottom:1px solid var(--border-hairline); flex-shrink:0; flex-wrap:nowrap; } -.board-header-title { flex:0 0 auto; font-size:12px; font-weight:600; color:var(--text-primary); } -.board-header-controls { display:flex; align-items:center; gap:6px; margin-left:auto; flex:0 0 auto; flex-wrap:nowrap; } -.board-search-wrap { position:relative; display:flex; align-items:center; flex:1 1 auto; min-width:0; max-width:620px; margin-left:8px; } -.board-search-icon { position:absolute; left:9px; color:var(--text-muted); pointer-events:none; } -.board-search-input { width:100%; height:var(--board-control-h,30px); padding:0 30px 0 30px; border-radius:var(--radius-control); border:1px solid var(--border-strong); background:var(--bg-raised); color:var(--text-primary); outline:none; transition:background .12s,border-color .12s; } -.board-search-input::placeholder { color:var(--text-muted); } -.board-search-input:focus { border-color:color-mix(in oklch,var(--accent-presence) 52%,transparent); background:var(--bg-hover); } -.board-search-kbd { position:absolute; right:7px; display:flex; align-items:center; justify-content:center; min-width:16px; height:16px; padding:0 4px; border:1px solid var(--border-hairline); border-radius:4px; background:var(--bg-base); color:var(--text-muted); font-family:var(--font-mono); font-size:10px; line-height:1; pointer-events:none; } -.board-search-clear { position:absolute; right:5px; display:flex; align-items:center; justify-content:center; width:20px; height:20px; border:0; border-radius:var(--radius-control); background:transparent; color:var(--text-muted); cursor:pointer; transition:background .1s,color .1s; } -.board-search-clear:hover { background:var(--bg-hover); color:var(--text-primary); } -.board-search-clear:focus-visible { outline:var(--ring-width) solid var(--ring-focus); outline-offset:1px; } +/* Tasks toolbar (redesign refresh): a roomier command bar — 16px title, a + tokenized search that grows to fill, bordered segment groups for grouping and + view, and first-class Select/Delete/Filter verbs. --board-control-h drives the + segment/icon control height so the reused classes (gantt, inspector) stay in + step. The mobile container query below re-inflates to touch targets. */ +.board-header { display:flex; align-items:center; gap:14px; padding:13px 22px; min-height:63px; --board-control-h:34px; border-bottom:1px solid var(--border-hairline); flex-shrink:0; flex-wrap:nowrap; } +.board-header-title { flex:0 0 auto; font-size:16px; font-weight:600; letter-spacing:-0.01em; color:var(--text-primary); } +.board-header-controls { display:flex; align-items:center; gap:10px; flex:0 0 auto; flex-wrap:nowrap; } -.board-toolbar-btn { display:inline-flex; align-items:center; gap:5px; height:var(--board-control-h,30px); padding:0 10px; border-radius:var(--radius-control); border:1px solid var(--border-strong); background:var(--bg-raised); color:var(--text-secondary); font-size:12px; font-weight:450; cursor:pointer; transition:background .12s,color .12s,border-color .12s; white-space:nowrap; } +/* ── Tokenized search ──────────────────────────────────────────────────────── + The field grows to fill the bar; committed key:value filters render as mono + chips inline, and a typeahead popover suggests keys/values. */ +.board-token-search { position:relative; flex:1 1 auto; min-width:0; } +.board-token-search-scrim { position:fixed; inset:0; z-index:30; } +.board-token-field { position:relative; z-index:31; display:flex; align-items:center; flex-wrap:wrap; gap:6px; min-height:36px; padding:4px 10px; background:var(--bg-sunken); border:1px solid var(--border-hairline); border-radius:6px; transition:border-color .12s; } +.board-token-field:focus-within { border-color:var(--accent-presence); } +.board-token-search-icon { flex-shrink:0; color:var(--text-muted); } +.board-token-chip { display:inline-flex; align-items:center; gap:5px; height:24px; padding:0 3px 0 8px; background:color-mix(in oklch,var(--accent-presence) 14%,transparent); border:1px solid color-mix(in oklch,var(--accent-presence) 30%,transparent); border-radius:4px; font-size:12px; color:var(--accent-presence); white-space:nowrap; } +.board-token-chip-key { font-family:var(--font-mono); font-weight:600; opacity:.8; } +.board-token-chip-remove { display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:transparent; border:0; border-radius:3px; color:inherit; cursor:pointer; opacity:.7; transition:opacity .12s,background .12s; } +.board-token-chip-remove:hover { opacity:1; background:color-mix(in oklch,var(--accent-presence) 22%,transparent); } +/* Base font-size stays at the iOS anti-zoom floor (16px); the design's tighter + 13.5px is applied desktop-only via @media (pointer: fine) below, so touch + devices never trigger Safari's focus-zoom. */ +.board-token-input { flex:1; min-width:140px; height:26px; background:transparent; border:0; outline:none; color:var(--text-primary); font-family:inherit; font-size:16px; } +.board-token-input::placeholder { color:var(--text-muted); } +@media (pointer: fine) { .board-token-input { font-size:13.5px; } } +.board-token-kbd { flex-shrink:0; font-family:var(--font-mono); font-size:12px; color:var(--text-muted); border:1px solid var(--border-hairline); border-radius:4px; padding:0 6px; line-height:18px; } +.board-token-suggest { position:absolute; top:45px; left:0; min-width:290px; max-width:380px; background:var(--popover); border:1px solid var(--border-hairline); border-radius:8px; box-shadow:0 14px 36px -10px rgba(0,0,0,.62); padding:5px; z-index:40; } +.board-token-suggest-label { font-size:10px; font-weight:600; letter-spacing:.09em; text-transform:uppercase; color:var(--text-muted); padding:6px 9px 5px; } +.board-token-suggest-item { display:flex; align-items:center; gap:11px; width:100%; height:33px; padding:0 9px; background:transparent; border:0; border-radius:4px; cursor:pointer; font-family:inherit; font-size:12.5px; color:var(--text-secondary); transition:background .12s,color .12s; } +.board-token-suggest-item:hover { background:var(--bg-hover); color:var(--text-primary); } +.board-token-suggest-tag { font-family:var(--font-mono); font-size:11.5px; font-weight:600; color:var(--accent-presence); min-width:96px; text-align:left; } +.board-token-suggest-desc { flex:1; text-align:left; color:var(--text-muted); } + +/* ── Small square/labelled toolbar buttons ─────────────────────────────────── + Shared shell for Select-multiple and Delete-selected verbs. */ +.board-icon-wrap { position:relative; } +.board-icon-btn { display:inline-flex; align-items:center; justify-content:center; gap:6px; height:36px; min-width:36px; padding:0 9px; background:transparent; border:1px solid var(--border-hairline); border-radius:6px; color:var(--text-secondary); cursor:pointer; font-family:inherit; font-size:12.5px; transition:background .12s,color .12s,border-color .12s; } +.board-icon-btn:hover:not(:disabled) { background:var(--bg-hover); color:var(--text-primary); } +.board-icon-btn:focus-visible { outline:var(--ring-width) solid var(--ring-focus); outline-offset:1px; } +.board-icon-btn:disabled { color:var(--text-muted); cursor:default; } +.board-icon-btn--active { background:color-mix(in oklch,var(--accent-presence) 16%,transparent); border-color:color-mix(in oklch,var(--accent-presence) 42%,transparent); color:var(--accent-presence); } +.board-icon-btn--danger { color:var(--color-danger); border-color:color-mix(in oklch,var(--color-danger) 42%,transparent); } +.board-icon-btn-count { font-family:var(--font-mono); font-size:11px; font-weight:600; } + +/* Delete-selected inline confirm popover. */ +.board-confirm-scrim { position:fixed; inset:0; z-index:35; } +.board-confirm-pop { position:absolute; top:42px; right:0; width:242px; background:var(--popover); border:1px solid var(--border-hairline); border-radius:8px; box-shadow:0 14px 36px -10px rgba(0,0,0,.62); padding:14px; z-index:45; text-align:left; } +.board-confirm-title { font-size:13px; font-weight:600; color:var(--text-primary); } +.board-confirm-desc { font-size:12px; color:var(--text-secondary); margin-top:5px; line-height:1.5; } +.board-confirm-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:14px; } +.board-confirm-delete { height:30px; padding:0 13px; background:var(--color-danger); border:0; border-radius:5px; color:#fff; font-family:inherit; font-size:12.5px; font-weight:600; cursor:pointer; transition:filter .12s; } +.board-confirm-delete:hover { filter:brightness(1.08); } + +/* ── Filter dropdown ─────────────────────────────────────────────────────────*/ +.board-filter { position:relative; } +.board-filter-scrim { position:fixed; inset:0; z-index:44; } +.board-filter-btn { display:inline-flex; align-items:center; justify-content:center; gap:7px; height:36px; padding:0 13px; background:transparent; border:1px solid var(--border-hairline); border-radius:6px; cursor:pointer; font-family:inherit; font-size:12.5px; color:var(--text-secondary); transition:background .12s,color .12s,border-color .12s; } +.board-filter-btn:hover { background:var(--bg-hover); color:var(--text-primary); } +.board-filter-btn:focus-visible { outline:var(--ring-width) solid var(--ring-focus); outline-offset:1px; } +.board-filter-btn--active { color:var(--accent-presence); border-color:color-mix(in oklch,var(--accent-presence) 42%,transparent); } +.board-filter-count { font-family:var(--font-mono); font-size:11px; font-weight:600; color:var(--accent-presence); background:color-mix(in oklch,var(--accent-presence) 18%,transparent); border-radius:999px; padding:1px 7px; } +.board-filter-caret { flex-shrink:0; color:var(--text-muted); transition:transform .15s; } +.board-filter-caret--open { transform:rotate(180deg); } +.board-filter-menu { position:absolute; top:42px; right:0; width:262px; background:var(--popover); border:1px solid var(--border-hairline); border-radius:8px; box-shadow:0 14px 36px -10px rgba(0,0,0,.62); padding:6px; z-index:46; max-height:420px; overflow-y:auto; } +.board-filter-menu-head { display:flex; align-items:center; justify-content:space-between; padding:6px 8px 5px; } +.board-filter-menu-title { font-size:10px; font-weight:600; letter-spacing:.09em; text-transform:uppercase; color:var(--text-muted); } +.board-filter-selectall { background:transparent; border:0; color:var(--accent-presence); font-family:inherit; font-size:11.5px; cursor:pointer; padding:0; } +.board-filter-item { display:flex; align-items:center; gap:10px; width:100%; height:33px; padding:0 8px; background:transparent; border:0; border-radius:5px; cursor:pointer; font-family:inherit; font-size:12.5px; color:var(--text-secondary); transition:background .12s,color .12s; } +.board-filter-item--mono { font-family:var(--font-mono); font-size:12px; } +.board-filter-item:hover { background:var(--bg-hover); color:var(--text-primary); } +.board-filter-check { display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; border-radius:4px; flex-shrink:0; border:1px solid var(--border-strong); color:transparent; transition:background .12s,border-color .12s; } +.board-filter-check--on { background:var(--accent-presence); border-color:var(--accent-presence); color:var(--primary-foreground); } +.board-filter-dot { width:8px; height:8px; border-radius:3px; flex-shrink:0; } +.board-filter-item-label { flex:1; text-align:left; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } + +.board-toolbar-btn { display:inline-flex; align-items:center; gap:5px; height:36px; padding:0 12px; border-radius:6px; border:1px solid var(--border-hairline); background:transparent; color:var(--text-secondary); font-size:12.5px; font-weight:450; cursor:pointer; transition:background .12s,color .12s,border-color .12s; white-space:nowrap; } .board-toolbar-btn:hover { background:var(--bg-hover); color:var(--text-primary); } .board-toolbar-btn:focus-visible { outline:var(--ring-width) solid var(--ring-focus); outline-offset:1px; } -.board-toolbar-btn--active { background:color-mix(in oklch,var(--accent-presence) 14%,var(--bg-raised)); border-color:color-mix(in oklch,var(--accent-presence) 40%,transparent); color:var(--text-primary); } -.board-toolbar-btn--danger { color:var(--color-danger); border-color:color-mix(in oklch,var(--color-danger) 30%,var(--border-strong)); } -.board-toolbar-btn--danger:hover { background:color-mix(in oklch,var(--color-danger) 12%,var(--bg-raised)); color:var(--text-primary); border-color:color-mix(in oklch,var(--color-danger) 55%,transparent); } +.board-toolbar-btn--active { background:color-mix(in oklch,var(--accent-presence) 14%,transparent); border-color:color-mix(in oklch,var(--accent-presence) 40%,transparent); color:var(--text-primary); } +.board-toolbar-btn--danger { color:var(--color-danger); border-color:color-mix(in oklch,var(--color-danger) 36%,transparent); } +.board-toolbar-btn--danger:hover { background:color-mix(in oklch,var(--color-danger) 12%,transparent); color:var(--text-primary); border-color:color-mix(in oklch,var(--color-danger) 55%,transparent); } .board-clear-confirm { display:flex; gap:6px; } -/* Tasks|Queue segment tabs (shared Tabs, variant=segment) — standardized to the - group-toggle footprint: same total height (control + 1px borders) and the - same 10px/12px button metrics as .board-group-toggle-btn, while keeping the - segment aesthetic (hairline container, rounded active pill). Same recipe as - .gh-compact-tabs below. */ -.board-header-tabs { flex:0 0 auto; height:calc(var(--board-control-h,28px) + 2px); padding:2px; } -.board-header-tabs [role="tab"] { height:100%; padding:0 10px; font-size:12px; } +/* Tasks|Queue segment tabs (shared Tabs, variant=segment) — matched to the + bordered segment groups: hairline container, rounded active pill. */ +.board-header-tabs { flex:0 0 auto; height:calc(var(--board-control-h,34px) + 2px); padding:2px; border-radius:6px; } +.board-header-tabs [role="tab"] { height:100%; padding:0 14px; font-size:13px; } -.board-group-toggle { display:flex; border:1px solid var(--border-strong); border-radius:var(--radius-control); overflow:hidden; } -/* Leading zoom glyph so the compact D/W/M reads as a timeline zoom, not a range filter. */ -.board-group-toggle .cg-zoom-cell { display:flex; align-items:center; padding:0 4px 0 8px; background:var(--bg-raised); color:var(--text-muted); border-right:1px solid var(--border-strong); } -.board-group-toggle-btn { display:flex; align-items:center; justify-content:center; padding:0 10px; height:var(--board-control-h,28px); background:var(--bg-raised); color:var(--text-muted); border:none; cursor:pointer; font-size:12px; font-weight:500; transition:background .12s,color .12s; white-space:nowrap; } -.board-group-toggle-btn+.board-group-toggle-btn { border-left:1px solid var(--border-strong); } -.board-group-toggle-btn:hover { background:var(--bg-hover); color:var(--text-secondary); } +/* Bordered segment groups (grouping + view). Buttons share a hairline frame with + a filled active cell — matching the design's segmented control aesthetic. */ +.board-group-toggle { display:inline-flex; border:1px solid var(--border-hairline); border-radius:6px; overflow:hidden; } +.board-group-toggle .cg-zoom-cell { display:flex; align-items:center; padding:0 4px 0 8px; background:transparent; color:var(--text-muted); border-right:1px solid var(--border-hairline); } +.board-group-toggle-btn { display:flex; align-items:center; justify-content:center; padding:0 14px; height:var(--board-control-h,34px); background:transparent; color:var(--text-secondary); border:none; cursor:pointer; font-size:12.5px; font-weight:500; transition:background .12s,color .12s; white-space:nowrap; } +.board-group-toggle-btn+.board-group-toggle-btn { border-left:1px solid var(--border-hairline); } +.board-group-toggle-btn:hover { background:var(--bg-hover); color:var(--text-primary); } .board-group-toggle-btn:focus-visible { outline:var(--ring-width) solid var(--ring-focus); outline-offset:-1px; z-index:1; position:relative; } -.board-group-toggle-btn--active { background:color-mix(in oklch,var(--accent-presence) 16%,var(--bg-raised)); color:var(--text-primary); } -.board-group-toggle-btn--solo { border:1px solid var(--border-strong); border-radius:var(--radius-control); } -.board-view-toggle { display:flex; border:1px solid var(--border-strong); border-radius:var(--radius-control); overflow:hidden; } -.board-view-toggle-btn { display:flex; align-items:center; justify-content:center; width:var(--board-control-h,28px); height:var(--board-control-h,28px); background:var(--bg-raised); color:var(--text-muted); border:none; cursor:pointer; transition:background .12s,color .12s; } -.board-view-toggle-btn+.board-view-toggle-btn { border-left:1px solid var(--border-strong); } -.board-view-toggle-btn:hover { background:var(--bg-hover); color:var(--text-secondary); } +.board-group-toggle-btn--active { background:var(--bg-elevated); color:var(--text-primary); } +.board-group-toggle-btn--solo { border:1px solid var(--border-hairline); border-radius:6px; } +.board-view-toggle { display:inline-flex; border:1px solid var(--border-hairline); border-radius:6px; overflow:hidden; } +.board-view-toggle-btn { display:flex; align-items:center; justify-content:center; width:36px; height:var(--board-control-h,34px); background:transparent; color:var(--text-secondary); border:none; cursor:pointer; transition:background .12s,color .12s; } +.board-view-toggle-btn+.board-view-toggle-btn { border-left:1px solid var(--border-hairline); } +.board-view-toggle-btn:hover { background:var(--bg-hover); color:var(--text-primary); } .board-view-toggle-btn:focus-visible { outline:var(--ring-width) solid var(--ring-focus); outline-offset:-1px; z-index:1; position:relative; } -.board-view-toggle-btn--active { background:color-mix(in oklch,var(--accent-presence) 16%,var(--bg-raised)); color:var(--text-primary); } +.board-view-toggle-btn--active { background:var(--bg-elevated); color:var(--accent-presence); } -.board-new-card-btn { height:var(--board-control-h,30px); padding:0 12px; border-radius:var(--radius-control); border:1px solid var(--border-strong); background:var(--accent-presence); color:var(--accent-presence-foreground); font-size:12px; font-weight:500; cursor:pointer; transition:background .12s,transform .1s; white-space:nowrap; } -.board-new-card-btn:hover { background:color-mix(in oklch, var(--accent-presence) 85%, #000); transform:scale(1.02); } +.board-new-card-btn { display:inline-flex; align-items:center; gap:7px; height:36px; padding:0 15px; border-radius:6px; border:0; background:var(--primary); color:var(--primary-foreground); font-size:13px; font-weight:600; cursor:pointer; transition:filter .12s,transform .1s; white-space:nowrap; } +.board-new-card-btn:hover { filter:brightness(1.08); } .board-new-card-btn:focus-visible { outline:var(--ring-width) solid var(--ring-focus); outline-offset:2px; } @container board (max-width: 767px) { @@ -80,33 +138,17 @@ line-height: 1.15; } - .board-search-wrap { + .board-token-search { flex: 1 0 100%; min-width: 0; - max-width: none; - margin-left: 0; - } - - .board-search-icon { - left: 14px; - } - - .board-search-input { - height: 44px; - padding-inline: 44px 36px; - border-radius: var(--radius-control); - font-size: 16px; } - .board-search-clear { - right: 8px; - width: 32px; - height: 32px; + .board-token-field { + min-height: 44px; } .board-header-controls { width: 100%; - margin-left: 0; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-2);