From ca09f8e13ccfce5a03d8778adc6445c70bbd4a2e Mon Sep 17 00:00:00 2001
From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com>
Date: Wed, 26 Aug 2026 10:36:03 +0100
Subject: [PATCH 01/17] =?UTF-8?q?hub-client:=20Phase=201=20=E2=80=94=20con?=
=?UTF-8?q?solidate=20UI=20icons=20into=20shared=20icons.tsx=20module?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Single source for UI icons with a documented contract: decorative
(aria-hidden), 24x24 stroke style (currentColor, stroke-width 2), size
prop. Consolidates 14 icons from FileSidebar, MinimalHeader,
ViewToggleControl, ProjectsHome, and ReplayDrawer; adds MoreIcon for
the upcoming kebab affordance. Brand logo and the replay waveform
(data viz, not an icon) stay local. Visual baselines diff clean.
Phase 1 of the UI/UX modernization (bd-iguk0hpd).
---
hub-client/src/components/FileSidebar.tsx | 65 +----
hub-client/src/components/MinimalHeader.tsx | 68 +----
hub-client/src/components/ProjectsHome.tsx | 33 +--
hub-client/src/components/ReplayDrawer.tsx | 20 +-
.../src/components/ViewToggleControl.tsx | 16 +-
hub-client/src/components/icons.tsx | 261 ++++++++++++++++++
6 files changed, 277 insertions(+), 186 deletions(-)
create mode 100644 hub-client/src/components/icons.tsx
diff --git a/hub-client/src/components/FileSidebar.tsx b/hub-client/src/components/FileSidebar.tsx
index c5a6a31cc..d2b0f64fc 100644
--- a/hub-client/src/components/FileSidebar.tsx
+++ b/hub-client/src/components/FileSidebar.tsx
@@ -19,72 +19,9 @@ import {
import { resolveDefaultDestination } from './fileUpload';
import { buildSnippet, type SearchFiles, type SearchResult } from '../services/search';
import { openPrintableDocument } from '../services/printableDocument';
+import { FilePlusIcon, UploadIcon, PrintIcon } from './icons';
import './FileSidebar.css';
-/** Document with a plus — "new file". Matches the MinimalHeader icon style. */
-function FilePlusIcon() {
- return (
-
- );
-}
-
-/** Arrow rising out of a tray — "upload". */
-function UploadIcon() {
- return (
-
- );
-}
-
-/** Printer — "open printable version". */
-function PrintIcon() {
- return (
-
- );
-}
-
export interface FileSidebarProps {
files: FileEntry[];
currentFile: FileEntry | null;
diff --git a/hub-client/src/components/MinimalHeader.tsx b/hub-client/src/components/MinimalHeader.tsx
index 9b7893ffb..b0127f713 100644
--- a/hub-client/src/components/MinimalHeader.tsx
+++ b/hub-client/src/components/MinimalHeader.tsx
@@ -6,6 +6,7 @@
*/
import ViewToggleControl from './ViewToggleControl';
+import { SwitchIcon, ShareIcon, PreviewIcon } from './icons';
import './MinimalHeader.css';
interface MinimalHeaderProps {
@@ -20,73 +21,6 @@ interface MinimalHeaderProps {
isOnline?: boolean;
}
-/** Grid of four squares — "switch / all projects". */
-function SwitchIcon() {
- return (
-
- );
-}
-
-/** Connected nodes — "share". */
-function ShareIcon() {
- return (
-
- );
-}
-
-/** Outward corners — "fullscreen preview". */
-function PreviewIcon() {
- return (
-
- );
-}
-
export default function MinimalHeader({
currentFilePath,
projectName,
diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx
index 50156acc5..60e8bfe69 100644
--- a/hub-client/src/components/ProjectsHome.tsx
+++ b/hub-client/src/components/ProjectsHome.tsx
@@ -41,6 +41,7 @@ import {
resolveSyncServerUrl,
} from '../utils/routing';
import ShareDialog from './ShareDialog';
+import { ForkIcon, PeekIcon, PeopleIcon, SortIcon } from './icons';
import { sortProjectItems, sortOrderLabel, type SortOrder } from '../utils/projectSort';
import { buildProjectListExport, parseProjectListImport } from '../services/projectListExport';
import type { Face } from '../utils/facepile';
@@ -132,23 +133,11 @@ function setPendingCollectionAssignment(title: string, collectionId: string): vo
);
}
-/** Fork glyph for the duplicate affordance (three nodes, branch lines). */
-const forkIcon = (
-
-);
+/** Fork glyph for the duplicate affordance. */
+const forkIcon = ;
/** Magnifying glass for the hover-to-peek affordance. */
-const peekIcon = (
-
-);
+const peekIcon = ;
/** Base64-encode without blowing the arg-spread limit on large files. */
function toBase64(bytes: Uint8Array): string {
@@ -1419,14 +1408,7 @@ export default function ProjectsHome({
setMembersFor(membersFor === collection.id ? null : collection.id);
}}
>
- {hasOthers && (
-
- )}
+ {hasOthers && }
{renderFacepile(people, 'md', 3)}
);
@@ -1442,10 +1424,7 @@ export default function ProjectsHome({
setOpenMenu(openMenu === sortMenuKey ? null : sortMenuKey);
}}
>
-
+
{openMenu === sortMenuKey && (
diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx
index ccbda1e3f..33b6a656e 100644
--- a/hub-client/src/components/ReplayDrawer.tsx
+++ b/hub-client/src/components/ReplayDrawer.tsx
@@ -3,6 +3,7 @@ import type { ReplayState, ReplayControls } from '../hooks/useReplayMode';
import { actorColor } from '../utils/palette';
import type { ActorIdentity } from '@quarto/preview-runtime';
import { getActorId } from '@quarto/preview-runtime';
+import { CommentsExpandIcon, CommentsShowIcon, CommentsHideIcon } from './icons';
import './ReplayDrawer.css';
import './ViewToggleControl.css';
@@ -75,12 +76,6 @@ function CommentsModeToggle({
/** Outstanding-comment count; badge hidden when 0 or absent. */
count?: number;
}) {
- // Speech-bubble outline shared by the show/hide icons; the expand
- // icon is the same bubble with a taller body.
- const bubblePath =
- 'M1 0 h10 a1 1 0 0 1 1 1 v5 a1 1 0 0 1 -1 1 H5 L2 10 V7 H1 a1 1 0 0 1 -1 -1 V1 a1 1 0 0 1 1 -1 Z';
- const tallBubblePath =
- 'M1 0 h10 a1 1 0 0 1 1 1 v6 a1 1 0 0 1 -1 1 H5 L2 10 V8 H1 a1 1 0 0 1 -1 -1 V1 a1 1 0 0 1 1 -1 Z';
return (
-
+
{count !== undefined && count > 0 && (
-
+
);
diff --git a/hub-client/src/components/icons.tsx b/hub-client/src/components/icons.tsx
new file mode 100644
index 000000000..3bc408dd1
--- /dev/null
+++ b/hub-client/src/components/icons.tsx
@@ -0,0 +1,261 @@
+/**
+ * Shared icon module — the single source for UI icons in hub-client.
+ *
+ * Contract:
+ * - Icons are **decorative**: every icon renders `aria-hidden="true"`.
+ * Meaning is conveyed by the wrapping control's `aria-label` (icon-only
+ * buttons) or by visible text next to the icon. Never give an icon its
+ * own accessible name.
+ * - One visual style: 24×24 viewBox, `currentColor` stroke, stroke-width 2,
+ * round caps/joins (the Lucide/Feather style already in use).
+ * - Sizes come from the `size` prop (default 16). Use 16 for toolbar/row
+ * icons, 13 for compact list affordances, 12×10 layout glyphs are the
+ * fixed-size exception (view-toggle and comments-mode pictograms).
+ * - Color is always `currentColor` — never hardcode a fill/stroke color;
+ * the parent control's CSS owns color.
+ *
+ * When adding an icon: follow the style above, give it a doc comment
+ * naming the concept it represents, and render it in the DevHarness
+ * gallery (`#/dev/gallery`) so states are covered by visual baselines.
+ */
+
+import type { ReactNode } from 'react';
+
+export interface IconProps {
+ /** Width/height in px. Default 16. */
+ size?: number;
+}
+
+interface StrokeIconProps extends IconProps {
+ children: ReactNode;
+ /** Override stroke width (default 2). */
+ strokeWidth?: number;
+}
+
+/** Shared wrapper for the standard 24×24 stroke-icon style. */
+function StrokeIcon({ size = 16, strokeWidth = 2, children }: StrokeIconProps) {
+ return (
+
+ );
+}
+
+/** Document with a plus — "new file". */
+export function FilePlusIcon({ size }: IconProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+/** Arrow rising out of a tray — "upload". */
+export function UploadIcon({ size }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+/** Printer — "open printable version". */
+export function PrintIcon({ size }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+/** Grid of four squares — "switch / all projects". */
+export function SwitchIcon({ size }: IconProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+/** Connected nodes — "share". */
+export function ShareIcon({ size }: IconProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+/** Outward corners — "fullscreen preview". */
+export function PreviewIcon({ size }: IconProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+/** Three nodes with branch lines — "duplicate / fork". */
+export function ForkIcon({ size = 13 }: IconProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+/** Magnifying glass — "peek / preview on hover". */
+export function PeekIcon({ size = 13 }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+/** Two people — "collection members". */
+export function PeopleIcon({ size = 12 }: IconProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+/** Opposing vertical arrows — "sort order". */
+export function SortIcon({ size = 13 }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+/** Horizontal ellipsis — "more actions" (kebab/menu affordance). */
+export function MoreIcon({ size = 16 }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* Layout pictograms (fixed 12×10 viewBox) — view-toggle glyphs. */
+/* ------------------------------------------------------------------ */
+
+interface LayoutGlyphProps {
+ children: ReactNode;
+}
+
+function LayoutGlyph({ children }: LayoutGlyphProps) {
+ return (
+
+ );
+}
+
+/** Wide left pane, dim right pane — "expand markup". */
+export function LayoutMarkupIcon() {
+ return (
+
+
+
+
+ );
+}
+
+/** Two equal panes — "split equally". */
+export function LayoutSplitIcon() {
+ return (
+
+
+
+
+ );
+}
+
+/** Dim left pane, wide right pane — "expand preview". */
+export function LayoutPreviewIcon() {
+ return (
+
+
+
+
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* Comments-mode pictograms (fixed 12×10 viewBox) — replay drawer. */
+/* ------------------------------------------------------------------ */
+
+const BUBBLE_PATH =
+ 'M1 0 h10 a1 1 0 0 1 1 1 v5 a1 1 0 0 1 -1 1 H5 L2 10 V7 H1 a1 1 0 0 1 -1 -1 V1 a1 1 0 0 1 1 -1 Z';
+const TALL_BUBBLE_PATH =
+ 'M1 0 h10 a1 1 0 0 1 1 1 v6 a1 1 0 0 1 -1 1 H5 L2 10 V8 H1 a1 1 0 0 1 -1 -1 V1 a1 1 0 0 1 1 -1 Z';
+
+/** Tall comment bubble — "expand all comments". */
+export function CommentsExpandIcon() {
+ return (
+
+
+
+ );
+}
+
+/** Comment bubble — "show comment bubbles". */
+export function CommentsShowIcon() {
+ return (
+
+
+
+ );
+}
+
+/** Dim comment bubble with strike-through — "hide comment bubbles". */
+export function CommentsHideIcon() {
+ return (
+
+
+
+
+ );
+}
From c7bdd7d904364a32f10f1cc87bad1b4f6ec134c7 Mon Sep 17 00:00:00 2001
From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com>
Date: Wed, 26 Aug 2026 10:37:26 +0100
Subject: [PATCH 02/17] =?UTF-8?q?hub-client:=20Phase=201=20=E2=80=94=20uni?=
=?UTF-8?q?fy=20monospace=20stacks=20onto=20--font-mono?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Migrates the three ad-hoc stacks ('SF Mono'…, 'JetBrains Mono'…,
'Source Code Pro'…) across 10 CSS files to var(--font-mono), adopting
the dominant JetBrains Mono stack per the phase's alignment rule.
src/debug/debug.css keeps its local token (standalone page). Visual
baselines diff clean.
Phase 1 of the UI/UX modernization (bd-iguk0hpd).
---
hub-client/src/components/Editor.css | 8 ++++----
hub-client/src/components/MarkdownSummary.css | 2 +-
hub-client/src/components/MinimalHeader.css | 2 +-
hub-client/src/components/ProjectSelector.css | 12 ++++++------
hub-client/src/components/ProjectsHome.css | 2 +-
hub-client/src/components/ReplayDrawer.css | 6 +++---
hub-client/src/components/ShareDialog.css | 2 +-
.../debug-inspector/DebugInspectorPanel.css | 2 +-
hub-client/src/components/tabs/AboutTab.css | 2 +-
hub-client/src/components/tabs/ProjectTab.css | 4 ++--
hub-client/src/theme.css | 4 ++--
11 files changed, 23 insertions(+), 23 deletions(-)
diff --git a/hub-client/src/components/Editor.css b/hub-client/src/components/Editor.css
index 12d7d6bad..4d3dca877 100644
--- a/hub-client/src/components/Editor.css
+++ b/hub-client/src/components/Editor.css
@@ -22,7 +22,7 @@
.diagnostic-item {
padding: 4px 0;
font-size: 13px;
- font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
+ font-family: var(--font-mono);
}
.diagnostic-item.diagnostic-error {
@@ -117,7 +117,7 @@
}
.current-file-indicator .file-path {
- font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: var(--font-mono);
font-size: 13px;
color: var(--editor-text-dim);
}
@@ -531,7 +531,7 @@
margin: 0;
white-space: pre-wrap;
word-break: break-word;
- font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
+ font-family: var(--font-mono);
font-size: 12px;
line-height: 1.4;
color: var(--error-overlay-text);
@@ -584,7 +584,7 @@
}
.preview-error-pass1-failure .diagnostic-source-file code {
- font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
+ font-family: var(--font-mono);
background: var(--alpha-black-05);
padding: 1px 4px;
border-radius: 2px;
diff --git a/hub-client/src/components/MarkdownSummary.css b/hub-client/src/components/MarkdownSummary.css
index c73c7f03c..ec0d6bc87 100644
--- a/hub-client/src/components/MarkdownSummary.css
+++ b/hub-client/src/components/MarkdownSummary.css
@@ -9,7 +9,7 @@
.markdown-summary-content {
margin: 0;
padding: 8px;
- font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
+ font-family: var(--font-mono);
font-size: 6px;
line-height: 1.4;
color: var(--summary-text);
diff --git a/hub-client/src/components/MinimalHeader.css b/hub-client/src/components/MinimalHeader.css
index f74211cfa..0930e5c3c 100644
--- a/hub-client/src/components/MinimalHeader.css
+++ b/hub-client/src/components/MinimalHeader.css
@@ -94,7 +94,7 @@
}
.header-doc .file-path {
- font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: var(--font-mono);
font-size: 13px;
color: var(--editor-text-dim);
min-width: 0;
diff --git a/hub-client/src/components/ProjectSelector.css b/hub-client/src/components/ProjectSelector.css
index 6d3bd54fa..896065e80 100644
--- a/hub-client/src/components/ProjectSelector.css
+++ b/hub-client/src/components/ProjectSelector.css
@@ -126,7 +126,7 @@
.project-selector h2 {
margin: 0 0 12px;
- font-family: 'Source Code Pro', monospace;
+ font-family: var(--font-mono);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
@@ -219,7 +219,7 @@
.project-docid {
color: var(--text-secondary);
- font-family: 'Source Code Pro', monospace;
+ font-family: var(--font-mono);
cursor: help;
}
@@ -459,7 +459,7 @@
.form-group label {
display: block;
- font-family: 'Source Code Pro', monospace;
+ font-family: var(--font-mono);
color: var(--text-secondary);
font-size: 11px;
font-weight: 500;
@@ -847,7 +847,7 @@
.color-picker label {
display: block;
- font-family: 'Source Code Pro', monospace;
+ font-family: var(--font-mono);
color: var(--text-secondary);
font-size: 11px;
font-weight: 500;
@@ -890,7 +890,7 @@
}
.version-info .commit-hash {
- font-family: 'Source Code Pro', monospace;
+ font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-card);
@@ -928,7 +928,7 @@
}
.project-set-id {
- font-family: 'Source Code Pro', monospace;
+ font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
background: var(--bg-input);
diff --git a/hub-client/src/components/ProjectsHome.css b/hub-client/src/components/ProjectsHome.css
index 4fabd4fc4..0cc666669 100644
--- a/hub-client/src/components/ProjectsHome.css
+++ b/hub-client/src/components/ProjectsHome.css
@@ -15,7 +15,7 @@
}
.projects-home .mono {
- font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: var(--font-mono);
}
.qh-loading,
diff --git a/hub-client/src/components/ReplayDrawer.css b/hub-client/src/components/ReplayDrawer.css
index 7a2c64a0a..04360d688 100644
--- a/hub-client/src/components/ReplayDrawer.css
+++ b/hub-client/src/components/ReplayDrawer.css
@@ -116,7 +116,7 @@
.replay-drawer__position {
font-size: 11px;
color: var(--editor-text-muted);
- font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
+ font-family: var(--font-mono);
}
.replay-drawer__actor {
@@ -125,7 +125,7 @@
gap: 4px;
font-size: 11px;
color: var(--editor-text-dim);
- font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
+ font-family: var(--font-mono);
border: 1px solid var(--replay-actor-border);
border-radius: 3px;
padding: 0 4px;
@@ -207,7 +207,7 @@
width: auto;
padding: 0 6px;
font-size: 10px;
- font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
+ font-family: var(--font-mono);
margin-left: 3px;
}
diff --git a/hub-client/src/components/ShareDialog.css b/hub-client/src/components/ShareDialog.css
index 70580432f..658102109 100644
--- a/hub-client/src/components/ShareDialog.css
+++ b/hub-client/src/components/ShareDialog.css
@@ -53,7 +53,7 @@
.url-field input {
color: var(--editor-text-muted);
- font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: var(--font-mono);
font-size: 13px;
cursor: text;
}
diff --git a/hub-client/src/components/debug-inspector/DebugInspectorPanel.css b/hub-client/src/components/debug-inspector/DebugInspectorPanel.css
index 69f2bfad5..c7423f141 100644
--- a/hub-client/src/components/debug-inspector/DebugInspectorPanel.css
+++ b/hub-client/src/components/debug-inspector/DebugInspectorPanel.css
@@ -19,7 +19,7 @@
color: var(--debug-text);
border-top: 2px solid var(--debug-blue);
box-shadow: 0 -4px 24px var(--alpha-black-45);
- font-family: 'Source Code Pro', ui-monospace, monospace;
+ font-family: var(--font-mono);
font-size: 12px;
}
diff --git a/hub-client/src/components/tabs/AboutTab.css b/hub-client/src/components/tabs/AboutTab.css
index 5d5821b18..df2ff7203 100644
--- a/hub-client/src/components/tabs/AboutTab.css
+++ b/hub-client/src/components/tabs/AboutTab.css
@@ -91,7 +91,7 @@
}
.commit-hash {
- font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: var(--font-mono);
font-size: 13px;
color: var(--accent-secondary);
cursor: help;
diff --git a/hub-client/src/components/tabs/ProjectTab.css b/hub-client/src/components/tabs/ProjectTab.css
index 707d2d6d2..6ed2a2a7d 100644
--- a/hub-client/src/components/tabs/ProjectTab.css
+++ b/hub-client/src/components/tabs/ProjectTab.css
@@ -45,7 +45,7 @@
}
.doc-id-value {
- font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: var(--font-mono);
font-size: 12px;
color: var(--editor-text-dim);
}
@@ -64,7 +64,7 @@
}
.sync-server {
- font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: var(--font-mono);
font-size: 12px;
color: var(--editor-text-muted);
word-break: break-all;
diff --git a/hub-client/src/theme.css b/hub-client/src/theme.css
index e2813e19a..7836fa954 100644
--- a/hub-client/src/theme.css
+++ b/hub-client/src/theme.css
@@ -164,8 +164,8 @@
--font-weight-bold: 700;
--leading-tight: 1.25;
--leading-base: 1.5;
- /* The dominant monospace stack (4 of 10 use sites; the rest migrate in
- Phase 1's monospace unification). */
+ /* The single monospace stack — all app surfaces migrated in Phase 1.
+ (src/debug/debug.css is a standalone page with its own local token.) */
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
/* Motion — 100-200ms, ease-out entrances; animate transform/opacity
From 805f9e73dbaa450e542819b9cee3d59f61ae9cf7 Mon Sep 17 00:00:00 2001
From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com>
Date: Wed, 26 Aug 2026 10:39:31 +0100
Subject: [PATCH 03/17] =?UTF-8?q?hub-client:=20Phase=201=20=E2=80=94=20uni?=
=?UTF-8?q?fy=20the=20button=20system?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
One canonical system in ui.css, two sizes: .qh-btn (variants primary/
outline/danger/ghost-accent, .small) and .qh-icon-btn (with .boxed
28x28 fixed target). Disabled and focus-visible states are now defined
once on the base classes (token focus ring) instead of per-variant.
MinimalHeader's ad-hoc .icon-btn becomes .qh-icon-btn.boxed with only
header-context colors overridden locally. Documented boundaries:
view-toggle is a segmented control, .qh-pager a nav strip, and the
header's .preview-btn a filled primary pill on header tokens — none
are restyled. Font sizes/weights migrate to type-scale tokens; radii
and paddings await the Phase 5 grid pass. Visual baselines diff clean.
Phase 1 of the UI/UX modernization (bd-iguk0hpd).
---
hub-client/src/components/MinimalHeader.css | 18 ++-----
hub-client/src/components/MinimalHeader.tsx | 4 +-
hub-client/src/ui.css | 56 +++++++++++++++++----
3 files changed, 53 insertions(+), 25 deletions(-)
diff --git a/hub-client/src/components/MinimalHeader.css b/hub-client/src/components/MinimalHeader.css
index 0930e5c3c..0e1c53c7a 100644
--- a/hub-client/src/components/MinimalHeader.css
+++ b/hub-client/src/components/MinimalHeader.css
@@ -17,24 +17,14 @@
gap: 6px;
}
-/* Shared icon action button (switch / share / preview). */
-.minimal-header .icon-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- padding: 0;
- background: none;
- border: 1px solid transparent;
- border-radius: 6px;
+/* Header icon actions (switch / share) use the shared .qh-icon-btn.boxed
+ system from ui.css; only the header-context colors are overridden here. */
+.minimal-header .qh-icon-btn {
color: var(--editor-text-muted);
- cursor: pointer;
- flex-shrink: 0;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
-.minimal-header .icon-btn:hover {
+.minimal-header .qh-icon-btn:hover {
background: var(--view-toggle-hover-bg);
color: var(--editor-text);
}
diff --git a/hub-client/src/components/MinimalHeader.tsx b/hub-client/src/components/MinimalHeader.tsx
index b0127f713..6fdafe060 100644
--- a/hub-client/src/components/MinimalHeader.tsx
+++ b/hub-client/src/components/MinimalHeader.tsx
@@ -34,7 +34,7 @@ export default function MinimalHeader({
);
};
@@ -510,7 +537,6 @@ export default function FileSidebar({
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
- onClick={handleSidebarClick}
>
closeContextMenu()}
+ triggerRef={{ current: contextMenu.trigger ?? null }}
+ aria-label={`Actions for ${contextMenu.file.path}`}
>
{onOpenInNewTab && (
- handleOpenInNewTab(contextMenu.file!)}>
+
+
)}
{onCopyLink && (
- handleCopyLink(contextMenu.file!)}>
+
+
)}
{onRenameFile && (
- startRename(contextMenu.file!)}>
+
+
)}
{onDeleteFile && (
- handleDelete(contextMenu.file!)}
- >
+
+
)}
-
+
)}
);
diff --git a/hub-client/src/components/Menu.tsx b/hub-client/src/components/Menu.tsx
new file mode 100644
index 000000000..f8cda4f89
--- /dev/null
+++ b/hub-client/src/components/Menu.tsx
@@ -0,0 +1,372 @@
+/**
+ * Menu — the single accessible menu primitive for hub-client.
+ *
+ * Implements the WAI-ARIA APG menu/menu-button pattern:
+ * - `role="menu"` container with `role="menuitem"` children
+ * - ArrowUp/ArrowDown move focus (wrapping); Home/End jump to first/last
+ * - Type-ahead: printable characters focus the next matching item
+ * - ArrowRight opens a submenu (MenuSubmenu); ArrowLeft closes it
+ * - Escape closes the menu and returns focus to the trigger
+ * - Tab closes the menu (menus do not trap focus)
+ * - Pointer-down outside the menu closes it (without stealing focus)
+ * - The focused item is scrolled into view
+ *
+ * Usage: render inside a `.qh-menu-anchor` (position: relative) parent for
+ * anchored placement, or pass `fixed={{ x, y }}` for context-menu placement
+ * at cursor coordinates. The trigger element owns open/close state; pass it
+ * via `triggerRef` so trigger clicks aren't treated as outside clicks and
+ * focus returns to it on close.
+ *
+ * Destructive-action pattern (the single rule for the app): menu items
+ * that destroy data use `danger` styling and must be guarded by a
+ * confirmation dialog (see ProjectsHome's remove flow) unless the action
+ * is trivially undoable. "Confirm vs undo" is decided per action at its
+ * call site, but every destructive menu item must have one or the other.
+ */
+
+import {
+ useEffect,
+ useRef,
+ useCallback,
+ useState,
+ type ReactNode,
+ type MouseEvent as ReactMouseEvent,
+} from 'react';
+
+export interface MenuProps {
+ /** Close the menu. `returnFocus` is true for keyboard-driven closes. */
+ onClose: (returnFocus: boolean) => void;
+ /** Fixed-position placement (context menus). Omit for anchored placement. */
+ fixed?: { x: number; y: number };
+ /** The trigger element; clicks on it are not "outside" clicks. */
+ triggerRef?: { current: HTMLElement | null };
+ /** CSS selector whose matches are not "outside" clicks — use when the
+ * trigger owns toggle behavior and sits inside a shared anchor (e.g.
+ * '.qh-menu-anchor'). */
+ ignoreOutsideSelector?: string;
+ /** Accessible name for the menu. */
+ 'aria-label'?: string;
+ className?: string;
+ children: ReactNode;
+}
+
+const ITEM_SELECTOR = '[role="menuitem"]:not([aria-disabled="true"])';
+
+export function Menu({
+ onClose,
+ fixed,
+ triggerRef,
+ ignoreOutsideSelector,
+ 'aria-label': ariaLabel,
+ className = '',
+ children,
+}: MenuProps) {
+ const rootRef = useRef