Skip to content

Commit 4ee4b0f

Browse files
authored
Merge pull request #31 from markrai/ux/minor_aesthetic_usability_fixes_2
multiple UX enhancements added from April 3rd '26 backlog
2 parents 0e35ff4 + 2c5b576 commit 4ee4b0f

13 files changed

Lines changed: 202 additions & 78 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@
33
> **Upgrades:** No breaking changes in **3.7.x** / **3.8.x** / **3.9.x** unless noted below.
44
55

6+
## [3.9.3] - 2026-04-05
7+
8+
### Improvements
9+
10+
- **Board search (Escape)** — While the search field is focused, **Esc** blurs it and, when there is text, clears the query using the same path as the clear control (**`setSearchParam("")`** + board reload). Escape handling runs **before** the global modal gate so search dismisses consistently.
11+
- **Settings****Tab** cycles the visible settings tabs (wrapped); **Shift+Tab** is left for normal focus. Tab switching goes through a single **`switchSettingsTab`** helper (workflow dirty confirm, cache invalidation, re-render). Sprints tab empty copy now says **Create one above** (the form is above the list).
12+
- **Main navigation****Shift+Tab** cycles **Dashboard → Projects → Temporary** in reverse (**Tab** still cycles forward). Tab vs Shift+Tab are dispatched explicitly by chord so the two actions cannot both run.
13+
- **Dashboard** — Initial dashboard load also fetches **`/api/projects`** so chip counts stay correct on a direct **`/dashboard`** visit; failed project fetch does not wipe an existing in-memory list.
14+
- **Projects / Dashboard chips****Temporary** vs **Temporary Boards** label uses one shared helper (**`temporaryBoardsNavLabel`**, **767px** breakpoint) so dashboard and projects stay aligned.
15+
16+
---
17+
618
## [3.9.1] - 2026-04-04
719

820
### Fixes

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<p align="center">
22
<img width="372" src="internal/httpapi/web/githublogo.png" alt="scrumboy logo" />
33
<br />
4-
<img src="https://img.shields.io/badge/version-v3.9.1-blue" alt="version" />
4+
<img src="https://img.shields.io/badge/version-v3.9.3-blue" alt="version" />
55
<a href="LICENSE">
66
<img src="https://img.shields.io/badge/license-AGPL--v3-orange" alt="license" />
77
</a>

internal/httpapi/web/dist/core/keybindings.js

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const DEFAULT_KEY_CHORDS = {
5757
projectsList8: "8",
5858
projectsList9: "9",
5959
cycleMainNavTabs: "tab",
60+
cycleMainNavTabsReverse: "shift+tab",
6061
};
6162
export const KEY_ACTION_LIST = [
6263
{ id: "newTodo", label: "New Todo", contexts: ["board"] },
@@ -67,6 +68,11 @@ export const KEY_ACTION_LIST = [
6768
label: "Cycle Dashboard / Projects / Temporary",
6869
contexts: ["dashboard", "projects"],
6970
},
71+
{
72+
id: "cycleMainNavTabsReverse",
73+
label: "Cycle Dashboard / Projects / Temporary (reverse)",
74+
contexts: ["dashboard", "projects"],
75+
},
7076
{ id: "createProject", label: "Create project", contexts: ["projects"] },
7177
{ id: "boardEscapeBack", label: "Back to projects (Esc)", contexts: ["board"] },
7278
...DASHBOARD_PROJECT_IDS.map((id, i) => ({
@@ -406,7 +412,8 @@ export function executeAction(actionId) {
406412
void Promise.resolve(deps.openSettings());
407413
return;
408414
}
409-
case "cycleMainNavTabs": {
415+
case "cycleMainNavTabs":
416+
case "cycleMainNavTabsReverse": {
410417
if (view !== "dashboard" && view !== "projects")
411418
return;
412419
const route = getRoute();
@@ -421,7 +428,8 @@ export function executeAction(actionId) {
421428
else {
422429
return;
423430
}
424-
const next = (idx + 1) % 3;
431+
const delta = actionId === "cycleMainNavTabsReverse" ? 2 : 1;
432+
const next = (idx + delta) % 3;
425433
const persistProjectsTab = (value) => {
426434
setProjectsTab(value);
427435
localStorage.setItem("projectsTab", value);
@@ -528,13 +536,25 @@ function onGlobalKeydown(ev) {
528536
const chord = chordFromKeyboardEvent(ev);
529537
if (!chord)
530538
return;
539+
// --- Escape: search blur MUST run before isModalOpen() check ---
540+
if (chord === "escape") {
541+
const active = document.activeElement;
542+
if (active && active.id === "searchInput") {
543+
ev.preventDefault();
544+
active.blur();
545+
const clearBtn = document.getElementById("searchClear");
546+
if (clearBtn)
547+
clearBtn.click();
548+
return;
549+
}
550+
}
531551
if (isModalOpen()) {
532552
return;
533553
}
534554
if (ev.repeat)
535555
return;
536556
const view = getCurrentView();
537-
const tabCyclesMainNav = chord === "tab" && (view === "dashboard" || view === "projects");
557+
const tabCyclesMainNav = (chord === "tab" || chord === "shift+tab") && (view === "dashboard" || view === "projects");
538558
if (tabCyclesMainNav) {
539559
if (isTypingInTextField())
540560
return;
@@ -565,7 +585,9 @@ function onGlobalKeydown(ev) {
565585
if (tryExec("openSettings"))
566586
return;
567587
if (view === "dashboard" || view === "projects") {
568-
if (tryExec("cycleMainNavTabs"))
588+
if (chord === "tab" && tryExec("cycleMainNavTabs"))
589+
return;
590+
if (chord === "shift+tab" && tryExec("cycleMainNavTabsReverse"))
569591
return;
570592
}
571593
if (view === "projects") {

internal/httpapi/web/dist/dialogs/settings.js

Lines changed: 53 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { invalidateBoard, refreshSprintsAndChips } from '../orchestration/board-
1010
import { emit } from '../events.js';
1111
import { normalizeSprints } from '../sprints.js';
1212
import { recordLocalMutation } from '../realtime/guard.js';
13-
import { KEY_ACTION_LIST, chordFromKeyboardEvent, formatChordForDisplay, getResolvedChordForAction, reloadKeybindingsFromStorage, saveKeybindingOverride, setKeybindingsCaptureListening, } from '../core/keybindings.js';
13+
import { KEY_ACTION_LIST, chordFromKeyboardEvent, formatChordForDisplay, getResolvedChordForAction, isTypingInTextField, reloadKeybindingsFromStorage, saveKeybindingOverride, setKeybindingsCaptureListening, } from '../core/keybindings.js';
1414
/** Active keybinding capture listener (settings customization); removed when starting a new capture or on abort. */
1515
let keybindingCaptureKeydown = null;
1616
function resetKeybindingCaptureUI() {
@@ -148,6 +148,33 @@ function invalidateChartCache() {
148148
cachedRealBurndownData = null;
149149
cachedRealBurndownURL = null;
150150
}
151+
/**
152+
* Single source of truth for all settings tab switches (click + keyboard).
153+
* Handles workflow dirty checks, cache invalidation, re-render, and dialog height fix.
154+
*/
155+
async function switchSettingsTab(tabName) {
156+
if (tabName === getSettingsActiveTab())
157+
return;
158+
if (getSettingsActiveTab() === "workflow" && isWorkflowDraftDirty()) {
159+
const discard = await showConfirmDialog("You have unsaved changes. Discard them?", "Unsaved changes", "Discard");
160+
if (!discard)
161+
return;
162+
resetWorkflowDraftToBaseline();
163+
}
164+
if (tabName === "workflow") {
165+
invalidateWorkflowLaneCountsCache();
166+
clearWorkflowDraftState();
167+
}
168+
setSettingsActiveTab(tabName);
169+
await renderSettingsModal();
170+
const dialog = document.getElementById("settingsDialog");
171+
if (dialog && dialog.open) {
172+
const currentHeight = dialog.style.height;
173+
dialog.style.height = "auto";
174+
void dialog.offsetHeight;
175+
dialog.style.height = currentHeight || "";
176+
}
177+
}
151178
// Invalidate sprints cache when sprints are created/activated/closed (so Charts tab shows fresh list)
152179
/** Auto-select sprint for Charts: active > last closed > first planned. */
153180
function computeDefaultBurndownSprintIndex(sprints) {
@@ -910,7 +937,7 @@ async function renderSprintsTabContent() {
910937
minute: "2-digit",
911938
});
912939
const listHTML = sprints.length === 0
913-
? "<div class='muted'>No sprints yet. Create one below.</div>"
940+
? "<div class='muted'>No sprints yet. Create one above.</div>"
914941
: sprints.map((sp) => {
915942
const isEditing = editingSprintId === sp.id;
916943
const dateRange = `${formatDate(sp.plannedStartAt)}${formatDate(sp.plannedEndAt)}`;
@@ -1504,37 +1531,34 @@ export async function renderSettingsModal(options) {
15041531
else {
15051532
contentEl.classList.remove("settings-content--profile");
15061533
}
1507-
// Setup tab switching
1534+
// Setup tab switching (click)
15081535
document.querySelectorAll(".settings-tab").forEach(tab => {
1509-
tab.addEventListener("click", async (e) => {
1536+
tab.addEventListener("click", (e) => {
15101537
const tabName = e.target.getAttribute("data-tab");
1511-
if (!tabName || tabName === getSettingsActiveTab())
1512-
return;
1513-
if (getSettingsActiveTab() === "workflow" && isWorkflowDraftDirty()) {
1514-
const discard = await showConfirmDialog("You have unsaved changes. Discard them?", "Unsaved changes", "Discard");
1515-
if (!discard)
1516-
return;
1517-
resetWorkflowDraftToBaseline();
1518-
}
1519-
if (tabName === "workflow") {
1520-
invalidateWorkflowLaneCountsCache();
1521-
clearWorkflowDraftState();
1522-
}
1523-
setSettingsActiveTab(tabName);
1524-
await renderSettingsModal();
1525-
// Force dialog to recalculate height after content change
1526-
const dialog = document.getElementById("settingsDialog");
1527-
if (dialog && dialog.open) {
1528-
// Reset height to force recalculation
1529-
const currentHeight = dialog.style.height;
1530-
dialog.style.height = "auto";
1531-
// Force a reflow
1532-
void dialog.offsetHeight;
1533-
// Reset to let CSS take over
1534-
dialog.style.height = currentHeight || "";
1535-
}
1538+
if (tabName)
1539+
void switchSettingsTab(tabName);
15361540
}, { signal });
15371541
});
1542+
// Setup tab switching (keyboard: Tab cycles visible tabs)
1543+
const settingsDlgForKeyboard = document.getElementById("settingsDialog");
1544+
if (settingsDlgForKeyboard) {
1545+
settingsDlgForKeyboard.addEventListener("keydown", (e) => {
1546+
if (e.key !== "Tab" || e.shiftKey)
1547+
return;
1548+
if (isTypingInTextField())
1549+
return;
1550+
e.preventDefault();
1551+
const tabs = Array.from(settingsDlgForKeyboard.querySelectorAll(".settings-tab[data-tab]"));
1552+
if (tabs.length === 0)
1553+
return;
1554+
const current = getSettingsActiveTab();
1555+
const idx = tabs.findIndex((t) => t.getAttribute("data-tab") === current);
1556+
const next = (idx + 1) % tabs.length;
1557+
const nextTab = tabs[next].getAttribute("data-tab");
1558+
if (nextTab)
1559+
void switchSettingsTab(nextTab);
1560+
}, { signal });
1561+
}
15381562
// Setup backup tab if it's active
15391563
if (getSettingsActiveTab() === "backup") {
15401564
// Wait a tick for DOM to be ready
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
const MOBILE_BREAKPOINT = 767;
2+
export function temporaryBoardsNavLabel() {
3+
return window.innerWidth <= MOBILE_BREAKPOINT ? "Temporary" : "Temporary Boards";
4+
}

internal/httpapi/web/dist/views/dashboard.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { apiFetch } from '../api.js';
33
import { navigate } from '../router.js';
44
import { escapeHTML, renderUserAvatar, sanitizeHexColor } from '../utils.js';
55
import { getDashboardLoading, getDashboardNextCursor, getDashboardSummary, getDashboardTodos, getDashboardTodoSort, getProjects, getUser, } from '../state/selectors.js';
6-
import { appendDashboardTodos, setDashboardLoading, setDashboardNextCursor, setDashboardTodoSort, setProjectsTab, setSettingsActiveTab, setDashboardSummary, setDashboardTodos, } from '../state/mutations.js';
6+
import { appendDashboardTodos, setDashboardLoading, setDashboardNextCursor, setDashboardTodoSort, setProjects, setProjectsTab, setSettingsActiveTab, setDashboardSummary, setDashboardTodos, } from '../state/mutations.js';
77
import { renderSettingsModal } from '../dialogs/settings.js';
8+
import { temporaryBoardsNavLabel } from '../nav-labels.js';
89
const BOUND_FLAG = Symbol('bound');
910
const DASHBOARD_SORT_HINT = "Order matches each project's board: column, then drag order. Projects appear in a fixed order (not alphabetical or by activity).";
1011
function dashboardTodosQueryString() {
@@ -40,7 +41,7 @@ function renderTopTabs() {
4041
const projects = getProjects() || [];
4142
const durableProjects = projects.filter((p) => !p.expiresAt);
4243
const temporaryBoards = projects.filter((p) => !!p.expiresAt);
43-
const temporaryLabel = window.innerWidth <= 767 ? "Temporary" : "Temporary Boards";
44+
const temporaryLabel = temporaryBoardsNavLabel();
4445
return `
4546
<div class="chips" style="margin-top: 10px;">
4647
<button class="chip chip--active" id="dashboardTabBtn" type="button">Dashboard</button>
@@ -473,13 +474,17 @@ export async function renderDashboard() {
473474
setDashboardLoading(true);
474475
try {
475476
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
476-
const [summary, todosResp] = await Promise.all([
477+
const [summary, todosResp, projects] = await Promise.all([
477478
apiFetch(`/api/dashboard/summary?tz=${encodeURIComponent(tz)}`),
478479
apiFetch(`/api/dashboard/todos?${dashboardTodosQueryString()}`),
480+
apiFetch("/api/projects").catch(() => null),
479481
]);
480482
setDashboardSummary(summary);
481483
setDashboardTodos(todosResp.items || []);
482484
setDashboardNextCursor(todosResp.nextCursor || null);
485+
if (projects) {
486+
setProjects(projects);
487+
}
483488
}
484489
catch (err) {
485490
const e = err;

internal/httpapi/web/dist/views/projects.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { escapeHTML, showToast, renderUserAvatar } from '../utils.js';
55
import { getProjectsTab, getProjectView, getUser, } from '../state/selectors.js';
66
import { setProjects, setProjectsTab, setProjectView, setSettingsActiveTab, } from '../state/mutations.js';
77
import { renderSettingsModal } from '../dialogs/settings.js';
8+
import { temporaryBoardsNavLabel } from '../nav-labels.js';
89
// Symbol for idempotent listener attachment
910
const BOUND_FLAG = Symbol('bound');
1011
// Board prefetch cache for Projects → Board navigation (hover to prefetch, click to use)
@@ -349,7 +350,7 @@ export async function renderProjects() {
349350
const temporaryBoards = projects.filter((p) => !!p.expiresAt);
350351
const activeList = getProjectsTab() === "temporary" ? temporaryBoards : durableProjects;
351352
const emptyMsg = getProjectsTab() === "temporary" ? "No temporary boards yet." : "No projects yet.";
352-
const temporaryLabel = window.innerWidth <= 767 ? "Temporary" : "Temporary Boards";
353+
const temporaryLabel = temporaryBoardsNavLabel();
353354
const tabsHTML = `
354355
<div class="chips" style="margin-top: 10px; margin-bottom: 12px;">
355356
<button class="chip" id="dashboardTabBtn" type="button">

internal/httpapi/web/modules/core/keybindings.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ export type KeyActionId =
3939
| "projectsList7"
4040
| "projectsList8"
4141
| "projectsList9"
42-
| "cycleMainNavTabs";
42+
| "cycleMainNavTabs"
43+
| "cycleMainNavTabsReverse";
4344

4445
export interface KeybindingDeps {
4546
openSettings: () => void | Promise<void>;
@@ -95,6 +96,7 @@ export const DEFAULT_KEY_CHORDS: Record<KeyActionId, string> = {
9596
projectsList8: "8",
9697
projectsList9: "9",
9798
cycleMainNavTabs: "tab",
99+
cycleMainNavTabsReverse: "shift+tab",
98100
};
99101

100102
export interface KeyActionMeta {
@@ -113,6 +115,11 @@ export const KEY_ACTION_LIST: KeyActionMeta[] = [
113115
label: "Cycle Dashboard / Projects / Temporary",
114116
contexts: ["dashboard", "projects"],
115117
},
118+
{
119+
id: "cycleMainNavTabsReverse",
120+
label: "Cycle Dashboard / Projects / Temporary (reverse)",
121+
contexts: ["dashboard", "projects"],
122+
},
116123
{ id: "createProject", label: "Create project", contexts: ["projects"] },
117124
{ id: "boardEscapeBack", label: "Back to projects (Esc)", contexts: ["board"] },
118125
...DASHBOARD_PROJECT_IDS.map((id, i) => ({
@@ -416,7 +423,8 @@ export function executeAction(actionId: KeyActionId): void {
416423
if (deps) void Promise.resolve(deps.openSettings());
417424
return;
418425
}
419-
case "cycleMainNavTabs": {
426+
case "cycleMainNavTabs":
427+
case "cycleMainNavTabsReverse": {
420428
if (view !== "dashboard" && view !== "projects") return;
421429
const route = getRoute();
422430
const tab = getProjectsTab();
@@ -428,7 +436,8 @@ export function executeAction(actionId: KeyActionId): void {
428436
} else {
429437
return;
430438
}
431-
const next = (idx + 1) % 3;
439+
const delta = actionId === "cycleMainNavTabsReverse" ? 2 : 1;
440+
const next = (idx + delta) % 3;
432441
const persistProjectsTab = (value: "projects" | "temporary"): void => {
433442
setProjectsTab(value);
434443
localStorage.setItem("projectsTab", value);
@@ -535,14 +544,26 @@ function onGlobalKeydown(ev: KeyboardEvent): void {
535544
const chord = chordFromKeyboardEvent(ev);
536545
if (!chord) return;
537546

547+
// --- Escape: search blur MUST run before isModalOpen() check ---
548+
if (chord === "escape") {
549+
const active = document.activeElement;
550+
if (active && (active as HTMLElement).id === "searchInput") {
551+
ev.preventDefault();
552+
(active as HTMLElement).blur();
553+
const clearBtn = document.getElementById("searchClear");
554+
if (clearBtn) clearBtn.click();
555+
return;
556+
}
557+
}
558+
538559
if (isModalOpen()) {
539560
return;
540561
}
541562

542563
if (ev.repeat) return;
543564

544565
const view = getCurrentView();
545-
const tabCyclesMainNav = chord === "tab" && (view === "dashboard" || view === "projects");
566+
const tabCyclesMainNav = (chord === "tab" || chord === "shift+tab") && (view === "dashboard" || view === "projects");
546567
if (tabCyclesMainNav) {
547568
if (isTypingInTextField()) return;
548569
} else if (shouldBlockForFocus()) {
@@ -566,7 +587,8 @@ function onGlobalKeydown(ev: KeyboardEvent): void {
566587
}
567588
if (tryExec("openSettings")) return;
568589
if (view === "dashboard" || view === "projects") {
569-
if (tryExec("cycleMainNavTabs")) return;
590+
if (chord === "tab" && tryExec("cycleMainNavTabs")) return;
591+
if (chord === "shift+tab" && tryExec("cycleMainNavTabsReverse")) return;
570592
}
571593
if (view === "projects") {
572594
if (tryExec("createProject")) return;

0 commit comments

Comments
 (0)