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({
+ {open && ( + setOpen(false)} + triggerRef={triggerRef} + aria-label="Gallery actions" + > + GALLERY + + Open + + + Alpha + Beta + + + Copy link + + + Duplicate + + + Unavailable action + + + + Delete + + + )} +
+ + Last action: {lastAction} + +
+ ); +} + +export default function DevGalleryPage() { + const [inputValue, setInputValue] = useState('Editable text'); + return ( +
+

+ Component gallery +

+ +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + +
+ +
+ +
+ +
+ + setInputValue(e.target.value)} + aria-label="Example input" + /> + + + + + + + + + Error text wired via aria-describedby. + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/hub-client/src/components/DevHarness.tsx b/hub-client/src/components/DevHarness.tsx index e2b27f553..5d0089bd3 100644 --- a/hub-client/src/components/DevHarness.tsx +++ b/hub-client/src/components/DevHarness.tsx @@ -22,6 +22,7 @@ import Toast from './Toast'; import UpdateAvailableToast from './UpdateAvailableToast'; import EphemeralSessionBanner from './EphemeralSessionBanner'; import DevTokensPage from './DevTokensPage'; +import DevGalleryPage from './DevGalleryPage'; import { ViewModeProvider } from './ViewModeContext'; import type { ProjectEntry } from '@quarto/preview-renderer/types/project'; import type { FileEntry } from '@quarto/preview-renderer/types/project'; @@ -223,6 +224,8 @@ const DEV_PAGES: Record React.ReactNode> = { tokens: () => , + gallery: () => , + 'projects-home': () => ( {}} diff --git a/hub-client/src/components/FileSidebar.css b/hub-client/src/components/FileSidebar.css index 996c24ebe..63f1a3aca 100644 --- a/hub-client/src/components/FileSidebar.css +++ b/hub-client/src/components/FileSidebar.css @@ -209,42 +209,25 @@ font-size: 32px; } -/* Context menu */ -.context-menu { - position: fixed; - background: var(--context-menu-bg); - border: 1px solid var(--context-menu-border); - border-radius: 6px; - padding: 4px 0; - min-width: 120px; - box-shadow: 0 4px 12px var(--context-menu-shadow); - z-index: var(--z-modal); -} - -.context-menu button { - display: block; - width: 100%; - padding: 8px 16px; - background: none; - border: none; - border-radius: 6px; - text-align: left; - color: var(--editor-text-dim); +/* Context menu: the shared .qh-menu component (ui.css) with .qh-menu-fixed + placement. */ + +/* Visible kebab affordance on file rows — the menu is no longer + right-click-only. Hidden until the row is hovered or the button is + focused (keyboard users always have a visible, tabbable path). */ +.file-item .file-kebab { + margin-inline-start: auto; + padding: 2px 4px; font-size: 13px; - cursor: pointer; - transition: background 0.15s; -} - -.context-menu button:hover { - background: var(--context-menu-hover); -} - -.context-menu button.danger { - color: var(--context-menu-danger); + color: var(--editor-text-muted); + opacity: 0; + flex-shrink: 0; } -.context-menu button.danger:hover { - background: var(--context-menu-danger-bg); +.file-item:hover .file-kebab, +.file-item .file-kebab:focus-visible, +.file-item .file-kebab[aria-expanded="true"] { + opacity: 1; } /* ── Full-text search ─────────────────────────────────────────────── */ diff --git a/hub-client/src/components/FileSidebar.tsx b/hub-client/src/components/FileSidebar.tsx index d2b0f64fc..b8b8cd046 100644 --- a/hub-client/src/components/FileSidebar.tsx +++ b/hub-client/src/components/FileSidebar.tsx @@ -19,7 +19,8 @@ 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 { FilePlusIcon, UploadIcon, PrintIcon, MoreIcon } from './icons'; +import { Menu, MenuItem } from './Menu'; import './FileSidebar.css'; export interface FileSidebarProps { @@ -63,6 +64,8 @@ interface ContextMenuState { x: number; y: number; file: FileEntry | null; + /** The kebab button that opened the menu (kebab-opened menus only). */ + trigger?: HTMLElement | null; } /** Image extensions for drag-drop detection */ @@ -269,13 +272,6 @@ export default function FileSidebar({ setContextMenu((prev) => ({ ...prev, visible: false })); }, []); - // Handle clicks outside context menu - const handleSidebarClick = useCallback(() => { - if (contextMenu.visible) { - closeContextMenu(); - } - }, [contextMenu.visible, closeContextMenu]); - // Rename handlers const startRename = useCallback((file: FileEntry) => { setRenamingFile(file); @@ -420,6 +416,37 @@ export default function FileSidebar({ ) : ( {fileName} )} + {!isRenaming && (onOpenInNewTab || onCopyLink || onRenameFile || onDeleteFile) && ( + + )} ); }; @@ -510,7 +537,6 @@ export default function FileSidebar({ onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} - onClick={handleSidebarClick} >
+ )} {onCopyLink && ( - + )} {onRenameFile && ( - + )} {onDeleteFile && ( - + )} -
+ )} ); 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(null); + // Element to return focus to on keyboard-driven close: the trigger if + // given, otherwise whatever was focused when the menu opened. + const returnFocusRef = useRef(null); + if (returnFocusRef.current === null && typeof document !== 'undefined') { + returnFocusRef.current = + triggerRef?.current ?? + (document.activeElement instanceof HTMLElement ? document.activeElement : null); + } + + const items = useCallback((): HTMLElement[] => { + const root = rootRef.current; + if (!root) return []; + return Array.from(root.querySelectorAll(ITEM_SELECTOR)); + }, []); + + const focusItem = useCallback( + (el: HTMLElement | undefined) => { + if (!el) return; + el.focus(); + el.scrollIntoView({ block: 'nearest' }); + }, + [], + ); + + const close = useCallback( + (returnFocus: boolean) => { + // Return focus synchronously, before the parent re-renders: if the + // activating item opens a dialog, the dialog's own focus management + // must win, and it runs in a post-render effect after this. + if (returnFocus && returnFocusRef.current?.isConnected) { + returnFocusRef.current.focus(); + } + onClose(returnFocus); + }, + [onClose], + ); + + // Focus the first item on open. + useEffect(() => { + const first = items()[0]; + first?.focus(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Viewport-edge flip for fixed (context-menu) placement: if the menu + // overflows the viewport, shift it back inside. + useEffect(() => { + const root = rootRef.current; + if (!root || !fixed) return; + const rect = root.getBoundingClientRect(); + const margin = 4; + let { x, y } = fixed; + if (rect.bottom > window.innerHeight - margin) { + y = Math.max(margin, window.innerHeight - margin - rect.height); + } + if (rect.right > window.innerWidth - margin) { + x = Math.max(margin, window.innerWidth - margin - rect.width); + } + if (x !== fixed.x || y !== fixed.y) { + root.style.top = `${y}px`; + root.style.left = `${x}px`; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Close on pointer-down outside the menu (and outside the trigger, which + // owns its own toggle behavior). + useEffect(() => { + const onPointerDown = (e: PointerEvent) => { + const target = e.target as HTMLElement; + if (rootRef.current?.contains(target)) return; + if (triggerRef?.current?.contains(target)) return; + if (ignoreOutsideSelector && target.closest(ignoreOutsideSelector)) return; + close(false); + }; + document.addEventListener('pointerdown', onPointerDown, true); + return () => document.removeEventListener('pointerdown', onPointerDown, true); + }, [close, triggerRef, ignoreOutsideSelector]); + + const typeAhead = useRef({ buffer: '', timer: 0 }); + + const onKeyDown = (e: React.KeyboardEvent) => { + const list = items(); + const activeIndex = list.indexOf(document.activeElement as HTMLElement); + switch (e.key) { + case 'ArrowDown': + case 'ArrowUp': { + e.preventDefault(); + const delta = e.key === 'ArrowDown' ? 1 : -1; + const next = + activeIndex === -1 + ? 0 + : (activeIndex + delta + list.length) % list.length; + focusItem(list[next]); + return; + } + case 'Home': + e.preventDefault(); + focusItem(list[0]); + return; + case 'End': + e.preventDefault(); + focusItem(list[list.length - 1]); + return; + case 'Escape': + e.preventDefault(); + e.stopPropagation(); + close(true); + return; + case 'Tab': + // Menus don't trap focus; close and let the tab proceed. + close(false); + return; + default: + break; + } + // Type-ahead: accumulate printable characters, focus the next item + // whose text starts with the buffer. + if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) { + const ta = typeAhead.current; + window.clearTimeout(ta.timer); + ta.buffer += e.key.toLowerCase(); + ta.timer = window.setTimeout(() => { + ta.buffer = ''; + }, 500); + const start = activeIndex === -1 ? 0 : activeIndex + 1; + for (let i = 0; i < list.length; i += 1) { + const candidate = list[(start + i) % list.length]; + if (candidate.textContent?.trim().toLowerCase().startsWith(ta.buffer)) { + focusItem(candidate); + break; + } + } + } + }; + + // Activating an item closes the menu and returns focus to the trigger. + // Items that need the menu to stay open (e.g. in-place "copied!" + // feedback) stop propagation in their own handler, so this never runs. + const onClick = (e: ReactMouseEvent) => { + const item = (e.target as HTMLElement).closest('[role="menuitem"]'); + if (!item) return; + close(true); + }; + + return ( +
+ {children} +
+ ); +} + +export interface MenuItemProps { + onSelect: () => void; + /** Destructive action — must be confirm-guarded or undoable (see header). */ + danger?: boolean; + strong?: boolean; + accent?: boolean; + disabled?: boolean; + /** Right-aligned hint text (e.g. a shortcut or id). */ + hint?: ReactNode; + /** Second line of muted explanatory text. */ + subtext?: ReactNode; + /** Keep the menu open on select (for in-place feedback like "Copied!"). */ + keepOpen?: boolean; + children: ReactNode; +} + +export function MenuItem({ + onSelect, + danger, + strong, + accent, + disabled, + hint, + subtext, + keepOpen, + children, +}: MenuItemProps) { + const classes = [ + 'qh-menu-item', + danger ? 'danger' : '', + strong ? 'strong' : '', + accent ? 'accent' : '', + hint ? 'with-hint' : '', + ] + .filter(Boolean) + .join(' '); + return ( + + ); +} + +export function MenuDivider() { + return
; +} + +export function MenuLabel({ children }: { children: ReactNode }) { + // role="presentation": a label is not a menuitem but may appear as a + // child of role="menu". + return ( +
+ {children} +
+ ); +} + +export interface MenuSubmenuProps { + /** The parent item's label. */ + label: ReactNode; + children: ReactNode; +} + +/** + * A submenu parent item. Opens on click, ArrowRight, or Enter/Space; + * ArrowLeft inside the submenu closes it and refocuses this item. + */ +export function MenuSubmenu({ label, children }: MenuSubmenuProps) { + const [open, setOpen] = useState(false); + const itemRef = useRef(null); + + return ( +
setOpen(true)} + onMouseLeave={(e) => { + // Don't collapse under the keyboard user: if focus is inside this + // submenu, the mouse leaving must not drop the focused subtree. + if (e.currentTarget.contains(document.activeElement)) return; + setOpen(false); + }} + > + + {open && ( +
{ + if (e.key === 'ArrowLeft') { + e.preventDefault(); + e.stopPropagation(); + setOpen(false); + itemRef.current?.focus(); + } + }} + > + {children} +
+ )} +
+ ); +} diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index 60e8bfe69..b1ad4ac5b 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -42,6 +42,7 @@ import { } from '../utils/routing'; import ShareDialog from './ShareDialog'; import { ForkIcon, PeekIcon, PeopleIcon, SortIcon } from './icons'; +import { Menu, MenuItem, MenuDivider, MenuLabel, MenuSubmenu } from './Menu'; import { sortProjectItems, sortOrderLabel, type SortOrder } from '../utils/projectSort'; import { buildProjectListExport, parseProjectListImport } from '../services/projectListExport'; import type { Face } from '../utils/facepile'; @@ -252,7 +253,7 @@ export default function ProjectsHome({ // Menus / popovers. openMenu identifies the ⋯ menu by project id or // `collection:`; submenus and the peek popover are tracked separately. const [openMenu, setOpenMenu] = useState(null); - const [moveSubmenuOpen, setMoveSubmenuOpen] = useState(false); + const [newMenuOpen, setNewMenuOpen] = useState(false); const [avatarMenuOpen, setAvatarMenuOpen] = useState(false); const [peekFor, setPeekFor] = useState(null); @@ -452,7 +453,6 @@ export default function ProjectsHome({ const closeAllMenus = useCallback(() => { setOpenMenu(null); - setMoveSubmenuOpen(false); setNewMenuOpen(false); setAvatarMenuOpen(false); setPeekFor(null); @@ -1035,117 +1035,89 @@ export default function ProjectsHome({ ); } - // Menus are plain groups of buttons, not ARIA menus: role="menu" - // would require menuitem children and the full menu keyboard pattern, - // which these action lists don't implement (WCAG 4.1.2). + // The shared APG menu primitive (components/Menu.tsx): arrow-key nav, + // type-ahead, Escape with focus return, submenus. const renderProjectMenu = (item: ProjectItem) => ( -
- -
- - {moveSubmenuOpen === 'move' && ( -
- {collections - .filter((c) => !c.projectIds.includes(item.indexDocId.replace(/^automerge:/, ''))) - .map((collection) => ( - - ))} - {collectionOf(item.indexDocId) && ( - - )} - -
+ {collection.name} + + ))} + {collectionOf(item.indexDocId) && ( + { requestMove(item.indexDocId, null); closeAllMenus(); }}> + No collection + )} -
-
- - {moveSubmenuOpen === 'add' && ( -
- {collections - .filter((c) => !c.projectIds.includes(item.indexDocId.replace(/^automerge:/, ''))) - .map((collection) => ( - - ))} - {collections.every((c) => c.projectIds.includes(item.indexDocId.replace(/^automerge:/, ''))) && ( -
- Already in every collection -
- )} + openNewCollection(item.indexDocId)}> + + New collection… + + + + {collections + .filter((c) => !c.projectIds.includes(item.indexDocId.replace(/^automerge:/, ''))) + .map((collection) => ( + { addToCollection(item.indexDocId, collection.id); closeAllMenus(); }} + > + {collection.name} + + ))} + {collections.every((c) => c.projectIds.includes(item.indexDocId.replace(/^automerge:/, ''))) && ( +
+ Already in every collection
)} -
- - - - - -
- -
+ + ); /** Refresh a peek summary via a short background connection. Contributors @@ -1427,21 +1399,21 @@ export default function ProjectsHome({ {openMenu === sortMenuKey && ( -
+ closeAllMenus()} ignoreOutsideSelector=".qh-menu-anchor, .qh-peek" aria-label="Sort collection"> {(['newest', 'oldest', 'name'] as SortOrder[]).map((o) => ( - + ))} -
+ )} - -
- -
+ + )}
{collectionItems.length === 0 ? ( @@ -1559,18 +1527,17 @@ export default function ProjectsHome({ + New ▾ {newMenuOpen && ( -
-
START FROM — QUARTO PROJECT TYPES
+ setNewMenuOpen(false)} ignoreOutsideSelector=".qh-menu-anchor" aria-label="New project"> + START FROM — QUARTO PROJECT TYPES {(projectChoices.length > 0 ? projectChoices : [{ id: 'default', name: 'Default', description: 'A minimal Quarto project' }] ).map((choice) => ( - + openNewDialog(choice)}> + {choice.name} + ))} -
+ )}
@@ -1704,17 +1671,17 @@ export default function ProjectsHome({ Sort {sortMenuOpen && ( -
+ setSortMenuOpen(false)} ignoreOutsideSelector=".qh-menu-anchor" aria-label="Sort projects"> {(['newest', 'oldest', 'name'] as SortOrder[]).map((o) => ( - + ))} -
+ )}
{everythingElse.length === 0 ? ( diff --git a/hub-client/src/ui.css b/hub-client/src/ui.css index a47960d75..8df2f9a81 100644 --- a/hub-client/src/ui.css +++ b/hub-client/src/ui.css @@ -177,6 +177,16 @@ .qh-menu-right { left: auto; right: 0; } +/* Context-menu placement at cursor coordinates (fixed instead of + anchor-relative); the Menu component flips at viewport edges. */ +.qh-menu-fixed { + position: fixed; + top: auto; + left: auto; + min-width: 180px; + z-index: var(--z-modal); +} + .qh-menu-label { font-size: 10.5px; font-weight: 700; @@ -203,6 +213,8 @@ } .qh-menu-item:hover { background: var(--context-menu-hover); } +.qh-menu-item[aria-disabled="true"] { opacity: 0.5; cursor: default; } +.qh-menu-item[aria-disabled="true"]:hover { background: none; } .qh-menu-item.strong { font-weight: 600; } .qh-menu-item.accent { color: var(--posit-teal); font-weight: 600; } .qh-menu-item.danger { color: var(--error-text); } From ec1de16b809a4b1fcc8af04a952f840771c08864 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:09:03 +0100 Subject: [PATCH 05/17] =?UTF-8?q?hub-client:=20Phase=201=20=E2=80=94=20Too?= =?UTF-8?q?ltip=20component=20replaces=20native=20title=20attributes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New components/Tooltip.tsx implements the APG tooltip pattern: 400ms hover delay, immediate on focus, aria-describedby wiring, Escape dismiss, viewport-edge flip with horizontal clamp. Token-styled; appearance to be ratified at the Phase 5 gate. Hover handlers live on the wrapper span so disabled controls keep their tooltips. Converts every title= in the app chrome (MinimalHeader, FileSidebar, ViewToggleControl, ReplayDrawer, ProjectsHome, OutlinePanel, EphemeralSessionBanner, AboutTab, ProjectTab, JoinCollectionLanding). Not converted, deliberately: iframe title= (accessible name, not a tooltip), AST-rendered content links (document content), the classic ProjectSelector (out of scope), and src/debug tooling. Redundant titles duplicating visible text (project card/row names) were dropped; icon-only buttons gained aria-labels where the title was the only name (peek, fork, menu, pager, swatches). Also fixes a real dark-theme bug the gallery axe scan exposed: bare .qh-btn declared no color, so default buttons rendered black-on-dark (1.35:1). Remaining gallery contrast entries are token-level brand fills/accents, deferred to the Phase 5 visual gate per the plan. Phase 1 of the UI/UX modernization (bd-iguk0hpd). --- hub-client/src/components/DevGalleryPage.tsx | 12 +- .../EphemeralSessionBanner.test.tsx | 11 +- .../src/components/EphemeralSessionBanner.tsx | 15 +- hub-client/src/components/FileSidebar.tsx | 78 +++---- .../src/components/JoinCollectionLanding.tsx | 16 +- hub-client/src/components/MinimalHeader.tsx | 66 +++--- hub-client/src/components/OutlinePanel.tsx | 14 +- hub-client/src/components/ProjectsHome.tsx | 192 ++++++++++-------- .../src/components/ReplayDrawer.test.tsx | 22 +- hub-client/src/components/ReplayDrawer.tsx | 105 +++++----- hub-client/src/components/Tooltip.css | 38 ++++ hub-client/src/components/Tooltip.tsx | 130 ++++++++++++ .../src/components/ViewToggleControl.tsx | 58 +++--- hub-client/src/components/tabs/AboutTab.tsx | 12 +- hub-client/src/components/tabs/ProjectTab.tsx | 14 +- hub-client/src/ui.css | 3 + 16 files changed, 515 insertions(+), 271 deletions(-) create mode 100644 hub-client/src/components/Tooltip.css create mode 100644 hub-client/src/components/Tooltip.tsx diff --git a/hub-client/src/components/DevGalleryPage.tsx b/hub-client/src/components/DevGalleryPage.tsx index b5b10b719..929eace73 100644 --- a/hub-client/src/components/DevGalleryPage.tsx +++ b/hub-client/src/components/DevGalleryPage.tsx @@ -13,6 +13,7 @@ import React, { useState } from 'react'; import { Menu, MenuItem, MenuDivider, MenuLabel, MenuSubmenu } from './Menu'; +import Tooltip from './Tooltip'; import { FilePlusIcon, UploadIcon, @@ -142,7 +143,8 @@ export default function DevGalleryPage() { style={{ padding: 'var(--space-5)', maxWidth: 900, - background: 'var(--bg-primary, transparent)', + background: 'var(--page-bg)', + color: 'var(--text-primary)', minHeight: '100vh', }} > @@ -201,6 +203,14 @@ export default function DevGalleryPage() { +
+ + + + + +
+
{ expect(banner.textContent).toContain("edits won't be saved to disk"); }); - it('hover text explains the cause and names the --allow-edit fix', () => { + it('tooltip explains the cause and names the --allow-edit fix', () => { render(); - const { title } = screen.getByRole('status'); - expect(title).toContain('never written'); - expect(title).toContain('--allow-edit'); + fireEvent.focus(screen.getByRole('status')); + const tip = screen.getByRole('tooltip'); + expect(tip.textContent).toContain('never written'); + expect(tip.textContent).toContain('--allow-edit'); }); }); diff --git a/hub-client/src/components/EphemeralSessionBanner.tsx b/hub-client/src/components/EphemeralSessionBanner.tsx index fee049d59..8f788e0b2 100644 --- a/hub-client/src/components/EphemeralSessionBanner.tsx +++ b/hub-client/src/components/EphemeralSessionBanner.tsx @@ -8,14 +8,15 @@ * guest reads the host's value through the tunnel). The copy is fixed * — nothing here is interpolated from server data. */ + +import Tooltip from './Tooltip'; + export default function EphemeralSessionBanner() { return ( -
- Ephemeral session — edits won't be saved to disk -
+ +
+ Ephemeral session — edits won't be saved to disk +
+
); } diff --git a/hub-client/src/components/FileSidebar.tsx b/hub-client/src/components/FileSidebar.tsx index b8b8cd046..47d7115d0 100644 --- a/hub-client/src/components/FileSidebar.tsx +++ b/hub-client/src/components/FileSidebar.tsx @@ -21,6 +21,7 @@ import { buildSnippet, type SearchFiles, type SearchResult } from '../services/s import { openPrintableDocument } from '../services/printableDocument'; import { FilePlusIcon, UploadIcon, PrintIcon, MoreIcon } from './icons'; import { Menu, MenuItem } from './Menu'; +import Tooltip from './Tooltip'; import './FileSidebar.css'; export interface FileSidebarProps { @@ -384,7 +385,11 @@ export default function FileSidebar({ const lastSlash = file.path.lastIndexOf('/'); const parentFolderPath = lastSlash >= 0 ? file.path.slice(0, lastSlash) : ''; + const rowTip = onOpenInNewTab + ? `${file.path} — Ctrl/Cmd+click to open in new tab` + : file.path; return ( +
handleFileDragStart(e, file) : undefined } - title={ - onOpenInNewTab - ? `${file.path}\nCtrl/Cmd+click to open in new tab` - : file.path - } > {getFileIcon(file.path)} {isRenaming ? ( @@ -448,6 +448,7 @@ export default function FileSidebar({ )}
+
); }; @@ -507,7 +508,6 @@ export default function FileSidebar({ key={result.path} className={`search-result qh-row-hover qh-active-accent-row ${isActive ? 'active' : ''}`} onClick={() => onSelectFile(file)} - title={result.path} >
{getFileIcon(result.path)} @@ -539,32 +539,35 @@ export default function FileSidebar({ onDrop={handleDrop} >
- - - {canOpenPrintable && ( + + + + + + {canOpenPrintable && ( + + + )}
{printableError && ( @@ -591,14 +594,15 @@ export default function FileSidebar({ aria-label="Search files" /> {isSearching && ( - + + + )}
)} diff --git a/hub-client/src/components/JoinCollectionLanding.tsx b/hub-client/src/components/JoinCollectionLanding.tsx index bff4f47aa..4dbc1be4a 100644 --- a/hub-client/src/components/JoinCollectionLanding.tsx +++ b/hub-client/src/components/JoinCollectionLanding.tsx @@ -10,6 +10,7 @@ */ import { useState, useEffect } from 'react'; +import Tooltip from './Tooltip'; import type { CollectionsStatus } from '../hooks/useCollectionSets'; import { CollectionConnectError } from '../services/collectionConnectError'; import type { UserSettings } from '../services/storage/types'; @@ -142,13 +143,14 @@ export default function JoinCollectionLanding({
{COLOR_PALETTE.map((c) => ( -
diff --git a/hub-client/src/components/MinimalHeader.tsx b/hub-client/src/components/MinimalHeader.tsx index 6fdafe060..08ccf0fd9 100644 --- a/hub-client/src/components/MinimalHeader.tsx +++ b/hub-client/src/components/MinimalHeader.tsx @@ -7,6 +7,7 @@ import ViewToggleControl from './ViewToggleControl'; import { SwitchIcon, ShareIcon, PreviewIcon } from './icons'; +import Tooltip from './Tooltip'; import './MinimalHeader.css'; interface MinimalHeaderProps { @@ -33,23 +34,25 @@ export default function MinimalHeader({ return (
- - {onShare && ( + + + {onShare && ( + + + )}
-
- - {isOnline ? 'Online' : 'Offline'} -
+
+
+ {onToggleFullscreenPreview && !isFullscreenPreview && ( - + + + )}
diff --git a/hub-client/src/components/OutlinePanel.tsx b/hub-client/src/components/OutlinePanel.tsx index 358b36b6d..5804f1500 100644 --- a/hub-client/src/components/OutlinePanel.tsx +++ b/hub-client/src/components/OutlinePanel.tsx @@ -8,6 +8,7 @@ */ import { useState, useCallback } from 'react'; +import Tooltip from './Tooltip'; import type { Symbol, SymbolKind } from '@quarto/preview-renderer/types/intelligence'; import type { ThumbnailMap } from '../hooks/useSectionThumbnails'; import './OutlinePanel.css'; @@ -106,17 +107,18 @@ function SymbolTree({ {isCollapsed ? '▶' : '▼'} )} - + + {thumbnail && ( {shown.map((u, i) => ( - - {u.initials} - + + + {u.initials} + + ))} - {extra > 0 && +{extra}} + {extra > 0 && ( + + +{extra} + + )} ); }; @@ -1038,7 +1045,7 @@ export default function ProjectsHome({ // The shared APG menu primitive (components/Menu.tsx): arrow-key nav, // type-ahead, Escape with focus return, submenus. const renderProjectMenu = (item: ProjectItem) => ( - closeAllMenus()} ignoreOutsideSelector=".qh-menu-anchor, .qh-peek" aria-label={`Actions for ${item.title}`}> + closeAllMenus()} ignoreOutsideSelector=".qh-menu-anchor, .qh-peek" aria-label={`Actions for ${item.description}`}> { closeAllMenus(); handleOpen(item); }}> Open @@ -1261,7 +1268,9 @@ export default function ProjectsHome({
INVITE BY LINK
- {inviteUrl.replace(/^https?:\/\//, '').slice(0, 34)}… + + {inviteUrl.replace(/^https?:\/\//, '').slice(0, 34)}… + {peekFor === item.indexDocId && renderPeek(item)} - + + + + ); })()} - + + + {openMenu === sortMenuKey && ( closeAllMenus()} ignoreOutsideSelector=".qh-menu-anchor, .qh-peek" aria-label="Sort collection"> {(['newest', 'oldest', 'name'] as SortOrder[]).map((o) => ( @@ -1416,17 +1432,20 @@ export default function ProjectsHome({ )} - + + + {membersFor === collection.id && renderMembersPopover(collection)} {openMenu === menuKey && ( closeAllMenus()} ignoreOutsideSelector=".qh-menu-anchor, .qh-peek" aria-label={`Actions for ${collection.name}`}> @@ -1467,24 +1486,28 @@ export default function ProjectsHome({ ) : (
{page > 0 && ( - + + + )}
{pageItems.map(renderCard)}
{page < pageCount - 1 ? ( - + + + ) : pageCount > 1 ? (
{page + 1}/{pageCount} @@ -1545,7 +1568,7 @@ export default function ProjectsHome({ className="qh-avatar" style={authPicture ? undefined : { backgroundColor: userSettings?.userColor ?? 'var(--posit-blue)' }} onClick={() => setAvatarMenuOpen((v) => !v)} - title={userSettings?.userName} + aria-label={userSettings?.userName ? `Account: ${userSettings.userName}` : 'Account'} > {authPicture ? ( @@ -1596,13 +1619,14 @@ export default function ProjectsHome({
CURSOR COLOR
{COLOR_PALETTE.map((color) => ( -
@@ -1700,14 +1724,12 @@ export default function ProjectsHome({ onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); - setMoveSubmenuOpen(false); setOpenMenu(openMenu === item.indexDocId ? null : item.indexDocId); }} > @@ -1725,27 +1747,29 @@ export default function ProjectsHome({ > {peekFor === item.indexDocId && renderPeek(item)} - + + +
diff --git a/hub-client/src/components/ReplayDrawer.test.tsx b/hub-client/src/components/ReplayDrawer.test.tsx index 99e966c23..9f664c172 100644 --- a/hub-client/src/components/ReplayDrawer.test.tsx +++ b/hub-client/src/components/ReplayDrawer.test.tsx @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { render, screen, fireEvent, act, cleanup } from '@testing-library/react'; import ReplayDrawer from './ReplayDrawer'; import type { ReplayState, ReplayControls } from '../hooks/useReplayMode'; @@ -384,7 +384,7 @@ describe('ReplayDrawer', () => { expect(pill.getAttribute('aria-busy')).toBe('true'); }); - it('title text follows the on/off state when enabled', () => { + it('tooltip text follows the on/off state when enabled', () => { const { rerender } = render( { onAttributionChange={vi.fn()} />, ); - expect(screen.getByLabelText(/Authors/).getAttribute('title')).toBe('Show authors overlay'); + const pill = screen.getByLabelText(/Authors/); + fireEvent.focus(pill); + expect(screen.getByRole('tooltip').textContent).toBe('Show authors overlay'); + fireEvent.blur(pill); rerender( { onAttributionChange={vi.fn()} />, ); - expect(screen.getByLabelText(/Authors/).getAttribute('title')).toBe('Hide authors overlay'); + fireEvent.focus(screen.getByLabelText(/Authors/)); + expect(screen.getByRole('tooltip').textContent).toBe('Hide authors overlay'); }); - it('renders disabled with an explanatory title when attributionDisabled is true', () => { + it('renders disabled with an explanatory tooltip when attributionDisabled is true', () => { render( { ); const pill = screen.getByLabelText(/unavailable/); expect((pill as HTMLButtonElement).disabled).toBe(true); - expect(pill.getAttribute('title')).toMatch(/not available for this format/); + // Disabled buttons swallow pointer events, so the tooltip's hover + // handlers live on its wrapper span. Hover shows after a 400ms delay. + vi.useFakeTimers(); + fireEvent.mouseOver(pill.parentElement!); + act(() => vi.advanceTimersByTime(450)); + expect(screen.getByRole('tooltip').textContent).toMatch(/not available for this format/); + vi.useRealTimers(); }); it('suppresses on/generating modifier classes while disabled', () => { diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index 33b6a656e..cae7e8111 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -4,6 +4,7 @@ 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 Tooltip from './Tooltip'; import './ReplayDrawer.css'; import './ViewToggleControl.css'; @@ -83,44 +84,46 @@ function CommentsModeToggle({ aria-label="Comment display mode" style={{ marginLeft: '6px' }} > - - - + + + + + + + + + {count !== undefined && count > 0 && ( {count} @@ -153,22 +156,23 @@ function AttributionToggle({ attributionOn, onAttributionChange, generating, dis ? 'Authors overlay unavailable for this format' : `Authors overlay ${attributionOn ? 'on' : 'off'}`; return ( - + + + ); } @@ -314,7 +318,6 @@ export default function ReplayDrawer({ className="replay-drawer__toggle" onClick={disabled ? undefined : controls.enter} disabled={disabled} - title={disabled ? 'Replay is not available for binary files' : undefined} > Replay diff --git a/hub-client/src/components/Tooltip.css b/hub-client/src/components/Tooltip.css new file mode 100644 index 000000000..aa4dc3c99 --- /dev/null +++ b/hub-client/src/components/Tooltip.css @@ -0,0 +1,38 @@ +/* Tooltip — token-styled, non-interactive (APG tooltip pattern). + Rendered above the anchor by default; .qh-tooltip-bottom is the + viewport-flipped variant. */ + +.qh-tooltip-anchor { + position: relative; + display: inline-flex; +} + +/* Block-level anchors (file rows, cards) need the anchor to not disturb + flex/grid layout. */ +.qh-tooltip-anchor.qh-tooltip-anchor-block { + display: contents; +} + +.qh-tooltip { + position: absolute; + bottom: calc(100% + 6px); + inset-inline-start: 50%; + translate: -50% 0; + z-index: var(--z-toast); + padding: var(--space-1) var(--space-2); + background: var(--text-primary); + color: var(--bg-modal); + font-size: var(--text-xs); + font-weight: var(--font-weight-normal); + line-height: var(--leading-tight); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-2); + width: max-content; + max-width: 300px; + pointer-events: none; +} + +.qh-tooltip-bottom { + bottom: auto; + top: calc(100% + 6px); +} diff --git a/hub-client/src/components/Tooltip.tsx b/hub-client/src/components/Tooltip.tsx new file mode 100644 index 000000000..6f74a3bab --- /dev/null +++ b/hub-client/src/components/Tooltip.tsx @@ -0,0 +1,130 @@ +/** + * Tooltip — the single tooltip primitive for hub-client, replacing native + * `title` attributes (which are unstyled, delay-less, and inaccessible to + * touch/keyboard users). + * + * Behavior (WAI-ARIA APG tooltip pattern): + * - Shows after a 400ms hover delay; immediately on keyboard focus + * - Wired via `aria-describedby` on the wrapped element + * - Escape dismisses; hides on pointer-leave and blur + * - Flips below the anchor when it would overflow the viewport top, + * clamped horizontally inside the viewport + * - Non-interactive content only — never put focusable elements inside + * + * Usage: wrap a single element that accepts aria-describedby and event + * handlers (a DOM element or a component forwarding them): + * + * - - + + + + + + + + +
); } diff --git a/hub-client/src/components/tabs/AboutTab.tsx b/hub-client/src/components/tabs/AboutTab.tsx index c8706d3cc..609bd7950 100644 --- a/hub-client/src/components/tabs/AboutTab.tsx +++ b/hub-client/src/components/tabs/AboutTab.tsx @@ -8,6 +8,7 @@ */ import { useState, useEffect } from 'react'; +import Tooltip from '../Tooltip'; import { renderContentToHtml, isWasmReady } from '@quarto/preview-runtime'; import changelogMd from '../../../changelog.md?raw'; import moreInfoMd from '../../../resources/more-info.md?raw'; @@ -166,12 +167,11 @@ export default function AboutTab({ wasmStatus }: AboutTabProps) {
commit - - {__GIT_COMMIT_HASH__} - + + + {__GIT_COMMIT_HASH__} + +
diff --git a/hub-client/src/components/tabs/ProjectTab.tsx b/hub-client/src/components/tabs/ProjectTab.tsx index 25a2c8b89..af0e746d1 100644 --- a/hub-client/src/components/tabs/ProjectTab.tsx +++ b/hub-client/src/components/tabs/ProjectTab.tsx @@ -12,6 +12,7 @@ */ import { useState, useCallback } from 'react'; +import Tooltip from '../Tooltip'; import html2canvas from 'html2canvas'; import { projectFolderName } from '@quarto/quarto-sync-client'; import type { ProjectEntry } from '@quarto/preview-renderer/types/project'; @@ -119,14 +120,15 @@ export default function ProjectTab({ project, onExportZip }: ProjectTabProps) {
- + +
diff --git a/hub-client/src/ui.css b/hub-client/src/ui.css index 8df2f9a81..52cdd2a5b 100644 --- a/hub-client/src/ui.css +++ b/hub-client/src/ui.css @@ -55,6 +55,9 @@ .qh-btn { font-size: var(--text-base); font-weight: var(--font-weight-semibold); + /* Buttons don't inherit color (UA stylesheet) — the base must declare + it, or bare .qh-btn renders black-on-dark in the dark theme. */ + color: var(--text-primary); padding: 8px 14px; border-radius: 7px; border: 1px solid transparent; From e7bea395a8ab279797019f46d4ca5cf11374999d Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:17:06 +0100 Subject: [PATCH 06/17] =?UTF-8?q?hub-client:=20Phase=201=20=E2=80=94=20rou?= =?UTF-8?q?te=20all=20ProjectsHome=20dialogs=20through=20ModalDialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eight ad-hoc ProjectsHome dialogs (duplicate, new/rename collection, confirm, move warning, rename project, new project, connect/import) hand-rolled role=dialog with no focus trap and no focus restoration. They now use the shared ModalDialog: header/content/actions via the shared classes (.qh-form-dialog zeroes the padding so the hairlines span), Escape closes, Tab cycles, focus restores on close. Two focus bugs fixed along the way: - ModalDialog captured its focus-restore target in a mount effect — after autoFocus children had already taken focus — so restoration targeted a detached input and focus was lost to . Capture now happens during render. - Menu returned focus to its trigger synchronously on activation, stealing focus back from a dialog the item had just opened. The return is now deferred past the commit and skipped when focus is inside a dialog. Coverage: e2e/projects-home-dialogs.visual.spec.ts drives the New and Connect/Import dialogs through the real UI (menu keyboard activation, focus trap wrap, Escape/close-button with focus return). Phase 1 of the UI/UX modernization (bd-iguk0hpd). --- .../e2e/projects-home-dialogs.visual.spec.ts | 62 ++++ hub-client/src/components/Menu.tsx | 14 +- hub-client/src/components/ModalDialog.tsx | 15 +- hub-client/src/components/ProjectsHome.tsx | 271 ++++++++++-------- hub-client/src/ui.css | 7 + 5 files changed, 237 insertions(+), 132 deletions(-) create mode 100644 hub-client/e2e/projects-home-dialogs.visual.spec.ts diff --git a/hub-client/e2e/projects-home-dialogs.visual.spec.ts b/hub-client/e2e/projects-home-dialogs.visual.spec.ts new file mode 100644 index 000000000..f34f1ede2 --- /dev/null +++ b/hub-client/e2e/projects-home-dialogs.visual.spec.ts @@ -0,0 +1,62 @@ +/** + * Interaction spec for the ProjectsHome dialogs after their conversion to + * the shared ModalDialog (Phase 1): opened through the real UI (New menu, + * Connect/Import button), each must present the WCAG dialog contract — + * role="dialog", aria-modal, labelled by its title, Escape closes, focus + * returns to the trigger, and the header close button works. + * + * Runs in the no-server visual config (DevHarness, not screenshots). + */ + +import { test, expect } from '@playwright/test'; +import { bootHarness } from './helpers/visual'; + +test.beforeEach(async ({ page }) => { + await bootHarness(page, 'projects-home', '.projects-home', 'light'); +}); + +test('New project dialog opens via the New menu and closes with focus return', async ({ + page, +}) => { + const trigger = page.getByRole('button', { name: '+ New ▾' }); + await trigger.click(); + const menu = page.locator('[role="menu"]'); + await expect(menu).toBeVisible(); + + // Keyboard: first item focused; Enter opens the dialog. + await expect(menu.locator('[role="menuitem"]').first()).toBeFocused(); + await page.keyboard.press('Enter'); + + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible(); + await expect(dialog).toHaveAttribute('aria-modal', 'true'); + await expect(dialog.locator('h2')).toHaveText('New default'); + // Focus is inside the dialog (the name input autofocuses). + await expect(dialog.locator('#qh-new-name')).toBeFocused(); + + await page.keyboard.press('Escape'); + await expect(page.locator('[role="dialog"]')).toHaveCount(0); + await expect(trigger).toBeFocused(); +}); + +test('Connect/Import dialog: header close button closes and returns focus', async ({ page }) => { + const trigger = page.locator('button:has-text("Connect / Import")'); + await trigger.click(); + + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('h2')).toHaveText('Add an existing project'); + + // Tab cycles within the dialog (focus trap): Shift+Tab from the first + // focusable wraps to the last. + await dialog.locator('.close-btn').focus(); + await page.keyboard.press('Shift+Tab'); + const focused = await page.evaluate(() => document.activeElement?.textContent); + // The Import submit is disabled until a file is chosen, so the last + // enabled focusable is Cancel. + expect(focused).toBe('Cancel'); + + await dialog.locator('.close-btn').click(); + await expect(page.locator('[role="dialog"]')).toHaveCount(0); + await expect(trigger).toBeFocused(); +}); diff --git a/hub-client/src/components/Menu.tsx b/hub-client/src/components/Menu.tsx index f8cda4f89..be7509d50 100644 --- a/hub-client/src/components/Menu.tsx +++ b/hub-client/src/components/Menu.tsx @@ -88,11 +88,15 @@ export function Menu({ const close = useCallback( (returnFocus: boolean) => { - // Return focus synchronously, before the parent re-renders: if the - // activating item opens a dialog, the dialog's own focus management - // must win, and it runs in a post-render effect after this. - if (returnFocus && returnFocusRef.current?.isConnected) { - returnFocusRef.current.focus(); + // Defer the focus return past the React commit: if the activating + // item opened a dialog, the dialog owns focus now (its autoFocus + // child is focused) and the menu must not steal it back. + if (returnFocus) { + const target = returnFocusRef.current; + queueMicrotask(() => { + if (document.activeElement?.closest('[role="dialog"]')) return; + if (target?.isConnected) target.focus(); + }); } onClose(returnFocus); }, diff --git a/hub-client/src/components/ModalDialog.tsx b/hub-client/src/components/ModalDialog.tsx index d993cff9a..d19bc12fd 100644 --- a/hub-client/src/components/ModalDialog.tsx +++ b/hub-client/src/components/ModalDialog.tsx @@ -45,13 +45,20 @@ export default function ModalDialog({ const dialogRef = useRef(null); // Capture the element focused when the dialog opens and return focus - // to it when the dialog closes (unmounts). + // to it when the dialog closes (unmounts). The capture must happen + // during render, not in an effect: an autoFocus child takes focus + // during commit, before effects run, and would otherwise be captured + // as the restore target (and it's detached by unmount, so focus would + // be lost to ). const restoreFocusTo = useRef(null); - useEffect(() => { + if (restoreFocusTo.current === null && typeof document !== 'undefined') { restoreFocusTo.current = document.activeElement; + } + useEffect(() => { + const restoreTo = restoreFocusTo.current; return () => { - if (restoreFocusTo.current instanceof HTMLElement) { - restoreFocusTo.current.focus(); + if (restoreTo instanceof HTMLElement && restoreTo.isConnected) { + restoreTo.focus(); } }; }, []); diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index ae5912100..ee42984ea 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -44,6 +44,7 @@ import ShareDialog from './ShareDialog'; import { ForkIcon, PeekIcon, PeopleIcon, SortIcon } from './icons'; import { Menu, MenuItem, MenuDivider, MenuLabel, MenuSubmenu } from './Menu'; import Tooltip from './Tooltip'; +import ModalDialog from './ModalDialog'; import { sortProjectItems, sortOrderLabel, type SortOrder } from '../utils/projectSort'; import { buildProjectListExport, parseProjectListImport } from '../services/projectListExport'; import type { Face } from '../utils/facepile'; @@ -1787,15 +1788,18 @@ export default function ProjectsHome({ {/* Duplicate (fork) */} {duplicateFor && ( -
{ if (!duplicatingId) setDuplicateFor(null); }}> -
e.stopPropagation()}> -

Duplicate "{duplicateFor.description}"

-

- A fresh copy of all {duplicateFor.summary ? `${duplicateFor.summary.fileCount} ` : ''}files — no - edit history carries over. -

- {formError &&
{formError}
} -
{ e.preventDefault(); handleDuplicate(); }}> + { if (!duplicatingId) setDuplicateFor(null); }} + className="qh-form-dialog" + > + { e.preventDefault(); handleDuplicate(); }}> +
+

+ A fresh copy of all {duplicateFor.summary ? `${duplicateFor.summary.fileCount} ` : ''}files — no + edit history carries over. +

+ {formError &&
{formError}
} {c.name} ))} -
- - -
- -
-
+
+
+ + +
+ + )} {/* New collection */} {newCollectionDialog && ( -
setNewCollectionDialog(null)}> -
e.stopPropagation()}> -

New collection

- {newCollectionDialog.forProject && ( -

The project will be moved onto it.

- )} -
{ e.preventDefault(); commitNewCollection(); }}> + setNewCollectionDialog(null)} + className="qh-form-dialog" + > + { e.preventDefault(); commitNewCollection(); }}> +
+ {newCollectionDialog.forProject && ( +

The project will be moved onto it.

+ )} -
- - -
- -
-
+
+
+ + +
+ + )} {/* Rename collection */} {renameCollectionTarget && ( -
setRenameCollectionTarget(null)}> -
e.stopPropagation()}> -

Rename collection

-

Renames it for everyone subscribed to it.

-
{ - e.preventDefault(); - if (renameCollectionValue.trim()) { - onRenameCollection?.(renameCollectionTarget.id, renameCollectionValue.trim()); - } - setRenameCollectionTarget(null); - }} - > + setRenameCollectionTarget(null)} + className="qh-form-dialog" + > + { + e.preventDefault(); + if (renameCollectionValue.trim()) { + onRenameCollection?.(renameCollectionTarget.id, renameCollectionValue.trim()); + } + setRenameCollectionTarget(null); + }} + > +
+

Renames it for everyone subscribed to it.

setRenameCollectionValue(e.target.value)} autoFocus /> -
- - -
- -
-
+
+
+ + +
+ + )} {/* Generic destructive confirmation */} {confirmState && ( -
setConfirmState(null)}> -
e.stopPropagation()}> -

{confirmState.title}

+ setConfirmState(null)} + className="qh-form-dialog" + > +

{confirmState.body}

-
- - -
-
+
+ + +
+ )} {/* Shared-collection move warning */} {pendingMove && ( -
setPendingMove(null)}> -
e.stopPropagation()}> -

Move "{pendingMove.name}" out of {pendingMove.fromName}?

+ setPendingMove(null)} + className="qh-form-dialog" + > +

Please note you're changing {pendingMove.othersCount === 1 ? "another person's" @@ -1931,33 +1947,36 @@ export default function ProjectsHome({ /> Don't show this again -

- - -
-
+
+ + +
+ )} {/* Rename dialog */} {renameFor && ( -
setRenameFor(null)}> -
e.stopPropagation()}> -

Rename project

-
{ e.preventDefault(); commitRename(); }}> + setRenameFor(null)} + className="qh-form-dialog" + > + { e.preventDefault(); commitRename(); }}> +
-
- - -
- -
-
+
+
+ + +
+ + )} {/* New project dialog */} {newDialogChoice && ( -
setNewDialogChoice(null)}> -
e.stopPropagation()}> -

New {newDialogChoice.name.toLowerCase()}

-

Starter files will be created for you

- {formError &&
{formError}
} -
+ setNewDialogChoice(null)} + className="qh-form-dialog" + > + +
+

Starter files will be created for you

+ {formError &&
{formError}
} setShowServerField(true)}>Change…
)} -
- - -
- -
-
+
+
+ + +
+ + )} {/* Connect / Import dialog */} {addDialogOpen && ( -
setAddDialogOpen(false)}> -
e.stopPropagation()}> -

Add an existing project

+ setAddDialogOpen(false)} + className="qh-form-dialog wide" + > +
@@ -2118,7 +2143,7 @@ export default function ProjectsHome({ onChange={(e) => setImportTitle(e.target.value)} placeholder="My imported project" /> -
+
-
+ )} Date: Wed, 26 Aug 2026 11:19:32 +0100 Subject: [PATCH 07/17] =?UTF-8?q?hub-client:=20Phase=201=20=E2=80=94=20uni?= =?UTF-8?q?fy=20the=20notification=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One system, three documented tiers in components/notifications.css: transient status (.toast, auto-dismiss, bottom-center), dismissible persistent (.update-available-toast, bottom-right — persistent wins the corner so transient never covers its actions), and session banner (.ephemeral-session-banner, in-flow mode indicator, never dismissible). Toast.css and UpdateAvailableToast.css are gone; the banner rule moves out of Editor.css. Class names are unchanged (baselines/tests reference them); values are preserved, with font sizes/radii migrated to scale tokens and the redundant font-family overrides dropped (identical to the root stack). The persistent toast's dismiss button gains the token focus ring, and the fixed placements are now logical-property based (two lint:css exceptions burned down properly rather than carried). Phase 1 of the UI/UX modernization (bd-iguk0hpd). --- hub-client/scripts/lint-css-exceptions.json | 8 +- hub-client/src/components/Editor.css | 12 -- .../src/components/EphemeralSessionBanner.tsx | 1 + hub-client/src/components/Toast.css | 26 ---- hub-client/src/components/Toast.tsx | 2 +- .../src/components/UpdateAvailableToast.css | 48 -------- .../src/components/UpdateAvailableToast.tsx | 2 +- hub-client/src/components/notifications.css | 113 ++++++++++++++++++ 8 files changed, 117 insertions(+), 95 deletions(-) delete mode 100644 hub-client/src/components/Toast.css delete mode 100644 hub-client/src/components/UpdateAvailableToast.css create mode 100644 hub-client/src/components/notifications.css diff --git a/hub-client/scripts/lint-css-exceptions.json b/hub-client/scripts/lint-css-exceptions.json index ce13c9a79..e903b1de6 100644 --- a/hub-client/scripts/lint-css-exceptions.json +++ b/hub-client/scripts/lint-css-exceptions.json @@ -94,12 +94,6 @@ "components/SkipLink.css": [ "left: 8px;" ], - "components/Toast.css": [ - "left: 50%;" - ], - "components/UpdateAvailableToast.css": [ - "right: 20px;" - ], "components/debug-inspector/DebugInspectorPanel.css": [ "border-right: 1px solid var(--debug-surface-0);", "margin-left: 6px;", @@ -134,4 +128,4 @@ "text-align: left;" ] } -} +} \ No newline at end of file diff --git a/hub-client/src/components/Editor.css b/hub-client/src/components/Editor.css index 4d3dca877..50eaada96 100644 --- a/hub-client/src/components/Editor.css +++ b/hub-client/src/components/Editor.css @@ -408,18 +408,6 @@ flex-shrink: 0; } -/* Ephemeral-session banner — `q2 preview --ui editor` without --allow-edit */ -.ephemeral-session-banner { - background: var(--warning-bg); - color: var(--warning-text); - border-bottom: 1px solid var(--warning-border); - padding: 4px 16px; - font-size: 12px; - text-align: center; - flex-shrink: 0; - cursor: help; -} - /* Replay mode banner — overlays bottom edge of header */ .replay-mode-banner { position: absolute; diff --git a/hub-client/src/components/EphemeralSessionBanner.tsx b/hub-client/src/components/EphemeralSessionBanner.tsx index 8f788e0b2..f0e3bc846 100644 --- a/hub-client/src/components/EphemeralSessionBanner.tsx +++ b/hub-client/src/components/EphemeralSessionBanner.tsx @@ -10,6 +10,7 @@ */ import Tooltip from './Tooltip'; +import './notifications.css'; export default function EphemeralSessionBanner() { return ( diff --git a/hub-client/src/components/Toast.css b/hub-client/src/components/Toast.css deleted file mode 100644 index 1f5adc701..000000000 --- a/hub-client/src/components/Toast.css +++ /dev/null @@ -1,26 +0,0 @@ -.toast { - position: fixed; - bottom: 20px; - left: 50%; - transform: translateX(-50%); - background: var(--toast-bg); - color: var(--toast-text); - padding: 10px 20px; - border-radius: 6px; - font-size: 14px; - font-family: system-ui, -apple-system, sans-serif; - box-shadow: 0 4px 12px var(--toast-shadow); - z-index: var(--z-toast); - animation: toast-fade-in 0.2s ease-out; -} - -@keyframes toast-fade-in { - from { - opacity: 0; - transform: translateX(-50%) translateY(10px); - } - to { - opacity: 1; - transform: translateX(-50%) translateY(0); - } -} diff --git a/hub-client/src/components/Toast.tsx b/hub-client/src/components/Toast.tsx index 90e36cbfb..dc208987a 100644 --- a/hub-client/src/components/Toast.tsx +++ b/hub-client/src/components/Toast.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import './Toast.css'; +import './notifications.css'; interface ToastProps { message: string; diff --git a/hub-client/src/components/UpdateAvailableToast.css b/hub-client/src/components/UpdateAvailableToast.css deleted file mode 100644 index 4b4c88d11..000000000 --- a/hub-client/src/components/UpdateAvailableToast.css +++ /dev/null @@ -1,48 +0,0 @@ -/* UpdateAvailableToast — fixed bottom-right toast for the SW update - prompt. Shares the .toast color tokens (theme.css) but sits in the - corner and stays put: unlike .toast it is persistent, so it must not - read as a transient notification. */ - -.update-available-toast { - position: fixed; - bottom: 20px; - right: 20px; - display: flex; - align-items: center; - gap: 12px; - background: var(--toast-bg); - color: var(--toast-text); - padding: 10px 12px 10px 16px; - border-radius: 8px; - font-size: 14px; - font-family: system-ui, -apple-system, sans-serif; - box-shadow: 0 4px 12px var(--toast-shadow); - z-index: var(--z-max); /* above the transient .toast (--z-toast) */ - animation: update-available-toast-in 0.2s ease-out; -} - -.update-available-toast-dismiss { - background: none; - border: none; - padding: 4px 6px; - font-size: 16px; - line-height: 1; - color: inherit; - opacity: 0.7; - cursor: pointer; -} - -.update-available-toast-dismiss:hover { - opacity: 1; -} - -@keyframes update-available-toast-in { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} diff --git a/hub-client/src/components/UpdateAvailableToast.tsx b/hub-client/src/components/UpdateAvailableToast.tsx index a45af0c6b..2e563a6fd 100644 --- a/hub-client/src/components/UpdateAvailableToast.tsx +++ b/hub-client/src/components/UpdateAvailableToast.tsx @@ -15,7 +15,7 @@ import { useState, useSyncExternalStore } from 'react'; import { pwaPrompt } from '../pwaPrompt'; import type { PwaPromptStore } from '../pwaPrompt'; -import './UpdateAvailableToast.css'; +import './notifications.css'; export default function UpdateAvailableToast({ prompt = pwaPrompt, diff --git a/hub-client/src/components/notifications.css b/hub-client/src/components/notifications.css new file mode 100644 index 000000000..b0ef2f742 --- /dev/null +++ b/hub-client/src/components/notifications.css @@ -0,0 +1,113 @@ +/* ---- notification system ---- + One notification model, three tiers. Pick by how long the information + must live, not by which component happens to render it: + + 1. Transient status (.toast) — brief confirmation feedback ("Auto-saved"). + Auto-dismisses after ~2s, role="status", bottom-center. No actions, + no dismiss button: it is gone before the user needs one. + 2. Dismissible persistent (.update-available-toast) — something the user + must be able to act on or explicitly dismiss (the SW update prompt). + Stays until acted on or dismissed, bottom-right (must not read as + transient), role="status", always has a labelled dismiss button. + 3. Session banner (.ephemeral-session-banner) — a mode indicator that is + true for the whole session (ephemeral edits). Inline at the top of + the layout, warning-tinted, never dismissible: dismissing a mode + indicator would misrepresent the session. + + Placement rules: floating notifications stack from the bottom edge — + transient centered, persistent in the corner (persistent wins the + corner so the transient never covers its actions). Banners are in + flow, above the content they describe. */ + +.toast { + position: fixed; + bottom: 20px; + /* Direction-agnostic horizontal centering (no physical left/translate). */ + inset-inline: 0; + margin-inline: auto; + width: max-content; + max-width: calc(100vw - 40px); + background: var(--toast-bg); + color: var(--toast-text); + padding: 10px 20px; + border-radius: var(--radius-sm); + font-size: var(--text-md); + box-shadow: 0 4px 12px var(--toast-shadow); + z-index: var(--z-toast); + animation: toast-fade-in 0.2s ease-out; +} + +@keyframes toast-fade-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Tier 2: persistent. Sits in the corner and stays put; above the + transient tier so the two never overlap. */ +.update-available-toast { + position: fixed; + bottom: 20px; + inset-inline-end: 20px; + display: flex; + align-items: center; + gap: 12px; + background: var(--toast-bg); + color: var(--toast-text); + padding: 10px 12px 10px 16px; + border-radius: var(--radius-md); + font-size: var(--text-md); + box-shadow: 0 4px 12px var(--toast-shadow); + z-index: var(--z-max); + animation: update-available-toast-in 0.2s ease-out; +} + +.update-available-toast-dismiss { + background: none; + border: none; + padding: 4px 6px; + font-size: 16px; + line-height: 1; + color: inherit; + opacity: 0.7; + cursor: pointer; +} + +.update-available-toast-dismiss:hover { + opacity: 1; +} + +.update-available-toast-dismiss:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + opacity: 1; +} + +@keyframes update-available-toast-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Tier 3: session banner. In flow at the top of the editor layout; + `q2 preview --ui editor` without --allow-edit. */ +.ephemeral-session-banner { + background: var(--warning-bg); + color: var(--warning-text); + border-bottom: 1px solid var(--warning-border); + padding: 4px 16px; + font-size: var(--text-sm); + text-align: center; + flex-shrink: 0; + cursor: help; +} From 2d1335bcb2597fa241dde0ca5658a16e7811898b Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:24:47 +0100 Subject: [PATCH 08/17] =?UTF-8?q?hub-client:=20Phase=201=20=E2=80=94=20for?= =?UTF-8?q?m-control=20contract,=20design-system=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Form controls: .qh-input gains disabled and aria-invalid states; the validation pattern is documented (field-level aria-invalid + error text via aria-describedby; form-level .qh-error.inline). The SettingsTab custom checkbox was invisible to keyboard users — the token focus ring now lands on the custom box (WCAG 2.4.7). Gallery gains disabled-input and tooltip sections. Status-indicator audit (plan item): every status now pairs color with text or label — connection (dot + Online/Offline text), renderer status (dot + label), collaborators (color dot + name), diagnostics (Monaco markers, not color-only). No changes needed beyond the Phase 1 header work; recorded here for the plan checkbox. Adds hub-client/design-system.md: the token-layer contract, the primitives table, and the how-to-add-a-component rules — the drift-prevention counterpart to lint:css. Phase 1 of the UI/UX modernization (bd-iguk0hpd). --- hub-client/design-system.md | 56 +++++++++++++++++++ hub-client/src/components/DevGalleryPage.tsx | 9 +++ .../src/components/tabs/SettingsTab.css | 7 +++ hub-client/src/ui.css | 15 ++++- 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 hub-client/design-system.md diff --git a/hub-client/design-system.md b/hub-client/design-system.md new file mode 100644 index 000000000..891c6a0ff --- /dev/null +++ b/hub-client/design-system.md @@ -0,0 +1,56 @@ +# Hub-client design system + +The hub-client UI is built from a small set of tokens and primitives. +This page is the contract: follow it when adding or touching UI, and the +app stays coherent. The enforcement counterparts are `npm run lint:css` +(off-token values) and the `#/dev/` gallery (visual + keyboard coverage). + +## Tokens (`src/theme.css`) + +Three layers — new values land on the right one: + +1. **Primitive ramps** — raw palette/scale values (`--posit-teal`, + `--posit-blue-dark-2`). Never referenced directly by components. +2. **Semantic aliases** — meaning-named tokens (`--text-primary`, + `--border-color`, `--editor-accent-bg`), defined per theme. +3. **Scale tokens** — the shared scales: spacing (`--space-1`…`--space-8`, + 4px base), radii (`--radius-sm/md/lg`), elevation (`--shadow-1/2/3`), + z-layers (`--z-sticky`…`--z-max`), type (`--text-xs`…`--text-xl`, + `--font-weight-*`, `--leading-*`, `--font-mono`), motion + (`--duration-fast/base`, `--ease-out/standard`), and `--focus-ring`. + +Rules: no hex/rgb colors outside `theme.css`; no bare z-index integers; +no `outline: none` without a `:focus-visible` counterpart; logical +properties (`margin-inline-start`, `inset-inline-end`) over physical ones. +All enforced by `lint:css`. + +## Primitives + +| Primitive | Where | Notes | +|---|---|---| +| Buttons | `ui.css` `.qh-btn` (+ `.primary` `.outline` `.danger` `.ghost-accent`, `.small`), `.qh-icon-btn` (+ `.boxed`), `.qh-link` | Two sizes; disabled + focus-visible live on the base classes. Not buttons: `.view-toggle-btn` (segmented control), `.qh-pager` (nav strip), `.preview-btn` (header primary pill). | +| Menu | `components/Menu.tsx` | The only action menu. APG menu-button pattern: arrows/Home/End, type-ahead, submenus, Escape + focus return. Destructive items use `danger` and must be confirm-guarded or undoable. Popovers containing forms (avatar menu) are not menus — they use `.qh-menu` styling only. | +| Tooltip | `components/Tooltip.tsx` | The only tooltip. Never use `title=`. Non-interactive content only. | +| Notifications | `components/notifications.css` | Three tiers — transient (auto-dismiss), dismissible persistent, session banner. Pick by how long the information must live; see the file header. | +| Dialogs | `components/ModalDialog.tsx` | Every dialog routes through it (focus trap, restoration, Escape). Structure content with `.dialog-content` / `.dialog-actions`; form dialogs add `.qh-form-dialog`. | +| Form controls | `ui.css` `.qh-input`, `.qh-field-label`, `.qh-tabs` | Validation: `aria-invalid` + error text via `aria-describedby` (field-level) or `.qh-error.inline` (form-level). | +| Icons | `components/icons.tsx` | The only icon source. Decorative (`aria-hidden`), 24×24 stroke style, `currentColor`. | +| Utilities | `ui.css` `.qh-truncate`, `.qh-row-hover`, `.qh-active-accent-row` | Single-purpose shared classes — adopt, don't fork. | + +## The gallery (`#/dev/gallery`) + +Every primitive renders on the gallery page in its meaningful states, in +both themes, covered by Playwright visual baselines and axe-core scans +(`npm run test:visual`). The tokens gallery is `#/dev/tokens`. + +## Adding a component + +1. Style with tokens only — if a value has no token, add one at the right + layer in `theme.css` (or use the nearest scale step). +2. Reuse the primitives above. If none fits, that's a design decision — + build it accessibly (keyboard path, ARIA pattern from the APG) and add + it here. +3. Add the component to the gallery in its default/hover/focus/disabled/ + error states; the visual and axe baselines pick it up from there. +4. Every pointer affordance needs a keyboard path; every icon-only control + needs an `aria-label`; status is never conveyed by color alone. diff --git a/hub-client/src/components/DevGalleryPage.tsx b/hub-client/src/components/DevGalleryPage.tsx index 929eace73..79a4c009b 100644 --- a/hub-client/src/components/DevGalleryPage.tsx +++ b/hub-client/src/components/DevGalleryPage.tsx @@ -227,6 +227,15 @@ export default function DevGalleryPage() { + + + Date: Wed, 26 Aug 2026 11:25:28 +0100 Subject: [PATCH 09/17] hub-client: changelog entries for Phase 1 user-visible changes --- hub-client/changelog.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 692d1ede7..99f2cdd3e 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -26,6 +26,9 @@ WASM rebuild is needed for a changelog-only edit. ### 2026-08-26 - [`00e0617a`](https://github.com/quarto-dev/q2/commits/00e0617a): Comment bubbles now show the comment's full text — quoted words, emphasis, and inline code inside a comment were previously dropped from the bubble. +- [`c740728a`](https://github.com/quarto-dev/q2/commits/c740728a): All project-list dialogs (new project, rename, duplicate, connect/import, and friends) now share the accessible dialog chrome: Escape closes, Tab stays inside the dialog, and focus returns to the button that opened it. +- [`73d9cbe5`](https://github.com/quarto-dev/q2/commits/73d9cbe5): Tooltips are now styled and keyboard-accessible throughout — hover shows them after a short delay, keyboard focus shows them immediately, and Escape dismisses. +- [`7e5de6fe`](https://github.com/quarto-dev/q2/commits/7e5de6fe): File rows in the sidebar gain a visible ⋯ actions button (on hover or keyboard focus), and all menus are now fully keyboard-operable: arrow keys, Home/End, type-ahead, submenus, and Escape. ### 2026-08-25 From ccc79aa92fd1dc5c319709ef4aaf0f479588bba4 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:57:01 +0100 Subject: [PATCH 10/17] hub-client: render tooltips in a portal so overflow containers can't clip them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar is overflow-hidden, so the in-place absolute-positioned bubble was cut off at the sidebar edge — native title tooltips never clipped because the OS drew them. The bubble now renders on document.body with fixed positioning measured from the anchor (centered above, flip below, horizontal clamp), tracks the anchor on scroll and resize, and stays hidden until measured so it never flashes unpositioned. Phase 1 of the UI/UX modernization (bd-iguk0hpd). --- hub-client/src/components/Tooltip.css | 17 ++--- hub-client/src/components/Tooltip.tsx | 106 +++++++++++++++++--------- 2 files changed, 75 insertions(+), 48 deletions(-) diff --git a/hub-client/src/components/Tooltip.css b/hub-client/src/components/Tooltip.css index aa4dc3c99..6d92e844f 100644 --- a/hub-client/src/components/Tooltip.css +++ b/hub-client/src/components/Tooltip.css @@ -1,9 +1,8 @@ /* Tooltip — token-styled, non-interactive (APG tooltip pattern). - Rendered above the anchor by default; .qh-tooltip-bottom is the - viewport-flipped variant. */ + Rendered in a portal on document.body with fixed positioning computed + in Tooltip.tsx (flip + clamp), so overflow containers never clip it. */ .qh-tooltip-anchor { - position: relative; display: inline-flex; } @@ -14,10 +13,9 @@ } .qh-tooltip { - position: absolute; - bottom: calc(100% + 6px); - inset-inline-start: 50%; - translate: -50% 0; + /* top/left are set inline from measured coordinates; the bubble is + visibility:hidden until then. */ + position: fixed; z-index: var(--z-toast); padding: var(--space-1) var(--space-2); background: var(--text-primary); @@ -31,8 +29,3 @@ max-width: 300px; pointer-events: none; } - -.qh-tooltip-bottom { - bottom: auto; - top: calc(100% + 6px); -} diff --git a/hub-client/src/components/Tooltip.tsx b/hub-client/src/components/Tooltip.tsx index 6f74a3bab..5d3752e14 100644 --- a/hub-client/src/components/Tooltip.tsx +++ b/hub-client/src/components/Tooltip.tsx @@ -11,12 +11,17 @@ * clamped horizontally inside the viewport * - Non-interactive content only — never put focusable elements inside * + * The bubble renders in a portal on document.body with fixed positioning, + * so it is never clipped by overflow containers (the sidebar is + * overflow-hidden and would cut off an in-place bubble — native title + * tooltips never clipped because the OS drew them). + * * Usage: wrap a single element that accepts aria-describedby and event * handlers (a DOM element or a component forwarding them): * *