Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e2b763566 | ||
|
|
757ac7394d | ||
|
|
e3d8e01e5b | ||
|
|
4b348dcbc0 | ||
|
|
d82db48e4b | ||
|
|
20bf7ee03d | ||
|
|
bfc5d9a871 |
@@ -1,5 +1,12 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
|
||||
// Tabbable controls inside the dialog, in DOM order. type="hidden" inputs are
|
||||
// excluded — they'd match `input:not([disabled])` and, sitting at a boundary,
|
||||
// break the wrap math. Hoisted out of the handler so it isn't rebuilt per Tab.
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), ' +
|
||||
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
/**
|
||||
* A centered modal dialog over a dimmed backdrop. Closes on Escape or a backdrop
|
||||
* click, unless `busy` (a mutation is in flight) — then it stays put so the
|
||||
@@ -27,12 +34,57 @@ export function Modal({
|
||||
busyRef.current = busy
|
||||
|
||||
useEffect(() => {
|
||||
cardRef.current?.focus()
|
||||
const card = cardRef.current
|
||||
// Remember who opened the dialog so focus can return there on close —
|
||||
// otherwise it lands on <body> and a keyboard user loses their place.
|
||||
const opener = document.activeElement as HTMLElement | null
|
||||
card?.focus()
|
||||
|
||||
const focusable = () =>
|
||||
Array.from(card?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [])
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && !busyRef.current) onCloseRef.current()
|
||||
if (e.key === 'Escape' && !busyRef.current) {
|
||||
onCloseRef.current()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const items = focusable()
|
||||
if (items.length === 0) {
|
||||
e.preventDefault()
|
||||
card?.focus()
|
||||
return
|
||||
}
|
||||
const first = items[0]
|
||||
const last = items[items.length - 1]
|
||||
const active = document.activeElement
|
||||
// If focus is NOT inside the dialog, pull it back in rather than let Tab
|
||||
// escape. This is the robust case that covers focus having fallen to
|
||||
// <body> — a control that was removed (ShareGardenModal's remove-share
|
||||
// button) or disabled while busy — as well as any externally-stolen focus.
|
||||
if (!card || !card.contains(active)) {
|
||||
e.preventDefault()
|
||||
;(e.shiftKey ? last : first).focus()
|
||||
return
|
||||
}
|
||||
if (e.shiftKey && (active === first || active === card)) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
// Restore focus to the opener only if it's still in the document — the
|
||||
// delete/clear flows this trap targets often remove the element that
|
||||
// opened the dialog (a garden card, a plop row). A disconnected node's
|
||||
// focus() silently no-ops and leaves focus on <body>, so fall through to
|
||||
// that case explicitly rather than pretend it worked.
|
||||
if (opener && opener.isConnected) opener.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,9 +17,16 @@ interface ToastState {
|
||||
|
||||
let nextId = 1
|
||||
|
||||
// Error toasts no longer auto-dismiss (#85), so a burst of failures could grow
|
||||
// the stack without bound and push older ones off-screen. Cap it: keep the most
|
||||
// recent MAX_TOASTS and drop the oldest, so the newest — the one that just
|
||||
// happened — is always visible.
|
||||
const MAX_TOASTS = 4
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
push: (message, tone = 'info') => set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }] })),
|
||||
push: (message, tone = 'info') =>
|
||||
set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }].slice(-MAX_TOASTS) })),
|
||||
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||
}))
|
||||
|
||||
@@ -32,21 +39,34 @@ export const toast = {
|
||||
// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export.
|
||||
function ToastItem({ item }: { item: Toast }) {
|
||||
const dismiss = useToastStore((s) => s.dismiss)
|
||||
const isError = item.tone === 'error'
|
||||
useEffect(() => {
|
||||
// Error toasts are the primary report that a mutation failed, so they do NOT
|
||||
// auto-dismiss — a user who looked away at second 4 would otherwise lose the
|
||||
// only notice, with nothing to retrieve (#85). Info toasts still time out.
|
||||
if (isError) return
|
||||
const t = setTimeout(() => dismiss(item.id), 4000)
|
||||
return () => clearTimeout(t)
|
||||
}, [item.id, dismiss])
|
||||
}, [item.id, dismiss, isError])
|
||||
return (
|
||||
<div
|
||||
role={item.tone === 'error' ? 'alert' : 'status'}
|
||||
role={isError ? 'alert' : 'status'}
|
||||
className={cn(
|
||||
'pointer-events-auto rounded-md border px-3 py-2 text-sm shadow-md',
|
||||
item.tone === 'error'
|
||||
'pointer-events-auto flex items-start gap-2 rounded-md border px-3 py-2 text-sm shadow-md',
|
||||
isError
|
||||
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||
: 'border-border bg-surface text-fg',
|
||||
)}
|
||||
>
|
||||
{item.message}
|
||||
<span className="flex-1">{item.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(item.id)}
|
||||
aria-label="Dismiss"
|
||||
className="-mr-1 shrink-0 rounded px-1 text-current opacity-60 outline-none hover:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -235,7 +235,13 @@ export function GardenCanvas({
|
||||
className="h-full w-full select-none"
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerDown={onCanvasPointerDown}
|
||||
// role="application" tells a screen reader this is an interactive canvas
|
||||
// to operate, not a document to read linearly. The <title> names it, and
|
||||
// objects inside are individually focusable buttons (see ObjectShape).
|
||||
role="application"
|
||||
aria-label={`${garden.name} — garden layout. Tab between objects; Enter selects; arrow keys nudge a selection.`}
|
||||
>
|
||||
<title>{garden.name} garden layout</title>
|
||||
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
|
||||
{drawnGridCm != null && (
|
||||
<>
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
import type { EditorObject } from './types'
|
||||
import { objectDisplayName } from './kinds'
|
||||
|
||||
// Shared styling for the small From/To date inputs, so the two stay in step and
|
||||
// don't drift from each other.
|
||||
const dateInputClass =
|
||||
'rounded-md border border-border bg-surface px-1.5 py-1 text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40'
|
||||
|
||||
/**
|
||||
* The garden's journal: write an entry, read the season back.
|
||||
*
|
||||
@@ -42,7 +47,15 @@ export function JournalPanel({
|
||||
scopeObjectId: number | null
|
||||
onScopeChange: (id: number | null) => void
|
||||
}) {
|
||||
const filter = scopeObjectId != null ? { objectId: scopeObjectId } : {}
|
||||
// Date-range narrowing (#85): the backend and JournalFilter already supported
|
||||
// from/to; they just had no UI. Empty inputs don't filter.
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
const filter = {
|
||||
...(scopeObjectId != null ? { objectId: scopeObjectId } : {}),
|
||||
...(from ? { from } : {}),
|
||||
...(to ? { to } : {}),
|
||||
}
|
||||
const journal = useJournal(gardenId, filter)
|
||||
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
|
||||
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
|
||||
@@ -70,6 +83,41 @@ export function JournalPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<label className="flex items-center gap-1">
|
||||
<span>From</span>
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
max={to || undefined}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
className={dateInputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<span>To</span>
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
min={from || undefined}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className={dateInputClass}
|
||||
/>
|
||||
</label>
|
||||
{(from || to) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFrom('')
|
||||
setTo('')
|
||||
}}
|
||||
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<Composer
|
||||
gardenId={gardenId}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, type PointerEvent } from 'react'
|
||||
import { memo, type KeyboardEvent, type PointerEvent } from 'react'
|
||||
import { objectTransform } from './shared'
|
||||
import { kindDef, objectDisplayName } from './kinds'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
const DEFAULT_FILL = '#8a8a8a'
|
||||
@@ -57,11 +58,50 @@ export const ObjectShape = memo(function ObjectShape({
|
||||
onSelect(object.id)
|
||||
}
|
||||
|
||||
// Keyboard path into selection (#84): the arrow-key nudge handler already
|
||||
// exists but only ever acted on a pointer selection, so it was unreachable
|
||||
// without a mouse. Enter/Space on a focused object selects it, which is the
|
||||
// step that was missing.
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onSelect(object.id)
|
||||
}
|
||||
}
|
||||
|
||||
const stroke = selected ? '#2f7a3e' : '#00000033'
|
||||
const strokeWidth = selected ? 2 : 1
|
||||
|
||||
// A concise accessible name: the object's label plus its kind's canonical
|
||||
// label, e.g. "North Bed, In-ground" — reusing kindDef so it never diverges
|
||||
// from what the UI shows (an ad-hoc kind.replace() gave "in ground"). The
|
||||
// dimensions aren't included; they need the garden's unit context this
|
||||
// component doesn't hold, so they're a follow-up.
|
||||
const kindLabel = kindDef(object.kind)?.label ?? object.kind
|
||||
const label = `${objectDisplayName(object)}, ${kindLabel}`
|
||||
|
||||
// Keyboard focus needs to be VISIBLE — that's the point of making the canvas
|
||||
// keyboard-reachable. The `object-shape` class carries a :focus-visible rule
|
||||
// (styles/index.css) that draws a dashed ring; :focus-visible means it shows
|
||||
// for keyboard focus but NOT a mouse click, which is exactly what we want. CSS
|
||||
// rather than React state because onFocus on an SVG <g> is unreliable and a
|
||||
// presentation attribute is overridden by any CSS rule.
|
||||
return (
|
||||
<g transform={objectTransform(object)} onPointerDown={handleDown} style={{ cursor: 'pointer' }}>
|
||||
<g
|
||||
className="object-shape"
|
||||
transform={objectTransform(object)}
|
||||
onPointerDown={handleDown}
|
||||
onKeyDown={handleKey}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
// aria-current, not aria-pressed: selecting an object isn't a toggle (a
|
||||
// toggle is what aria-pressed means). aria-current marks it as the active
|
||||
// item among the objects. Omitted, not "false", when unselected.
|
||||
aria-current={selected || undefined}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{object.shape === 'circle' ? (
|
||||
<ellipse
|
||||
cx={0}
|
||||
|
||||
@@ -160,6 +160,15 @@ export async function streamChat(
|
||||
handlers.onError('Could not reach the server.')
|
||||
return
|
||||
}
|
||||
if (res.status === 401) {
|
||||
// Session expired mid-conversation (#85). Reporting this as "the assistant is
|
||||
// unavailable" would send the user chasing a config problem that isn't there.
|
||||
// Send them to sign in again, preserving where they were.
|
||||
handlers.onError('Your session has expired — please sign in again.')
|
||||
const back = encodeURIComponent(location.pathname + location.search)
|
||||
window.location.assign(`/login?redirect=${back}`)
|
||||
return
|
||||
}
|
||||
if (!res.ok || !res.body) {
|
||||
// 503 is the assistant being turned off at runtime (#79) — the route exists,
|
||||
// there's just no model behind it. Distinct from a 404, which would mean the
|
||||
|
||||
@@ -398,8 +398,11 @@ export function GardenEditorPage() {
|
||||
})
|
||||
}
|
||||
|
||||
// 100dvh, not 100vh: on mobile Safari/Chrome 100vh is the *largest* viewport
|
||||
// (URL bar hidden), so with the bar showing the editor overflowed and pushed
|
||||
// the canvas bottom + Fit button under the browser chrome (#85).
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
|
||||
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 md:flex-row">
|
||||
<div className="shrink-0 md:w-40">
|
||||
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
||||
{garden.name}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function PublicGardenPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3">
|
||||
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
||||
{garden.name}
|
||||
|
||||
@@ -28,6 +28,19 @@
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Keyboard focus on a canvas object (#84). :focus-visible shows the ring for
|
||||
keyboard focus but not a mouse click; the dashed accent ring distinguishes
|
||||
"focused" from the solid ring that marks "selected". A CSS rule overrides
|
||||
the shape's inline stroke presentation attributes. */
|
||||
.object-shape {
|
||||
outline: none;
|
||||
}
|
||||
.object-shape:focus-visible :is(rect, ellipse) {
|
||||
stroke: var(--color-accent-strong);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark theme: override the same tokens so utilities recolor automatically. */
|
||||
|
||||
Reference in New Issue
Block a user