diff --git a/README.md b/README.md index 9551de69..b6acde8b 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,10 @@ not an exhaustive list; the in-app **What's New** is the complete, current recor own working hours — with schedule-aware chat answers and an automatic "Due soon" when an event names a project. Your milestones and pinboard timelines show alongside as markers you can toggle off. PM only reads your calendars unless you turn on editing for a Google account; - then you can create events on its calendars (+ New event, or press c), and change and delete - events you organise without guests, repeating ones included. Every change goes straight to + then you can create events on its calendars (+ New event, or press c; Shift+C opens a quick box, + and in the day, week, month and agenda views the new event shows as you type, except in the + Terminal look), and change and + delete events you organise without guests, repeating ones included. Every change goes straight to Google, a save never overwrites a field someone changed there in the meantime, and a new event whose save loses the connection is looked for in Google before it's ever sent again. - **Bring your own model.** Choose your model through OpenRouter — separate chat and diff --git a/design-system-docs/README.md b/design-system-docs/README.md index 02d4a573..cc1f8ff1 100644 --- a/design-system-docs/README.md +++ b/design-system-docs/README.md @@ -164,7 +164,7 @@ Drive it with a single `depth` value; gate optional blocks on it. Don't fork lay Slate = cards; Terminal = table. Depth scales it from name+status only → +meta → full table with cost/active. - **Project** — file list beside a project-scoped chat thread (two-pane). Treatment per System. - **Documents** — ingestion: a drop zone + recent items with type/date/index-state. Rules / cards / mono table per System. -- **Calendar** — week/agenda; "+ New event" (or `c`) creates a Google event, and one you organise can be edited from its details. Serif agenda / day cards / mono list per System. +- **Calendar** — week/agenda; "+ New event" (or `c`) creates a Google event, and one you organise can be edited from its details. Shift+C opens quick create, a small non-modal box under the button whose draft is drawn as a dashed ghost (`DRAFT_EVENT_CLASS`: `outline-ink3`, dashed inside the edge with `-outline-offset-1` so a clipping list can't cut it, never greyed as past; a dashed ring among Minimal's month dots) in the day, week, month and agenda views, except in the Terminal look, which draws only what's in the calendar; its accessible name starts with what it is (`draftNote`). Serif agenda / day cards / mono list per System. - **Review** — queue of proposed actions as inline approval cards (Approve / Edit / Dismiss). - **Settings** — appearance (System / Accent / Depth / Mode), models & keys (key field → keychain), help-mode toggle. Editorial = ruled sections, Slate = card panel, Terminal = key/value list. - **Command palette** (`⌘K`) — overlay with a query line + result rows. diff --git a/package-lock.json b/package-lock.json index d27108a3..b81c75e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pm", - "version": "3.150.0-alpha", + "version": "3.151.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pm", - "version": "3.150.0-alpha", + "version": "3.151.0-alpha", "license": "AGPL-3.0-or-later", "dependencies": { "@fontsource/atkinson-hyperlegible": "^5.3.0", diff --git a/package.json b/package.json index 4f70c260..5bed7a12 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pm", "private": true, - "version": "3.150.0-alpha", + "version": "3.151.0-alpha", "license": "AGPL-3.0-or-later", "type": "module", "engines": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0790bea6..0c72d3b6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3072,7 +3072,7 @@ dependencies = [ [[package]] name = "pm" -version = "3.150.0-alpha" +version = "3.151.0-alpha" dependencies = [ "argon2", "base64 0.23.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5344c28a..c5f6d832 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pm" -version = "3.150.0-alpha" +version = "3.151.0-alpha" description = "PM — a local-first personal manager (Archivist + Personal Assistant)" authors = ["Bobby Yu"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d1b89227..9fb30896 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "PM", - "version": "3.150.0-alpha", + "version": "3.151.0-alpha", "identifier": "org.itsatlas.pm", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/ModelPicker.test.tsx b/src/components/ModelPicker.test.tsx index ada36382..995b8ea4 100644 --- a/src/components/ModelPicker.test.tsx +++ b/src/components/ModelPicker.test.tsx @@ -4,10 +4,13 @@ // ModelPicker is a hand-rolled menu inside the Settings dialog (Settings › AI › "Add a model…"). Its // Escape listener sits on `document` and the dialog's on `window`, so without stopping the key one -// Escape closed the menu AND Settings (or raised the unsaved-changes guard). +// Escape closed the menu AND Settings (or raised the unsaved-changes guard). It follows the Popover +// primitive's rule: a modal dialog over it owns the key, a non-modal layer only a key pressed in it. +import { useRef } from "react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { useDialogLayer } from "../lib/useDialogLayer"; vi.mock("../lib/ipc", () => ({ listModels: () => Promise.resolve([]), @@ -57,4 +60,54 @@ describe("ModelPicker inside a dialog", () => { fireEvent.keyDown(screen.getByText("Add a model…"), { key: "Escape" }); expect(onDialogClose).toHaveBeenCalledTimes(1); }); + + // The calendar's quick create stays open through a click in the sidebar, so it can sit behind + // Settings: a non-modal layer, which owns only a key pressed inside it. + it("closes only itself on Escape beside a non-modal layer", async () => { + function Layer() { + const ref = useRef(null); + useDialogLayer(true, ref); + return
; + } + const onDialogClose = vi.fn(); + render( + <> + + + {}} triggerLabel="Add a model…" /> + + , + ); + fireEvent.click(screen.getByText("Add a model…")); + const search = await screen.findByPlaceholderText("Search models…"); + fireEvent.keyDown(search, { key: "Escape" }); + await waitFor(() => expect(screen.queryByPlaceholderText("Search models…")).toBeNull()); + expect(onDialogClose).not.toHaveBeenCalled(); + fireEvent.keyDown(screen.getByText("Add a model…"), { key: "Escape" }); + expect(onDialogClose).toHaveBeenCalledTimes(1); + }); + + // A modal dialog opened over the open menu (one of Settings' own questions) holds the keyboard. + it("leaves Escape to a modal dialog opened over it", async () => { + const onDialogClose = vi.fn(); + const onQuestionClose = vi.fn(); + const page = (asking: boolean) => ( + + {}} triggerLabel="Add a model…" /> + {asking && ( + + + + )} + + ); + const { rerender } = render(page(false)); + fireEvent.click(screen.getByText("Add a model…")); + await screen.findByPlaceholderText("Search models…"); + rerender(page(true)); + fireEvent.keyDown(screen.getByText("Reset"), { key: "Escape" }); + expect(onQuestionClose).toHaveBeenCalledTimes(1); + expect(screen.queryByPlaceholderText("Search models…")).not.toBeNull(); + expect(onDialogClose).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index 1dcf1ae4..5fe6bfcd 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { listModels } from "../lib/ipc"; -import { isCoveredByDialog } from "../lib/useDialogLayer"; +import { isKeyForDialogOver } from "../lib/useDialogLayer"; import type { ModelInfo } from "../lib/types"; import { Button, Input, Skeleton } from "./ui"; @@ -52,9 +52,11 @@ export function ModelPicker({ value, onChange, triggerLabel = "Choose a model… }; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; - // Same rule as the Popover primitive: a dialog opened over this menu owns the key; otherwise - // the menu closes and the key stops here, so the Settings dialog around it stays open. - if (isCoveredByDialog(rootRef.current)) return; + // Same rule as the Popover primitive: a modal dialog opened over this menu owns the key, and a + // non-modal layer beside it (the calendar's quick create, left open behind Settings) only a key + // pressed inside it; otherwise the menu closes and the key stops here, so the Settings dialog + // around it stays open. + if (isKeyForDialogOver(rootRef.current, e.target)) return; e.stopPropagation(); setOpen(false); }; diff --git a/src/components/calendar/CalendarHeader.test.tsx b/src/components/calendar/CalendarHeader.test.tsx index 2c219bbd..2b7dadfa 100644 --- a/src/components/calendar/CalendarHeader.test.tsx +++ b/src/components/calendar/CalendarHeader.test.tsx @@ -7,6 +7,7 @@ // account is connected, and when no calendar can take an event it stays focusable and says why // rather than going grey and silent. +import { createRef, type RefObject } from "react"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -21,11 +22,17 @@ afterEach(cleanup); const REASON = "None of your calendars can take new events."; -function header(over: { onCreate?: () => void; createReason?: string | null } = {}) { +interface Over { + onCreate?: () => void; + createReason?: string | null; + createRef?: RefObject; +} + +function header(over: Over = {}) { return render(element(over)); } -function element(over: { onCreate?: () => void; createReason?: string | null } = {}) { +function element(over: Over = {}) { return ( { expect(screen.queryByRole("status")).toBeNull(); }); - // One primary button on screen: the editor's Save (C1-L5). + // One primary button on screen: the editor's or quick create's Save (C1-L5). it("New event is a secondary button", () => { for (const createReason of [null, REASON]) { header({ onCreate: vi.fn(), createReason }); @@ -128,6 +135,12 @@ describe("CalendarHeader", () => { expect(screen.getByRole("status").textContent).toBe(""); }); + it("hands quick create the New event button, to open under and give focus back to", () => { + const button = createRef(); + header({ onCreate: vi.fn(), createReason: null, createRef: button }); + expect(button.current).toBe(screen.getByRole("button", { name: "+ New event" })); + }); + it("has no New event without a Google account", () => { header(); expect(screen.queryByRole("button", { name: "+ New event" })).toBeNull(); diff --git a/src/components/calendar/CalendarHeader.tsx b/src/components/calendar/CalendarHeader.tsx index 3d6d6742..7376b1e4 100644 --- a/src/components/calendar/CalendarHeader.tsx +++ b/src/components/calendar/CalendarHeader.tsx @@ -5,15 +5,16 @@ // date-range label, the view switcher (active = accent), and Refresh-now. // // "+ New event" (#884, C10) is the one control here that changes a calendar: it opens the editor for -// a new Google event, as `c` does, and sits first, where Google's Create does. It shows once a Google -// account is connected; when no calendar can take an event it stays focusable and says why (its -// tooltip, and a click), rather than going grey and silent. It's secondary, so the editor's Save stays -// the one primary button on screen. Everything else here only changes what you see. +// a new Google event, as `c` does, and sits first, where Google's Create does; Shift+C's quick create +// opens under it (`createRef`). It shows once a Google account is connected; when no calendar can +// take an event it stays focusable and says why (its tooltip, and a click), rather than going grey +// and silent. It's secondary, so the editor's or quick create's Save stays the one primary button on +// screen. Everything else here only changes what you see. // // The last-synced time rides ON the Refresh button rather than beside it: it is the one fact that // tells you whether pressing Refresh is worth it. -import { useEffect, useId, useState } from "react"; +import { useEffect, useId, useState, type Ref } from "react"; import { DAY_COUNT_MAX, DAY_COUNT_MIN, @@ -57,6 +58,8 @@ interface Props { onCreate?: () => void; /** Why no new event can be made now, or null when one can. */ createReason?: string | null; + /** The "+ New event" button: quick create opens under it, and gives focus back to it. */ + createRef?: Ref; } /** How long a click's "why not" stays beside the button. */ @@ -92,6 +95,7 @@ export function CalendarHeader({ lastSync, onCreate, createReason = null, + createRef, }: Props) { const { showMeta } = useDepth(); const { system } = useTheme(); @@ -126,8 +130,9 @@ export function CalendarHeader({ > {onCreate && (
- +