# 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](./2026-05-14-task-form-dialog-kbd-design.md)
**Mockup:** [docs/mockups/2026-05-14-task-form-dialog-mockup.html](./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**

```ts
// 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**

```ts
// 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 1–80+; 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:

```ts
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:

```ts
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`**

```ts
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:

```ts
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):

```ts
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:

```ts
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:

```tsx
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**

```tsx
// 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`:

```ts
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
} from '@/components/ui/dialog';
```

- [ ] **Step 6a.2 — Replace header markup**

Find:

```tsx
<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:

```tsx
<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**

```ts
import { useRovingFocus } from '@/hooks/useRovingFocus';
```

- [ ] **Step 6b.2 — Wire up at top of component body**

Just after the existing `useState` calls, add:

```ts
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`:

```tsx
<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**

```ts
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`:

```tsx
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:

```tsx
<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:

```tsx
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>`:

```tsx
<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):

```tsx
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:

```tsx
<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.tsx` — `DateField` now owns time handling. Then replace the Due `<Popover>` body with:

```tsx
<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.

### 6e. Remove ⌘+Enter footer hint

- [ ] **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:
```json
"newTaskDescription": "Capture what needs doing. Set properties below or refine later.",
"editTaskDescription": "Update the task details, properties, and attachments."
```

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

ES:
```json
"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:
```json
"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:
```json
"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.2–6c.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.
