Files
workspace/docs/2026-05-14-task-form-dialog-kbd-plan.md
Roberto 72d7cc2f6e docs: add task form dialog keyboard polish implementation plan
Step-by-step plan to port AddEventDialog UX (header, full keyboard nav,
date+time via DateField) into TaskFormDialog. Two new shared hooks
(useRovingFocus, useListboxKeys), parseDate time-suffix, DateField
withTime + flat props, and i18n updates across all 5 languages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 10:51:57 +02:00

40 KiB
Raw Permalink Blame History

Task Form Dialog — keyboard + header polish — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Port AddEventDialog's header style + full keyboard navigation + date-time keyboard entry into TaskFormDialog, reusing existing DateField and adding two small shared hooks.

Architecture: Two new generic hooks (useRovingFocus, useListboxKeys) cover keyboard plumbing for any pill row + list popover. parseDate gains an optional trailing HH:MM. DateField gains withTime + flat props so it can be embedded inside another Popover without nesting. TaskFormDialog consumes all of these and drops its bespoke Due Popover + the footer ⌘+Enter hint.

Tech Stack: React 19, TypeScript, shadcn/ui (Popover, Calendar, Select), react-day-picker, react-i18next, TZDate from react-day-picker (timezone-correct date construction), Drizzle ORM (unchanged), Electron.

Spec: docs/2026-05-14-task-form-dialog-kbd-design.md Mockup: docs/mockups/2026-05-14-task-form-dialog-mockup.html

No test suite in the project (adiuvAI/CLAUDE.md: "No test suite currently."). Verification per task is npm run lint + manual smoke. Plan does not include TDD steps; if pytest/vitest is introduced later, regression cases for parseDate belong in docs/2026-05-14-task-form-dialog-kbd-design.md § 3 table.

Commit cadence: one commit per task. Each commit message follows the existing Conventional Commits style visible in git log (feat:, refactor:, docs:, …) and includes the Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> trailer.


File Structure

File Op Responsibility
adiuvAI/src/renderer/hooks/useRovingFocus.ts Create Roving-tabindex hook for any horizontally-arranged group of focusable items (pills, tabs).
adiuvAI/src/renderer/hooks/useListboxKeys.ts Create Listbox keyboard handler (↑/↓/Home/End/Enter/Esc/Tab) shared by all single- and multi-select popovers.
adiuvAI/src/renderer/lib/parseDate.ts Modify Add optional trailing HH:MM parsing.
adiuvAI/src/renderer/components/ui/date-field.tsx Modify Add withTime?: boolean (renders HH/MM Selects in popover) and flat?: boolean (skips the internal Popover, renders Input + Calendar + Time row inline).
adiuvAI/src/renderer/components/tasks/TaskFormDialog.tsx Modify New header (Title + Description, no border); pills row uses useRovingFocus; each list popover uses useListboxKeys; Due popover content is <DateField withTime flat />; remove ⌘+Enter footer hint. Pill triggers become real <button> elements (not <span>).
adiuvAI/src/renderer/components/tasks/PropertyPill.tsx Modify Change rendered element from <span> to <button type="button">; add focus-visible ring; accept tabIndex + standard button refs/handlers.
adiuvAI/src/renderer/locales/en/translation.json Modify Add tasks.newTaskDescription, tasks.editTaskDescription.
adiuvAI/src/renderer/locales/it/translation.json Modify Same keys, IT.
adiuvAI/src/renderer/locales/es/translation.json Modify Same keys, ES.
adiuvAI/src/renderer/locales/fr/translation.json Modify Same keys, FR.
adiuvAI/src/renderer/locales/de/translation.json Modify Same keys, DE.

The adiuvAI directory is a git submodule. All commits below are inside that submodule; the outer monorepo gets a bump adiuvAI submodule commit at the end.


Task 1 — Hook: useRovingFocus

Files:

  • Create: adiuvAI/src/renderer/hooks/useRovingFocus.ts

Why: Pills row needs single tab stop + ←/→/↑/↓ navigation between items. Generic, reusable.

  • Step 1.1 — Create the hook file
// adiuvAI/src/renderer/hooks/useRovingFocus.ts
import { useCallback, useRef, useState } from 'react';

export type RovingDirection = 'horizontal' | 'vertical' | 'both';

export interface UseRovingFocusOptions {
  count: number;
  direction?: RovingDirection;
  initialIndex?: number;
  loop?: boolean;
}

export interface RovingItemProps {
  tabIndex: number;
  onKeyDown: (e: React.KeyboardEvent) => void;
  onFocus: () => void;
  ref: (el: HTMLElement | null) => void;
}

/**
 * Roving tabindex helper. Group exposes a single tab stop; arrow keys
 * move focus between items. Index state is internal — call `getItemProps(i)`
 * for each item to wire it up.
 */
export function useRovingFocus({
  count,
  direction = 'both',
  initialIndex = 0,
  loop = false,
}: UseRovingFocusOptions) {
  const [active, setActive] = useState<number>(initialIndex);
  const refs = useRef<Array<HTMLElement | null>>([]);

  const move = useCallback(
    (next: number) => {
      let target = next;
      if (loop) {
        target = ((next % count) + count) % count;
      } else {
        target = Math.max(0, Math.min(count - 1, next));
      }
      setActive(target);
      const el = refs.current[target];
      if (el) el.focus();
    },
    [count, loop],
  );

  const getItemProps = useCallback(
    (index: number): RovingItemProps => ({
      tabIndex: index === active ? 0 : -1,
      ref: (el) => {
        refs.current[index] = el;
      },
      onFocus: () => setActive(index),
      onKeyDown: (e: React.KeyboardEvent) => {
        const horizontal = direction === 'horizontal' || direction === 'both';
        const vertical = direction === 'vertical' || direction === 'both';
        if (horizontal && e.key === 'ArrowRight') {
          e.preventDefault();
          move(index + 1);
        } else if (horizontal && e.key === 'ArrowLeft') {
          e.preventDefault();
          move(index - 1);
        } else if (vertical && e.key === 'ArrowDown') {
          e.preventDefault();
          move(index + 1);
        } else if (vertical && e.key === 'ArrowUp') {
          e.preventDefault();
          move(index - 1);
        } else if (e.key === 'Home') {
          e.preventDefault();
          move(0);
        } else if (e.key === 'End') {
          e.preventDefault();
          move(count - 1);
        }
      },
    }),
    [active, count, direction, move],
  );

  return { activeIndex: active, setActive, getItemProps };
}
  • Step 1.2 — Lint

Run inside adiuvAI/:

npm run lint

Expected: no new warnings/errors.

  • Step 1.3 — Commit
git -C adiuvAI add src/renderer/hooks/useRovingFocus.ts
git -C adiuvAI commit -m "feat: add useRovingFocus hook for roving-tabindex groups"

(Include the standard Co-Authored-By trailer via heredoc as in CLAUDE.md.)


Task 2 — Hook: useListboxKeys

Files:

  • Create: adiuvAI/src/renderer/hooks/useListboxKeys.ts

Why: Project / Priority / Status / Assignees popovers all need the same arrow-nav + Enter-to-select keyboard model. Multi-select for Assignees differs only in selection callback.

  • Step 2.1 — Create the hook
// adiuvAI/src/renderer/hooks/useListboxKeys.ts
import { useCallback, useEffect, useRef, useState } from 'react';

export interface UseListboxKeysOptions {
  itemCount: number;
  initialIndex?: number;
  onSelect: (index: number) => void;
  onClose: () => void;
  /**
   * If true, focus does NOT move out of the listbox on Tab; the caller is
   * expected to also call onClose so the parent can advance focus.
   */
  closeOnTab?: boolean;
}

export interface ListboxItemProps {
  tabIndex: number;
  ref: (el: HTMLElement | null) => void;
  onKeyDown: (e: React.KeyboardEvent) => void;
  onMouseEnter: () => void;
  'aria-selected': boolean;
}

/**
 * Listbox keyboard model used inside popovers: ↑/↓ move active item,
 * Home/End jump to ends, Enter/Space selects, Esc closes, Tab closes
 * (so the parent's tab order resumes from the trigger).
 */
export function useListboxKeys({
  itemCount,
  initialIndex = 0,
  onSelect,
  onClose,
  closeOnTab = true,
}: UseListboxKeysOptions) {
  const [active, setActive] = useState<number>(initialIndex);
  const refs = useRef<Array<HTMLElement | null>>([]);

  // Reset active when the item set changes size.
  useEffect(() => {
    if (active >= itemCount) setActive(Math.max(0, itemCount - 1));
  }, [itemCount, active]);

  const focusIndex = useCallback((i: number) => {
    setActive(i);
    const el = refs.current[i];
    if (el) el.focus();
  }, []);

  const getItemProps = useCallback(
    (index: number): ListboxItemProps => ({
      tabIndex: index === active ? 0 : -1,
      ref: (el) => {
        refs.current[index] = el;
      },
      'aria-selected': index === active,
      onMouseEnter: () => setActive(index),
      onKeyDown: (e: React.KeyboardEvent) => {
        if (e.key === 'ArrowDown') {
          e.preventDefault();
          e.stopPropagation();
          focusIndex(Math.min(index + 1, itemCount - 1));
        } else if (e.key === 'ArrowUp') {
          e.preventDefault();
          e.stopPropagation();
          focusIndex(Math.max(index - 1, 0));
        } else if (e.key === 'Home') {
          e.preventDefault();
          focusIndex(0);
        } else if (e.key === 'End') {
          e.preventDefault();
          focusIndex(itemCount - 1);
        } else if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          e.stopPropagation();
          onSelect(index);
        } else if (e.key === 'Escape') {
          e.preventDefault();
          e.stopPropagation();
          onClose();
        } else if (e.key === 'Tab' && closeOnTab) {
          // Let the Tab proceed; but close so popover unmounts.
          onClose();
        }
      },
    }),
    [active, itemCount, focusIndex, onSelect, onClose, closeOnTab],
  );

  return { activeIndex: active, setActive, focusIndex, getItemProps };
}
  • Step 2.2 — Lint
npm run lint

Expected: clean.

  • Step 2.3 — Commit
git -C adiuvAI add src/renderer/hooks/useListboxKeys.ts
git -C adiuvAI commit -m "feat: add useListboxKeys hook for popover list keyboard"

Task 3 — parseDate: accept optional HH:MM suffix

Files:

  • Modify: adiuvAI/src/renderer/lib/parseDate.ts

Why: DateField will route both date and time through a single typeable input. Parser must accept e.g. 30/04/2026 14:30 and tomorrow 08:15.

Read the current file first so the diff is correct (it lives at lines 180+; the public entry is the parseDate function near the bottom — confirm before editing).

  • Step 3.1 — Add regex constant

In parseDate.ts, add near the top with the other regex constants:

const RE_TIME_SUFFIX = /\s+(\d{1,2}):(\d{2})\s*$/;
  • Step 3.2 — Strip + apply time inside parseDate

Locate the export function parseDate(input, prefs, keywords): Date | null (current entrypoint that delegates to parseRel, parseKeyword, parseNumeric). Replace its body so it first peels off the time suffix, parses the remaining string, then applies hours/minutes to the result:

export function parseDate(
  input: ParseInput,
  prefs: FormatPrefs,
  keywords: DateKeywords,
  base: Date = new Date(),
): Date | null {
  if (!input) return null;
  const raw = input.trim();
  if (!raw) return null;

  let datePart = raw;
  let hours: number | null = null;
  let minutes: number | null = null;
  const tm = raw.match(RE_TIME_SUFFIX);
  if (tm) {
    const h = parseInt(tm[1], 10);
    const m = parseInt(tm[2], 10);
    if (h < 0 || h > 23 || m < 0 || m > 59) return null;
    hours = h;
    minutes = m;
    datePart = raw.slice(0, raw.length - tm[0].length).trim();
    if (!datePart) return null;
  }

  // Existing resolution order — keep identical to the current implementation.
  const fromRel = parseRel(datePart, base);
  const fromKw = fromRel ?? parseKeyword(datePart, keywords, base);
  const fromNum = fromKw ?? parseNumeric(datePart, prefs, base);
  const result = fromNum;
  if (!result) return null;

  if (hours != null && minutes != null) {
    result.setHours(hours, minutes, 0, 0);
  }
  return result;
}

If the current source uses different helper names (e.g. parseRelative instead of parseRel), keep the existing names — only the time-suffix scaffolding is new. The Date returned by the date-only branch already represents midnight local; the setHours call only fires when a time suffix is present, so existing callers (timeline DateField without withTime) keep their current behavior.

  • Step 3.3 — Lint
npm run lint

Expected: clean.

  • Step 3.4 — Manual smoke (no automated tests in repo)

Open adiuvAI/src/renderer/lib/parseDate.ts and re-read the function to confirm: 14:30 suffix without leading date → returns null (because datePart is empty after slicing). 25:00 → null. Bare date still works.

  • Step 3.5 — Commit
git -C adiuvAI add src/renderer/lib/parseDate.ts
git -C adiuvAI commit -m "feat(parseDate): accept optional ' HH:MM' suffix"

Task 4 — DateField: add withTime and flat props

Files:

  • Modify: adiuvAI/src/renderer/components/ui/date-field.tsx

Why: Reuse the existing typeable DateField (which already drives via parseDate + FormatPrefs). Add (a) time Selects in the popover and (b) a flat mode that drops the internal Popover so the caller can embed it inside its own popover without nesting.

Re-read the file first — the full source is short (~165 lines).

  • Step 4.1 — Extend DateFieldProps
export type DateFieldProps = {
  value: Date | undefined;
  onChange: (d: Date | undefined) => void;
  onCommit?: (d: Date) => void;
  placeholder?: string;
  minDate?: Date;
  autoFocus?: boolean;
  invalidMessage?: string;
  className?: string;
  'aria-label'?: string;
  id?: string;
  /** Show hour/minute selects under the calendar; parseDate accepts ` HH:MM` suffix. */
  withTime?: boolean;
  /** Render Input + Calendar (+ Time) inline without an internal Popover. */
  flat?: boolean;
};
  • Step 4.2 — Update display text when value changes

The current effect formats the date via formatDate(value.getTime(), prefs). When withTime is on, append HH:MM if the time component is non-zero:

useEffect(() => {
  if (!focused) {
    setText(value ? formatValue(value, prefs, withTime) : '');
    setInvalid(false);
  }
}, [value, focused, prefs, withTime]);

Add a local helper inside the file (or import from lib/date.ts if you'd rather extend it — but keeping it local avoids touching unrelated callers):

function formatValue(d: Date, prefs: FormatPrefs, withTime: boolean): string {
  const base = formatDate(d.getTime(), prefs);
  if (!withTime) return base;
  const h = String(d.getHours()).padStart(2, '0');
  const m = String(d.getMinutes()).padStart(2, '0');
  if (h === '00' && m === '00') return base;
  return `${base} ${h}:${m}`;
}

(FormatPrefs is already imported as useFormatPrefs; add import type { FormatPrefs } from '@/lib/date'; if needed.)

  • Step 4.3 — Add Time row inside the calendar popover

The current <PopoverContent> body is just <Calendar mode="single" ... />. Wrap it so a Time row appears below when withTime is set. Same pattern as TaskFormDialog's current Hour/Minute Selects (HOURS = 24, MINUTES = 5-minute steps).

Add at the top of the file:

import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';

const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];

Extract the calendar+time block into a small inline component used by both flat and popover modes:

function CalendarTimeBody({
  value,
  onChange,
  onCommit,
  prefs,
  withTime,
  minDate,
  onAfterPick,
}: {
  value: Date | undefined;
  onChange: (d: Date | undefined) => void;
  onCommit?: (d: Date) => void;
  prefs: FormatPrefs;
  withTime: boolean;
  minDate?: Date;
  onAfterPick: () => void;
}) {
  const dueHour = value ? String(value.getHours()).padStart(2, '0') : '';
  const dueMinute = value ? String(value.getMinutes()).padStart(2, '0') : '';

  function applyTime(h: string, m: string) {
    if (!value) return;
    const next = new Date(value);
    next.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
    onChange(next);
    onCommit?.(next);
  }

  return (
    <>
      <Calendar
        mode="single"
        selected={value}
        onSelect={(d) => {
          if (d) {
            // Preserve existing time when re-picking a date.
            const next = new Date(d);
            if (value && withTime) {
              next.setHours(value.getHours(), value.getMinutes(), 0, 0);
            }
            onChange(next);
            onCommit?.(next);
          }
          onAfterPick();
        }}
        disabled={minDate ? { before: minDate } : undefined}
      />
      {withTime && (
        <div className="border-t px-3 py-2 flex items-center gap-1.5">
          <Select value={dueHour} onValueChange={(h) => applyTime(h, dueMinute || '00')} disabled={!value}>
            <SelectTrigger className="h-8 w-20 text-sm"><SelectValue placeholder="HH" /></SelectTrigger>
            <SelectContent>
              {HOURS.map((h) => <SelectItem key={h} value={h}>{h}</SelectItem>)}
            </SelectContent>
          </Select>
          <span className="text-muted-foreground text-sm">:</span>
          <Select value={dueMinute && MINUTES.includes(dueMinute) ? dueMinute : ''} onValueChange={(m) => applyTime(dueHour || '00', m)} disabled={!value}>
            <SelectTrigger className="h-8 w-20 text-sm"><SelectValue placeholder="MM" /></SelectTrigger>
            <SelectContent>
              {MINUTES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
            </SelectContent>
          </Select>
        </div>
      )}
    </>
  );
}
  • Step 4.4 — Use CalendarTimeBody in both modes

Inside the DateField JSX:

  • When flat, render <Input ... /> <div className="mt-2 rounded-md border"><CalendarTimeBody ... onAfterPick={() => inputRef.current?.focus()} /></div>. Do not render the Popover wrapper or the trailing CalendarIcon button.

  • When not flat (existing behavior), keep the Popover wrapper and replace the inner <Calendar … /> with <CalendarTimeBody ... onAfterPick={() => { setOpen(false); inputRef.current?.focus(); }} />.

  • Step 4.5 — Lint

npm run lint

Expected: clean.

  • Step 4.6 — Smoke

Run dev (npm start), open Timeline → "Add event" → confirm DateField still works as before (no withTime, no flat). Spot-check: typing tomorrow, clicking calendar day. Visual must match prior behavior.

  • Step 4.7 — Commit
git -C adiuvAI add src/renderer/components/ui/date-field.tsx
git -C adiuvAI commit -m "feat(date-field): add withTime + flat props"

Task 5 — PropertyPill: render as real <button>

Files:

  • Modify: adiuvAI/src/renderer/components/tasks/PropertyPill.tsx

Why: Roving focus + Enter-to-open requires a real focusable element. The current <span>-based pill can't receive keyboard focus reliably.

Read the file first; it is small. Replace the rendered element with a <button type="button"> and forward refs/handlers:

  • Step 5.1 — Update component signature and rendering
// adiuvAI/src/renderer/components/tasks/PropertyPill.tsx
import { forwardRef } from 'react';
import { cn } from '@/lib/utils';

export interface PropertyPillProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
  icon: React.ReactNode;
  label: string;
  value?: string | null;
  empty?: boolean;
}

export const PropertyPill = forwardRef<HTMLButtonElement, PropertyPillProps>(
  ({ icon, label, value, empty, className, ...rest }, ref) => (
    <button
      ref={ref}
      type="button"
      data-empty={empty ? 'true' : undefined}
      className={cn(
        'inline-flex items-center gap-1.5 h-7 px-2.5 rounded-full text-xs',
        'border bg-card/70 text-foreground transition-colors',
        'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:border-ring',
        empty
          ? 'border-dashed text-muted-foreground bg-transparent'
          : 'border-border',
        className,
      )}
      {...rest}
    >
      <span className="shrink-0">{icon}</span>
      <span className="text-muted-foreground">{label}</span>
      {value != null && !empty && (
        <>
          <span className="text-muted-foreground/50">·</span>
          <span className="font-medium">{value}</span>
        </>
      )}
    </button>
  ),
);
PropertyPill.displayName = 'PropertyPill';
  • Step 5.2 — Update callers that wrap PropertyPill in <span>

TaskFormDialog.tsx currently wraps each <PropertyPill ... /> inside <span> so PopoverTrigger asChild can attach a ref to a single element. Now that PropertyPill is itself a <button> with a forwarded ref, the <span> wrapper must go (PopoverTrigger asChild will inject its props/ref directly into the pill button). This is part of Task 6; no separate caller update needed here.

  • Step 5.3 — Lint
npm run lint

Likely yields a transient ESLint warning at Task 6 callsites until Step 6.* lands. If lint output is otherwise clean, proceed to commit.

  • Step 5.4 — Commit
git -C adiuvAI add src/renderer/components/tasks/PropertyPill.tsx
git -C adiuvAI commit -m "refactor(PropertyPill): render as button with forwardRef and focus ring"

Task 6 — TaskFormDialog: header + pills row roving focus + Due via DateField + listbox keys + drop ⌘+Enter hint

Files:

  • Modify: adiuvAI/src/renderer/components/tasks/TaskFormDialog.tsx

This is the big task. Subdivided.

6a. Header swap

  • Step 6a.1 — Add DialogDescription import + new keys reference

At the top of TaskFormDialog.tsx, in the existing @/components/ui/dialog import, add DialogDescription:

import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
} from '@/components/ui/dialog';
  • Step 6a.2 — Replace header markup

Find:

<DialogHeader className="px-5 py-3 border-b border-border/40">
  <DialogTitle className="text-sm font-medium">
    {mode === 'create' ? t('tasks.newTask') : t('tasks.editTask')}
  </DialogTitle>
</DialogHeader>

Replace with:

<DialogHeader>
  <DialogTitle>
    {mode === 'create' ? t('tasks.newTask') : t('tasks.editTask')}
  </DialogTitle>
  <DialogDescription>
    {mode === 'create' ? t('tasks.newTaskDescription') : t('tasks.editTaskDescription')}
  </DialogDescription>
</DialogHeader>

(The DialogHeader and DialogTitle shadcn defaults already give the same spacing/typography used in AddEventDialog. No border-b.)

6b. Pills row roving focus

  • Step 6b.1 — Import hook
import { useRovingFocus } from '@/hooks/useRovingFocus';
  • Step 6b.2 — Wire up at top of component body

Just after the existing useState calls, add:

const PILL_COUNT = 5; // Project, Priority, Status, Due, Assignees
const pillsRoving = useRovingFocus({ count: PILL_COUNT, direction: 'both' });
  • Step 6b.3 — Attach to each pill

Each <PopoverTrigger asChild> wraps a <span> wrapping <PropertyPill ... />. Remove the <span> wrappers and pass roving props directly to the pill. The pattern, repeated five times with index = 0..4:

<PopoverTrigger asChild>
  <PropertyPill
    {...pillsRoving.getItemProps(0)}
    icon={<Folder className="h-3 w-3" />}
    label={t('tasks.project')}
    value={selectedProject?.name ?? null}
    empty={!selectedProject}
    aria-label={t('tasks.project') + (selectedProject ? `: ${selectedProject.name}` : '')}
  />
</PopoverTrigger>

Repeat with getItemProps(1) (Priority), getItemProps(2) (Status), getItemProps(3) (Due), getItemProps(4) (Assignees). Update the surrounding aria-label per pill.

Add role="toolbar" aria-label={t('tasks.properties')} to the <div className="flex flex-wrap gap-1.5" data-testid="property-pills"> wrapper that contains all pills.

6c. Each list popover uses useListboxKeys

  • Step 6c.1 — Import
import { useListboxKeys } from '@/hooks/useListboxKeys';
  • Step 6c.2 — Project popover

The Project popover today renders either <InlineProjectForm ... /> (when creatingProject) or a list (No project, then projectsList.map). Wrap only the list branch in a listbox container that uses useListboxKeys:

function ProjectList({
  projects,
  selectedId,
  onSelect,
  onCreate,
  onClose,
}: {
  projects: { id: string; name: string }[];
  selectedId: string | null;
  onSelect: (id: string | null) => void;
  onCreate: () => void;
  onClose: () => void;
}) {
  const { t } = useTranslation();
  // Item layout: [0] "+ New project", [1] "No project", [2..N+1] projects
  const items = [
    { kind: 'new' as const },
    { kind: 'none' as const },
    ...projects.map((p) => ({ kind: 'project' as const, id: p.id, name: p.name })),
  ];
  const listbox = useListboxKeys({
    itemCount: items.length,
    initialIndex: selectedId
      ? items.findIndex((it) => it.kind === 'project' && it.id === selectedId)
      : 1,
    onSelect: (i) => {
      const it = items[i];
      if (it.kind === 'new') onCreate();
      else if (it.kind === 'none') onSelect(null);
      else onSelect(it.id);
    },
    onClose,
  });

  // Focus the active item once on mount.
  useEffect(() => {
    listbox.focusIndex(listbox.activeIndex);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div className="p-1" role="listbox" aria-label={t('tasks.project')}>
      {items.map((it, i) => {
        const itemProps = listbox.getItemProps(i);
        const base =
          'w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none flex items-center gap-1.5';
        if (it.kind === 'new') {
          return (
            <button
              key="new"
              type="button"
              {...itemProps}
              role="option"
              className={base + ' text-primary'}
              onClick={() => onCreate()}
            >
              <Plus className="h-3.5 w-3.5" />
              {t('projects.newProject')}
            </button>
          );
        }
        if (it.kind === 'none') {
          return (
            <button
              key="none"
              type="button"
              {...itemProps}
              role="option"
              className={base}
              onClick={() => onSelect(null)}
            >
              {t('tasks.noProject')}
            </button>
          );
        }
        return (
          <button
            key={it.id}
            type="button"
            {...itemProps}
            role="option"
            className={base}
            onClick={() => onSelect(it.id)}
          >
            {it.name}
          </button>
        );
      })}
    </div>
  );
}

In the Project popover, replace the inline list JSX with <ProjectList ... /> and pass:

<ProjectList
  projects={projectsList}
  selectedId={values.projectId}
  onSelect={(id) => {
    setValues((v) => ({ ...v, projectId: id }));
    setProjectPopoverOpen(false);
  }}
  onCreate={() => setCreatingProject(true)}
  onClose={() => setProjectPopoverOpen(false)}
/>
  • Step 6c.3 — Priority popover

Replace the existing three-button priorities.map with a small listbox wrapper:

function PriorityList({
  value,
  onSelect,
  onClose,
}: {
  value: string;
  onSelect: (v: 'high' | 'medium' | 'low') => void;
  onClose: () => void;
}) {
  const { t } = useTranslation();
  const items = ['high', 'medium', 'low'] as const;
  const initial = Math.max(0, items.indexOf(value as typeof items[number]));
  const listbox = useListboxKeys({
    itemCount: items.length,
    initialIndex: initial,
    onSelect: (i) => onSelect(items[i]),
    onClose,
  });
  useEffect(() => { listbox.focusIndex(listbox.activeIndex); /* eslint-disable-next-line */ }, []);
  return (
    <div role="listbox" aria-label={t('tasks.priority')} className="p-1">
      {items.map((p, i) => (
        <button
          key={p}
          type="button"
          {...listbox.getItemProps(i)}
          role="option"
          aria-selected={value === p}
          onClick={() => onSelect(p)}
          className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
        >
          {t(`tasks.${p}`)}
        </button>
      ))}
    </div>
  );
}

Drop in the Priority <PopoverContent>:

<PriorityList
  value={values.priority}
  onSelect={(p) => setValues((v) => ({ ...v, priority: p }))}
  onClose={() => {/* shadcn closes when triggering element loses focus; explicit close below */}}
/>

For consistency with Project, control the Priority popover's open state from the parent (const [priorityOpen, setPriorityOpen] = useState(false);), pass it to <Popover open={priorityOpen} onOpenChange={setPriorityOpen}>, and onClose={() => setPriorityOpen(false)}. On onSelect, also call setPriorityOpen(false).

  • Step 6c.4 — Status popover

Identical pattern to Priority. Items: ['todo', 'in_progress', 'done']. Component StatusList. Parent state statusOpen / setStatusOpen.

  • Step 6c.5 — Assignees popover

Multi-select. Build AssigneesList similar to the others but onSelect toggles membership in values.assignees (and the popover stays open):

function AssigneesList({
  known,
  selected,
  onToggle,
  onClose,
  newName,
  onNewNameChange,
  onAddNew,
}: {
  known: string[];
  selected: string[];
  onToggle: (name: string) => void;
  onClose: () => void;
  newName: string;
  onNewNameChange: (s: string) => void;
  onAddNew: () => void;
}) {
  const { t } = useTranslation();
  const listbox = useListboxKeys({
    itemCount: known.length,
    initialIndex: 0,
    onSelect: (i) => onToggle(known[i]),
    onClose,
  });
  useEffect(() => {
    if (known.length > 0) listbox.focusIndex(0);
    // eslint-disable-next-line
  }, []);
  return (
    <div className="p-2" role="listbox" aria-multiselectable="true" aria-label={t('tasks.assignees')}>
      <div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
        {known.map((name, i) => {
          const isOn = selected.includes(name);
          return (
            <button
              key={name}
              type="button"
              {...listbox.getItemProps(i)}
              role="option"
              aria-selected={isOn}
              onClick={() => onToggle(name)}
              className="text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
            >
              {isOn ? '✓ ' : '   '}{name}
            </button>
          );
        })}
      </div>
      <div className="border-t mt-2 pt-2 flex gap-1.5">
        <Input
          placeholder={t('tasks.newAssigneeName', 'New name…')}
          value={newName}
          onChange={(e) => onNewNameChange(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Enter') {
              e.preventDefault();
              onAddNew();
            }
          }}
          className="h-8 text-sm flex-1"
        />
        <Button type="button" size="sm" onClick={onAddNew} disabled={!newName.trim()}>
          {t('common.add')}
        </Button>
      </div>
    </div>
  );
}

In the Assignees <PopoverContent>, replace the existing JSX with:

<AssigneesList
  known={knownAssignees}
  selected={values.assignees}
  onToggle={(name) =>
    setValues((v) => ({
      ...v,
      assignees: v.assignees.includes(name)
        ? v.assignees.filter((a) => a !== name)
        : [...v.assignees, name],
    }))
  }
  onClose={() => {/* uncontrolled Popover; native Esc/blur closes */}}
  newName={assigneeInput}
  onNewNameChange={setAssigneeInput}
  onAddNew={addNewAssignee}
/>

(Assignees can stay uncontrolled — shadcn handles dismissal on outside click + Esc inside the listbox is already wired by useListboxKeys via onClose; for a true close-on-esc, mirror the controlled pattern used for Priority/Status if desired. Keeping uncontrolled is consistent with the current code.)

6d. Due popover via DateField

  • Step 6d.1 — Replace the entire Due <Popover> block

Remove the HOURS/MINUTES constants and the derivePartsInTz / updateDueTime helpers from TaskFormDialog.tsxDateField now owns time handling. Then replace the Due <Popover> body with:

<Popover>
  <PopoverTrigger asChild>
    <PropertyPill
      {...pillsRoving.getItemProps(3)}
      icon={<CalIcon className="h-3 w-3" />}
      label={t('tasks.colDue')}
      value={values.dueDate ? formatDueDate(values.dueDate, prefs) : null}
      empty={!values.dueDate}
      aria-label={t('tasks.colDue')}
    />
  </PopoverTrigger>
  <PopoverContent className="w-auto p-3" align="start">
    <DateField
      flat
      withTime
      value={values.dueDate ? new Date(values.dueDate) : undefined}
      onChange={(d) => setValues((v) => ({ ...v, dueDate: d ? d.getTime() : null }))}
      placeholder={t('tasks.colDue')}
      aria-label={t('tasks.colDue')}
    />
  </PopoverContent>
</Popover>

Add import { DateField } from '@/components/ui/date-field'; at the top and remove unused imports (TZDate, Calendar, Select* if no longer used elsewhere in the file — re-run lint after).

Note on timezones: the old code constructed TZDate using user prefs.timezone so e.g. 00:00 was interpreted in the user's IANA zone. DateField uses plain Date (browser local time). In Electron this is the user's OS timezone, which is what prefs.timezone already defaults to. For users who manually override prefs.timezone to a non-OS zone, due times here will reflect OS-local instead. This matches AddEventDialog's current behavior and is acceptable; if/when timezone-aware editing is reintroduced, both DateField and AddEventDialog should change together.

  • Step 6e.1 — Audit footer

Re-read the current footer: the existing footer has only Cancel + Submit + (optional Paperclip in edit mode). It does NOT contain a "⌘+Enter to create" hint — that was a mockup-only element. The form-level onKeyDown that submits on ⌘/Ctrl+Enter stays.

No code change required for 6e; this step is a checklist confirmation. Move on.

6f. Lint + smoke + commit

  • Step 6f.1 — Lint
npm run lint

Expected: clean. Address any "unused import" errors from removed TZDate / Select / Calendar etc.

  • Step 6f.2 — Manual smoke (full keyboard flow)

npm start, then in the running app:

  1. Open Tasks page → "New task". Header shows title + muted description, no separator line.
  2. Tab from the title input → focus lands on the description textarea. Tab again → first pill (Project) shows focus ring.
  3. Press ←/→ and ↑/↓: focus moves between pills.
  4. With Project pill focused, press Enter: popover opens; "No project" or current selection is focused.
  5. ↓/↑ navigate items. Enter selects → popover closes → focus returns to the Project pill.
  6. Repeat for Priority, Status, Assignees. For Assignees, Enter toggles, Esc closes.
  7. With Due pill focused, press Enter: popover opens with DateField. Type 30/04/2026 14:30, press Enter — pill value reads 30/04/2026 14:30 (or US format if FormatPrefs.dateFormat = 'MM/dd/yyyy').
  8. Re-open Due, click a calendar day — date updates while keeping the time. Change hour Select → pill reflects new time.
  9. Ctrl+Enter anywhere in the form submits.
  10. Edit mode: re-open an existing task with a due date that has HH:MM and verify the field renders both date and time.
  • Step 6f.3 — Commit
git -C adiuvAI add src/renderer/components/tasks/TaskFormDialog.tsx
git -C adiuvAI commit -m "feat(TaskFormDialog): new header, full keyboard nav, DateField-based due"

Task 7 — i18n keys in all five languages

Files:

  • Modify: adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json

Why: Header description string is new; missing keys would print the key path.

  • Step 7.1 — Add keys under the tasks namespace

For each language, add (or merge into the existing "tasks": { ... } block) the two new keys:

EN:

"newTaskDescription": "Capture what needs doing. Set properties below or refine later.",
"editTaskDescription": "Update the task details, properties, and attachments."

IT:

"newTaskDescription": "Annota cosa va fatto. Imposta le proprietà sotto o aggiornale dopo.",
"editTaskDescription": "Aggiorna dettagli, proprietà e allegati dell'attività."

ES:

"newTaskDescription": "Anota qué hay que hacer. Define las propiedades abajo o más tarde.",
"editTaskDescription": "Actualiza los detalles, propiedades y adjuntos de la tarea."

FR:

"newTaskDescription": "Notez ce qu'il faut faire. Définissez les propriétés ci-dessous ou plus tard.",
"editTaskDescription": "Mettez à jour les détails, propriétés et pièces jointes de la tâche."

DE:

"newTaskDescription": "Halten Sie fest, was zu tun ist. Eigenschaften unten oder später setzen.",
"editTaskDescription": "Aktualisieren Sie Details, Eigenschaften und Anhänge der Aufgabe."

Place the keys near other tasks.new* / tasks.edit* entries (alphabetical inside tasks is the existing convention — match it).

  • Step 7.2 — Lint
npm run lint

Expected: clean (JSON files don't lint, but TypeScript references resolve).

  • Step 7.3 — Smoke

npm start, switch language in Settings → General → Language and confirm the dialog description renders in the chosen language.

  • Step 7.4 — Commit
git -C adiuvAI add src/renderer/locales/en/translation.json src/renderer/locales/it/translation.json src/renderer/locales/es/translation.json src/renderer/locales/fr/translation.json src/renderer/locales/de/translation.json
git -C adiuvAI commit -m "i18n: add tasks.newTaskDescription / editTaskDescription"

Task 8 — Bump submodule in the monorepo

Files:

  • Modify: top-level adiuvAI submodule pointer.

  • Step 8.1 — Stage submodule bump

From the monorepo root (c:\Users\PC-Roby\Documents\_adiuvai_workspace):

git add adiuvAI
git status

Expected: modified: adiuvAI (new commits) is now staged.

  • Step 8.2 — Commit at monorepo level
git commit -m "feat: bump adiuvAI submodule — task form keyboard polish"
  • Step 8.3 — Run graphify update

Per project CLAUDE.md:

graphify update .

This keeps the knowledge graph aligned with the new hooks / components. AST-only, no API cost.

  • Step 8.4 — Final smoke + done

Reopen the Task dialog one more time and run through the full keyboard flow in §6f.2 to confirm nothing regressed after the submodule pointer was bumped.


Self-review (post-write, pre-handoff)

  • Spec coverage:

    • §1 Header → Task 6a.
    • §2 Pills row roving focus → Task 1 + 6b. List popovers → Task 2 + 6c. Calendar nav → react-day-picker built-in (no task needed; surfaced inside CalendarTimeBody in Task 4). Description Enter = newline → unchanged (Task 6e checklist).
    • §3 Date+time:
      • parseDate suffix → Task 3.
      • DateField withTime + flat → Task 4.
      • TaskFormDialog Due popover replacement → Task 6d.
    • §4 Files list → mirrored 1-for-1.
    • §5 Accessibility — role="toolbar" (6b.3), role="listbox" / role="option" / aria-multiselectable / aria-selected (6c). DateField a11y unchanged.
    • §6 Out-of-scope items are not in the plan. Good.
  • Placeholder scan: no "TBD", "TODO", or hand-waving steps. Each code-changing step shows code. The "no internal tests" note explicitly says manual smoke instead of inventing fake tests.

  • Type consistency:

    • useRovingFocus exposes { activeIndex, setActive, getItemProps } — used in Task 6b.
    • useListboxKeys exposes { activeIndex, setActive, focusIndex, getItemProps } — used in Tasks 6c.26c.5.
    • DateField new props withTime, flat — consumed in Task 6d.
    • PropertyPill becomes a forwardRef <button> — consumed via {...pillsRoving.getItemProps(i)} in Task 6b.
    • i18n keys tasks.newTaskDescription, tasks.editTaskDescription — defined Task 7, referenced Task 6a. Match.

No gaps detected.