diff --git a/Dockerfile b/Dockerfile index 4917668b..3fda9b0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,10 +29,15 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ CODECITY_CACHE_ROOT=/cache \ UV_LINK_MODE=copy -# System deps. Pinned via apt repo of the base image; Dependabot bumps base. +# System deps. The base image's apt snapshot can lag published security fixes, +# so apply available upgrades before installing — Trivy fails CI on FIXED +# HIGH/CRITICAL OS CVEs (e.g. libcurl, pulled in by git), and waiting on a base +# image rebuild leaves the gate red. Dependabot still bumps the base tag. # Note: PID 1 init duties are handled by Docker's --init flag (compose: init: true), # so we don't install tini here. -RUN apt-get update && apt-get install -y --no-install-recommends \ +RUN apt-get update \ + && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends \ git ca-certificates wget \ && rm -rf /var/lib/apt/lists/* diff --git a/app/package-lock.json b/app/package-lock.json index 876651e4..c4a1d3a3 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -23,6 +23,7 @@ "@types/node": "^25.6.0", "@types/three": "^0.184.0", "@vitest/coverage-v8": "^4.1.7", + "axe-core": "^4.12.1", "canvas": "^3.2.3", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", @@ -2003,6 +2004,16 @@ "js-tokens": "^10.0.0" } }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/babel-plugin-transform-hook-names": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", diff --git a/app/package.json b/app/package.json index 13fe3651..c37f1b18 100644 --- a/app/package.json +++ b/app/package.json @@ -32,6 +32,7 @@ "@types/node": "^25.6.0", "@types/three": "^0.184.0", "@vitest/coverage-v8": "^4.1.7", + "axe-core": "^4.12.1", "canvas": "^3.2.3", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", diff --git a/app/src/components/BranchSelect/BranchSelect.tsx b/app/src/components/BranchSelect/BranchSelect.tsx index f0bddc79..3b62e8c2 100644 --- a/app/src/components/BranchSelect/BranchSelect.tsx +++ b/app/src/components/BranchSelect/BranchSelect.tsx @@ -73,17 +73,17 @@ export function BranchSelect({ url, value, onChange, onError }: BranchSelectProp return (
- + {state.status === BranchStatus.Loading && ( -
- +
+
)} {state.status === BranchStatus.Ready && ( onCommit((e.currentTarget as HTMLInputElement).value)} diff --git a/app/src/components/CopyButton/CopyButton.tsx b/app/src/components/CopyButton/CopyButton.tsx index fdeaac66..657788de 100644 --- a/app/src/components/CopyButton/CopyButton.tsx +++ b/app/src/components/CopyButton/CopyButton.tsx @@ -49,14 +49,20 @@ export function CopyButton({ }; return ( - + <> + + {/* The copied state is otherwise a color-only flash; announce it. */} + + {copied ? 'Copied' : ''} + + ); } diff --git a/app/src/components/Field.tsx b/app/src/components/Field.tsx index 2a9e4f32..3a6daf70 100644 --- a/app/src/components/Field.tsx +++ b/app/src/components/Field.tsx @@ -36,6 +36,7 @@ export function Field({ store, fieldKey }: FieldProps) { // catches — so in practice the hook always runs with a real field. const binding = useField(store, fieldKey); const descId = useId(); + const controlId = useId(); const describedBy = def?.tip ? descId : undefined; if (!def) { // Misconfigured schema (a (store, key) with no field def). The @@ -62,6 +63,7 @@ export function Field({ store, fieldKey }: FieldProps) { value={binding.value as string} onCommit={binding.onCommit} describedBy={describedBy} + id={controlId} /> ); break; @@ -74,6 +76,7 @@ export function Field({ store, fieldKey }: FieldProps) { step={def.step!} onCommit={binding.onCommit} describedBy={describedBy} + id={controlId} /> ); break; @@ -86,12 +89,18 @@ export function Field({ store, fieldKey }: FieldProps) { step={def.step!} onCommit={binding.onCommit} describedBy={describedBy} + id={controlId} /> ); break; case FieldKind.Toggle: control = ( - + ); break; case FieldKind.Select: @@ -101,6 +110,7 @@ export function Field({ store, fieldKey }: FieldProps) { options={def.options!} onCommit={binding.onCommit} describedBy={describedBy} + label={def.label} /> ); break; @@ -115,6 +125,8 @@ export function Field({ store, fieldKey }: FieldProps) { step={def.step!} onCommit={(l, h) => binding.onCommit([l, h] as never)} describedBy={describedBy} + id={controlId} + label={def.label} /> ); break; @@ -134,6 +146,9 @@ export function Field({ store, fieldKey }: FieldProps) { tip={def.tip} inline={inline} descId={def.tip ? descId : undefined} + // Select renders a button group (no single labelable field); it's named + // in its own control, so don't point the row label at a missing id. + htmlFor={def.kind === FieldKind.Select ? undefined : controlId} store={store} keys={[fieldKey]} > diff --git a/app/src/components/HueMapField/HueMapField.tsx b/app/src/components/HueMapField/HueMapField.tsx index 6e77661c..ee9647d7 100644 --- a/app/src/components/HueMapField/HueMapField.tsx +++ b/app/src/components/HueMapField/HueMapField.tsx @@ -29,31 +29,41 @@ export function HueMapField({ store, fieldKey }: FieldProps) { const disabled = value === defaultVal; const tip = `Hue (0 to 359 degrees) for files with this extension.`; const descId = `${baseId}-${k}`; + const controlId = `${baseId}-${k}-c`; return ( - + { + e.preventDefault(); + e.stopPropagation(); + commit({ ...map, [k]: defaultVal }); + }} + > + + + } + > commit({ ...map, [k]: v })} /> - ); })} diff --git a/app/src/components/NewProjectForm/NewProjectForm.tsx b/app/src/components/NewProjectForm/NewProjectForm.tsx index 50759440..bd4560cf 100644 --- a/app/src/components/NewProjectForm/NewProjectForm.tsx +++ b/app/src/components/NewProjectForm/NewProjectForm.tsx @@ -95,19 +95,24 @@ export function NewProjectForm({ allowLocalRepos, prefill, onSubmit }: NewProjec }} >
- + onSourceInput((e.target as HTMLInputElement).value)} /> - {fieldError &&

{fieldError}

} + {fieldError && ( + + )}
{showLocalNotice && ( diff --git a/app/src/components/NumberInput.tsx b/app/src/components/NumberInput.tsx index 4b3c1710..dbfc4256 100644 --- a/app/src/components/NumberInput.tsx +++ b/app/src/components/NumberInput.tsx @@ -9,13 +9,24 @@ export interface NumberInputProps { step: number; onCommit: (v: number) => void; describedBy?: string; + /** id so a row label can associate via htmlFor. */ + id?: string; } -export function NumberInput({ value, min, max, step, onCommit, describedBy }: NumberInputProps) { +export function NumberInput({ + value, + min, + max, + step, + onCommit, + describedBy, + id, +}: NumberInputProps) { return ( is the shared centered icon + headline + subtitle block used // for every "nothing selected / nothing to show" empty state. -import type { ComponentChildren, Ref } from 'preact'; +import type { ComponentChildren, JSX, Ref } from 'preact'; import type { LucideIcon } from 'lucide-preact'; import { PaneHeader } from './PaneHeader/PaneHeader'; @@ -40,11 +40,14 @@ export interface PaneProps { /** Ref to the `.pane-body` element — for panes that mount body content * imperatively (e.g. FilePreviewPane's highlight.js output). */ bodyRef?: Ref; + /** Extra attributes spread onto the `.pane-body` (e.g. tabpanel role/id for a + * tabbed pane whose body is the panel). Only applies when Pane owns the body. */ + bodyProps?: JSX.HTMLAttributes; /** Content rendered AFTER the body (e.g. ControlsPane's sticky action bar), * outside the scrolling body region. */ footerSlot?: ComponentChildren; /** Ref to the outer `.pane` element — for panes that need to query their own - * DOM (e.g. ControlsPane collapsing its `
` sections). */ + * DOM. */ paneRef?: Ref; children?: ComponentChildren; } @@ -62,6 +65,7 @@ export function Pane({ closeTitle, bodyClass, bodyRef, + bodyProps, footerSlot, paneRef, children, @@ -82,7 +86,11 @@ export function Pane({ /> )} {ownsBody ? ( -
+
{children}
) : ( diff --git a/app/src/components/PaneTabs/PaneTabs.css b/app/src/components/PaneTabs/PaneTabs.css index e600e623..cffb57dd 100644 --- a/app/src/components/PaneTabs/PaneTabs.css +++ b/app/src/components/PaneTabs/PaneTabs.css @@ -17,7 +17,10 @@ padding: var(--cc-space-3) var(--cc-space-5) var(--cc-space-4); /* side padding runs the underline wider than the label */ border: 0; border-bottom: 2px solid transparent; - margin-bottom: -1px; /* overlap the strip's baseline */ + /* No negative margin to overlap the divider: overflow-x:auto forces this strip + * into a scroll container, and a 1px vertical overflow makes focusing a tab + * scroll it 1px, shifting the underline off the divider permanently. The + * underline sits flush above the divider instead. */ background: transparent; color: var(--cc-text-secondary); /* AA: muted fails 4.5:1 on the modal (bg-sidebar) surface */ font: inherit; diff --git a/app/src/components/PaneTabs/PaneTabs.tsx b/app/src/components/PaneTabs/PaneTabs.tsx index 944f0dac..fce5cff1 100644 --- a/app/src/components/PaneTabs/PaneTabs.tsx +++ b/app/src/components/PaneTabs/PaneTabs.tsx @@ -17,12 +17,45 @@ export interface PaneTabsProps { onSelect: (id: string) => void; /** Extra class on the strip root for context-specific spacing (e.g. modal). */ class?: string; + /** id of the tabpanel these tabs control. Wires aria-controls on every tab + * and gives each tab a stable id (`${panelId}-tab-${tabId}`) so the panel + * can point its aria-labelledby back at the active tab. */ + panelId?: string; } -export function PaneTabs({ tabs, active, onSelect, class: className }: PaneTabsProps) { +export function PaneTabs({ tabs, active, onSelect, class: className, panelId }: PaneTabsProps) { + // Arrow keys move selection within the tablist (automatic activation, like a + // click), wrapping at the ends; Home/End jump to the first/last. Focus follows + // to the newly selected tab, which becomes the strip's single tab stop. + function onKeyDown(e: KeyboardEvent, idx: number) { + let next: number; + switch (e.key) { + case 'ArrowRight': + case 'ArrowDown': + next = (idx + 1) % tabs.length; + break; + case 'ArrowLeft': + case 'ArrowUp': + next = (idx - 1 + tabs.length) % tabs.length; + break; + case 'Home': + next = 0; + break; + case 'End': + next = tabs.length - 1; + break; + default: + return; + } + e.preventDefault(); + onSelect(tabs[next].id); + const strip = (e.currentTarget as HTMLElement).parentElement; + (strip?.children[next] as HTMLElement | undefined)?.focus(); + } + return (
- {tabs.map((t) => { + {tabs.map((t, idx) => { const Icon = t.icon; const selected = t.id === active; return ( @@ -30,9 +63,13 @@ export function PaneTabs({ tabs, active, onSelect, class: className }: PaneTabsP key={t.id} type="button" role="tab" + id={panelId ? `${panelId}-tab-${t.id}` : undefined} aria-selected={selected ? 'true' : 'false'} + aria-controls={panelId} + tabIndex={selected ? 0 : -1} class={selected ? 'pane-tab pane-tab--active focus-inset' : 'pane-tab focus-inset'} onClick={() => onSelect(t.id)} + onKeyDown={(e) => onKeyDown(e, idx)} > {Icon &&
+
+
+ {hint &&
{hint}
} + {children} +
+
); } diff --git a/app/src/components/SegmentedSelect/SegmentedSelect.tsx b/app/src/components/SegmentedSelect/SegmentedSelect.tsx index f595ae26..11017fd7 100644 --- a/app/src/components/SegmentedSelect/SegmentedSelect.tsx +++ b/app/src/components/SegmentedSelect/SegmentedSelect.tsx @@ -14,24 +14,68 @@ export interface SegmentedSelectProps { options: SegmentedSelectOption[]; onCommit: (v: string) => void; describedBy?: string; + /** Names the radiogroup (the field label). */ + label?: string; } -export function SegmentedSelect({ value, options, onCommit, describedBy }: SegmentedSelectProps) { +export function SegmentedSelect({ + value, + options, + onCommit, + describedBy, + label, +}: SegmentedSelectProps) { + // A single-select radiogroup: arrow keys move + select (automatic activation), + // wrapping at the ends; Home/End jump. Focus follows to the checked option, + // which is the group's single tab stop (roving tabindex). + function onKeyDown(e: KeyboardEvent, idx: number) { + let next: number; + switch (e.key) { + case 'ArrowRight': + case 'ArrowDown': + next = (idx + 1) % options.length; + break; + case 'ArrowLeft': + case 'ArrowUp': + next = (idx - 1 + options.length) % options.length; + break; + case 'Home': + next = 0; + break; + case 'End': + next = options.length - 1; + break; + default: + return; + } + e.preventDefault(); + onCommit(options[next].value); + const group = (e.currentTarget as HTMLElement).parentElement; + (group?.children[next] as HTMLElement | undefined)?.focus(); + } + return ( - - {options.map((opt) => ( - - ))} + + {options.map((opt, idx) => { + const checked = opt.value === value; + return ( + + ); + })} ); } diff --git a/app/src/components/SelectionAnnouncer/SelectionAnnouncer.tsx b/app/src/components/SelectionAnnouncer/SelectionAnnouncer.tsx new file mode 100644 index 00000000..038e0ed5 --- /dev/null +++ b/app/src/components/SelectionAnnouncer/SelectionAnnouncer.tsx @@ -0,0 +1,39 @@ +// components/SelectionAnnouncer/SelectionAnnouncer.tsx — a visually-hidden live +// region that speaks the current city selection to screen readers. The canvas +// is a graphic and can't announce its own selection, so App mounts one of these +// and it mirrors the picker: whenever the selected building/street/tree/gem +// changes, the new selection is announced (WCAG 4.1.3 Status Messages). + +import { useComputed } from '@preact/signals'; +import { SCENE_HANDLE } from '@/state/stores/scene'; +import { NodeKind } from '@/types'; +import type { PickTarget } from '@/types'; + +function describe(sel: PickTarget | null): string { + if (!sel) return ''; + switch (sel.kind) { + case NodeKind.File: + return `Selected file: ${sel.file.path ?? sel.file.name}`; + case NodeKind.Directory: + return `Selected directory: ${sel.dir.path ?? sel.dir.name}`; + case NodeKind.Commit: + return `Selected commit ${sel.commit.sha.slice(0, 7)}, ${sel.commit.subject}`; + case NodeKind.Gem: + return 'Selected the repository'; + } +} + +export function SelectionAnnouncer() { + // Recomputes on every SCENE_HANDLE swap and picker-selection change. An equal + // string is deduped by the computed, so re-resolving the same selection across + // a rebuild doesn't re-announce. + const message = useComputed(() => { + const handle = SCENE_HANDLE.value; + return handle ? describe(handle.picker.selection.value) : ''; + }); + return ( +
+ {message.value} +
+ ); +} diff --git a/app/src/components/Sidebar/Sidebar.tsx b/app/src/components/Sidebar/Sidebar.tsx index 72255499..d8d3b975 100644 --- a/app/src/components/Sidebar/Sidebar.tsx +++ b/app/src/components/Sidebar/Sidebar.tsx @@ -84,6 +84,9 @@ export interface SidebarProps { /** DOM id — drives CSS (e.g. #left-sidebar). */ id: string; side: SidebarSide; + /** Accessible name for the complementary landmark, so the two