From 384b5b4c096e5b9f32206952c76e81e762b50620 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 26 Aug 2026 10:33:09 -0500 Subject: [PATCH 01/14] tabs polish: strip above content, inferred roster, roster editor - TabBar moves out of the controls row into a real tab strip above the grouped content; tab buttons carry a surface color, the strip does not - sidebar and content transition (150-200ms) instead of snapping when a codeowners tab hides the roster card - codeowners tabs get a roster inferred from the queue's authors so the sidebar (and author filtering) stays reachable; a note says where it comes from - author filter no longer resets on poll or first load for codeowners tabs: re-validation uses the tab's own roster, not board.members - roster editing lives on the board.members row of the settings modal (RosterControl) via POST /roster, which honors the ownership latch, refuses to drop the last member or defaultMember, and swaps the in-memory roster so /data.json agrees immediately Co-Authored-By: Claude Fable 5 --- src/__tests__/board.test.ts | 30 +++++ src/__tests__/config-store-latch.test.ts | 33 +++++- src/__tests__/view.test.ts | 46 +++++++- src/client/board/Board.tsx | 70 +++++++---- src/client/board/ConfigModal.tsx | 144 +++++++++++++++++++++-- src/client/board/Controls.tsx | 22 +--- src/client/board/Sidebar.tsx | 4 + src/client/board/TabBar.tsx | 41 +++++++ src/config.ts | 39 ++++++ src/data.ts | 19 +++ src/server.ts | 59 +++++++++- src/style.css | 66 ++++++++++- src/view.ts | 16 +++ 13 files changed, 533 insertions(+), 56 deletions(-) create mode 100644 src/client/board/TabBar.tsx diff --git a/src/__tests__/board.test.ts b/src/__tests__/board.test.ts index 0540344..23e9c1f 100644 --- a/src/__tests__/board.test.ts +++ b/src/__tests__/board.test.ts @@ -5,6 +5,7 @@ import { boardDemand, buildBoard, buildRoster, + inferRoster, channelForMR, configuredSlackChannels, projectPathFromWebUrl, @@ -360,6 +361,35 @@ describe("visibleMrsFor (the /data.json payload gate)", () => { }); }); +describe("inferRoster", () => { + function authored(username: string, name: string | null = null): BoardMR { + return { author: { id: username, username, name, avatarUrl: null } } as unknown as BoardMR; + } + + test("derives one entry per distinct author, busiest first", () => { + const roster = inferRoster([authored("zoe"), authored("adam"), authored("zoe"), authored("adam"), authored("zoe")]); + expect(roster).toEqual([ + { username: "zoe", name: null, count: 3 }, + { username: "adam", name: null, count: 2 }, + ]); + }); + + test("ties break alphabetically so the order is stable across polls", () => { + const roster = inferRoster([authored("zoe"), authored("adam"), authored("mira")]); + expect(roster.map((r) => r.username)).toEqual(["adam", "mira", "zoe"]); + }); + + test("keeps the author's display name and tolerates a missing one", () => { + const roster = inferRoster([authored("adam", "Adam Stranger"), authored("mira")]); + expect(roster.find((r) => r.username === "adam")?.name).toBe("Adam Stranger"); + expect(roster.find((r) => r.username === "mira")?.name).toBeNull(); + }); + + test("no MRs means no roster", () => { + expect(inferRoster([])).toEqual([]); + }); +}); + describe("buildRoster", () => { const members = [{ username: "alice" }, { username: "bob", name: "Bobby" }, { username: "carol" }]; diff --git a/src/__tests__/config-store-latch.test.ts b/src/__tests__/config-store-latch.test.ts index e9aa013..045639b 100644 --- a/src/__tests__/config-store-latch.test.ts +++ b/src/__tests__/config-store-latch.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, writeFileSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { getSetting, setSetting } from "@mattstack/rt-client"; -import { loadConfigFrom, saveMemberHidden, saveSwitchboardUrl, DEFAULT_SLACK_EMOJI } from "../config.ts"; +import { loadConfigFrom, saveMemberHidden, saveRosterMembers, saveSwitchboardUrl, DEFAULT_SLACK_EMOJI } from "../config.ts"; type GetSettingFn = typeof getSetting; type SetSettingFn = typeof setSetting; @@ -210,6 +210,37 @@ describe("loadConfigFrom: board.tabs overlay", () => { }); }); +describe("saveRosterMembers: latch-gated writer", () => { + test("unowned: rewrites config.json's members, store untouched", () => { + const p = tmpConfig({ ...base, members: [{ username: "alice" }] }); + const calls: Array<{ key: string; value: unknown; scope: string }> = []; + const next = [{ username: "alice" }, { username: "bob", name: "Bob" }]; + const cfg = saveRosterMembers(next, p, fakeResolve({}), fakeWrite(calls)); + expect(calls).toEqual([]); + expect(cfg.members).toEqual(next); + expect(JSON.parse(readFileSync(p, "utf8")).members).toEqual(next); + }); + + test("owned: writes board.members (team), config.json untouched", () => { + const p = tmpConfig({ ...base, members: [{ username: "alice" }] }); + const before = readFileSync(p, "utf8"); + const calls: Array<{ key: string; value: unknown; scope: string }> = []; + const next = [{ username: "alice" }, { username: "bob" }]; + saveRosterMembers(next, p, fakeResolve({ "board.members": [{ username: "alice" }] }), fakeWrite(calls)); + expect(calls).toEqual([{ key: "board.members", value: next, scope: "team" }]); + expect(readFileSync(p, "utf8")).toBe(before); + expect(loadConfigFrom(p, fakeResolve({ "board.members": next })).members).toEqual(next); + }); + + test("removal round-trips through the same writer", () => { + const p = tmpConfig({ ...base, members: [{ username: "alice" }, { username: "bob" }] }); + const calls: Array<{ key: string; value: unknown; scope: string }> = []; + const stored = [{ username: "alice" }, { username: "bob" }]; + saveRosterMembers([{ username: "alice" }], p, fakeResolve({ "board.members": stored }), fakeWrite(calls)); + expect(calls[0]!.value).toEqual([{ username: "alice" }]); + }); +}); + describe("saveMemberHidden: latch-gated writer", () => { test("unowned: writes config.json's inline hidden flag, store untouched", () => { const p = tmpConfig({ ...base, members: [{ username: "alice" }, { username: "bob" }] }); diff --git a/src/__tests__/view.test.ts b/src/__tests__/view.test.ts index 976a358..0f2dfd4 100644 --- a/src/__tests__/view.test.ts +++ b/src/__tests__/view.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { BoardMR } from "../data.ts"; import type { TabConfig } from "../config.ts"; -import { filterByMember, filterByTab, sortMRs, groupMRs, commentDot, dataAgeLabel, statusFlags, nestStacks, memberPeerState, joinRowState } from "../view.ts"; +import { filterByMember, filterByTab, rosterUsernamesFor, sortMRs, groupMRs, commentDot, dataAgeLabel, statusFlags, nestStacks, memberPeerState, joinRowState } from "../view.ts"; function mr(overrides: Partial): BoardMR { return { @@ -29,6 +29,50 @@ describe("filterByMember", () => { }); }); +describe("rosterUsernamesFor", () => { + const configUsernames = ["ada", "grace"]; + const rows = [ + mr({ iid: 1, author: { username: "ada" } as any, codeownerSections: ["Acme"] } as any), + mr({ iid: 2, author: { username: "outsider" } as any, codeownerSections: ["Acme"] } as any), + mr({ iid: 3, author: { username: "drifter" } as any, codeownerSections: [] } as any), + ]; + + test("an authors tab answers with the configured roster", () => { + const team: TabConfig = { id: "t", label: "T", source: { kind: "authors" } }; + expect([...rosterUsernamesFor(rows, team, configUsernames)].sort()).toEqual(["ada", "grace"]); + }); + + test("a codeowners tab answers with the authors of the rows it shows", () => { + const q: TabConfig = { id: "q", label: "Q", source: { kind: "codeowners", section: "Acme", excludeMembers: true } }; + const valid = rosterUsernamesFor(rows, q, configUsernames); + expect([...valid]).toEqual(["outsider"]); // the picked author survives re-validation + expect(valid.has("ada")).toBe(false); // excludeMembers still applies + expect(valid.has("drifter")).toBe(false); // untagged row is not on this tab + }); + + test("no tab falls back to the configured roster", () => { + expect([...rosterUsernamesFor(rows, undefined, configUsernames)].sort()).toEqual(["ada", "grace"]); + }); + + /* Board resolves the first load in two passes for this reason: the member's + valid set depends on which tab wins, so validating against the config + roster alone drops a stored codeowners-tab author on every reload. */ + test("two-pass resolution keeps a stored codeowners-tab author across a reload", () => { + const q: TabConfig = { id: "q", label: "Q", source: { kind: "codeowners", section: "Acme", excludeMembers: true } }; + const stored = { tab: "q", member: "outsider" }; + const tabIds = ["t", "q"]; + + // Single pass drops the stored author as "not on the team" and lands on + // defaultMember, so a reload on the queue would silently filter to you. + const onePass = parseViewState("", stored, configUsernames, "ada", tabIds); + expect(onePass.tab).toBe("q"); + expect(onePass.member).toBe("ada"); + + const valid = [...rosterUsernamesFor(rows, q, configUsernames)]; + expect(parseViewState("", stored, valid, "ada", tabIds).member).toBe("outsider"); + }); +}); + describe("filterByTab", () => { const members = new Set(["ada"]); const rows = [ diff --git a/src/client/board/Board.tsx b/src/client/board/Board.tsx index 5b4858c..56356dd 100644 --- a/src/client/board/Board.tsx +++ b/src/client/board/Board.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { BoardMR } from "../../data.ts"; -import { filterByMember, filterByTab, sortMRs, groupMRs, parseViewState, serializeViewState, dataAgeLabel } from "../../view.ts"; +import { inferRoster } from "../../data.ts"; +import { filterByMember, filterByTab, rosterUsernamesFor, sortMRs, groupMRs, parseViewState, serializeViewState, dataAgeLabel } from "../../view.ts"; import type { ViewState } from "../../view.ts"; import { selectionOf, postableOf, tabChangeClearsSelection } from "../../selection.ts"; import type { @@ -19,6 +20,7 @@ import { postAction } from "../api.ts"; import { ICONS, Panel, SideDrawer, ToastHost } from "@mattstack/tui-kit"; import { Sidebar } from "./Sidebar.tsx"; import { Controls } from "./Controls.tsx"; +import { TabBar } from "./TabBar.tsx"; import { SelectionBar } from "./SelectionBar.tsx"; import { RowView } from "./RowView.tsx"; import { GridView } from "./GridView.tsx"; @@ -69,7 +71,10 @@ export function Board() { }; const update = (patch: Partial) => { const clearsSelection = tabChangeClearsSelection(patch, state.tab); - const next = { ...state, ...patch }; + // Each tab has its own roster (a codeowners tab's is inferred from the rows + // in view), so a member picked on one tab usually does not exist on the + // next: carrying it over would land on an empty board. + const next = { ...state, ...patch, ...(clearsSelection ? { member: "all" } : {}) }; localStorage.setItem(STATE_KEY, JSON.stringify(next)); history.replaceState(null, "", serializeViewState(next) || location.pathname); setState(next); @@ -95,9 +100,23 @@ export function Board() { } // Tab validated once here, same as member -- never re-derived from the // URL on later polls, so a live tab pick survives the 60s refresh cycle. - setState(parseViewState(location.search, stored, usernames, d.defaultMember, d.tabs.map((t) => t.id))); + // Two passes because the member's valid set depends on which tab wins: + // a codeowners tab's roster is its own authors, so a stored pick there + // would otherwise be dropped as "not on the team" on every reload. + const tabIds = d.tabs.map((t) => t.id); + const firstPass = parseViewState(location.search, stored, usernames, d.defaultMember, tabIds); + const tab = d.tabs.find((t) => t.id === firstPass.tab) ?? d.tabs[0]; + const validMembers = [...rosterUsernamesFor(d.mrs, tab, usernames)]; + setState(parseViewState(location.search, stored, validMembers, d.defaultMember, tabIds)); } else { - setState((prev) => (prev.member === "all" || usernames.includes(prev.member) ? prev : { ...prev, member: "all" })); + // Validated against the ACTIVE TAB's roster: a codeowners tab's is + // inferred from the rows in view, so checking the config roster alone + // would drop a legitimately picked author on the next poll. + setState((prev) => { + if (prev.member === "all") return prev; + const tab = d.tabs.find((t) => t.id === prev.tab) ?? d.tabs[0]; + return rosterUsernamesFor(d.mrs, tab, usernames).has(prev.member) ? prev : { ...prev, member: "all" }; + }); } }, []); @@ -422,17 +441,22 @@ export function Board() { const tabFiltered = filterByTab(mrs, activeTab, rosterUsernames); // Codeowners tabs bypass member filtering entirely (and the sidebar that // drives it) -- the queue is scoped by section, not by roster author. - const filtered = isCodeownersTab ? tabFiltered : filterByMember(tabFiltered, state.member); + // A codeowners tab lists other teams' MRs, so the configured roster has + // nothing to drive there. Inferring one from the rows in view keeps the + // author filter (and the settings gears that live in this panel) available. + const roster = isCodeownersTab ? inferRoster(tabFiltered) : data.members; + const rosterTotal = isCodeownersTab ? tabFiltered.length : total; + const filtered = filterByMember(tabFiltered, state.member); const groups = groupMRs(filtered, state.group, data.members.map((m) => m.username), now).map((g) => ({ label: g.label, mrs: sortMRs(g.mrs, state.sort), })); const activeMember = - !isCodeownersTab && state.member !== "all" ? data.members.find((m) => m.username === state.member) ?? null : null; + state.member !== "all" ? roster.find((m) => m.username === state.member) ?? null : null; // Show each row's author only when the view mixes authors: the All view // grouped by anything but author (where the group header isn't the name), // or a codeowners tab, which is never narrowed to one author. - const showAuthor = isCodeownersTab || (state.member === "all" && state.group !== "author"); + const showAuthor = state.member === "all" && state.group !== "author"; // Under author grouping the header IS the name, so rows normally drop the // author tag -- but a stack pulled to its root's group can carry a // co-author's MR under someone else's header. Tag the rows whenever a group @@ -484,28 +508,25 @@ export function Board() { canPostSummary: data.slackEnabled && data.local && postableMrs.length > 0, postingSummary, onPostSummary: () => handlePostSummary(postableMrs), - tabs: data.tabs, - tabSyncing, }; return (
{/* Desktop roster (hidden on mobile, where it moves into the drawer). Also hidden on a codeowners tab: it isn't filtered by member, so the roster has nothing to drive. */} - {!isCodeownersTab && ( - update({ member })} - onSettings={openSettings} - onConfig={openConfig} - scopeUncovered={data.scopeUncovered} - /> - )} + update({ member })} + onSettings={openSettings} + onConfig={openConfig} + scopeUncovered={data.scopeUncovered} + note={isCodeownersTab ? "authors in this queue" : undefined} + />
@@ -527,6 +548,13 @@ export function Board() {
+ update({ tab })} + syncing={tabSyncing} + /> + {selectedMrs.length > 0 && ( void; + onOpenRoster: () => void; +}) { + const [adding, setAdding] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Dropping arms on the first click and sends on the second, keyed by + // username since every row carries the button. + const [armed, setArmed] = useState(null); + + const roster = Array.isArray(members) ? (members as Array<{ username?: unknown; name?: unknown }>) : []; + const hiddenSet = new Set(Array.isArray(hidden) ? (hidden as unknown[]).filter((u): u is string => typeof u === "string") : []); + + const edit = async (action: "add" | "remove", username: string) => { + setBusy(true); + setError(null); + const res = await postAction("/roster", { action, username }); + setBusy(false); + if (!res.ok) { + setError(res.text || `could not ${action} ${username}`); + return; + } + setArmed(null); + if (action === "add") setAdding(""); + // The write went through /roster (server-validated), so the kit's cached + // defs are stale until told otherwise. + onSaved(); + }; + + return ( +
+
+ {rosterSummary(members, hidden)} + +
+
    + {roster.map((m, i) => { + const username = typeof m.username === "string" ? m.username : ""; + if (!username) return null; + const name = typeof m.name === "string" ? m.name : null; + return ( +
  • + + {name ?? username} + {name && @{username}} + {hiddenSet.has(username) && checked out} + + {username === self ? ( + + you + + ) : armed === username ? ( + + ) : ( + + )} +
  • + ); + })} +
+
{ + e.preventDefault(); + const handle = adding.trim(); + if (handle && !busy) void edit("add", handle); + }} + > + setAdding(e.target.value)} + placeholder="gitlab username" + aria-label="add a teammate by gitlab username" + disabled={busy} + /> + +
+ {error &&

{error}

} +

# a new teammate's MRs land once rt has synced them

+
+ ); +} + function SettingRow({ def, store, onOpenRoster }: { def: ConfigDef; store: SettingsScopeState; onOpenRoster: () => void }) { const kind = rowKind(def); const row = useRowSave(store, def); @@ -330,14 +447,25 @@ function SettingRow({ def, store, onOpenRoster }: { def: ConfigDef; store: Setti if (kind === "roster") { const members = store.defs.find((d) => d.key === "board.members")?.effective.value; const hidden = store.defs.find((d) => d.key === "board.hiddenMembers")?.effective.value; - control = ( - <> - {rosterSummary(members, hidden)} - - - ); + const self = store.defs.find((d) => d.key === "board.defaultMember")?.effective.value; + control = def.key === "board.members" + ? ( +
); } +/** Control-specific caveats the registry description cannot know, shown in + the same info tip as the description. */ +const ROW_HINTS: Record = { + "board.members": "A new teammate's MRs land once rt has synced them.", + "board.tabs": "A new section's MRs land once rt has backfilled it; the tab shows \"syncing\" until then.", +}; + function SettingRow({ def, store, @@ -623,9 +638,11 @@ function SettingRow({ let control; if (kind === "tabs" && !malformed) { + const channel = getLeaf(store.defs.find((d) => d.key === "board.slack")?.effective.value, "channel"); control = ( { store.refresh(); onTabsSaved(); @@ -669,10 +686,13 @@ function SettingRow({ control = ; } + const help = [def.description, ROW_HINTS[def.key]].filter(Boolean).join("\n\n"); + return (
  • {def.key} + {def.secret ? "secret" : scopeLabel(def.scopes[0] ?? "user")} {set && kind !== "roster" && kind !== "tabs" && ( )}
    -

    {def.description}

    {control} {row.busy ? "saving…" : row.saved ? "saved ✓" : ""} diff --git a/src/client/board/InfoTip.tsx b/src/client/board/InfoTip.tsx new file mode 100644 index 0000000..6f27749 --- /dev/null +++ b/src/client/board/InfoTip.tsx @@ -0,0 +1,16 @@ +import { Tooltip } from "@mattstack/tui-kit"; + +/** An info glyph that reveals helper text on hover or focus, so a dense form + can keep its explanations without printing them under every row. The + button carries the text as its aria-label because the kit's tooltip card + is hidden from assistive tech by design. tui-kit candidate: a Tooltip + composition, not board logic. */ +export function InfoTip({ text, about }: { text: string; about: string }) { + return ( + + + + ); +} diff --git a/src/style.css b/src/style.css index 0e9080d..4e5239f 100644 --- a/src/style.css +++ b/src/style.css @@ -752,7 +752,7 @@ .tui-row[data-local="1"], .tui-card[data-local="1"] { cursor: context-menu; } /* ── board settings (ConfigModal) ─────────────────────────────────────────── */ -.tui-config-modal { max-width: min(760px, 94vw); } +.tui-config-modal { max-width: min(920px, 94vw); } .tui-config-filter { width: 100%; margin-bottom: 0.4rem; } .tui-config-group { margin: 0.9rem 0 0.25rem; color: var(--muted); font-size: 0.68rem; font-weight: 600; @@ -777,7 +777,14 @@ .tui-config-clear { background: none; border: none; color: var(--muted); cursor: pointer; padding: 0 4px; font: inherit; line-height: 1; } .tui-config-clear:hover:not(:disabled) { color: var(--fg); } .tui-config-clear:disabled { opacity: 0.5; cursor: default; } -.tui-config-desc { margin: 2px 0 6px; color: var(--muted); font-size: 0.74rem; } +.tui-info { + display: inline-flex; align-items: center; justify-content: center; + width: 15px; height: 15px; padding: 0; border-radius: 50%; + border: 1px solid var(--muted); background: none; color: var(--muted); + font: 600 10px/1 var(--font-mono); cursor: help; +} +.tui-info:hover, .tui-info:focus-visible { color: var(--fg); border-color: var(--fg); } +.tui-info-card { max-width: 400px; font-family: var(--font-sans); } .tui-config-control { display: flex; align-items: center; gap: 8px; } .tui-config-control > .tui-invite-input { flex: 1; max-width: 440px; } .tui-config-control .tui-invite-input.dirty { border-color: var(--accent); } @@ -842,7 +849,6 @@ .tui-roster-who { display: inline-flex; align-items: baseline; gap: 6px; min-width: 0; font-size: 13px; } .tui-roster-handle { color: var(--muted); font-family: var(--font-mono); font-size: 11px; } .tui-roster-out { color: var(--muted); font-size: 11px; } -.tui-config-hint { margin: 0; color: var(--muted); font-size: 11px; } .tui-roster-add { display: flex; gap: 6px; } .tui-roster-add .tui-modal-input { flex: 1; min-width: 0; } .tui-modal-input { @@ -859,14 +865,15 @@ .tui-modal-btn.danger { color: var(--bad, #e5484d); border-color: currentColor; } .tui-modal-error { margin: 4px 0 0; color: var(--bad, #e5484d); font-size: 12px; } @media (prefers-reduced-motion: reduce) { .tui-modal-btn { transition: none; } } -.tui-tabs-list { gap: 8px; max-height: 380px; } -.tui-tabs-item { display: flex; flex-direction: column; gap: 4px; padding: 6px; border: 1px solid var(--border); border-radius: 4px; } -.tui-tabs-item-head { display: flex; align-items: center; gap: 8px; } -.tui-tabs-item-head .tui-invite-input { flex: 1; min-width: 0; } -.tui-tabs-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 4px 10px; } -.tui-tabs-field { display: flex; align-items: center; gap: 6px; min-width: 0; } +.tui-tabs-list { gap: 12px; padding: 10px; max-height: 60vh; } +.tui-tabs-item { display: flex; flex-direction: column; gap: 10px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 6px; background: color-mix(in srgb, var(--panel) 60%, transparent); } +.tui-tabs-item-head { display: flex; align-items: center; gap: 12px; } +.tui-tabs-item-head .tui-invite-input { flex: 1; min-width: 0; font-size: 14px; } +.tui-tabs-fields { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 14px; align-items: center; } +.tui-tabs-field { display: contents; } .tui-tabs-field > span { color: var(--muted); font-family: var(--font-mono); font-size: 11px; white-space: nowrap; } -.tui-tabs-field .tui-invite-input { flex: 1; min-width: 0; } -.tui-tabs-check { grid-column: 1 / -1; } -.tui-tabs-add { flex-wrap: wrap; } +.tui-tabs-field .tui-invite-input { width: 100%; max-width: 420px; min-width: 0; } +.tui-tabs-check { display: flex; grid-column: 2; align-items: center; gap: 8px; } +.tui-tabs-add { flex-wrap: wrap; gap: 8px; padding: 4px 0; } +.tui-tabs-add .tui-modal-input { flex: 1 1 180px; } .tui-tabs-kind { flex: 0 0 auto; } From 4ed3b2f7192c901afb3397e7dad287a512affd9e Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 26 Aug 2026 10:44:59 -0500 Subject: [PATCH 04/14] tabs editor: dropping a tab requires typing its label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ✕ now opens a strip that warns the tab's section, channel, and skill settings are discarded; the drop button enables only once the label is typed back exactly, with a cancel beside it. Co-Authored-By: Claude Fable 5 --- src/client/board/ConfigModal.tsx | 38 +++++++++++++++++++++++++++++--- src/style.css | 3 +++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/client/board/ConfigModal.tsx b/src/client/board/ConfigModal.tsx index 6665d40..88a7ad1 100644 --- a/src/client/board/ConfigModal.tsx +++ b/src/client/board/ConfigModal.tsx @@ -456,7 +456,10 @@ function TabsControl({ }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + // Dropping a tab discards its config, so the armed row demands the tab's + // label typed back before the drop button enables. const [armed, setArmed] = useState(null); + const [dropText, setDropText] = useState(""); const [newLabel, setNewLabel] = useState(""); const [newKind, setNewKind] = useState("codeowners"); const [newSection, setNewSection] = useState(""); @@ -471,10 +474,16 @@ function TabsControl({ return false; } setArmed(null); + setDropText(""); onSaved(); return true; }; + const arm = (id: string | null) => { + setArmed(id); + setDropText(""); + }; + const patch = (id: string, change: (tab: TabConfig) => TabConfig) => void write(tabs.map((t) => (t.id === id ? change(t) : t))); const optional = (text: string) => (text.trim() === "" ? undefined : text.trim()); @@ -508,15 +517,38 @@ function TabsControl({ only tab ) : armed === tab.id ? ( - ) : ( - )}
    + {armed === tab.id && ( +
    { + e.preventDefault(); + if (dropText === tab.label && !busy) void write(tabs.filter((t) => t.id !== tab.id)); + }} + > + dropping this tab discards its section, channel, and skill settings + setDropText(e.target.value)} + placeholder={`type ${tab.label} to confirm`} + aria-label={`type the label of tab ${tab.id} to confirm dropping it`} + disabled={busy} + /> + +
    + )}
    {tab.source.kind === "codeowners" && ( <> diff --git a/src/style.css b/src/style.css index 4e5239f..f18f053 100644 --- a/src/style.css +++ b/src/style.css @@ -877,3 +877,6 @@ .tui-tabs-add { flex-wrap: wrap; gap: 8px; padding: 4px 0; } .tui-tabs-add .tui-modal-input { flex: 1 1 180px; } .tui-tabs-kind { flex: 0 0 auto; } +.tui-tabs-drop { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid var(--bad, #e5484d); border-radius: 4px; } +.tui-tabs-drop .tui-modal-error { flex: 1 1 100%; margin: 0; } +.tui-tabs-drop .tui-modal-input { flex: 1 1 200px; } From 5679b6fafae8a4ec1289739eb8077a36673569d0 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 26 Aug 2026 10:48:48 -0500 Subject: [PATCH 05/14] settings modal: composite rows collapse to a one-line summary Rows whose control is a block of fields (slack, triage, workspaces, cwds, rtRepos, the lists, roster, tabs) gain a chevron and a summary of what is set; the body animates open over 180ms via grid rows, stays mounted so drafts survive a collapse, and is inert while closed. Open rows are remembered per browser. Co-Authored-By: Claude Fable 5 --- src/client/__tests__/config-shapes.test.ts | 31 ++++++++ src/client/board/ConfigModal.tsx | 90 ++++++++++++++++++++-- src/client/board/Disclosure.tsx | 30 ++++++++ src/client/board/config-shapes.ts | 35 +++++++++ src/style.css | 21 +++++ 5 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 src/client/board/Disclosure.tsx diff --git a/src/client/__tests__/config-shapes.test.ts b/src/client/__tests__/config-shapes.test.ts index 485b1d5..c337ac1 100644 --- a/src/client/__tests__/config-shapes.test.ts +++ b/src/client/__tests__/config-shapes.test.ts @@ -14,6 +14,7 @@ import { scopeLabel, setLeaf, slugTabId, + summarizeShape, type ConfigDef, } from "../board/config-shapes.ts"; @@ -241,3 +242,33 @@ describe("slugTabId", () => { expect(slugTabId("???", [])).toBe("tab"); }); }); + +describe("summarizeShape", () => { + test("stringList lists entries and caps at four", () => { + const s = COMPOSITE_SHAPES["board.projects"]!; + expect(summarizeShape(s, [])).toBe("empty"); + expect(summarizeShape(s, ["a", "b"])).toBe("a, b"); + expect(summarizeShape(s, ["a", "b", "c", "d", "e", "f"])).toBe("a, b, c, d +2"); + }); + + test("pairList counts", () => { + const s = COMPOSITE_SHAPES["board.rtRepos"]!; + expect(summarizeShape(s, [{ project: "g/p", repo: "r" }])).toBe("1 pair"); + expect(summarizeShape(s, undefined)).toBe("0 pairs"); + }); + + test("leaves names the set leaves, skipping empty strings, capped at three", () => { + const s = COMPOSITE_SHAPES["board.slack"]!; + expect(summarizeShape(s, undefined)).toBe("nothing set"); + expect(summarizeShape(s, { channel: "team", singleTemplate: "" })).toBe("channel: team"); + expect(summarizeShape(s, { channel: "team", autoResolveIntervalMinutes: 5, emoji: { looking: "eyes", approved: "ok" } })).toBe( + "channel: team, autoResolveIntervalMinutes: 5, emoji.looking: eyes +1 more", + ); + }); + + test("tabs counts and lists labels", () => { + const s = COMPOSITE_SHAPES["board.tabs"]!; + expect(summarizeShape(s, [])).toBe("0 tabs"); + expect(summarizeShape(s, [{ id: "t", label: "Team", source: { kind: "authors" } }])).toBe("1 tab: Team"); + }); +}); diff --git a/src/client/board/ConfigModal.tsx b/src/client/board/ConfigModal.tsx index 88a7ad1..5f27698 100644 --- a/src/client/board/ConfigModal.tsx +++ b/src/client/board/ConfigModal.tsx @@ -4,6 +4,7 @@ import { useSettingsScope, type SettingsScopeState } from "@mattstack/settings-k import { postAction } from "../api.ts"; import type { TabConfig } from "../../config.ts"; import { InfoTip } from "./InfoTip.tsx"; +import { Disclosure, DisclosureToggle } from "./Disclosure.tsx"; import { COMPOSITE_SHAPES, addToList, @@ -19,6 +20,7 @@ import { scopeLabel, setLeaf, slugTabId, + summarizeShape, type CompositeShape, type ConfigDef, type LeafType, @@ -648,16 +650,54 @@ const ROW_HINTS: Record = { "board.tabs": "A new section's MRs land once rt has backfilled it; the tab shows \"syncing\" until then.", }; +const OPEN_ROWS_KEY = "board.config.openRows"; + +/** Which composite rows are expanded, remembered per browser so the modal + reopens the way it was left. Storage is a convenience only: any failure + reads as "all collapsed". */ +function useOpenRows(): [Set, (key: string) => void] { + const [open, setOpen] = useState>(() => { + try { + const raw = localStorage.getItem(OPEN_ROWS_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : []; + return new Set(Array.isArray(parsed) ? parsed.filter((k): k is string => typeof k === "string") : []); + } catch { + return new Set(); + } + }); + const toggle = (key: string) => { + setOpen((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + useEffect(() => { + try { + localStorage.setItem(OPEN_ROWS_KEY, JSON.stringify([...open])); + } catch { + // storage unavailable: the open set still works for this mount + } + }, [open]); + return [open, toggle]; +} + function SettingRow({ def, store, tabs, + open, + onToggle, onOpenRoster, onTabsSaved, }: { def: ConfigDef; store: SettingsScopeState; tabs: TabConfig[]; + /** Expanded, for a collapsible row; ignored otherwise. */ + open: boolean; + onToggle: () => void; onOpenRoster: () => void; onTabsSaved: () => void; }) { @@ -719,12 +759,41 @@ function SettingRow({ } const help = [def.description, ROW_HINTS[def.key]].filter(Boolean).join("\n\n"); + // Only rows whose control is a block of fields collapse; a single input, + // a readonly value, or the hidden-members pointer is already one line. + const collapsible = shape !== undefined && !malformed && !def.secret && kind !== "readonly" && def.key !== "board.hiddenMembers"; + let summary: string | null = null; + if (collapsible) { + summary = + kind === "roster" + ? rosterSummary(value, store.defs.find((d) => d.key === "board.hiddenMembers")?.effective.value) + : kind === "tabs" + ? summarizeShape(shape, tabs) + : summarizeShape(shape, value); + } + + const body = ( + <> +
    + {control} + {row.busy ? "saving…" : row.saved ? "saved ✓" : ""} +
    + {row.error &&

    {row.error}

    } + {def.effective.invalid &&

    stored value rejected: {def.effective.invalid}

    } + + ); return ( -
  • +
  • + {collapsible && } {def.key} + {collapsible && ( + + )} {def.secret ? "secret" : scopeLabel(def.scopes[0] ?? "user")} {set && kind !== "roster" && kind !== "tabs" && ( )}
    -
    - {control} - {row.busy ? "saving…" : row.saved ? "saved ✓" : ""} -
    - {row.error &&

    {row.error}

    } - {def.effective.invalid &&

    stored value rejected: {def.effective.invalid}

    } + {collapsible ? {body} : body}
  • ); } @@ -762,6 +826,7 @@ function ConfigModal({ }) { const store = useSettingsScope("board."); const [query, setQuery] = useState(""); + const [openRows, toggleRow] = useOpenRows(); const groups = groupByScope(filterDefs(store.defs, query)); return ( @@ -787,7 +852,16 @@ function ConfigModal({

    {g.scope}

      {g.defs.map((def) => ( - + toggleRow(def.key)} + onOpenRoster={onOpenRoster} + onTabsSaved={onTabsSaved} + /> ))}
    diff --git a/src/client/board/Disclosure.tsx b/src/client/board/Disclosure.tsx new file mode 100644 index 0000000..865fa03 --- /dev/null +++ b/src/client/board/Disclosure.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; + +/** A collapsible body that animates height via grid-template-rows, so + content of any height opens and closes smoothly without measuring. The + body stays mounted either way (a half-typed field survives a collapse) and + is `inert` while closed so nothing inside can take focus. tui-kit + candidate: layout only, no board logic. */ +export function Disclosure({ open, children }: { open: boolean; children: ReactNode }) { + return ( +
    +
    + {children} +
    +
    + ); +} + +export function DisclosureToggle({ open, label, onToggle }: { open: boolean; label: string; onToggle: () => void }) { + return ( + + ); +} diff --git a/src/client/board/config-shapes.ts b/src/client/board/config-shapes.ts index f493a8c..41a1b38 100644 --- a/src/client/board/config-shapes.ts +++ b/src/client/board/config-shapes.ts @@ -195,6 +195,41 @@ export function rosterSummary(members: unknown, hidden: unknown): string { return hiddenNames.size > 0 ? `${head}, ${hiddenNames.size} hidden` : head; } +function briefList(items: string[], cap: number, empty: string): string { + if (items.length === 0) return empty; + const shown = items.slice(0, cap).join(", "); + return items.length > cap ? `${shown} +${items.length - cap}` : shown; +} + +/** One line for a collapsed composite row: enough to know what is set + without opening it. Roster rows use rosterSummary instead. */ +export function summarizeShape(shape: CompositeShape, value: unknown): string { + switch (shape.kind) { + case "stringList": { + const items = Array.isArray(value) ? value.filter((x): x is string => typeof x === "string") : []; + return briefList(items, 4, "empty"); + } + case "pairList": { + const n = Array.isArray(value) ? value.length : 0; + return n === 1 ? "1 pair" : `${n} pairs`; + } + case "leaves": { + const set = Object.keys(shape.fields).flatMap((path) => { + const v = getLeaf(value, path); + return v === undefined || v === "" ? [] : [`${path}: ${typeof v === "string" ? v : JSON.stringify(v)}`]; + }); + return briefList(set, 3, "nothing set").replace(/ \+(\d+)$/, " +$1 more"); + } + case "tabs": { + const labels = Array.isArray(value) ? value.filter(isRecord).map((t) => (typeof t.label === "string" ? t.label : "?")) : []; + const head = labels.length === 1 ? "1 tab" : `${labels.length} tabs`; + return labels.length === 0 ? head : `${head}: ${briefList(labels, 4, "")}`; + } + case "roster": + return rosterSummary(value, undefined); + } +} + export function formatValue(value: unknown): string { return value === undefined ? "" : JSON.stringify(value); } diff --git a/src/style.css b/src/style.css index f18f053..9bb702b 100644 --- a/src/style.css +++ b/src/style.css @@ -769,6 +769,27 @@ border-radius: 1px; background: var(--accent); } .tui-config-head { display: flex; align-items: center; gap: 8px; } +.tui-disclosure-toggle { + flex: 0 0 auto; width: 14px; padding: 0; margin-left: -6px; border: none; background: none; + color: var(--muted); font: inherit; font-size: 0.8rem; line-height: 1; cursor: pointer; + transition: transform 180ms ease, color 120ms ease; +} +.tui-disclosure-toggle.open { transform: rotate(90deg); } +.tui-disclosure-toggle:hover { color: var(--fg); } +.tui-config-summary { + min-width: 0; padding: 0; border: none; background: none; text-align: left; cursor: pointer; + color: var(--muted); font-family: var(--font-mono); font-size: 0.74rem; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + transition: opacity 180ms ease; +} +.tui-config-summary.open { opacity: 0; pointer-events: none; } +.tui-disclosure { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 180ms ease; } +.tui-disclosure.open { grid-template-rows: 1fr; } +.tui-disclosure-body { min-height: 0; overflow: hidden; } +.tui-disclosure.open .tui-disclosure-body { padding-top: 6px; } +@media (prefers-reduced-motion: reduce) { + .tui-disclosure, .tui-disclosure-toggle, .tui-config-summary { transition: none; } +} .tui-config-keyname { font-weight: 600; font-family: var(--font-mono); font-size: 0.84rem; } .tui-config-badge { margin-left: auto; color: var(--muted); font-size: 0.66rem; white-space: nowrap; From dbef449c07d7be58ae5e1876572ca3fd9a6616ea Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 26 Aug 2026 10:52:01 -0500 Subject: [PATCH 06/14] fix: coderabbit round on PR #5 - a tab dropped while active no longer lingers as the view's tab id - the mobile drawer shows the inferred roster on codeowners tabs, as the desktop sidebar already did - matchesShape rejects an empty tab list and duplicate ids, matching parseTabs, so the modal flags such a store value instead of editing - the roster editor's "checked out" badge honors an inline hidden flag as well as the hiddenMembers overlay, like rosterSummary - /roster and /tabs require a local request, like every action endpoint - currentcolor keyword case Co-Authored-By: Claude Fable 5 --- src/client/__tests__/config-shapes.test.ts | 7 ++++- src/client/board/Board.tsx | 36 ++++++++++++---------- src/client/board/ConfigModal.tsx | 9 ++++-- src/client/board/config-shapes.ts | 7 ++++- src/server.ts | 2 ++ src/style.css | 2 +- 6 files changed, 41 insertions(+), 22 deletions(-) diff --git a/src/client/__tests__/config-shapes.test.ts b/src/client/__tests__/config-shapes.test.ts index c337ac1..7b91e25 100644 --- a/src/client/__tests__/config-shapes.test.ts +++ b/src/client/__tests__/config-shapes.test.ts @@ -219,10 +219,15 @@ describe("tabs shape", () => { }); test("accepts the shapes parseTabs accepts", () => { - expect(matchesShape(s, [])).toBe(true); + expect(matchesShape(s, [team])).toBe(true); expect(matchesShape(s, [team, acme])).toBe(true); }); + test("rejects an empty list and duplicate ids, like parseTabs", () => { + expect(matchesShape(s, [])).toBe(false); + expect(matchesShape(s, [team, { ...acme, id: "team" }])).toBe(false); + }); + test("rejects what parseTabs rejects", () => { expect(matchesShape(s, "team")).toBe(false); expect(matchesShape(s, [{ ...team, id: "" }])).toBe(false); diff --git a/src/client/board/Board.tsx b/src/client/board/Board.tsx index 034243f..22a5e90 100644 --- a/src/client/board/Board.tsx +++ b/src/client/board/Board.tsx @@ -113,9 +113,12 @@ export function Board() { // inferred from the rows in view, so checking the config roster alone // would drop a legitimately picked author on the next poll. setState((prev) => { - if (prev.member === "all") return prev; - const tab = d.tabs.find((t) => t.id === prev.tab) ?? d.tabs[0]; - return rosterUsernamesFor(d.mrs, tab, usernames).has(prev.member) ? prev : { ...prev, member: "all" }; + // A tab dropped in the settings modal must not linger as the active + // id, or re-adding one with that id would silently jump to it. + const tab = d.tabs.find((t) => t.id === prev.tab) ?? d.tabs[0]!; + const next = tab.id === prev.tab ? prev : { ...prev, tab: tab.id }; + if (next.member === "all") return next; + return rosterUsernamesFor(d.mrs, tab, usernames).has(next.member) ? next : { ...next, member: "all" }; }); } }, []); @@ -623,20 +626,19 @@ export function Board() { {ICONS.close} - {!isCodeownersTab && ( - { - update({ member }); - setMenuOpen(false); - }} - onSettings={openSettings} - onConfig={openConfig} - scopeUncovered={data.scopeUncovered} - /> - )} + { + update({ member }); + setMenuOpen(false); + }} + onSettings={openSettings} + onConfig={openConfig} + scopeUncovered={data.scopeUncovered} + note={isCodeownersTab ? "authors in this queue" : undefined} + />
    diff --git a/src/client/board/ConfigModal.tsx b/src/client/board/ConfigModal.tsx index 5f27698..32ea25e 100644 --- a/src/client/board/ConfigModal.tsx +++ b/src/client/board/ConfigModal.tsx @@ -352,8 +352,13 @@ function RosterControl({ // username since every row carries the button. const [armed, setArmed] = useState(null); - const roster = Array.isArray(members) ? (members as Array<{ username?: unknown; name?: unknown }>) : []; - const hiddenSet = new Set(Array.isArray(hidden) ? (hidden as unknown[]).filter((u): u is string => typeof u === "string") : []); + const roster = Array.isArray(members) ? (members as Array<{ username?: unknown; name?: unknown; hidden?: unknown }>) : []; + // Checked out is either the user overlay or a hidden flag on the entry + // itself, the same union rosterSummary counts. + const hiddenSet = new Set([ + ...(Array.isArray(hidden) ? (hidden as unknown[]).filter((u): u is string => typeof u === "string") : []), + ...roster.filter((m) => m.hidden === true && typeof m.username === "string").map((m) => m.username as string), + ]); const edit = async (action: "add" | "remove", username: string) => { setBusy(true); diff --git a/src/client/board/config-shapes.ts b/src/client/board/config-shapes.ts index 41a1b38..8b2c3e3 100644 --- a/src/client/board/config-shapes.ts +++ b/src/client/board/config-shapes.ts @@ -103,7 +103,12 @@ export function matchesShape(shape: CompositeShape, value: unknown): boolean { case "roster": return Array.isArray(value); case "tabs": - return Array.isArray(value) && value.every(isTabLike); + return ( + Array.isArray(value) && + value.length > 0 && + value.every(isTabLike) && + new Set(value.map((t) => (t as { id: string }).id)).size === value.length + ); } } diff --git a/src/server.ts b/src/server.ts index b9b4d95..c11ac2d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -610,6 +610,7 @@ const httpServer = Bun.serve({ // hidden overlay): both are single-writer config mutations that swap // the in-memory roster so this and every later /data.json agree. if (req.method !== "POST") return new Response("method not allowed", { status: 405 }); + if (!isLocalRequest(req)) return new Response("forbidden", { status: 403 }); let body: unknown; try { body = await req.json(); @@ -657,6 +658,7 @@ const httpServer = Bun.serve({ // in-memory config swaps so /data.json agrees at once, and the cache // drops so the next fetch declares the new sections to rt. if (req.method !== "POST") return new Response("method not allowed", { status: 405 }); + if (!isLocalRequest(req)) return new Response("forbidden", { status: 403 }); let body: unknown; try { body = await req.json(); diff --git a/src/style.css b/src/style.css index 9bb702b..386b66c 100644 --- a/src/style.css +++ b/src/style.css @@ -883,7 +883,7 @@ } .tui-modal-btn:hover:not(:disabled) { color: var(--fg); } .tui-modal-btn:disabled { opacity: 0.5; cursor: default; } -.tui-modal-btn.danger { color: var(--bad, #e5484d); border-color: currentColor; } +.tui-modal-btn.danger { color: var(--bad, #e5484d); border-color: currentcolor; } .tui-modal-error { margin: 4px 0 0; color: var(--bad, #e5484d); font-size: 12px; } @media (prefers-reduced-motion: reduce) { .tui-modal-btn { transition: none; } } .tui-tabs-list { gap: 12px; padding: 10px; max-height: 60vh; } From 0405c435cfe4c4e9cbac6004156b62890eca9e59 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 26 Aug 2026 10:55:42 -0500 Subject: [PATCH 07/14] settings modal: the whole row head toggles a collapsible row The chevron was the only trigger. The head is now a role=button row (key name, summary, and the slack around them), with the info tip and the clear button carved out via stopPropagation; Enter and Space work on the focused head. Co-Authored-By: Claude Fable 5 --- src/client/board/ConfigModal.tsx | 53 ++++++++++++++++++++++---------- src/client/board/Disclosure.tsx | 43 +++++++++++++++++++++----- src/style.css | 20 ++++++------ 3 files changed, 83 insertions(+), 33 deletions(-) diff --git a/src/client/board/ConfigModal.tsx b/src/client/board/ConfigModal.tsx index 32ea25e..cca6267 100644 --- a/src/client/board/ConfigModal.tsx +++ b/src/client/board/ConfigModal.tsx @@ -4,7 +4,7 @@ import { useSettingsScope, type SettingsScopeState } from "@mattstack/settings-k import { postAction } from "../api.ts"; import type { TabConfig } from "../../config.ts"; import { InfoTip } from "./InfoTip.tsx"; -import { Disclosure, DisclosureToggle } from "./Disclosure.tsx"; +import { Disclosure, DisclosureHead } from "./Disclosure.tsx"; import { COMPOSITE_SHAPES, addToList, @@ -788,24 +788,43 @@ function SettingRow({ ); + // Inside a collapsible head these must not toggle the row. + const stop = (e: { stopPropagation: () => void }) => e.stopPropagation(); + const head = ( + <> + {def.key} + + + + {collapsible && {summary}} + {def.secret ? "secret" : scopeLabel(def.scopes[0] ?? "user")} + {set && kind !== "roster" && kind !== "tabs" && ( + + )} + + ); + return (
  • -
    - {collapsible && } - {def.key} - - {collapsible && ( - - )} - {def.secret ? "secret" : scopeLabel(def.scopes[0] ?? "user")} - {set && kind !== "roster" && kind !== "tabs" && ( - - )} -
    + {collapsible ? ( + + {head} + + ) : ( +
    {head}
    + )} {collapsible ? {body} : body}
  • ); diff --git a/src/client/board/Disclosure.tsx b/src/client/board/Disclosure.tsx index 865fa03..fc60924 100644 --- a/src/client/board/Disclosure.tsx +++ b/src/client/board/Disclosure.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { KeyboardEvent, ReactNode } from "react"; /** A collapsible body that animates height via grid-template-rows, so content of any height opens and closes smoothly without measuring. The @@ -15,16 +15,45 @@ export function Disclosure({ open, children }: { open: boolean; children: ReactN ); } -export function DisclosureToggle({ open, label, onToggle }: { open: boolean; label: string; onToggle: () => void }) { +/** The whole header row is the trigger, chevron included: a row's title is a + far bigger target than a glyph. Controls that live inside the row (an info + tip, a clear button) stop propagation so they don't toggle. A div with the + button role rather than a button, because those inner controls are + buttons themselves and buttons cannot nest. */ +export function DisclosureHead({ + open, + label, + onToggle, + className, + children, +}: { + open: boolean; + label: string; + onToggle: () => void; + className?: string; + children: ReactNode; +}) { + const onKeyDown = (e: KeyboardEvent) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onToggle(); + } + }; return ( - + + {children} + ); } diff --git a/src/style.css b/src/style.css index 386b66c..13f8300 100644 --- a/src/style.css +++ b/src/style.css @@ -769,26 +769,28 @@ border-radius: 1px; background: var(--accent); } .tui-config-head { display: flex; align-items: center; gap: 8px; } -.tui-disclosure-toggle { - flex: 0 0 auto; width: 14px; padding: 0; margin-left: -6px; border: none; background: none; - color: var(--muted); font: inherit; font-size: 0.8rem; line-height: 1; cursor: pointer; +.tui-disclosure-head { cursor: pointer; user-select: none; margin: -4px -6px; padding: 4px 6px; border-radius: 4px; } +.tui-disclosure-head:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); } +.tui-disclosure-head:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; } +.tui-disclosure-chevron { + flex: 0 0 auto; display: inline-block; width: 12px; color: var(--muted); font-size: 0.8rem; line-height: 1; transition: transform 180ms ease, color 120ms ease; } -.tui-disclosure-toggle.open { transform: rotate(90deg); } -.tui-disclosure-toggle:hover { color: var(--fg); } +.tui-disclosure-head.open .tui-disclosure-chevron { transform: rotate(90deg); } +.tui-disclosure-head:hover .tui-disclosure-chevron { color: var(--fg); } +.tui-config-tip { display: inline-flex; } .tui-config-summary { - min-width: 0; padding: 0; border: none; background: none; text-align: left; cursor: pointer; - color: var(--muted); font-family: var(--font-mono); font-size: 0.74rem; + min-width: 0; color: var(--muted); font-family: var(--font-mono); font-size: 0.74rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity 180ms ease; } -.tui-config-summary.open { opacity: 0; pointer-events: none; } +.tui-config-summary.open { opacity: 0; } .tui-disclosure { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 180ms ease; } .tui-disclosure.open { grid-template-rows: 1fr; } .tui-disclosure-body { min-height: 0; overflow: hidden; } .tui-disclosure.open .tui-disclosure-body { padding-top: 6px; } @media (prefers-reduced-motion: reduce) { - .tui-disclosure, .tui-disclosure-toggle, .tui-config-summary { transition: none; } + .tui-disclosure, .tui-disclosure-chevron, .tui-config-summary { transition: none; } } .tui-config-keyname { font-weight: 600; font-family: var(--font-mono); font-size: 0.84rem; } .tui-config-badge { From 18f69cb0368dc8235392118528be2097efd064a9 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 26 Aug 2026 11:56:03 -0500 Subject: [PATCH 08/14] settings modal: info tip card gets an inverted surface The kit card paints --panel, the modal's own surface, so the tip sat flush with the row behind it. Now --fg on --bg with a shadow: a tooltip in either theme. Co-Authored-By: Claude Fable 5 --- src/style.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/style.css b/src/style.css index 13f8300..f4b5621 100644 --- a/src/style.css +++ b/src/style.css @@ -807,7 +807,11 @@ font: 600 10px/1 var(--font-mono); cursor: help; } .tui-info:hover, .tui-info:focus-visible { color: var(--fg); border-color: var(--fg); } -.tui-info-card { max-width: 400px; font-family: var(--font-sans); } +.tui-info-card { + max-width: 400px; font-family: var(--font-sans); + background: var(--fg); color: var(--bg); border-color: transparent; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28); +} .tui-config-control { display: flex; align-items: center; gap: 8px; } .tui-config-control > .tui-invite-input { flex: 1; max-width: 440px; } .tui-config-control .tui-invite-input.dirty { border-color: var(--accent); } From 1f5888039441f008e1ea92eda557d05936eed04c Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 26 Aug 2026 11:56:50 -0500 Subject: [PATCH 09/14] settings modal: drop the collapsed-row summary The head is the trigger now; a one-line digest of the value next to it was noise. summarizeShape and its tests go with it. Co-Authored-By: Claude Fable 5 --- src/client/__tests__/config-shapes.test.ts | 31 ------------------- src/client/board/ConfigModal.tsx | 12 -------- src/client/board/config-shapes.ts | 35 ---------------------- src/style.css | 8 +---- 4 files changed, 1 insertion(+), 85 deletions(-) diff --git a/src/client/__tests__/config-shapes.test.ts b/src/client/__tests__/config-shapes.test.ts index 7b91e25..6c0ba07 100644 --- a/src/client/__tests__/config-shapes.test.ts +++ b/src/client/__tests__/config-shapes.test.ts @@ -14,7 +14,6 @@ import { scopeLabel, setLeaf, slugTabId, - summarizeShape, type ConfigDef, } from "../board/config-shapes.ts"; @@ -247,33 +246,3 @@ describe("slugTabId", () => { expect(slugTabId("???", [])).toBe("tab"); }); }); - -describe("summarizeShape", () => { - test("stringList lists entries and caps at four", () => { - const s = COMPOSITE_SHAPES["board.projects"]!; - expect(summarizeShape(s, [])).toBe("empty"); - expect(summarizeShape(s, ["a", "b"])).toBe("a, b"); - expect(summarizeShape(s, ["a", "b", "c", "d", "e", "f"])).toBe("a, b, c, d +2"); - }); - - test("pairList counts", () => { - const s = COMPOSITE_SHAPES["board.rtRepos"]!; - expect(summarizeShape(s, [{ project: "g/p", repo: "r" }])).toBe("1 pair"); - expect(summarizeShape(s, undefined)).toBe("0 pairs"); - }); - - test("leaves names the set leaves, skipping empty strings, capped at three", () => { - const s = COMPOSITE_SHAPES["board.slack"]!; - expect(summarizeShape(s, undefined)).toBe("nothing set"); - expect(summarizeShape(s, { channel: "team", singleTemplate: "" })).toBe("channel: team"); - expect(summarizeShape(s, { channel: "team", autoResolveIntervalMinutes: 5, emoji: { looking: "eyes", approved: "ok" } })).toBe( - "channel: team, autoResolveIntervalMinutes: 5, emoji.looking: eyes +1 more", - ); - }); - - test("tabs counts and lists labels", () => { - const s = COMPOSITE_SHAPES["board.tabs"]!; - expect(summarizeShape(s, [])).toBe("0 tabs"); - expect(summarizeShape(s, [{ id: "t", label: "Team", source: { kind: "authors" } }])).toBe("1 tab: Team"); - }); -}); diff --git a/src/client/board/ConfigModal.tsx b/src/client/board/ConfigModal.tsx index cca6267..a7f7fdb 100644 --- a/src/client/board/ConfigModal.tsx +++ b/src/client/board/ConfigModal.tsx @@ -20,7 +20,6 @@ import { scopeLabel, setLeaf, slugTabId, - summarizeShape, type CompositeShape, type ConfigDef, type LeafType, @@ -767,16 +766,6 @@ function SettingRow({ // Only rows whose control is a block of fields collapse; a single input, // a readonly value, or the hidden-members pointer is already one line. const collapsible = shape !== undefined && !malformed && !def.secret && kind !== "readonly" && def.key !== "board.hiddenMembers"; - let summary: string | null = null; - if (collapsible) { - summary = - kind === "roster" - ? rosterSummary(value, store.defs.find((d) => d.key === "board.hiddenMembers")?.effective.value) - : kind === "tabs" - ? summarizeShape(shape, tabs) - : summarizeShape(shape, value); - } - const body = ( <>
    @@ -796,7 +785,6 @@ function SettingRow({ - {collapsible && {summary}} {def.secret ? "secret" : scopeLabel(def.scopes[0] ?? "user")} {set && kind !== "roster" && kind !== "tabs" && (
    @@ -313,11 +362,33 @@ function CompositeControl({ }) { switch (shape.kind) { case "stringList": - return ; + return ( + + ); case "pairList": - return []) : []} fields={shape.fields} row={row} />; + return ( + []) : [] + } + fields={shape.fields} + row={row} + /> + ); case "leaves": - return ; + return ( + + ); case "roster": return null; } @@ -351,12 +422,22 @@ function RosterControl({ // username since every row carries the button. const [armed, setArmed] = useState(null); - const roster = Array.isArray(members) ? (members as Array<{ username?: unknown; name?: unknown; hidden?: unknown }>) : []; + const roster = Array.isArray(members) + ? (members as Array<{ + username?: unknown; + name?: unknown; + hidden?: unknown; + }>) + : []; // Checked out is either the user overlay or a hidden flag on the entry // itself, the same union rosterSummary counts. const hiddenSet = new Set([ - ...(Array.isArray(hidden) ? (hidden as unknown[]).filter((u): u is string => typeof u === "string") : []), - ...roster.filter((m) => m.hidden === true && typeof m.username === "string").map((m) => m.username as string), + ...(Array.isArray(hidden) + ? (hidden as unknown[]).filter((u): u is string => typeof u === "string") + : []), + ...roster + .filter((m) => m.hidden === true && typeof m.username === "string") + .map((m) => m.username as string), ]); const edit = async (action: "add" | "remove", username: string) => { @@ -378,7 +459,9 @@ function RosterControl({ return (
    - {rosterSummary(members, hidden)} + + {rosterSummary(members, hidden)} + @@ -393,14 +476,20 @@ function RosterControl({ {name ?? username} {name && @{username}} - {hiddenSet.has(username) && checked out} + {hiddenSet.has(username) && ( + checked out + )} {username === self ? ( you ) : armed === username ? ( - ) : ( @@ -434,7 +523,11 @@ function RosterControl({ aria-label="add a teammate by gitlab username" disabled={busy} /> - @@ -490,14 +583,33 @@ function TabsControl({ setDropText(""); }; - const patch = (id: string, change: (tab: TabConfig) => TabConfig) => void write(tabs.map((t) => (t.id === id ? change(t) : t))); - const optional = (text: string) => (text.trim() === "" ? undefined : text.trim()); + const patch = (id: string, change: (tab: TabConfig) => TabConfig) => + void write(tabs.map((t) => (t.id === id ? change(t) : t))); + const optional = (text: string) => + text.trim() === "" ? undefined : text.trim(); const add = async () => { const label = newLabel.trim(); if (!label || busy) return; - const source: TabSource = newKind === "authors" ? { kind: "authors" } : { kind: "codeowners", section: newSection.trim(), excludeMembers: true }; - const ok = await write([...tabs, { id: slugTabId(label, tabs.map((t) => t.id)), label, source }]); + const source: TabSource = + newKind === "authors" + ? { kind: "authors" } + : { + kind: "codeowners", + section: newSection.trim(), + excludeMembers: true, + }; + const ok = await write([ + ...tabs, + { + id: slugTabId( + label, + tabs.map((t) => t.id), + ), + label, + source, + }, + ]); if (ok) { setNewLabel(""); setNewSection(""); @@ -515,19 +627,34 @@ function TabsControl({ placeholder="label" ariaLabel={`label for tab ${tab.id}`} disabled={busy} - onCommit={(text) => patch(tab.id, (t) => ({ ...t, label: text.trim() }))} + onCommit={(text) => + patch(tab.id, (t) => ({ ...t, label: text.trim() })) + } /> {tab.source.kind} {tabs.length === 1 ? ( - + only tab ) : armed === tab.id ? ( - ) : ( - )} @@ -537,10 +664,14 @@ function TabsControl({ className="tui-tabs-drop" onSubmit={(e) => { e.preventDefault(); - if (dropText === tab.label && !busy) void write(tabs.filter((t) => t.id !== tab.id)); + if (dropText === tab.label && !busy) + void write(tabs.filter((t) => t.id !== tab.id)); }} > - dropping this tab discards its section, channel, and skill settings + + dropping this tab discards its section, channel, and skill + settings + - @@ -565,7 +700,18 @@ function TabsControl({ placeholder="CODEOWNERS section" ariaLabel={`codeowners section for tab ${tab.id}`} disabled={busy} - onCommit={(text) => patch(tab.id, (t) => ({ ...t, source: { ...(t.source as Extract), section: text.trim() } }))} + onCommit={(text) => + patch(tab.id, (t) => ({ + ...t, + source: { + ...(t.source as Extract< + TabSource, + { kind: "codeowners" } + >), + section: text.trim(), + }, + })) + } />
    @@ -624,7 +790,13 @@ function TabsControl({ aria-label="new tab label" disabled={busy} /> - setNewKind(e.target.value as TabSource["kind"])} + > @@ -638,7 +810,15 @@ function TabsControl({ disabled={busy} /> )} - @@ -651,7 +831,8 @@ function TabsControl({ the same info tip as the description. */ const ROW_HINTS: Record = { "board.members": "A new teammate's MRs land once rt has synced them.", - "board.tabs": "A new section's MRs land once rt has backfilled it; the tab shows \"syncing\" until then.", + "board.tabs": + 'A new section\'s MRs land once rt has backfilled it; the tab shows "syncing" until then.', }; const OPEN_ROWS_KEY = "board.config.openRows"; @@ -664,7 +845,11 @@ function useOpenRows(): [Set, (key: string) => void] { try { const raw = localStorage.getItem(OPEN_ROWS_KEY); const parsed: unknown = raw ? JSON.parse(raw) : []; - return new Set(Array.isArray(parsed) ? parsed.filter((k): k is string => typeof k === "string") : []); + return new Set( + Array.isArray(parsed) + ? parsed.filter((k): k is string => typeof k === "string") + : [], + ); } catch { return new Set(); } @@ -710,15 +895,24 @@ function SettingRow({ const value = def.effective.value; const shape = COMPOSITE_SHAPES[def.key]; const set = isSet(def); - const malformed = shape !== undefined && shape.kind !== "roster" && value !== undefined && !matchesShape(shape, value); + const malformed = + shape !== undefined && + shape.kind !== "roster" && + value !== undefined && + !matchesShape(shape, value); let control; if (kind === "tabs" && !malformed) { - const channel = getLeaf(store.defs.find((d) => d.key === "board.slack")?.effective.value, "channel"); + const channel = getLeaf( + store.defs.find((d) => d.key === "board.slack")?.effective.value, + "channel", + ); control = ( { store.refresh(); onTabsSaved(); @@ -726,11 +920,14 @@ function SettingRow({ /> ); } else if (kind === "roster") { - const members = store.defs.find((d) => d.key === "board.members")?.effective.value; - const hidden = store.defs.find((d) => d.key === "board.hiddenMembers")?.effective.value; - const self = store.defs.find((d) => d.key === "board.defaultMember")?.effective.value; - control = def.key === "board.members" - ? ( + const members = store.defs.find((d) => d.key === "board.members")?.effective + .value; + const hidden = store.defs.find((d) => d.key === "board.hiddenMembers") + ?.effective.value; + const self = store.defs.find((d) => d.key === "board.defaultMember") + ?.effective.value; + control = + def.key === "board.members" ? (