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
2 changes: 1 addition & 1 deletion src/app/composer-zoom-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 8 additions & 5 deletions src/components/a11y-audit-fixes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 12 additions & 6 deletions src/components/board-bulk-select.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ assert.match(view, /<UndoToast/, "board renders the shared UndoToast for deletes
// Select mode is threaded into BOTH the kanban and the table.
assert.match(view, /<BoardKanban[\s\S]*?selectMode=\{cardSelect\.selectMode\}/, "kanban receives select mode");
assert.match(view, /<BoardTable[\s\S]*?selectMode=\{cardSelect\.selectMode\}/, "table receives select mode");
// The Select entry button only shows for kanban/table, desktop, with cards.
assert.match(view, /viewMode === "kanban" \|\| viewMode === "table"[\s\S]*?cardSelect\.setSelectMode\(true\)/, "a Select button enters select mode for kanban/table");
// The Select entry button only shows for kanban/table on desktop. Redesign:
// it's a visible toolbar verb that TOGGLES select mode (enter/exit).
assert.match(view, /viewMode === "kanban" \|\| viewMode === "table"[\s\S]*?cardSelect\.setSelectMode\(!cardSelect\.selectMode\)/, "a Select button toggles select mode for kanban/table");

// Kanban cards become checkboxes and stop being draggable in select mode.
assert.match(kanban, /<li draggable=\{!selectMode\}/, "kanban cards aren't draggable while selecting");
Expand All @@ -44,17 +45,22 @@ assert.match(view, /<StandardSelect<CardPriority \| "">[\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,
/<OverflowMenu ariaLabel="More task actions">/,
"board header renders the shared overflow menu",
);
assert.match(
view,
/<PopoverItem\s*icon="ph:check-square"\s*onSelect=\{\(\) => 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");
84 changes: 84 additions & 0 deletions src/components/board-filter-menu.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="board-filter">
{open ? <div className="board-filter-scrim" onClick={() => setOpen(false)} /> : null}
<button
type="button"
className={`board-filter-btn${hasFilters ? " board-filter-btn--active" : ""}`}
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-haspopup="true"
>
<Icon name="ph:funnel" width={14} />
Filter
{hasFilters ? <span className="board-filter-count">{activeCount}</span> : null}
<Icon name="ph:caret-down" width={11} className={`board-filter-caret${open ? " board-filter-caret--open" : ""}`} />
</button>
{open ? (
<div className="board-filter-menu" role="menu">
<div className="board-filter-menu-head">
Comment on lines +35 to +52
<span className="board-filter-menu-title">
Filter by {dimension === "project" ? "project" : "status"}
</span>
{hasFilters ? (
<button type="button" className="board-filter-selectall" onClick={onSelectAll}>
Select all
</button>
) : null}
</div>
{dimension === "status"
? statusOptions.map((o) => (
<button key={o.id} type="button" className="board-filter-item" onClick={() => onToggleStatus(o.id)} role="menuitemcheckbox" aria-checked={o.checked}>
<span className={`board-filter-check${o.checked ? " board-filter-check--on" : ""}`}>
{o.checked ? <Icon name="ph:check-bold" width={11} /> : null}
</span>
<span className={`board-filter-dot board-table-status-dot--${o.id}`} />
<span className="board-filter-item-label">{o.label}</span>
</button>
))
: projectOptions.map((o) => (
<button key={o.id} type="button" className="board-filter-item board-filter-item--mono" onClick={() => onToggleProject(o.id)} role="menuitemcheckbox" aria-checked={o.checked}>
<span className={`board-filter-check${o.checked ? " board-filter-check--on" : ""}`}>
{o.checked ? <Icon name="ph:check-bold" width={11} /> : null}
</span>
<span className="board-filter-item-label">{o.label}</span>
</button>
))}
</div>
) : null}
</div>
);
}
202 changes: 202 additions & 0 deletions src/components/board-token-search.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement | null>;
};

export function BoardTokenSearch({ value, onChange, familiars, cards, inputRef }: Props) {
const localRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
const text = e.target.value;
setInputText(text);
setOpen(true);
commit(chips, text);
};

const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
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 (
<div className="board-token-search">
{showSuggest ? <div className="board-token-search-scrim" onClick={() => setOpen(false)} /> : null}
<div className="board-token-field">
<Icon name="ph:magnifying-glass" width={15} className="board-token-search-icon" />
<label className="sr-only" htmlFor="board-search">Search tasks</label>
{chips.map((chip, i) => (
<span key={`${chip.key}:${chip.value}`} className="board-token-chip">
<span className="board-token-chip-key">{chip.key}:</span>
{chip.value}
<button
type="button"
className="board-token-chip-remove"
onClick={() => removeChip(i)}
aria-label={`Remove ${chip.key} filter`}
>
<Icon name="ph:x-bold" width={10} />
</button>
</span>
))}
<input
ref={ref}
id="board-search"
className="board-token-input"
value={inputText}
onChange={onInput}
onKeyDown={onKeyDown}
onFocus={() => setOpen(true)}
placeholder={chips.length ? "Add filter…" : "Search tasks or type is:open cwd:coven-cave url:github"}
/>
<kbd aria-hidden className="board-token-kbd">/</kbd>
</div>
{showSuggest ? (
<div className="board-token-suggest" role="listbox" aria-label="Search suggestions">
<div className="board-token-suggest-label">{suggestions.label}</div>
{suggestions.items.map((opt) => (
<button key={opt.tag} type="button" className="board-token-suggest-item" onClick={opt.onPick}>
Comment on lines +189 to +193
<span className="board-token-suggest-tag">{opt.tag}</span>
<span className="board-token-suggest-desc">{opt.label}</span>
</button>
))}
</div>
) : null}
</div>
);
}
4 changes: 2 additions & 2 deletions src/components/board-ux-polish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
);

Expand Down
4 changes: 2 additions & 2 deletions src/components/board-view-familiar-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading