From 8468756855ee14097e35a8de965812e235ec64b7 Mon Sep 17 00:00:00 2001 From: thephez Date: Thu, 14 May 2026 11:00:36 -0400 Subject: [PATCH 01/15] feat(dashnote): add slim toolbar, editor metadata strip, and activity log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements P1–P3 from the design-review implementation guide: slim NotesToolbar replaces the heavy header card on Notes, editor toolbar surfaces rev/Updated/View JSON with JSON drawer and ⌘S Save, and a new ActivityPanel (⌘L) buffers structured log entries from the dash/* helpers so users can see SDK calls as they happen. Co-Authored-By: Claude Opus 4.7 (1M context) --- example-apps/dashnote/src/App.tsx | 59 +++-- .../dashnote/src/components/ActivityPanel.tsx | 115 ++++++++++ .../dashnote/src/components/NoteEditor.tsx | 203 +++++++++++++++--- .../src/components/NoteJsonDrawer.tsx | 76 +++++++ .../dashnote/src/components/NotesToolbar.tsx | 58 +++++ .../src/components/NotesWorkspace.tsx | 6 +- example-apps/dashnote/src/dash/createNote.ts | 7 +- example-apps/dashnote/src/dash/deleteNote.ts | 10 +- example-apps/dashnote/src/dash/updateNote.ts | 10 +- example-apps/dashnote/src/lib/logger.ts | 31 ++- .../dashnote/src/session/SessionContext.tsx | 57 ++++- .../dashnote/test/ActivityPanel.test.tsx | 117 ++++++++++ example-apps/dashnote/test/App.test.tsx | 42 ++++ .../dashnote/test/DeleteNoteModal.test.tsx | 8 +- 14 files changed, 725 insertions(+), 74 deletions(-) create mode 100644 example-apps/dashnote/src/components/ActivityPanel.tsx create mode 100644 example-apps/dashnote/src/components/NoteJsonDrawer.tsx create mode 100644 example-apps/dashnote/src/components/NotesToolbar.tsx create mode 100644 example-apps/dashnote/test/ActivityPanel.test.tsx diff --git a/example-apps/dashnote/src/App.tsx b/example-apps/dashnote/src/App.tsx index 14726b6..b25d671 100644 --- a/example-apps/dashnote/src/App.tsx +++ b/example-apps/dashnote/src/App.tsx @@ -1,9 +1,11 @@ import { useEffect, useMemo, useState } from "react"; import { Toaster } from "sonner"; +import { ActivityPanel } from "./components/ActivityPanel"; import { AppShell } from "./components/AppShell"; import { HowItWorks } from "./components/HowItWorks"; import { LoginModal } from "./components/LoginModal"; +import { NotesToolbar } from "./components/NotesToolbar"; import { NotesWorkspace } from "./components/NotesWorkspace"; import { OperationResultNotice } from "./components/OperationResultNotice"; import { SettingsPanel } from "./components/SettingsPanel"; @@ -33,6 +35,7 @@ function App() { const { status, sdk, enterReadOnly, viewAsRemembered } = session; const [tab, setTab] = useState("notes"); const [loginOpen, setLoginOpen] = useState(false); + const [activityOpen, setActivityOpen] = useState(false); const mobileFullBleed = tab === "notes"; @@ -41,6 +44,17 @@ function App() { else if (status === "browsing" && !sdk) void viewAsRemembered(); }, [enterReadOnly, viewAsRemembered, status, sdk]); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "l") { + e.preventDefault(); + setActivityOpen((v) => !v); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + const header = useMemo(() => screenCopy[tab], [tab]); return ( @@ -56,31 +70,28 @@ function App() { onLoginOpen={() => setLoginOpen(true)} mobileFullBleed={mobileFullBleed} > -
-
- Dash Platform Notes Tutorial -
-

- {header.title} -

-

- {header.subtitle} -

-
+ {tab === "notes" ? ( + setActivityOpen(true)} + /> + ) : ( +
+
+ Dash Platform Notes Tutorial +
+

+ {header.title} +

+

+ {header.subtitle} +

+
+ )}
{session.error && ( @@ -105,6 +116,10 @@ function App() { setLoginOpen(false)} /> + setActivityOpen(false)} + /> ); } diff --git a/example-apps/dashnote/src/components/ActivityPanel.tsx b/example-apps/dashnote/src/components/ActivityPanel.tsx new file mode 100644 index 0000000..f44c6a6 --- /dev/null +++ b/example-apps/dashnote/src/components/ActivityPanel.tsx @@ -0,0 +1,115 @@ +import { useEffect } from "react"; + +import { formatRelativeTime } from "../lib/format"; +import { useSession } from "../session/useSession"; + +interface ActivityPanelProps { + open: boolean; + onClose: () => void; +} + +export function ActivityPanel({ open, onClose }: ActivityPanelProps) { + const { activityLog, clearActivityLog } = useSession(); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+ +
+ ); +} diff --git a/example-apps/dashnote/src/components/NoteEditor.tsx b/example-apps/dashnote/src/components/NoteEditor.tsx index 7a15dbc..b0deda9 100644 --- a/example-apps/dashnote/src/components/NoteEditor.tsx +++ b/example-apps/dashnote/src/components/NoteEditor.tsx @@ -1,6 +1,9 @@ +import { useEffect, useState } from "react"; + import type { NoteRecord } from "../dash/queries"; import { FIELD_BYTE_LIMIT } from "../lib/fieldLimits"; -import { formatTimestamp } from "../lib/format"; +import { formatRelativeTime, formatTimestamp } from "../lib/format"; +import { NoteJsonDrawer } from "./NoteJsonDrawer"; import { OperationResultNotice } from "./OperationResultNotice"; interface NoteEditorProps { @@ -22,7 +25,9 @@ interface NoteEditorProps { messageBytes: number; messageOversize: boolean; contractReady: boolean; + contractId: string | null; error: string | null; + conflictWarning?: string | null; onOpenLogin: () => void; onOpenSettings: () => void; isReadOnly?: boolean; @@ -48,7 +53,9 @@ export function NoteEditor({ messageBytes, messageOversize, contractReady, + contractId, error, + conflictWarning, onOpenLogin, onOpenSettings, isReadOnly = false, @@ -57,10 +64,34 @@ export function NoteEditor({ const hasSelection = selectedId !== null; const isNew = selectedId === "new"; const oversize = messageOversize; + const [jsonOpen, setJsonOpen] = useState(false); + + // Cmd/Ctrl-S triggers Save (matches the keyboard hint chip). + useEffect(() => { + if (!isDesktop || isReadOnly || !hasSelection) return; + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") { + if (!canEdit || saving || !dirty || oversize) return; + e.preventDefault(); + onSave(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [ + isDesktop, + isReadOnly, + hasSelection, + canEdit, + saving, + dirty, + oversize, + onSave, + ]); return (
-
+
{hasSelection && ( + )} + {canDelete && isDesktop && ( )} {isReadOnly ? ( @@ -128,9 +222,20 @@ export function NoteEditor({ type="button" onClick={onSave} disabled={!canEdit || saving || !dirty || oversize} - className="rounded-full bg-accent px-3 py-1.5 text-[12px] font-semibold text-bg transition hover:bg-accent-dim disabled:cursor-not-allowed disabled:bg-surface-2 disabled:text-ink-4" + aria-label={saving ? "Saving…" : isNew ? "Create note" : "Save"} + className="inline-flex items-center gap-2 rounded-md bg-accent px-3 py-1.5 text-[13px] font-semibold text-bg transition hover:bg-accent-dim disabled:cursor-not-allowed disabled:bg-surface-2 disabled:text-ink-4" > - {saving ? "Saving…" : isNew ? "Create note" : "Save"} + + {saving ? "Saving…" : isNew ? "Create note" : "Save"} + + {isDesktop && ( + + ⌘S + + )} ) )} @@ -138,6 +243,25 @@ export function NoteEditor({
+ {conflictWarning && ( +
+ + + + + {conflictWarning} +
+ )} + {error && ( {error} @@ -241,32 +365,6 @@ export function NoteEditor({
-
- {note && ( - <> -
- Revision - - {note.revision} - -
-
- Created - {formatTimestamp(note.createdAt)} -
-
- Updated - {formatTimestamp(note.updatedAt)} -
- - )} - {(messageBytes / FIELD_BYTE_LIMIT >= 0.75 || messageOversize) && ( -
- -
- )} -
- {canDelete && (
+ {isDesktop && hasSelection && contractReady && ( +
+ {note ? ( +
+ + $createdAt + {formatTimestamp(note.createdAt)} + + + $updatedAt + {formatTimestamp(note.updatedAt)} + +
+ ) : ( +
+ Platform metadata appears after the first save. +
+ )} +
+ + {messageBytes.toLocaleString()} /{" "} + {FIELD_BYTE_LIMIT.toLocaleString()} B + +
+ +
+
+
+ )} + setJsonOpen(false)} + />
); } diff --git a/example-apps/dashnote/src/components/NoteJsonDrawer.tsx b/example-apps/dashnote/src/components/NoteJsonDrawer.tsx new file mode 100644 index 0000000..18bc152 --- /dev/null +++ b/example-apps/dashnote/src/components/NoteJsonDrawer.tsx @@ -0,0 +1,76 @@ +import { useEffect } from "react"; + +import type { NoteRecord } from "../dash/queries"; + +interface NoteJsonDrawerProps { + open: boolean; + note: NoteRecord | null; + contractId: string | null; + onClose: () => void; +} + +export function NoteJsonDrawer({ + open, + note, + contractId, + onClose, +}: NoteJsonDrawerProps) { + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open || !note) return null; + + const payload = { + $id: note.id, + $type: "note", + $ownerId: note.ownerId, + $dataContractId: contractId, + $revision: note.revision, + $createdAt: note.createdAt, + $updatedAt: note.updatedAt, + title: note.title, + message: note.message, + }; + + return ( +
+ +
+ ); +} diff --git a/example-apps/dashnote/src/components/NotesToolbar.tsx b/example-apps/dashnote/src/components/NotesToolbar.tsx new file mode 100644 index 0000000..1946f4f --- /dev/null +++ b/example-apps/dashnote/src/components/NotesToolbar.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from "react"; + +interface NotesToolbarProps { + title: string; + onOpenActivity?: () => void; + rightSlot?: ReactNode; +} + +/** + * Slim page-title row used on the notes tab in place of the heavy + * header card. Settings + How-it-works keep the card pattern. + */ +export function NotesToolbar({ + title, + onOpenActivity, + rightSlot, +}: NotesToolbarProps) { + return ( +
+
+

+ {title} +

+ + testnet + +
+
+ {onOpenActivity && ( + + )} + {rightSlot} +
+
+ ); +} diff --git a/example-apps/dashnote/src/components/NotesWorkspace.tsx b/example-apps/dashnote/src/components/NotesWorkspace.tsx index 5139db6..69160fe 100644 --- a/example-apps/dashnote/src/components/NotesWorkspace.tsx +++ b/example-apps/dashnote/src/components/NotesWorkspace.tsx @@ -634,7 +634,7 @@ export function NotesWorkspace({ onAction={onOpenSettings} /> ) : ( -
+
@@ -674,7 +674,9 @@ export function NotesWorkspace({ messageBytes={messageBytes} messageOversize={messageOversize} contractReady={contractReady} - error={error ?? conflictWarning} + contractId={contractId} + error={error} + conflictWarning={conflictWarning} onOpenLogin={onOpenLogin} onOpenSettings={onOpenSettings} /> diff --git a/example-apps/dashnote/src/dash/createNote.ts b/example-apps/dashnote/src/dash/createNote.ts index e5516f4..c71300c 100644 --- a/example-apps/dashnote/src/dash/createNote.ts +++ b/example-apps/dashnote/src/dash/createNote.ts @@ -24,7 +24,7 @@ export async function createNote({ message, log, }: CreateNoteParams): Promise { - log?.("Creating note…"); + log?.("Creating note…", { level: "info", detail: "documents.create" }); const { identity, identityKey, signer } = await keyManager.getAuth(); const { Document } = await loadSdkModule(); const trimmedTitle = title?.trim(); @@ -52,6 +52,9 @@ export async function createNote({ if (!noteId) { throw new Error("Created note returned no ID."); } - log?.("Note created.", "success"); + log?.("Note created.", { + level: "success", + detail: `id ${noteId.slice(0, 8)}…`, + }); return noteId; } diff --git a/example-apps/dashnote/src/dash/deleteNote.ts b/example-apps/dashnote/src/dash/deleteNote.ts index 0ffe2a5..f43ceb8 100644 --- a/example-apps/dashnote/src/dash/deleteNote.ts +++ b/example-apps/dashnote/src/dash/deleteNote.ts @@ -21,7 +21,10 @@ export async function deleteNote({ noteId, log, }: DeleteNoteParams): Promise { - log?.(`Deleting note ${noteId}…`); + log?.(`Deleting note ${noteId.slice(0, 8)}…`, { + level: "info", + detail: "documents.delete", + }); const { identity, identityKey, signer } = await keyManager.getAuth(); await sdk.documents.delete({ document: { @@ -33,5 +36,8 @@ export async function deleteNote({ identityKey, signer, }); - log?.("Note deleted.", "success"); + log?.("Note deleted.", { + level: "success", + detail: `id ${noteId.slice(0, 8)}…`, + }); } diff --git a/example-apps/dashnote/src/dash/updateNote.ts b/example-apps/dashnote/src/dash/updateNote.ts index 1e4556f..bb94a5b 100644 --- a/example-apps/dashnote/src/dash/updateNote.ts +++ b/example-apps/dashnote/src/dash/updateNote.ts @@ -29,7 +29,10 @@ export async function updateNote({ message, log, }: UpdateNoteParams): Promise { - log?.(`Saving note ${noteId}…`); + log?.(`Saving note ${noteId.slice(0, 8)}…`, { + level: "info", + detail: "documents.get → replace", + }); const { identity, identityKey, signer } = await keyManager.getAuth(); const existingDoc = await sdk.documents.get(contractId, "note", noteId); if (!existingDoc) { @@ -56,5 +59,8 @@ export async function updateNote({ identityKey, signer, }); - log?.("Note saved.", "success"); + log?.("Note saved.", { + level: "success", + detail: `rev ${revision.toString()}`, + }); } diff --git a/example-apps/dashnote/src/lib/logger.ts b/example-apps/dashnote/src/lib/logger.ts index 5544323..4f76861 100644 --- a/example-apps/dashnote/src/lib/logger.ts +++ b/example-apps/dashnote/src/lib/logger.ts @@ -3,7 +3,36 @@ */ export type LogLevel = "info" | "success" | "error"; -export type Logger = (message: string, level?: LogLevel) => void; +export interface LogOptions { + level?: LogLevel; + detail?: string; +} + +// Positional `LogLevel` second arg is accepted for backwards-compatibility with +// existing call sites (`log("…", "success")`); new code should pass the object +// form so it can carry `detail` for the activity panel. +export type Logger = ( + message: string, + levelOrOptions?: LogLevel | LogOptions, +) => void; + +export interface LogEntry { + id: string; + level: LogLevel; + message: string; + detail?: string; + timestamp: number; +} + +export const ACTIVITY_LOG_LIMIT = 200; + +export function normalizeLogOptions( + levelOrOptions?: LogLevel | LogOptions, +): LogOptions { + if (!levelOrOptions) return {}; + if (typeof levelOrOptions === "string") return { level: levelOrOptions }; + return levelOrOptions; +} export function errorMessage(err: unknown): string { if (err instanceof Error) return err.message; diff --git a/example-apps/dashnote/src/session/SessionContext.tsx b/example-apps/dashnote/src/session/SessionContext.tsx index e79189e..4b40e79 100644 --- a/example-apps/dashnote/src/session/SessionContext.tsx +++ b/example-apps/dashnote/src/session/SessionContext.tsx @@ -16,7 +16,13 @@ import { import { resolveDpnsName } from "../dash/resolveDpnsName"; import type { DashKeyManager, DashSdk } from "../dash/types"; import { detectSecretShape } from "../lib/detectSecretShape"; -import { errorMessage, type Logger } from "../lib/logger"; +import { + ACTIVITY_LOG_LIMIT, + errorMessage, + normalizeLogOptions, + type LogEntry, + type Logger, +} from "../lib/logger"; import { clearCachedNotes } from "../lib/notesCache"; import { clearRememberedIdentity, @@ -70,6 +76,8 @@ export interface SessionValue { dpnsName: string | null; setContractId: (id: string | null) => void; log: Logger; + activityLog: LogEntry[]; + clearActivityLog: () => void; login: (secret: string, options?: LoginOptions) => Promise; enterReadOnly: () => Promise; viewAsRemembered: () => Promise; @@ -101,14 +109,35 @@ export function SessionProvider({ children }: { children: ReactNode }) { initialRemembered?.name ?? null, ); - const log = useCallback((message, level = "info") => { + const [activityLog, setActivityLog] = useState([]); + + const log = useCallback((message, levelOrOptions) => { + const { level = "info", detail } = normalizeLogOptions(levelOrOptions); const method = level === "error" ? "error" : level === "success" ? "info" : "log"; - console[method](`[${level}] ${message}`); + console[method](`[${level}] ${message}${detail ? ` (${detail})` : ""}`); + const entry: LogEntry = { + id: + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + level, + message, + detail, + timestamp: Date.now(), + }; + setActivityLog((prev) => { + const next = [entry, ...prev]; + return next.length > ACTIVITY_LOG_LIMIT + ? next.slice(0, ACTIVITY_LOG_LIMIT) + : next; + }); if (level === "success") toast.success(message); if (level === "error") toast.error(message); }, []); + const clearActivityLog = useCallback(() => setActivityLog([]), []); + const setContractId = useCallback( (id: string | null) => { const trimmed = id?.trim() ?? ""; @@ -134,11 +163,17 @@ export function SessionProvider({ children }: { children: ReactNode }) { const connect = useCallback(async () => { setStatus("connecting"); setError(null); - log("Connecting to Dash Platform testnet…"); + log("Connecting to Dash Platform testnet…", { + level: "info", + detail: 'createClient("testnet")', + }); const { createClient } = await loadSdkModule(); const connected = (await createClient("testnet")) as unknown as DashSdk; setSdk(connected); - log("Connected to Dash Platform testnet."); + log("Connected to Dash Platform testnet.", { + level: "info", + detail: "sdk.connect", + }); return connected; }, [log]); @@ -188,7 +223,13 @@ export function SessionProvider({ children }: { children: ReactNode }) { const resolvedId = resolvedKeyManager.identityId ?? null; setIdentityId(resolvedId ?? null); setStatus("authenticated"); - log(`Identity resolved: ${resolvedId ?? "(unknown)"}`, "success"); + log(`Identity resolved: ${resolvedId ?? "(unknown)"}`, { + level: "success", + detail: + shape === "mnemonic" + ? "IdentityKeyManager.create" + : "loginWithPrivateKey", + }); // Resolve the DPNS name after auth so we can persist it alongside // the identity ID — DPNS bindings are permanent, so what we save @@ -346,6 +387,8 @@ export function SessionProvider({ children }: { children: ReactNode }) { dpnsName, setContractId, log, + activityLog, + clearActivityLog, login, enterReadOnly, viewAsRemembered, @@ -363,6 +406,8 @@ export function SessionProvider({ children }: { children: ReactNode }) { dpnsName, setContractId, log, + activityLog, + clearActivityLog, login, enterReadOnly, viewAsRemembered, diff --git a/example-apps/dashnote/test/ActivityPanel.test.tsx b/example-apps/dashnote/test/ActivityPanel.test.tsx new file mode 100644 index 0000000..29a9d7b --- /dev/null +++ b/example-apps/dashnote/test/ActivityPanel.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ActivityPanel } from "../src/components/ActivityPanel"; +import type { LogEntry } from "../src/lib/logger"; + +const { mockUseSession, mockClearActivityLog } = vi.hoisted(() => ({ + mockUseSession: vi.fn(), + mockClearActivityLog: vi.fn(), +})); + +vi.mock("../src/session/useSession", () => ({ + useSession: mockUseSession, +})); + +function makeEntries(): LogEntry[] { + return [ + { + id: "entry-1", + level: "success", + message: "Note saved.", + detail: "rev 2", + timestamp: Date.now(), + }, + { + id: "entry-2", + level: "info", + message: "Saving note abc12345…", + detail: "documents.get → replace", + timestamp: Date.now() - 1000, + }, + { + id: "entry-3", + level: "error", + message: "Login failed: bad key", + timestamp: Date.now() - 2000, + }, + ]; +} + +beforeEach(() => { + mockUseSession.mockReset(); + mockClearActivityLog.mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +describe("ActivityPanel", () => { + it("renders nothing when closed", () => { + mockUseSession.mockReturnValue({ + activityLog: makeEntries(), + clearActivityLog: mockClearActivityLog, + }); + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders an empty-state message when there is no activity", () => { + mockUseSession.mockReturnValue({ + activityLog: [], + clearActivityLog: mockClearActivityLog, + }); + render(); + expect(screen.getByText(/no activity yet/i)).toBeTruthy(); + }); + + it("renders entries with messages and details", () => { + mockUseSession.mockReturnValue({ + activityLog: makeEntries(), + clearActivityLog: mockClearActivityLog, + }); + render(); + + expect(screen.getByText("Note saved.")).toBeTruthy(); + expect(screen.getByText("rev 2")).toBeTruthy(); + expect(screen.getByText("documents.get → replace")).toBeTruthy(); + expect(screen.getByText("Login failed: bad key")).toBeTruthy(); + }); + + it("clears the log when Clear is clicked", () => { + mockUseSession.mockReturnValue({ + activityLog: makeEntries(), + clearActivityLog: mockClearActivityLog, + }); + render(); + fireEvent.click(screen.getByRole("button", { name: /clear/i })); + expect(mockClearActivityLog).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when Escape is pressed", () => { + mockUseSession.mockReturnValue({ + activityLog: makeEntries(), + clearActivityLog: mockClearActivityLog, + }); + const onClose = vi.fn(); + render(); + fireEvent.keyDown(window, { key: "Escape" }); + expect(onClose).toHaveBeenCalled(); + }); + + it("calls onClose when the scrim is clicked", () => { + mockUseSession.mockReturnValue({ + activityLog: makeEntries(), + clearActivityLog: mockClearActivityLog, + }); + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole("dialog")); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/example-apps/dashnote/test/App.test.tsx b/example-apps/dashnote/test/App.test.tsx index d4be5d4..e9b2d2b 100644 --- a/example-apps/dashnote/test/App.test.tsx +++ b/example-apps/dashnote/test/App.test.tsx @@ -61,6 +61,31 @@ vi.mock("../src/components/LoginModal", () => ({ LoginModal: ({ open }: { open: boolean }) =>
login:{String(open)}
, })); +vi.mock("../src/components/ActivityPanel", () => ({ + ActivityPanel: ({ open }: { open: boolean }) => ( +
activity:{String(open)}
+ ), +})); + +vi.mock("../src/components/NotesToolbar", () => ({ + NotesToolbar: ({ + title, + onOpenActivity, + }: { + title: string; + onOpenActivity?: () => void; + }) => ( +
+ toolbar:{title} + {onOpenActivity && ( + + )} +
+ ), +})); + function makeSession(overrides: Record = {}) { return { status: "idle", @@ -141,6 +166,23 @@ describe("App", () => { expect(screen.getByText("login:true")).toBeTruthy(); }); + it("opens the activity panel via ⌘L hotkey and toolbar button", () => { + mockUseSession.mockReturnValue(makeSession({ status: "readonly" })); + + render(); + expect(screen.getByText("activity:false")).toBeTruthy(); + + fireEvent.keyDown(window, { key: "l", metaKey: true }); + expect(screen.getByText("activity:true")).toBeTruthy(); + + // Toggling again closes it. + fireEvent.keyDown(window, { key: "l", metaKey: true }); + expect(screen.getByText("activity:false")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /open activity/i })); + expect(screen.getByText("activity:true")).toBeTruthy(); + }); + it("renders SettingsPanel when the settings tab is selected", () => { mockUseSession.mockReturnValue( makeSession({ diff --git a/example-apps/dashnote/test/DeleteNoteModal.test.tsx b/example-apps/dashnote/test/DeleteNoteModal.test.tsx index f891399..da68d7b 100644 --- a/example-apps/dashnote/test/DeleteNoteModal.test.tsx +++ b/example-apps/dashnote/test/DeleteNoteModal.test.tsx @@ -90,10 +90,14 @@ describe("DeleteNoteModal", () => { />, ); expect( - screen.getByRole("button", { name: /deleting…/i }).hasAttribute("disabled"), + screen + .getByRole("button", { name: /deleting…/i }) + .hasAttribute("disabled"), ).toBe(true); expect( - screen.getByRole("button", { name: /^cancel$/i }).hasAttribute("disabled"), + screen + .getByRole("button", { name: /^cancel$/i }) + .hasAttribute("disabled"), ).toBe(true); }); From a29e1d523b482680f08e457795b96a55120e00b1 Mon Sep 17 00:00:00 2001 From: thephez Date: Thu, 14 May 2026 11:19:50 -0400 Subject: [PATCH 02/15] feat(dashnote): add sign-in hero with View source and editor preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the padlock empty state on the Notes tab with a two-column teaching hero. Left column has the original "stores notes against your testnet identity" copy joined to the tutorial framing, a Sign in CTA, and a View source link to the dashnote folder on GitHub. Right column mirrors the real NoteEditor layout — Revision pill + "Updated", title + body, and the \$createdAt / \$updatedAt mono strip — so the preview matches what a logged-in user actually sees. The "no contract" branch still uses the original EmptyState. Also moves e.preventDefault() ahead of the bail-out gates in the NoteEditor ⌘S handler so the browser Save Page dialog never opens when the in-app save is unavailable (read-only, saving in flight, no changes, or oversize). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dashnote/src/components/NoteEditor.tsx | 2 +- .../src/components/NotesWorkspace.tsx | 129 ++++++++++++++---- .../dashnote/test/NotesWorkspace.test.tsx | 17 ++- 3 files changed, 120 insertions(+), 28 deletions(-) diff --git a/example-apps/dashnote/src/components/NoteEditor.tsx b/example-apps/dashnote/src/components/NoteEditor.tsx index b0deda9..c9e5b46 100644 --- a/example-apps/dashnote/src/components/NoteEditor.tsx +++ b/example-apps/dashnote/src/components/NoteEditor.tsx @@ -71,8 +71,8 @@ export function NoteEditor({ if (!isDesktop || isReadOnly || !hasSelection) return; const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") { - if (!canEdit || saving || !dirty || oversize) return; e.preventDefault(); + if (!canEdit || saving || !dirty || oversize) return; onSave(); } }; diff --git a/example-apps/dashnote/src/components/NotesWorkspace.tsx b/example-apps/dashnote/src/components/NotesWorkspace.tsx index 69160fe..798b744 100644 --- a/example-apps/dashnote/src/components/NotesWorkspace.tsx +++ b/example-apps/dashnote/src/components/NotesWorkspace.tsx @@ -584,31 +584,7 @@ export function NotesWorkspace({ return (
{!canRead ? ( -
@@ -165,28 +188,33 @@ export function NoteList({ key={note.id} type="button" onClick={() => onSelect(note.id)} - className={`block w-full rounded-[18px] border px-3 py-3 text-left transition ${ + className={`relative block w-full overflow-hidden rounded-lg px-3 py-3 text-left transition ${ active - ? "border-accent bg-surface-2 shadow-[0_16px_35px_-28px_rgba(0,0,0,0.5)]" - : "border-transparent bg-transparent hover:border-line hover:bg-surface-2" + ? "bg-surface-2" + : "bg-transparent hover:bg-surface-2" }`} > -
-
-
+ {active && ( +