From 8cadd6cd4afd07eb21ba97ed86c863d488cda46a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?=
Date: Mon, 25 May 2026 21:23:14 +0000
Subject: [PATCH 01/13] docs(specs): add quick-capture, mobile-first
scratchpad, voice + Mac satellite
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Spec updates landing first per the implementation plan at
.agents/plans/scratchpad-capture-voice-mobile.md:
- B.2: scratchpad voice mic affordance; mobile-default route to
/scratchpad on narrow viewports; mobile-tightened layout (composer
safe-area pinning, ≥36px tap targets, history sidebar behind toggle).
- B.7: rename "per-workspace scratchpad.md" to the actual global
scratchpad at config.dataDir/scratchpad.md; document the new
GET /quick-capture daemon-served HTML page that posts to the existing
block endpoint; add a Security/trust-model subsection naming the
daemon's localhost-trusted, CORS-open posture.
- B.1: document /?modal=new-workspace deeplink for external launchers.
- C: note that scripts/mac-satellite/ is macOS-only and outside make check.
---
.../plans/scratchpad-capture-voice-mobile.md | 285 ++++++++++++++++++
specs/B.1-repositories-workspaces.md | 1 +
specs/B.2-ade-cockpit.md | 5 +-
specs/B.7-operations-activity-mcp.md | 8 +-
specs/C-technical-stack.md | 1 +
5 files changed, 297 insertions(+), 3 deletions(-)
create mode 100644 .agents/plans/scratchpad-capture-voice-mobile.md
diff --git a/.agents/plans/scratchpad-capture-voice-mobile.md b/.agents/plans/scratchpad-capture-voice-mobile.md
new file mode 100644
index 00000000..dde318c9
--- /dev/null
+++ b/.agents/plans/scratchpad-capture-voice-mobile.md
@@ -0,0 +1,285 @@
+Activate the /implement-task skill first.
+
+# Plan: Scratchpad capture — voice, mobile, Mac satellite (Phase 1)
+
+## Acceptance Criteria
+
+Derived verbatim from the source scratchpad block:
+
+- [ ] AC1 — Voice mode: easy iPhone-first capture into the scratchpad.
+- [ ] AC2 — Mobile: scratchpad is the first view that opens.
+- [ ] AC3 — Mac satellite: a global shortcut (e.g. `cmd+shift+s`) opens a Spotlight-like scratchpad capture.
+- [ ] AC4 — Mac satellite: a separate shortcut shows the native "new workspace" menu so an agent can be started without opening the cockpit.
+
+Phase-1 operationalised acceptance criteria (what the implementation in this PR delivers):
+
+- [ ] AC1a — In the cockpit scratchpad composer, a microphone button is visible when the browser exposes a Web Speech API (`SpeechRecognition` or `webkitSpeechRecognition`, including iOS Safari). Tapping it starts recognition; live results append to the composer text. Tapping again, blurring the composer, or 5s of silence stops it. If neither API is exposed, the button is not rendered (no broken UI).
+- [ ] AC2a — On viewports matching `(max-width: 820px)`, navigating to the cockpit root with an **otherwise-bare URL** (no search, no hash) immediately routes the user to `/scratchpad`. The redirect runs only when `isBareRootLanding(window.location)` is true (mirroring the existing helper in `apps/web/src/lib/last-route.ts:57`) so any deep-link search/hash combination — including `/?modal=new-workspace` — continues to land on the cockpit. On wider viewports, `/` continues to render the cockpit dashboard unchanged.
+- [ ] AC2b — At 375px width (iPhone reference), the scratchpad layout is usable without horizontal scroll: composer is pinned to the bottom respecting `env(safe-area-inset-bottom)`; tap targets (mic, delete, save) are ≥ 36×36 logical px; history sidebar is hidden by default behind a toggle button in the page header.
+- [ ] AC3a — The daemon serves a standalone HTML page at `GET /quick-capture` (no React Router, no cockpit chrome): a single autofocused `
) : null}
- setComposer(event.target.value)}
- onInput={(event) => {
- const el = event.currentTarget;
- el.style.height = "auto";
- el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
- }}
- onKeyDown={onComposerKey}
- onBlur={onComposerBlur}
- disabled={!loaded}
- rows={2}
- />
+
+ setComposer(event.target.value)}
+ onInput={(event) => {
+ const el = event.currentTarget;
+ el.style.height = "auto";
+ el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
+ }}
+ onKeyDown={onComposerKey}
+ onBlur={onComposerBlur}
+ disabled={!loaded}
+ rows={2}
+ />
+
+ setComposer((prev) => (prev.trim().length === 0 ? text : `${prev.trim()} ${text}`))
+ }
+ />
+
{undo ? (
@@ -651,16 +659,24 @@ function BlockItem(props: BlockItemProps) {
if (block.isEditing) {
return (
-
onChange(block.id, event.target.value)}
- onBlur={(event) => onBlur(block.id, event.target.value)}
- onKeyDown={(event) => onKey(block.id, event)}
- />
+
+ onChange(block.id, event.target.value)}
+ onBlur={(event) => onBlur(block.id, event.target.value)}
+ onKeyDown={(event) => onKey(block.id, event)}
+ />
+ {
+ const merged = block.draft.trim().length === 0 ? text : `${block.draft.trim()} ${text}`;
+ onChange(block.id, merged);
+ }}
+ />
+
);
}
diff --git a/apps/web/src/scratchpad.css b/apps/web/src/scratchpad.css
index be666c05..f3ea8e4b 100644
--- a/apps/web/src/scratchpad.css
+++ b/apps/web/src/scratchpad.css
@@ -273,6 +273,51 @@
color: var(--color-danger, #f08097);
}
+.scratchpad-composer-row,
+.scratchpad-block-edit-row {
+ display: flex;
+ align-items: flex-end;
+ gap: 6px;
+ width: 100%;
+}
+
+.scratchpad-composer-row > .scratchpad-composer-input,
+.scratchpad-block-edit-row > .scratchpad-block-textarea {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+/* Voice capture mic toggle — rendered next to the composer textarea and per-block editor. */
+.scratchpad-mic {
+ flex: 0 0 auto;
+ width: 36px;
+ height: 36px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--panel);
+ color: inherit;
+ cursor: pointer;
+}
+
+.scratchpad-mic:hover {
+ background: var(--panel-strong, var(--panel));
+}
+
+.scratchpad-mic.is-listening {
+ background: var(--color-action, #4f8cff);
+ color: var(--topbar-bg, #fff);
+ border-color: var(--color-action, #4f8cff);
+ animation: scratchpad-mic-pulse 1.4s ease-in-out infinite;
+}
+
+@keyframes scratchpad-mic-pulse {
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(79, 140, 255, 0.4); }
+ 50% { box-shadow: 0 0 0 6px rgba(79, 140, 255, 0); }
+}
+
/* Undo toast for optimistic-delete. */
.scratchpad-undo-toast {
position: fixed;
From 6cba33ead8a878fdde66f61690955ecaea5b595b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?=
Date: Mon, 25 May 2026 21:34:32 +0000
Subject: [PATCH 04/13] =?UTF-8?q?feat(cockpit):=20mobile-first=20scratchpa?=
=?UTF-8?q?d=20=E2=80=94=20narrow=20viewport=20lands=20on=20/scratchpad?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On viewports matching (max-width: 820px), a bare landing at the cockpit
root now redirects to /scratchpad so opening Citadel on iPhone goes
straight to the capture surface. Deeplinks with any search or hash
(including /?modal=new-workspace) are unaffected — the redirect mirrors
the isBareRootLanding semantics in last-route.ts.
The pre-router gate (applyBootstrapNavigationFromWindow) intentionally
runs the mobile rule BEFORE bootstrapLastRoute so a mobile user with a
persisted desktop route (e.g. /settings) still lands on scratchpad. On
desktop the mobile rule no-ops and bootstrap restores the saved route
unchanged.
Mobile-tightened scratchpad layout: composer pinned with safe-area
padding (viewport-fit=cover now in index.html so env() is non-zero on
iOS), history sidebar collapsed behind a History toggle in the page
header, mic/history controls sized to ≥36×36 tap targets.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/web/index.html | 2 +-
apps/web/src/lib/bootstrap-redirect.test.ts | 64 ++++++++++++++++++++
apps/web/src/lib/bootstrap-redirect.ts | 37 ++++++++++++
apps/web/src/lib/mobile-redirect.test.ts | 29 +++++++++
apps/web/src/lib/mobile-redirect.ts | 19 ++++++
apps/web/src/main.tsx | 15 ++---
apps/web/src/routes/scratchpad.tsx | 21 ++++++-
apps/web/src/scratchpad.css | 66 +++++++++++++++++++++
8 files changed, 243 insertions(+), 10 deletions(-)
create mode 100644 apps/web/src/lib/bootstrap-redirect.test.ts
create mode 100644 apps/web/src/lib/bootstrap-redirect.ts
create mode 100644 apps/web/src/lib/mobile-redirect.test.ts
create mode 100644 apps/web/src/lib/mobile-redirect.ts
diff --git a/apps/web/index.html b/apps/web/index.html
index c880c7fb..c9975da3 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -2,7 +2,7 @@
-
+
diff --git a/apps/web/src/lib/bootstrap-redirect.test.ts b/apps/web/src/lib/bootstrap-redirect.test.ts
new file mode 100644
index 00000000..4ea2e3d7
--- /dev/null
+++ b/apps/web/src/lib/bootstrap-redirect.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it, vi } from "vitest";
+import { applyBootstrapNavigation } from "./bootstrap-redirect.js";
+
+function memoryStorage(seed: Record = {}) {
+ const data = new Map(Object.entries(seed));
+ return {
+ getItem: (k: string) => data.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ data.set(k, v);
+ },
+ removeItem: (k: string) => {
+ data.delete(k);
+ },
+ };
+}
+
+function locOf(href: string) {
+ // href may be "/" | "/foo?bar=1" | "/?modal=x" | "/foo#frag"
+ const hashIdx = href.indexOf("#");
+ const hash = hashIdx >= 0 ? href.slice(hashIdx) : "";
+ const noHash = hashIdx >= 0 ? href.slice(0, hashIdx) : href;
+ const qIdx = noHash.indexOf("?");
+ const search = qIdx >= 0 ? noHash.slice(qIdx) : "";
+ const pathname = qIdx >= 0 ? noHash.slice(0, qIdx) : noHash;
+ return { pathname, search, hash };
+}
+
+describe("applyBootstrapNavigation — mobile redirect wins over saved last-route", () => {
+ it("narrow viewport + bare root + saved /settings → URL becomes /scratchpad (mobile wins)", () => {
+ const history = { replaceState: vi.fn() };
+ const storage = memoryStorage({ "citadel:lastRoute": "/settings" });
+ applyBootstrapNavigation({ location: locOf("/"), history, storage, narrow: true });
+ expect(history.replaceState).toHaveBeenCalledWith({}, "", "/scratchpad");
+ expect(history.replaceState).toHaveBeenCalledTimes(1);
+ });
+
+ it("wide viewport + bare root + saved /settings → URL becomes /settings (bootstrap wins)", () => {
+ const history = { replaceState: vi.fn() };
+ const storage = memoryStorage({ "citadel:lastRoute": "/settings" });
+ applyBootstrapNavigation({ location: locOf("/"), history, storage, narrow: false });
+ expect(history.replaceState).toHaveBeenCalledWith(null, "", "/settings");
+ });
+
+ it("narrow viewport + /?modal=new-workspace → URL untouched (deeplink wins over mobile default)", () => {
+ const history = { replaceState: vi.fn() };
+ const storage = memoryStorage();
+ applyBootstrapNavigation({ location: locOf("/?modal=new-workspace"), history, storage, narrow: true });
+ expect(history.replaceState).not.toHaveBeenCalled();
+ });
+
+ it("narrow viewport + deep path (/settings) → URL untouched", () => {
+ const history = { replaceState: vi.fn() };
+ const storage = memoryStorage();
+ applyBootstrapNavigation({ location: locOf("/settings"), history, storage, narrow: true });
+ expect(history.replaceState).not.toHaveBeenCalled();
+ });
+
+ it("wide viewport + bare root + no saved route → URL untouched", () => {
+ const history = { replaceState: vi.fn() };
+ const storage = memoryStorage();
+ applyBootstrapNavigation({ location: locOf("/"), history, storage, narrow: false });
+ expect(history.replaceState).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/lib/bootstrap-redirect.ts b/apps/web/src/lib/bootstrap-redirect.ts
new file mode 100644
index 00000000..98025453
--- /dev/null
+++ b/apps/web/src/lib/bootstrap-redirect.ts
@@ -0,0 +1,37 @@
+import { bootstrapLastRoute } from "./last-route.js";
+import { MOBILE_MEDIA_QUERY, mobileScratchpadRedirect } from "./mobile-redirect.js";
+
+type StorageLike = Pick;
+type HistoryLike = Pick;
+
+export type BootstrapNavigationInput = {
+ location: Pick;
+ history: HistoryLike;
+ storage?: StorageLike | null;
+ narrow: boolean;
+};
+
+// Synchronous pre-router navigation gate. Runs the mobile-scratchpad redirect
+// BEFORE bootstrapLastRoute so a mobile user with a persisted desktop route
+// (e.g. /settings) still lands on the scratchpad — which is what AC2 promises
+// ("scratchpad is the first view that opens on mobile"). On wide viewports the
+// mobile rule no-ops and bootstrapLastRoute owns the decision unchanged.
+export function applyBootstrapNavigation(input: BootstrapNavigationInput): void {
+ const { location, history, narrow } = input;
+ const mobileTarget = mobileScratchpadRedirect(location, narrow);
+ if (mobileTarget) {
+ history.replaceState({}, "", mobileTarget);
+ return;
+ }
+ bootstrapLastRoute(location, history, input.storage ?? null);
+}
+
+export function applyBootstrapNavigationFromWindow(): void {
+ if (typeof window === "undefined") return;
+ const narrow = window.matchMedia(MOBILE_MEDIA_QUERY).matches;
+ applyBootstrapNavigation({
+ location: window.location,
+ history: window.history,
+ narrow,
+ });
+}
diff --git a/apps/web/src/lib/mobile-redirect.test.ts b/apps/web/src/lib/mobile-redirect.test.ts
new file mode 100644
index 00000000..734067cd
--- /dev/null
+++ b/apps/web/src/lib/mobile-redirect.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { mobileScratchpadRedirect } from "./mobile-redirect.js";
+
+const loc = (pathname: string, search = "", hash = "") => ({ pathname, search, hash });
+
+describe("mobileScratchpadRedirect", () => {
+ it("redirects to /scratchpad only when the URL is bare AND the viewport is narrow", () => {
+ expect(mobileScratchpadRedirect(loc("/"), true)).toBe("/scratchpad");
+ });
+
+ it("returns null when the viewport is wide", () => {
+ expect(mobileScratchpadRedirect(loc("/"), false)).toBeNull();
+ });
+
+ it("returns null when the path is not the bare root", () => {
+ expect(mobileScratchpadRedirect(loc("/scratchpad"), true)).toBeNull();
+ expect(mobileScratchpadRedirect(loc("/settings"), true)).toBeNull();
+ expect(mobileScratchpadRedirect(loc("/operations"), true)).toBeNull();
+ });
+
+ it("returns null when the root carries a query string (regression: ?modal=new-workspace must NOT be eaten on mobile)", () => {
+ expect(mobileScratchpadRedirect(loc("/", "?modal=new-workspace"), true)).toBeNull();
+ expect(mobileScratchpadRedirect(loc("/", "?anything=x"), true)).toBeNull();
+ });
+
+ it("returns null when the root carries a hash fragment", () => {
+ expect(mobileScratchpadRedirect(loc("/", "", "#foo"), true)).toBeNull();
+ });
+});
diff --git a/apps/web/src/lib/mobile-redirect.ts b/apps/web/src/lib/mobile-redirect.ts
new file mode 100644
index 00000000..48c2975c
--- /dev/null
+++ b/apps/web/src/lib/mobile-redirect.ts
@@ -0,0 +1,19 @@
+import { isBareRootLanding } from "./last-route.js";
+
+// Match the breakpoint baked into apps/web/src/styles.css and responsive.css —
+// keep the value here in sync so the redirect threshold and the mobile CSS
+// kick in at the same width.
+export const MOBILE_MEDIA_QUERY = "(max-width: 820px)";
+
+// Returns the scratchpad path when the user is landing on the bare root on a
+// narrow viewport, so the scratchpad becomes the first view on mobile. Returns
+// null in every other case — any deep link (different pathname, or root with
+// search/hash, e.g. /?modal=new-workspace) wins over the mobile default.
+export function mobileScratchpadRedirect(
+ loc: Pick,
+ narrow: boolean,
+): "/scratchpad" | null {
+ if (!narrow) return null;
+ if (!isBareRootLanding(loc)) return null;
+ return "/scratchpad";
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index c293b990..ebbee2a9 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -4,7 +4,8 @@ import { useEffect } from "react";
import { createRoot } from "react-dom/client";
import { queryClient } from "./api.js";
import { Cockpit } from "./cockpit.js";
-import { bootstrapLastRoute, clearLastRoute, saveLastRoute } from "./lib/last-route.js";
+import { applyBootstrapNavigationFromWindow } from "./lib/bootstrap-redirect.js";
+import { clearLastRoute, saveLastRoute } from "./lib/last-route.js";
import { DashboardView } from "./routes/dashboard.js";
import { HistoryView } from "./routes/history.js";
import { OnboardingView } from "./routes/onboarding.js";
@@ -130,12 +131,12 @@ function NotFoundView() {
);
}
-// Restore the last visited route BEFORE the router boots so it picks the
-// correct initial location off the URL bar. The decision logic lives in
-// bootstrapLastRoute so it can be unit-tested independently.
-if (typeof window !== "undefined") {
- bootstrapLastRoute(window.location, window.history);
-}
+// Decide the initial URL BEFORE the router boots so it picks the right
+// location off the URL bar. applyBootstrapNavigationFromWindow first checks
+// the mobile-default rule (narrow viewport + bare root → /scratchpad) and,
+// when that doesn't fire, falls through to bootstrapLastRoute to restore a
+// persisted desktop route. Each rule is unit-tested in lib/.
+applyBootstrapNavigationFromWindow();
const router = createRouter({
routeTree: rootRoute.addChildren([
diff --git a/apps/web/src/routes/scratchpad.tsx b/apps/web/src/routes/scratchpad.tsx
index 2b4cd84a..76020779 100644
--- a/apps/web/src/routes/scratchpad.tsx
+++ b/apps/web/src/routes/scratchpad.tsx
@@ -1,6 +1,6 @@
import type { ScratchpadBlockSummary, ScratchpadHistorySummary, ScratchpadSnapshot } from "@citadel/contracts";
import { Link } from "@tanstack/react-router";
-import { ArrowLeft, Trash2, X } from "lucide-react";
+import { ArrowLeft, Clock, Trash2, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "../api.js";
import { VoiceCaptureButton } from "../components/voice-capture-button.js";
@@ -31,6 +31,9 @@ export function ScratchpadView() {
const [restoring, setRestoring] = useState(false);
const [composerError, setComposerError] = useState(null);
const [undo, setUndo] = useState(null);
+ // On narrow viewports the history sidebar is hidden behind this toggle.
+ // Desktop CSS always shows the sidebar regardless of this flag.
+ const [historyOpen, setHistoryOpen] = useState(false);
const listRef = useRef(null);
const composerRef = useRef(null);
@@ -426,6 +429,17 @@ export function ScratchpadView() {
{renderStatus(updatedAt)}
+ setHistoryOpen((open) => !open)}
+ >
+
+ History
+
@@ -500,7 +514,10 @@ export function ScratchpadView() {
>
)}
-
+
Versions
{history.length}
diff --git a/apps/web/src/scratchpad.css b/apps/web/src/scratchpad.css
index f3ea8e4b..50d67cff 100644
--- a/apps/web/src/scratchpad.css
+++ b/apps/web/src/scratchpad.css
@@ -709,3 +709,69 @@
opacity: 0.6;
cursor: not-allowed;
}
+
+/* History toggle — visible in the page header. Lives next to the status pill;
+ the @media rule below promotes it to the primary affordance on mobile and
+ uses it to drive the sidebar's visibility (which the desktop CSS ignores). */
+.scratchpad-history-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 36px;
+ padding: 0 10px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--panel);
+ color: inherit;
+ cursor: pointer;
+ font-size: 12px;
+ margin-left: 10px;
+}
+
+.scratchpad-history-toggle.is-open {
+ background: var(--color-action, #4f8cff);
+ color: var(--topbar-bg, #fff);
+ border-color: var(--color-action, #4f8cff);
+}
+
+/* Mobile-tightened scratchpad layout. The cockpit-wide breakpoint at 820px is
+ in styles.css; this rule scopes mobile-only adjustments to the scratchpad
+ page so they don't bleed into the rest of the cockpit. */
+@media (max-width: 820px) {
+ .scratchpad-page {
+ /* Use dvh so the iOS URL-bar collapse doesn't leave a strip of empty
+ viewport at the bottom that pushes the composer off-screen. */
+ height: 100dvh;
+ }
+
+ .scratchpad-body {
+ padding: 8px;
+ gap: 8px;
+ flex-direction: column;
+ }
+
+ .scratchpad-blocks-pane {
+ /* Composer + safe-area gap go inside this pane via padding-bottom on the
+ composer itself; the pane stays a normal flex child. */
+ flex: 1 1 auto;
+ }
+
+ .scratchpad-composer {
+ /* Respect the iOS home-indicator inset so the composer doesn't sit on top
+ of it. viewport-fit=cover in index.html is required for the inset value
+ to be non-zero on iOS. */
+ padding-bottom: max(10px, env(safe-area-inset-bottom));
+ }
+
+ /* History sidebar is hidden by default on mobile; the toggle in the header
+ promotes it to a full-pane drawer when the user wants it. */
+ .scratchpad-history {
+ display: none;
+ }
+
+ .scratchpad-history.is-open {
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+ }
+}
From c57ced573bab8b08927ec6fe2d9d5ce671fb693b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?=
Date: Mon, 25 May 2026 21:36:11 +0000
Subject: [PATCH 05/13] feat(daemon): GET /quick-capture serves a
Spotlight-style standalone page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A self-contained HTML page (inline CSS+JS, no bundler) designed to be
opened in a chromeless popup by external shortcuts. It posts to the
existing POST /api/scratchpad/blocks — no new write surface — and on
success attempts window.close(). In Chrome --app= mode close succeeds;
in Safari and regular tabs the close call no-ops and the page swaps
itself for a "Captured. Press ⌘W to close." confirmation ~60ms later.
Includes the same Web Speech API mic toggle as the cockpit composer
(graceful absence when neither SpeechRecognition nor webkitSpeechRecognition
is exposed). Registered before registerSpaFallback so the SPA wildcard
doesn't swallow the path; route tests guard against accidental sub-path
matching and method confusion.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/daemon/src/app.ts | 2 +
apps/daemon/src/quick-capture-route.test.ts | 72 ++++++++++++
apps/daemon/src/quick-capture-route.ts | 121 ++++++++++++++++++++
3 files changed, 195 insertions(+)
create mode 100644 apps/daemon/src/quick-capture-route.test.ts
create mode 100644 apps/daemon/src/quick-capture-route.ts
diff --git a/apps/daemon/src/app.ts b/apps/daemon/src/app.ts
index 675b6fb3..d4cdd9d3 100644
--- a/apps/daemon/src/app.ts
+++ b/apps/daemon/src/app.ts
@@ -40,6 +40,7 @@ import { deriveReadiness, workspaceAppHookSample } from "./readiness.js";
import { registerRuntimeUsageRoutes } from "./runtime-usage-routes.js";
import { registerScheduledAgentRoutes } from "./scheduled-agent-routes.js";
import { backfillIfEmpty } from "./scratchpad-history.js";
+import { registerQuickCaptureRoute } from "./quick-capture-route.js";
import { registerScratchpadRoutes } from "./scratchpad-routes.js";
import { scratchpadPath } from "./scratchpad.js";
import { registerSpaFallback } from "./spa-fallback-route.js";
@@ -746,6 +747,7 @@ export function createDaemonApp(input: {
req.on("close", () => sseClients.delete(res));
});
+ registerQuickCaptureRoute({ app });
registerSpaFallback({ app });
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
diff --git a/apps/daemon/src/quick-capture-route.test.ts b/apps/daemon/src/quick-capture-route.test.ts
new file mode 100644
index 00000000..7973d745
--- /dev/null
+++ b/apps/daemon/src/quick-capture-route.test.ts
@@ -0,0 +1,72 @@
+import http from "node:http";
+import express from "express";
+import { afterEach, describe, expect, it } from "vitest";
+import { registerQuickCaptureRoute } from "./quick-capture-route.js";
+
+const servers: http.Server[] = [];
+
+afterEach(async () => {
+ await Promise.all(
+ servers.splice(0).map(
+ (server) =>
+ new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
+ ),
+ );
+});
+
+async function startApp(): Promise {
+ const app = express();
+ registerQuickCaptureRoute({ app });
+ // Mimic the daemon's final 404 so we can probe fall-through behavior.
+ app.use((_req, res) => res.status(404).json({ error: "not_found" }));
+ const server = http.createServer(app);
+ servers.push(server);
+ return new Promise((resolve) => {
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("expected TCP address");
+ resolve(`http://127.0.0.1:${address.port}`);
+ });
+ });
+}
+
+describe("GET /quick-capture", () => {
+ it("returns 200 with text/html", async () => {
+ const baseUrl = await startApp();
+ const response = await fetch(`${baseUrl}/quick-capture`);
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toMatch(/text\/html/);
+ expect(response.headers.get("content-type")).toMatch(/charset=utf-8/i);
+ });
+
+ it("includes a and references /api/scratchpad/blocks verbatim", async () => {
+ const baseUrl = await startApp();
+ const response = await fetch(`${baseUrl}/quick-capture`);
+ const body = await response.text();
+ expect(body).toMatch(/ {
+ const baseUrl = await startApp();
+ const body = await (await fetch(`${baseUrl}/quick-capture`)).text();
+ expect(body).toContain("Press ⌘W to close");
+ });
+
+ it("does NOT match a sub-path (regression guard against wildcard registration)", async () => {
+ const baseUrl = await startApp();
+ const response = await fetch(`${baseUrl}/quick-capture/anything`);
+ expect(response.status).toBe(404);
+ expect(response.headers.get("content-type")).toMatch(/application\/json/);
+ });
+
+ it("POST /quick-capture is not the HTML page", async () => {
+ const baseUrl = await startApp();
+ const response = await fetch(`${baseUrl}/quick-capture`, { method: "POST" });
+ // Express returns 404 for an unregistered method+path combination via the
+ // final handler — confirms registration was GET-only.
+ expect(response.status).toBe(404);
+ const ct = response.headers.get("content-type") ?? "";
+ expect(ct).not.toMatch(/text\/html/);
+ });
+});
diff --git a/apps/daemon/src/quick-capture-route.ts b/apps/daemon/src/quick-capture-route.ts
new file mode 100644
index 00000000..6315b1ba
--- /dev/null
+++ b/apps/daemon/src/quick-capture-route.ts
@@ -0,0 +1,121 @@
+import type express from "express";
+
+// Standalone HTML page served outside /api/*. Designed to be opened in a
+// Spotlight-style chromeless popup window by external shortcuts
+// (scripts/mac-satellite/quick-capture.sh) and post via the existing block
+// endpoint — no new daemon write surface, just a convenience UI.
+//
+// Inlined CSS + JS keep the page bundler-free; the daemon serves it as a
+// single string, which means it works even when the cockpit's Vite/SPA bundle
+// isn't built (helpful for the daemon-only dev loop). Voice capture reuses
+// webkitSpeechRecognition / SpeechRecognition with the same 10s silence
+// timeout as the cockpit hook.
+const HTML = `
+
+
+
+
+Citadel — Quick capture
+
+
+
+
+
Quick capture · ⌘+Enter to save · Esc to close
+
+
+ 🎙
+
+
+
+
+
+
+`;
+
+export function registerQuickCaptureRoute({ app }: { app: express.Express }): void {
+ app.get("/quick-capture", (_req, res) => {
+ res.setHeader("Cache-Control", "no-cache");
+ res.type("text/html; charset=utf-8").send(HTML);
+ });
+}
From 6eb95fcd1b3448141d402738db25fd8c070d4422 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?=
Date: Mon, 25 May 2026 21:37:32 +0000
Subject: [PATCH 06/13] feat(cockpit): deeplink /?modal=new-workspace opens
Create Workspace modal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
External launchers (the Mac satellite new-workspace shortcut) jump
straight into workspace creation by opening the cockpit at this URL.
The Cockpit component checks shouldOpenNewWorkspaceModal on mount, opens
the existing modal, and consumeNewWorkspaceDeeplink strips the param via
history.replaceState — preserving other params and the hash — so a page
refresh doesn't re-open it.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/web/src/cockpit.tsx | 16 ++++
.../src/lib/new-workspace-deeplink.test.ts | 75 +++++++++++++++++++
apps/web/src/lib/new-workspace-deeplink.ts | 28 +++++++
3 files changed, 119 insertions(+)
create mode 100644 apps/web/src/lib/new-workspace-deeplink.test.ts
create mode 100644 apps/web/src/lib/new-workspace-deeplink.ts
diff --git a/apps/web/src/cockpit.tsx b/apps/web/src/cockpit.tsx
index 237e83db..e84ed071 100644
--- a/apps/web/src/cockpit.tsx
+++ b/apps/web/src/cockpit.tsx
@@ -14,6 +14,7 @@ import { Stage } from "./stage.js";
import { UsageIndicator } from "./usage-indicator.js";
import { startColumnDrag, useCockpitLayout } from "./use-cockpit-layout.js";
import { useResolvedTheme } from "./use-resolved-theme.js";
+import { consumeNewWorkspaceDeeplink, shouldOpenNewWorkspaceModal } from "./lib/new-workspace-deeplink.js";
import { prToneFor } from "./workspace-card.js";
const STORAGE_LAST_WORKSPACE = "citadel.last-workspace";
@@ -46,6 +47,21 @@ export function Cockpit() {
}
}, [search.workspace, navigate, location.pathname, setActiveWorkspaceId]);
+ // Deeplink: /?modal=new-workspace opens the existing Create Workspace modal
+ // on mount, then strips the param so a refresh doesn't re-open it. Used by
+ // external launchers (scripts/mac-satellite/new-workspace.sh).
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ if (!shouldOpenNewWorkspaceModal(window.location.search)) return;
+ setCreateWorkspaceOpen(true);
+ consumeNewWorkspaceDeeplink({
+ pathname: window.location.pathname,
+ search: window.location.search,
+ hash: window.location.hash,
+ history: window.history,
+ });
+ }, []);
+
const activeWorkspace = useMemo(() => {
if (!data?.workspaces.length) return null;
return (
diff --git a/apps/web/src/lib/new-workspace-deeplink.test.ts b/apps/web/src/lib/new-workspace-deeplink.test.ts
new file mode 100644
index 00000000..cab1e810
--- /dev/null
+++ b/apps/web/src/lib/new-workspace-deeplink.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it, vi } from "vitest";
+import { consumeNewWorkspaceDeeplink, shouldOpenNewWorkspaceModal } from "./new-workspace-deeplink.js";
+
+describe("shouldOpenNewWorkspaceModal", () => {
+ it("true for ?modal=new-workspace", () => {
+ expect(shouldOpenNewWorkspaceModal("?modal=new-workspace")).toBe(true);
+ });
+
+ it("false for an empty search string", () => {
+ expect(shouldOpenNewWorkspaceModal("")).toBe(false);
+ });
+
+ it("false for any other modal value", () => {
+ expect(shouldOpenNewWorkspaceModal("?modal=add-repo")).toBe(false);
+ expect(shouldOpenNewWorkspaceModal("?modal=")).toBe(false);
+ });
+
+ it("false when the param is missing entirely", () => {
+ expect(shouldOpenNewWorkspaceModal("?source=mobile&workspace=ws_1")).toBe(false);
+ });
+
+ it("true when the param is one of several", () => {
+ expect(shouldOpenNewWorkspaceModal("?source=satellite&modal=new-workspace")).toBe(true);
+ });
+});
+
+describe("consumeNewWorkspaceDeeplink", () => {
+ it("strips only the modal param via replaceState, preserving other params", () => {
+ const history = { replaceState: vi.fn() };
+ consumeNewWorkspaceDeeplink({
+ pathname: "/",
+ search: "?source=satellite&modal=new-workspace",
+ hash: "",
+ history,
+ });
+ expect(history.replaceState).toHaveBeenCalledTimes(1);
+ const [, , url] = history.replaceState.mock.calls[0] ?? [];
+ expect(url).toBe("/?source=satellite");
+ });
+
+ it("strips the search entirely when modal was the only param", () => {
+ const history = { replaceState: vi.fn() };
+ consumeNewWorkspaceDeeplink({
+ pathname: "/",
+ search: "?modal=new-workspace",
+ hash: "",
+ history,
+ });
+ const [, , url] = history.replaceState.mock.calls[0] ?? [];
+ expect(url).toBe("/");
+ });
+
+ it("preserves the hash fragment when stripping the modal param", () => {
+ const history = { replaceState: vi.fn() };
+ consumeNewWorkspaceDeeplink({
+ pathname: "/dashboard",
+ search: "?modal=new-workspace",
+ hash: "#x",
+ history,
+ });
+ const [, , url] = history.replaceState.mock.calls[0] ?? [];
+ expect(url).toBe("/dashboard#x");
+ });
+
+ it("does nothing when the param isn't present", () => {
+ const history = { replaceState: vi.fn() };
+ consumeNewWorkspaceDeeplink({
+ pathname: "/",
+ search: "?other=1",
+ hash: "",
+ history,
+ });
+ expect(history.replaceState).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/lib/new-workspace-deeplink.ts b/apps/web/src/lib/new-workspace-deeplink.ts
new file mode 100644
index 00000000..6331c284
--- /dev/null
+++ b/apps/web/src/lib/new-workspace-deeplink.ts
@@ -0,0 +1,28 @@
+// Deeplink helper for the cockpit's Create Workspace modal.
+//
+// External launchers (e.g. scripts/mac-satellite/new-workspace.sh) jump
+// straight into workspace creation by opening the cockpit at
+// /?modal=new-workspace. The Cockpit component checks shouldOpenNewWorkspaceModal
+// on mount and, if true, opens the existing modal AND strips the param from
+// the URL via consumeNewWorkspaceDeeplink so a page refresh doesn't re-open it.
+
+export function shouldOpenNewWorkspaceModal(search: string): boolean {
+ const params = new URLSearchParams(search);
+ return params.get("modal") === "new-workspace";
+}
+
+type HistoryLike = Pick;
+
+export function consumeNewWorkspaceDeeplink(input: {
+ pathname: string;
+ search: string;
+ hash: string;
+ history: HistoryLike;
+}): void {
+ if (!shouldOpenNewWorkspaceModal(input.search)) return;
+ const params = new URLSearchParams(input.search);
+ params.delete("modal");
+ const next = params.toString();
+ const url = `${input.pathname}${next ? `?${next}` : ""}${input.hash}`;
+ input.history.replaceState({}, "", url);
+}
From 2676f60a5e763e0e4abd93c29f5320be4aa9390b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?=
Date: Mon, 25 May 2026 21:38:54 +0000
Subject: [PATCH 07/13] feat(mac-satellite): Spotlight-style global-shortcut
launchers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two thin POSIX-shell helpers that wrap the daemon's web surfaces into
global-shortcut targets on macOS:
- quick-capture.sh opens GET /quick-capture in a Chrome --app= popup
(Safari fallback) sized like Spotlight. Liveness-probes the daemon
first; surfaces a macOS notification if it's not reachable.
- new-workspace.sh opens /?modal=new-workspace in the default browser,
which the cockpit recognises as a deeplink to the Create Workspace
modal.
Both default to the systemd long-term daemon at 127.0.0.1:4010,
overridable via CITADEL_HOST/CITADEL_PORT. Worktree daemons (4110+) are
deliberately NOT auto-discovered — a global shortcut can't infer which
worktree is active.
README documents Hammerspoon (recommended, silent) and Shortcuts.app
(fallback, banner) bindings plus the trust model (daemon is single-user
localhost-trusted; cross-LAN exposure is operator-responsibility).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
scripts/mac-satellite/README.md | 76 ++++++++++++++++++++++++++
scripts/mac-satellite/new-workspace.sh | 28 ++++++++++
scripts/mac-satellite/quick-capture.sh | 61 +++++++++++++++++++++
3 files changed, 165 insertions(+)
create mode 100644 scripts/mac-satellite/README.md
create mode 100755 scripts/mac-satellite/new-workspace.sh
create mode 100755 scripts/mac-satellite/quick-capture.sh
diff --git a/scripts/mac-satellite/README.md b/scripts/mac-satellite/README.md
new file mode 100644
index 00000000..f6f58330
--- /dev/null
+++ b/scripts/mac-satellite/README.md
@@ -0,0 +1,76 @@
+# Citadel — Mac satellite shortcuts
+
+Two tiny shell helpers that turn the local Citadel daemon's web surfaces into
+Spotlight-style global-shortcut targets. Both target the long-term systemd
+daemon on `127.0.0.1:4010` by default.
+
+| Script | What it does | Suggested shortcut |
+|---|---|---|
+| `quick-capture.sh` | Opens `GET /quick-capture` in a chromeless 640×220 popup. Type a thought, mic-dictate it, ⌘+Enter to save into the scratchpad as a new block, Esc/⌘W to dismiss. | `⌘+⇧+S` |
+| `new-workspace.sh` | Opens `/?modal=new-workspace` in the user's default browser. Cockpit auto-opens the Create Workspace modal. | `⌘+⇧+N` |
+
+> These scripts are intentionally **not** wired into `make check` — they
+> depend on macOS-only tooling (`open`, `osascript`, AppleScript, Chrome
+> `--app=`) that cannot run in Linux CI.
+
+## Prerequisite
+
+The Citadel daemon must be running and reachable. The default target is the
+long-term systemd daemon on `127.0.0.1:4010` (see `make install`).
+
+## Trust model
+
+Citadel is a **single-user, local-first** tool. The daemon binds `127.0.0.1`
+by default with CORS allow-all and no per-request authentication —
+`POST /api/scratchpad/blocks` (which the quick-capture page submits to) is
+unauthenticated by design. These scripts do not change that posture; they're
+a convenience layer over an already-open localhost surface. If you've
+configured the daemon to bind a LAN address or you tunnel/forward the port,
+you own the corresponding network gate (SSH tunnel, VPN, auth-adding proxy).
+
+## Worktrees
+
+The shortcuts target **one** daemon — the long-term systemd one. Worktree
+daemons (port `4110–4209` per CLAUDE.md) are deliberately NOT auto-discovered:
+a global shortcut bound at the OS level can't know which worktree is "active",
+and silently picking one would be a footgun. If you want a shortcut bound to
+a specific worktree, set `CITADEL_PORT` (and optionally `CITADEL_HOST`) in
+the shortcut's environment — Hammerspoon and Shortcuts.app both support env
+overrides per binding.
+
+## Wiring the shortcut — Hammerspoon (recommended)
+
+[Hammerspoon](https://www.hammerspoon.org/) is a free macOS automation tool.
+After installing it and granting Accessibility permission, drop this into
+`~/.hammerspoon/init.lua`:
+
+```lua
+local function run(cmd)
+ return function() hs.execute(cmd, true) end
+end
+
+local repo = os.getenv("HOME") .. "/path/to/citadel"
+
+-- ⌘ + Shift + S → quick-capture popup
+hs.hotkey.bind({"cmd", "shift"}, "s", run(repo .. "/scripts/mac-satellite/quick-capture.sh"))
+
+-- ⌘ + Shift + N → new-workspace
+hs.hotkey.bind({"cmd", "shift"}, "n", run(repo .. "/scripts/mac-satellite/new-workspace.sh"))
+```
+
+Then `Reload Config` from Hammerspoon's menu bar icon. Done.
+
+## Wiring the shortcut — Shortcuts.app (fallback)
+
+If you'd rather not install Hammerspoon:
+
+1. Open **Shortcuts.app** → ➕ to create a new shortcut.
+2. Add a **Run Shell Script** action. Set the script to the absolute path of
+ `quick-capture.sh`. Set Shell to `/bin/sh`.
+3. Name the shortcut `Citadel Quick Capture`.
+4. Click the ⓘ (info) panel → **Add Keyboard Shortcut** → press `⌘+⇧+S`.
+5. Repeat for `new-workspace.sh` with `⌘+⇧+N`.
+
+Shortcuts.app shows a brief macOS-style banner when the shortcut runs, which
+is harmless but visible. Hammerspoon is silent — that's why it's the
+recommendation.
diff --git a/scripts/mac-satellite/new-workspace.sh b/scripts/mac-satellite/new-workspace.sh
new file mode 100755
index 00000000..656af39f
--- /dev/null
+++ b/scripts/mac-satellite/new-workspace.sh
@@ -0,0 +1,28 @@
+#!/bin/sh
+# Citadel new-workspace launcher (macOS).
+#
+# Opens the cockpit at /?modal=new-workspace in the user's default browser.
+# The cockpit recognises the deeplink on mount, auto-opens the existing
+# Create Workspace modal, and strips the param from the URL — so the user
+# starts a new workspace without navigating through the cockpit shell.
+#
+# Daemon target: the long-term systemd Citadel daemon at 127.0.0.1:4010
+# (overridable via CITADEL_HOST / CITADEL_PORT). See quick-capture.sh for
+# why worktree daemons aren't auto-discovered.
+
+set -eu
+
+CITADEL_HOST="${CITADEL_HOST:-127.0.0.1}"
+CITADEL_PORT="${CITADEL_PORT:-4010}"
+URL="http://${CITADEL_HOST}:${CITADEL_PORT}/?modal=new-workspace"
+
+if ! curl -fsS --max-time 2 "http://${CITADEL_HOST}:${CITADEL_PORT}/api/state" > /dev/null 2>&1 \
+ && ! curl -fsS --max-time 2 "http://${CITADEL_HOST}:${CITADEL_PORT}/api/scratchpad" > /dev/null 2>&1; then
+ osascript -e "display notification \"Citadel daemon not reachable at ${CITADEL_HOST}:${CITADEL_PORT}\" with title \"New workspace\""
+ echo "citadel new-workspace: daemon not reachable at ${URL}" >&2
+ exit 1
+fi
+
+# `open` opens in the user's default browser. Intentionally NOT a chromeless
+# popup — the user wants to land in their pinned cockpit tab if they have one.
+open "$URL"
diff --git a/scripts/mac-satellite/quick-capture.sh b/scripts/mac-satellite/quick-capture.sh
new file mode 100755
index 00000000..1b15611b
--- /dev/null
+++ b/scripts/mac-satellite/quick-capture.sh
@@ -0,0 +1,61 @@
+#!/bin/sh
+# Citadel quick-capture launcher (macOS).
+#
+# Opens the daemon's /quick-capture page as a Spotlight-shaped chromeless
+# popup so the user can dictate or type a thought and ⌘+Enter it into the
+# global scratchpad. Bind to a global shortcut (e.g. cmd+shift+s) via
+# Hammerspoon or Shortcuts.app — see README.md in this directory.
+#
+# Daemon target: the long-term systemd Citadel daemon at 127.0.0.1:4010.
+# Worktree-isolated daemons (4110+ per CLAUDE.md) are deliberately NOT
+# auto-discovered — a global shortcut cannot infer which worktree is "active".
+# Override with CITADEL_HOST / CITADEL_PORT env vars if you want the shortcut
+# bound to a specific worktree.
+
+set -eu
+
+CITADEL_HOST="${CITADEL_HOST:-127.0.0.1}"
+CITADEL_PORT="${CITADEL_PORT:-4010}"
+URL="http://${CITADEL_HOST}:${CITADEL_PORT}/quick-capture"
+
+# Liveness probe — fail fast with a useful message rather than opening a blank
+# tab. --max-time keeps us snappy for the global-shortcut UX.
+if ! curl -fsS --max-time 2 "http://${CITADEL_HOST}:${CITADEL_PORT}/api/scratchpad" > /dev/null 2>&1; then
+ osascript -e "display notification \"Citadel daemon not reachable at ${CITADEL_HOST}:${CITADEL_PORT}\" with title \"Quick capture\""
+ echo "citadel quick-capture: daemon not reachable at ${URL}" >&2
+ exit 1
+fi
+
+# Prefer Chrome --app= mode so the page renders chromeless and window.close()
+# actually works after a successful capture. Fall back to Safari for users
+# without Chrome (the page will show its "Press ⌘W to close" hint there).
+CHROME=""
+for candidate in \
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" \
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
+do
+ if [ -x "$candidate" ]; then
+ CHROME="$candidate"
+ break
+ fi
+done
+
+if [ -n "$CHROME" ]; then
+ # --user-data-dir keeps the satellite popup in its own profile so it doesn't
+ # share state with the user's main browser. --window-size sizes the popup
+ # roughly Spotlight-shaped; --window-position aims at the center of a
+ # 1440-wide screen — the user can move it once and Chrome remembers.
+ PROFILE_DIR="${HOME}/.cache/citadel-mac-satellite"
+ mkdir -p "$PROFILE_DIR"
+ "$CHROME" \
+ --user-data-dir="$PROFILE_DIR" \
+ --app="$URL" \
+ --window-size=640,220 \
+ --window-position=400,300 \
+ >/dev/null 2>&1 &
+else
+ # Safari fallback — opens in a normal window; user closes with ⌘W after the
+ # page's "Captured. Press ⌘W to close." confirmation.
+ open -a Safari "$URL"
+fi
From f390551f182c798cb65f2066be2f5f29782f112e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?=
Date: Mon, 25 May 2026 21:39:51 +0000
Subject: [PATCH 08/13] test(e2e): mobile scratchpad redirect, deeplink, and
quick-capture flow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three new Playwright specs:
- scratchpad-mobile.spec.ts — on narrow viewports (≤820px), bare root
redirects to /scratchpad; History toggle is in the header; the version
history sidebar is hidden by default and visible after toggling.
- scratchpad-mobile-deeplink.spec.ts — BLOCKER-2 regression guard:
/?modal=new-workspace on a narrow viewport stays at / (no mobile
redirect) and auto-opens the Create Workspace modal.
- quick-capture.spec.ts — hits the daemon-served /quick-capture page,
fills the textarea, ⌘+Enter, and asserts via the API that a new
scratchpad block surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
e2e/quick-capture.spec.ts | 41 ++++++++++++++++++++++++++
e2e/scratchpad-mobile-deeplink.spec.ts | 19 ++++++++++++
e2e/scratchpad-mobile.spec.ts | 24 +++++++++++++++
3 files changed, 84 insertions(+)
create mode 100644 e2e/quick-capture.spec.ts
create mode 100644 e2e/scratchpad-mobile-deeplink.spec.ts
create mode 100644 e2e/scratchpad-mobile.spec.ts
diff --git a/e2e/quick-capture.spec.ts b/e2e/quick-capture.spec.ts
new file mode 100644
index 00000000..5839b098
--- /dev/null
+++ b/e2e/quick-capture.spec.ts
@@ -0,0 +1,41 @@
+import { expect, test } from "@playwright/test";
+
+const API_BASE =
+ process.env.CITADEL_API_BASE || `http://127.0.0.1:${process.env.CITADEL_PLAYWRIGHT_DAEMON_PORT || "4012"}`;
+
+// The /quick-capture page is daemon-served (outside /api/*), so the test hits
+// the daemon directly rather than going through the cockpit web app.
+test.describe("quick-capture page", () => {
+ test.beforeEach(async ({ request }) => {
+ // Reset the scratchpad so the captured block is the only one we see.
+ await request.put(`${API_BASE}/api/scratchpad`, { data: { content: "" } });
+ });
+
+ test("submits a block via Cmd-Enter and the new block surfaces in the scratchpad", async ({ page, request }) => {
+ await page.goto(`${API_BASE}/quick-capture`);
+ const textarea = page.locator("textarea#t");
+ await expect(textarea).toBeFocused();
+ await textarea.fill("captured from /quick-capture");
+ await textarea.press("ControlOrMeta+Enter");
+
+ // The page either closes (Chromium opens us as a regular tab so close is
+ // a no-op) or swaps for the confirmation message. Either way, the block
+ // is created — assert via the API rather than relying on the page's
+ // post-submit state.
+ await expect
+ .poll(async () => {
+ const res = await request.get(`${API_BASE}/api/scratchpad/blocks`);
+ const body = (await res.json()) as { blocks: Array<{ text: string }> };
+ return body.blocks.map((b) => b.text).join("|");
+ })
+ .toContain("captured from /quick-capture");
+ });
+
+ test("response is HTML and references the existing block endpoint", async ({ request }) => {
+ const response = await request.get(`${API_BASE}/quick-capture`);
+ expect(response.status()).toBe(200);
+ expect(response.headers()["content-type"]).toMatch(/text\/html/);
+ const body = await response.text();
+ expect(body).toContain("/api/scratchpad/blocks");
+ });
+});
diff --git a/e2e/scratchpad-mobile-deeplink.spec.ts b/e2e/scratchpad-mobile-deeplink.spec.ts
new file mode 100644
index 00000000..6b82c25c
--- /dev/null
+++ b/e2e/scratchpad-mobile-deeplink.spec.ts
@@ -0,0 +1,19 @@
+import { expect, test } from "@playwright/test";
+
+// Regression guard for the BLOCKER-2 fix: the mobile redirect must NOT eat
+// /?modal=new-workspace. On a narrow viewport the cockpit should still
+// render at / and auto-open the Create Workspace modal via the deeplink.
+test.describe("scratchpad — mobile deeplink", () => {
+ test.skip(({ viewport }) => (viewport?.width ?? 0) > 820, "mobile-only behavior");
+
+ test("?modal=new-workspace lands on cockpit (no redirect) and opens the modal", async ({ page }) => {
+ await page.goto("/?modal=new-workspace");
+ // URL must NOT be /scratchpad even though we're on a narrow viewport.
+ await expect(page).not.toHaveURL(/\/scratchpad$/);
+ // The existing Create Workspace modal opens via the deeplink. The modal
+ // header is "Create Workspace" — match loosely so future copy tweaks
+ // don't break the regression guard.
+ const heading = page.getByRole("heading", { name: /create workspace|new workspace/i });
+ await expect(heading).toBeVisible();
+ });
+});
diff --git a/e2e/scratchpad-mobile.spec.ts b/e2e/scratchpad-mobile.spec.ts
new file mode 100644
index 00000000..ab1efc33
--- /dev/null
+++ b/e2e/scratchpad-mobile.spec.ts
@@ -0,0 +1,24 @@
+import { expect, test } from "@playwright/test";
+
+// Mobile-first scratchpad behavior. Skipped on the desktop/tablet projects so
+// we don't false-positive — the redirect is gated on (max-width: 820px).
+test.describe("scratchpad — mobile first", () => {
+ test.skip(({ viewport }) => (viewport?.width ?? 0) > 820, "mobile-only behavior");
+
+ test("bare root on a narrow viewport redirects to /scratchpad", async ({ page }) => {
+ await page.goto("/");
+ await expect(page).toHaveURL(/\/scratchpad$/);
+ const composer = page.locator(".scratchpad-composer-input");
+ await expect(composer).toBeVisible();
+ });
+
+ test("history sidebar is hidden by default; History toggle is present in the header", async ({ page }) => {
+ await page.goto("/scratchpad");
+ const toggle = page.getByRole("button", { name: /history/i });
+ await expect(toggle).toBeVisible();
+ // Default: not open → list is not visible.
+ await expect(page.locator(".scratchpad-history-list")).toBeHidden();
+ await toggle.click();
+ await expect(page.locator(".scratchpad-history-list")).toBeVisible();
+ });
+});
From ffce9272474f32d89b6105c6dc695cb57e56a560 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?=
Date: Mon, 25 May 2026 21:48:30 +0000
Subject: [PATCH 09/13] chore(lint): biome format + organizeImports across
touched files
Auto-fixes from biome check --write across the files this branch
touched; also picks up a stray pre-existing format nit in
apps/daemon/src/terminal-routes.ts that surfaced once biome ran
across the full surface for make check. Switches the speech-recognition
test cleanup from `delete` to undefined-assignment to satisfy
lint/performance/noDelete (the controller's resolveCtor uses ??, so
undefined and missing are equivalent for the support probe).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/daemon/src/app.ts | 2 +-
apps/daemon/src/quick-capture-route.test.ts | 10 ++++++----
apps/daemon/src/spa-fallback-route.test.ts | 10 ++++++----
apps/daemon/src/terminal-routes.ts | 5 ++++-
apps/web/src/cockpit.tsx | 2 +-
.../web/src/lib/speech-recognition-controller.test.ts | 11 +++++++----
apps/web/src/lib/speech-recognition-controller.ts | 4 +++-
apps/web/src/routes/scratchpad.tsx | 5 +----
apps/web/src/scratchpad.css | 9 +++++++--
9 files changed, 36 insertions(+), 22 deletions(-)
diff --git a/apps/daemon/src/app.ts b/apps/daemon/src/app.ts
index d4cdd9d3..ab45cd3a 100644
--- a/apps/daemon/src/app.ts
+++ b/apps/daemon/src/app.ts
@@ -36,11 +36,11 @@ import { callDaemonMcpTool, readMcpResource } from "./daemon-mcp-tool.js";
import { registerWorkspaceExtraRoutes } from "./extra-routes.js";
import { registerMcpRoutes } from "./mcp-routes.js";
import { registerNamespaceRoutes } from "./namespace-routes.js";
+import { registerQuickCaptureRoute } from "./quick-capture-route.js";
import { deriveReadiness, workspaceAppHookSample } from "./readiness.js";
import { registerRuntimeUsageRoutes } from "./runtime-usage-routes.js";
import { registerScheduledAgentRoutes } from "./scheduled-agent-routes.js";
import { backfillIfEmpty } from "./scratchpad-history.js";
-import { registerQuickCaptureRoute } from "./quick-capture-route.js";
import { registerScratchpadRoutes } from "./scratchpad-routes.js";
import { scratchpadPath } from "./scratchpad.js";
import { registerSpaFallback } from "./spa-fallback-route.js";
diff --git a/apps/daemon/src/quick-capture-route.test.ts b/apps/daemon/src/quick-capture-route.test.ts
index 7973d745..f9054d90 100644
--- a/apps/daemon/src/quick-capture-route.test.ts
+++ b/apps/daemon/src/quick-capture-route.test.ts
@@ -7,10 +7,12 @@ const servers: http.Server[] = [];
afterEach(async () => {
await Promise.all(
- servers.splice(0).map(
- (server) =>
- new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
- ),
+ servers
+ .splice(0)
+ .map(
+ (server) =>
+ new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
+ ),
);
});
diff --git a/apps/daemon/src/spa-fallback-route.test.ts b/apps/daemon/src/spa-fallback-route.test.ts
index 3a784be8..8515b7d2 100644
--- a/apps/daemon/src/spa-fallback-route.test.ts
+++ b/apps/daemon/src/spa-fallback-route.test.ts
@@ -12,10 +12,12 @@ const servers: http.Server[] = [];
afterEach(async () => {
for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
await Promise.all(
- servers.splice(0).map(
- (server) =>
- new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
- ),
+ servers
+ .splice(0)
+ .map(
+ (server) =>
+ new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
+ ),
);
});
diff --git a/apps/daemon/src/terminal-routes.ts b/apps/daemon/src/terminal-routes.ts
index e291bf72..2157a0f1 100644
--- a/apps/daemon/src/terminal-routes.ts
+++ b/apps/daemon/src/terminal-routes.ts
@@ -266,7 +266,10 @@ export function registerTerminalRoutes(input: {
return;
}
}
- res.status(502).type("text/plain").send(error instanceof Error ? error.message : "terminal_proxy_failed");
+ res
+ .status(502)
+ .type("text/plain")
+ .send(error instanceof Error ? error.message : "terminal_proxy_failed");
});
});
diff --git a/apps/web/src/cockpit.tsx b/apps/web/src/cockpit.tsx
index e84ed071..9ad15b63 100644
--- a/apps/web/src/cockpit.tsx
+++ b/apps/web/src/cockpit.tsx
@@ -9,12 +9,12 @@ import { readinessForWorkspace } from "./cockpit-readiness.js";
import { useWorkspaceCockpitSummary } from "./cockpit-tools.js";
import { CommandPalette } from "./command-palette.js";
import { Inspector } from "./inspector.js";
+import { consumeNewWorkspaceDeeplink, shouldOpenNewWorkspaceModal } from "./lib/new-workspace-deeplink.js";
import { Navigator } from "./navigator.js";
import { Stage } from "./stage.js";
import { UsageIndicator } from "./usage-indicator.js";
import { startColumnDrag, useCockpitLayout } from "./use-cockpit-layout.js";
import { useResolvedTheme } from "./use-resolved-theme.js";
-import { consumeNewWorkspaceDeeplink, shouldOpenNewWorkspaceModal } from "./lib/new-workspace-deeplink.js";
import { prToneFor } from "./workspace-card.js";
const STORAGE_LAST_WORKSPACE = "citadel.last-workspace";
diff --git a/apps/web/src/lib/speech-recognition-controller.test.ts b/apps/web/src/lib/speech-recognition-controller.test.ts
index 491abc05..7dccaea6 100644
--- a/apps/web/src/lib/speech-recognition-controller.test.ts
+++ b/apps/web/src/lib/speech-recognition-controller.test.ts
@@ -20,7 +20,8 @@ class MockRecognition {
continuous = false;
interimResults = false;
lang = "";
- onresult: ((event: { results: ArrayLike & { isFinal: boolean }> }) => void) | null = null;
+ onresult: ((event: { results: ArrayLike & { isFinal: boolean }> }) => void) | null =
+ null;
onerror: ((event: { error: string }) => void) | null = null;
onend: (() => void) | null = null;
@@ -57,9 +58,11 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers();
- // Scrub the globals the support probe reads.
- delete (globalThis as { SpeechRecognition?: unknown }).SpeechRecognition;
- delete (globalThis as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition;
+ // Scrub the globals the support probe reads. Use `undefined` assignment
+ // rather than `delete` so biome's noDelete lint stays quiet; the probe
+ // uses `??` so undefined and missing are equivalent.
+ (globalThis as { SpeechRecognition?: unknown }).SpeechRecognition = undefined;
+ (globalThis as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition = undefined;
});
describe("isSpeechRecognitionSupported", () => {
diff --git a/apps/web/src/lib/speech-recognition-controller.ts b/apps/web/src/lib/speech-recognition-controller.ts
index 85ca1b1d..41d5d31d 100644
--- a/apps/web/src/lib/speech-recognition-controller.ts
+++ b/apps/web/src/lib/speech-recognition-controller.ts
@@ -56,7 +56,9 @@ export function resolveCtor(): SpeechRecognitionCtor | undefined {
return g.SpeechRecognition ?? g.webkitSpeechRecognition;
}
-export function createSpeechRecognitionController(input: SpeechRecognitionControllerInput): SpeechRecognitionController {
+export function createSpeechRecognitionController(
+ input: SpeechRecognitionControllerInput,
+): SpeechRecognitionController {
const { Ctor, onTranscript, onError, onStateChange } = input;
const silenceMs = input.silenceTimeoutMs ?? SILENCE_TIMEOUT_MS;
let active: RecognitionInstance | null = null;
diff --git a/apps/web/src/routes/scratchpad.tsx b/apps/web/src/routes/scratchpad.tsx
index 76020779..bbf9606e 100644
--- a/apps/web/src/routes/scratchpad.tsx
+++ b/apps/web/src/routes/scratchpad.tsx
@@ -514,10 +514,7 @@ export function ScratchpadView() {
>
)}
-
+
Versions
{history.length}
diff --git a/apps/web/src/scratchpad.css b/apps/web/src/scratchpad.css
index 50d67cff..d9de4682 100644
--- a/apps/web/src/scratchpad.css
+++ b/apps/web/src/scratchpad.css
@@ -314,8 +314,13 @@
}
@keyframes scratchpad-mic-pulse {
- 0%, 100% { box-shadow: 0 0 0 0 rgba(79, 140, 255, 0.4); }
- 50% { box-shadow: 0 0 0 6px rgba(79, 140, 255, 0); }
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 0 rgba(79, 140, 255, 0.4);
+ }
+ 50% {
+ box-shadow: 0 0 0 6px rgba(79, 140, 255, 0);
+ }
}
/* Undo toast for optimistic-delete. */
From 5a1d4f3e0b01eea66889c686f7124c8f015b15b3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?=
Date: Mon, 25 May 2026 22:01:25 +0000
Subject: [PATCH 10/13] =?UTF-8?q?fix(scratchpad):=20self-review=20pass=20?=
=?UTF-8?q?=E2=80=94=20voice=20blur,=20dispose=20race,=20tap=20targets?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses the surviving findings from /review-pr:
- VoiceCaptureButton: move onError invocation from render into a
useEffect keyed on speech.error so a stateful onError can't cause a
render loop. Expose an imperative `stop()` via controlRef so hosts
can satisfy spec B.2 #9 ("blurring stops voice capture").
- Scratchpad composer + per-block editor: wire the new mic stop()
handle into their onBlur paths so dictation stops on textarea blur.
- Extract `appendTranscript()` (composer/editor used to copy-paste the
trim-and-join one-liner). The daemon-served quick-capture page keeps
its inlined copy because it's bundler-free.
- speech-recognition-controller.dispose(): null the onresult/onerror/
onend handlers BEFORE abort() so the async onend the browser fires
during teardown cannot reach stopAndReport on a disposed controller.
New test simulates the late callback.
- scratchpad.css mobile breakpoint: enlarge .scratchpad-block-delete
to 36×36 to meet spec B.2 #11 tap-target requirement.
- specs/B.2-ade-cockpit.md line 108: change "per-workspace" → "global"
to match the B.7 storage description.
- quick-capture-route.ts: template the silence timeout from the new
QUICK_CAPTURE_SILENCE_TIMEOUT_MS constant instead of a magic 10000.
- New integration test (quick-capture-route.test.ts) registers both
the quick-capture route AND the SPA fallback in both orders and
asserts the inline HTML wins only when registered first. Catches
any future swap that would let the SPA wildcard swallow the page.
- New bootstrap-redirect test for the primary AC2 case: narrow + bare
+ no saved route → /scratchpad.
- E2E scratchpad-mobile: assert composer focus after redirect and
mic boundingBox ≥ 36×36 (skipped when SpeechRecognition not exposed
in headless Chromium).
- E2E scratchpad-mobile-deeplink: tighter assertion that the URL stays
bare-root (not /scratchpad) and that consumeNewWorkspaceDeeplink
strips the modal param after mount.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/daemon/src/quick-capture-route.test.ts | 66 ++++++++++++++++++-
apps/daemon/src/quick-capture-route.ts | 17 +++--
.../src/components/voice-capture-button.tsx | 22 ++++++-
apps/web/src/lib/append-transcript.test.ts | 20 ++++++
apps/web/src/lib/append-transcript.ts | 11 ++++
apps/web/src/lib/bootstrap-redirect.test.ts | 7 ++
.../lib/speech-recognition-controller.test.ts | 16 +++++
.../src/lib/speech-recognition-controller.ts | 6 ++
apps/web/src/routes/scratchpad.tsx | 24 ++++---
apps/web/src/scratchpad.css | 8 +++
e2e/scratchpad-mobile-deeplink.spec.ts | 8 ++-
e2e/scratchpad-mobile.spec.ts | 13 ++++
specs/B.2-ade-cockpit.md | 2 +-
13 files changed, 201 insertions(+), 19 deletions(-)
create mode 100644 apps/web/src/lib/append-transcript.test.ts
create mode 100644 apps/web/src/lib/append-transcript.ts
diff --git a/apps/daemon/src/quick-capture-route.test.ts b/apps/daemon/src/quick-capture-route.test.ts
index f9054d90..e8cfc237 100644
--- a/apps/daemon/src/quick-capture-route.test.ts
+++ b/apps/daemon/src/quick-capture-route.test.ts
@@ -1,7 +1,11 @@
+import fs from "node:fs";
import http from "node:http";
+import os from "node:os";
+import path from "node:path";
import express from "express";
import { afterEach, describe, expect, it } from "vitest";
-import { registerQuickCaptureRoute } from "./quick-capture-route.js";
+import { QUICK_CAPTURE_SILENCE_TIMEOUT_MS, registerQuickCaptureRoute } from "./quick-capture-route.js";
+import { registerSpaFallback } from "./spa-fallback-route.js";
const servers: http.Server[] = [];
@@ -71,4 +75,64 @@ describe("GET /quick-capture", () => {
const ct = response.headers.get("content-type") ?? "";
expect(ct).not.toMatch(/text\/html/);
});
+
+ it("templates the silence timeout from QUICK_CAPTURE_SILENCE_TIMEOUT_MS into the inline JS", async () => {
+ const baseUrl = await startApp();
+ const body = await (await fetch(`${baseUrl}/quick-capture`)).text();
+ expect(body).toContain(
+ `setTimeout(function(){ if (rec) try { rec.stop(); } catch(_){} }, ${QUICK_CAPTURE_SILENCE_TIMEOUT_MS})`,
+ );
+ expect(body).not.toContain("__SILENCE_TIMEOUT_MS__");
+ });
+});
+
+// Integration: /quick-capture must win over the SPA fallback when both
+// routes are registered in the same order as apps/daemon/src/app.ts. A
+// regression that swaps the order would have the wildcard SPA route swallow
+// /quick-capture and serve the cockpit index.html — this test catches that.
+describe("/quick-capture + SPA fallback ordering", () => {
+ const tmpDirs: string[] = [];
+
+ afterEach(() => {
+ for (const dir of tmpDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ async function start(quickCaptureFirst: boolean): Promise {
+ const webDist = fs.mkdtempSync(path.join(os.tmpdir(), "citadel-qc-order-"));
+ tmpDirs.push(webDist);
+ fs.writeFileSync(path.join(webDist, "index.html"), "cockpit-shell ");
+ const app = express();
+ if (quickCaptureFirst) {
+ registerQuickCaptureRoute({ app });
+ registerSpaFallback({ app, webDist });
+ } else {
+ registerSpaFallback({ app, webDist });
+ registerQuickCaptureRoute({ app });
+ }
+ const server = http.createServer(app);
+ servers.push(server);
+ return new Promise((resolve) => {
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("expected TCP address");
+ resolve(`http://127.0.0.1:${address.port}`);
+ });
+ });
+ }
+
+ it("correct order: GET /quick-capture returns the quick-capture HTML (not the SPA shell)", async () => {
+ const baseUrl = await start(true);
+ const body = await (await fetch(`${baseUrl}/quick-capture`)).text();
+ expect(body).toContain(" {
+ const baseUrl = await start(false);
+ const body = await (await fetch(`${baseUrl}/quick-capture`)).text();
+ // With the wrong order, the wildcard returns the cockpit shell. This test
+ // documents the failure mode the correct-order test guards against.
+ expect(body).toContain("cockpit-shell");
+ });
});
diff --git a/apps/daemon/src/quick-capture-route.ts b/apps/daemon/src/quick-capture-route.ts
index 6315b1ba..fcb9a4a2 100644
--- a/apps/daemon/src/quick-capture-route.ts
+++ b/apps/daemon/src/quick-capture-route.ts
@@ -8,8 +8,12 @@ import type express from "express";
// Inlined CSS + JS keep the page bundler-free; the daemon serves it as a
// single string, which means it works even when the cockpit's Vite/SPA bundle
// isn't built (helpful for the daemon-only dev loop). Voice capture reuses
-// webkitSpeechRecognition / SpeechRecognition with the same 10s silence
-// timeout as the cockpit hook.
+// webkitSpeechRecognition / SpeechRecognition with a silence timeout that
+// matches apps/web/src/lib/speech-recognition-controller.ts (kept in sync
+// via the constant below — drift on the cockpit side would not silently
+// diverge because both surfaces document the value).
+export const QUICK_CAPTURE_SILENCE_TIMEOUT_MS = 10_000;
+
const HTML = `
@@ -57,7 +61,7 @@ const HTML = `
var rec = null;
var silenceTimer = null;
function armSilence(){ if(silenceTimer) clearTimeout(silenceTimer);
- silenceTimer = setTimeout(function(){ if (rec) try { rec.stop(); } catch(_){} }, 10000); }
+ silenceTimer = setTimeout(function(){ if (rec) try { rec.stop(); } catch(_){} }, __SILENCE_TIMEOUT_MS__); }
function startRec(){
if (!SR) return;
rec = new SR();
@@ -113,9 +117,14 @@ const HTML = `
`;
+function renderHtml(): string {
+ return HTML.replace("__SILENCE_TIMEOUT_MS__", String(QUICK_CAPTURE_SILENCE_TIMEOUT_MS));
+}
+
export function registerQuickCaptureRoute({ app }: { app: express.Express }): void {
+ const body = renderHtml();
app.get("/quick-capture", (_req, res) => {
res.setHeader("Cache-Control", "no-cache");
- res.type("text/html; charset=utf-8").send(HTML);
+ res.type("text/html; charset=utf-8").send(body);
});
}
diff --git a/apps/web/src/components/voice-capture-button.tsx b/apps/web/src/components/voice-capture-button.tsx
index bd352e27..a3dff58b 100644
--- a/apps/web/src/components/voice-capture-button.tsx
+++ b/apps/web/src/components/voice-capture-button.tsx
@@ -1,13 +1,21 @@
import { Mic, MicOff } from "lucide-react";
-import type { CSSProperties } from "react";
+import { type CSSProperties, useEffect, useImperativeHandle } from "react";
+import type { Ref } from "react";
import { useSpeechRecognition } from "../lib/use-speech-recognition.js";
+export type VoiceCaptureButtonHandle = {
+ // Imperative stop — parents call this when the host textarea loses focus so
+ // recognition matches the spec's "blurring stops it" clause.
+ stop: () => void;
+};
+
export type VoiceCaptureButtonProps = {
onTranscript: (text: string) => void;
onError?: (message: string) => void;
className?: string;
style?: CSSProperties;
label?: string;
+ controlRef?: Ref;
};
// Renders a mic toggle that dictates into the host composer via the Web Speech
@@ -15,11 +23,19 @@ export type VoiceCaptureButtonProps = {
// component returns null so the host UI gracefully degrades — no broken icon,
// no permission prompt, no console noise.
export function VoiceCaptureButton(props: VoiceCaptureButtonProps) {
- const { onTranscript, onError, className, style, label = "Voice capture" } = props;
+ const { onTranscript, onError, className, style, label = "Voice capture", controlRef } = props;
const speech = useSpeechRecognition({ onTranscript });
+ // Errors are surfaced via an effect (not render) so an `onError` that updates
+ // parent state never triggers a render loop.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: onError is allowed to be unstable; we intentionally key on the error string only.
+ useEffect(() => {
+ if (speech.error && onError) onError(speech.error);
+ }, [speech.error]);
+
+ useImperativeHandle(controlRef, () => ({ stop: () => speech.stop() }), [speech.stop]);
+
if (!speech.supported) return null;
- if (speech.error) onError?.(speech.error);
const ariaLabel = speech.listening ? `${label}: stop listening` : `${label}: start listening`;
return (
diff --git a/apps/web/src/lib/append-transcript.test.ts b/apps/web/src/lib/append-transcript.test.ts
new file mode 100644
index 00000000..73ec65cd
--- /dev/null
+++ b/apps/web/src/lib/append-transcript.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { appendTranscript } from "./append-transcript.js";
+
+describe("appendTranscript", () => {
+ it("returns the addition verbatim when existing is empty", () => {
+ expect(appendTranscript("", "hello")).toBe("hello");
+ });
+
+ it("returns the addition verbatim when existing is whitespace-only", () => {
+ expect(appendTranscript(" \n ", "hello")).toBe("hello");
+ });
+
+ it("appends with a single space when existing has trimmed content", () => {
+ expect(appendTranscript("hello", "world")).toBe("hello world");
+ });
+
+ it("trims trailing whitespace from existing before joining", () => {
+ expect(appendTranscript("hello\n", "world")).toBe("hello world");
+ });
+});
diff --git a/apps/web/src/lib/append-transcript.ts b/apps/web/src/lib/append-transcript.ts
new file mode 100644
index 00000000..4cd670a9
--- /dev/null
+++ b/apps/web/src/lib/append-transcript.ts
@@ -0,0 +1,11 @@
+// Append a transcript chunk to existing composer/editor text. Pure function so
+// composer, per-block editor, and any future voice surface share the same
+// leading-space semantics. The daemon-served quick-capture page has its own
+// inline copy (it can't import this) — its semantics are kept in sync by
+// convention; see apps/daemon/src/quick-capture-route.ts for the shadow
+// implementation.
+export function appendTranscript(existing: string, addition: string): string {
+ const trimmed = existing.trim();
+ if (trimmed.length === 0) return addition;
+ return `${trimmed} ${addition}`;
+}
diff --git a/apps/web/src/lib/bootstrap-redirect.test.ts b/apps/web/src/lib/bootstrap-redirect.test.ts
index 4ea2e3d7..2e578f97 100644
--- a/apps/web/src/lib/bootstrap-redirect.test.ts
+++ b/apps/web/src/lib/bootstrap-redirect.test.ts
@@ -61,4 +61,11 @@ describe("applyBootstrapNavigation — mobile redirect wins over saved last-rout
applyBootstrapNavigation({ location: locOf("/"), history, storage, narrow: false });
expect(history.replaceState).not.toHaveBeenCalled();
});
+
+ it("narrow viewport + bare root + no saved route → URL becomes /scratchpad (primary AC2 case)", () => {
+ const history = { replaceState: vi.fn() };
+ const storage = memoryStorage();
+ applyBootstrapNavigation({ location: locOf("/"), history, storage, narrow: true });
+ expect(history.replaceState).toHaveBeenCalledWith({}, "", "/scratchpad");
+ });
});
diff --git a/apps/web/src/lib/speech-recognition-controller.test.ts b/apps/web/src/lib/speech-recognition-controller.test.ts
index 7dccaea6..29b2c27c 100644
--- a/apps/web/src/lib/speech-recognition-controller.test.ts
+++ b/apps/web/src/lib/speech-recognition-controller.test.ts
@@ -171,6 +171,22 @@ describe("createSpeechRecognitionController", () => {
expect(rec.stopCalls).toBe(stopsBefore);
});
+ it("dispose() detaches handlers so a late onend from abort() cannot fire onStateChange after dispose", () => {
+ const onState = vi.fn();
+ const controller = createSpeechRecognitionController({ Ctor, onStateChange: onState });
+ controller.start();
+ const rec = MockRecognition.instances[0];
+ if (!rec) throw new Error("expected a mock recognition instance");
+ controller.dispose();
+ // Real browsers fire onend asynchronously after abort(). Simulate that:
+ // call the handler the controller had attached, if it didn't null it.
+ rec.onend?.();
+ rec.onerror?.({ error: "aborted" });
+ // After dispose, onStateChange must NOT have been called with `false`
+ // by a late handler — only the explicit dispose-time absence matters.
+ expect(onState).not.toHaveBeenCalledWith(false);
+ });
+
it("returns null when the API is unsupported and start() is a no-op", () => {
const onState = vi.fn();
const controller = createSpeechRecognitionController({ Ctor: undefined, onStateChange: onState });
diff --git a/apps/web/src/lib/speech-recognition-controller.ts b/apps/web/src/lib/speech-recognition-controller.ts
index 41d5d31d..035e7582 100644
--- a/apps/web/src/lib/speech-recognition-controller.ts
+++ b/apps/web/src/lib/speech-recognition-controller.ts
@@ -123,6 +123,12 @@ export function createSpeechRecognitionController(
disposed = true;
clearSilenceTimer();
if (active) {
+ // Null the handlers BEFORE abort() so the async onend/onerror events
+ // browsers emit during teardown cannot reach stopAndReport — otherwise
+ // onStateChange would fire on a disposed controller.
+ active.onresult = null;
+ active.onerror = null;
+ active.onend = null;
try {
active.abort();
} catch {
diff --git a/apps/web/src/routes/scratchpad.tsx b/apps/web/src/routes/scratchpad.tsx
index bbf9606e..86bce2fa 100644
--- a/apps/web/src/routes/scratchpad.tsx
+++ b/apps/web/src/routes/scratchpad.tsx
@@ -3,7 +3,8 @@ import { Link } from "@tanstack/react-router";
import { ArrowLeft, Clock, Trash2, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "../api.js";
-import { VoiceCaptureButton } from "../components/voice-capture-button.js";
+import { VoiceCaptureButton, type VoiceCaptureButtonHandle } from "../components/voice-capture-button.js";
+import { appendTranscript } from "../lib/append-transcript.js";
import { sideBySideDiff } from "./scratchpad-diff.js";
import { formatBytes, pillLabel, pillSlug } from "./scratchpad-helpers.js";
import { renderBlockMarkdown } from "./scratchpad-markdown.js";
@@ -274,7 +275,10 @@ export function ScratchpadView() {
[submitComposer],
);
+ const composerMicRef = useRef(null);
const onComposerBlur = useCallback(() => {
+ // Spec B.2 Scratchpad #9: voice capture stops on textarea blur.
+ composerMicRef.current?.stop();
if (composer.trim().length > 0) void submitComposer(composer);
}, [composer, submitComposer]);
@@ -494,9 +498,8 @@ export function ScratchpadView() {
rows={2}
/>
- setComposer((prev) => (prev.trim().length === 0 ? text : `${prev.trim()} ${text}`))
- }
+ controlRef={composerMicRef}
+ onTranscript={(text) => setComposer((prev) => appendTranscript(prev, text))}
/>
@@ -649,6 +652,7 @@ function BlockItem(props: BlockItemProps) {
[block.isEditing, block.text],
);
const editorRef = useRef(null);
+ const blockMicRef = useRef(null);
useEffect(() => {
if (block.isEditing) {
@@ -681,14 +685,16 @@ function BlockItem(props: BlockItemProps) {
value={block.draft}
onInput={onTextareaInput}
onChange={(event) => onChange(block.id, event.target.value)}
- onBlur={(event) => onBlur(block.id, event.target.value)}
+ onBlur={(event) => {
+ // Spec B.2 Scratchpad #9: blurring stops voice capture.
+ blockMicRef.current?.stop();
+ onBlur(block.id, event.target.value);
+ }}
onKeyDown={(event) => onKey(block.id, event)}
/>
{
- const merged = block.draft.trim().length === 0 ? text : `${block.draft.trim()} ${text}`;
- onChange(block.id, merged);
- }}
+ controlRef={blockMicRef}
+ onTranscript={(text) => onChange(block.id, appendTranscript(block.draft, text))}
/>
diff --git a/apps/web/src/scratchpad.css b/apps/web/src/scratchpad.css
index d9de4682..0a0952d8 100644
--- a/apps/web/src/scratchpad.css
+++ b/apps/web/src/scratchpad.css
@@ -768,6 +768,14 @@
padding-bottom: max(10px, env(safe-area-inset-bottom));
}
+ /* Spec B.2 Scratchpad #11: tap targets ≥ 36×36 on narrow viewports. The
+ desktop delete affordance is 26×26 for a tighter hover feel; on touch
+ surfaces it has to grow so the user can reliably hit it. */
+ .scratchpad-block-delete {
+ width: 36px;
+ height: 36px;
+ }
+
/* History sidebar is hidden by default on mobile; the toggle in the header
promotes it to a full-pane drawer when the user wants it. */
.scratchpad-history {
diff --git a/e2e/scratchpad-mobile-deeplink.spec.ts b/e2e/scratchpad-mobile-deeplink.spec.ts
index 6b82c25c..1ba8469e 100644
--- a/e2e/scratchpad-mobile-deeplink.spec.ts
+++ b/e2e/scratchpad-mobile-deeplink.spec.ts
@@ -9,11 +9,17 @@ test.describe("scratchpad — mobile deeplink", () => {
test("?modal=new-workspace lands on cockpit (no redirect) and opens the modal", async ({ page }) => {
await page.goto("/?modal=new-workspace");
// URL must NOT be /scratchpad even though we're on a narrow viewport.
- await expect(page).not.toHaveURL(/\/scratchpad$/);
+ // The mobile redirect must mirror isBareRootLanding semantics; any
+ // search param disqualifies the redirect.
+ await expect(page).toHaveURL(/^[^?]*\/(?:\?.*)?$/);
+ await expect(page).not.toHaveURL(/\/scratchpad/);
// The existing Create Workspace modal opens via the deeplink. The modal
// header is "Create Workspace" — match loosely so future copy tweaks
// don't break the regression guard.
const heading = page.getByRole("heading", { name: /create workspace|new workspace/i });
await expect(heading).toBeVisible();
+ // After the deeplink fires, consumeNewWorkspaceDeeplink strips the param
+ // so a refresh doesn't re-open the modal. URL should land at the bare root.
+ await expect(page).toHaveURL(/^[^?]*\/$/);
});
});
diff --git a/e2e/scratchpad-mobile.spec.ts b/e2e/scratchpad-mobile.spec.ts
index ab1efc33..7ad7c238 100644
--- a/e2e/scratchpad-mobile.spec.ts
+++ b/e2e/scratchpad-mobile.spec.ts
@@ -10,6 +10,19 @@ test.describe("scratchpad — mobile first", () => {
await expect(page).toHaveURL(/\/scratchpad$/);
const composer = page.locator(".scratchpad-composer-input");
await expect(composer).toBeVisible();
+ // Plan AC2b: composer should be focused after the redirect lands.
+ await expect(composer).toBeFocused();
+
+ // Plan AC2b: tap targets (mic, delete, save) ≥ 36×36 logical px. If
+ // SpeechRecognition isn't exposed in headless Chromium the mic returns
+ // null and the locator count is 0 — accept that and assert the size only
+ // when the element is present.
+ const mic = page.locator(".scratchpad-mic").first();
+ if ((await mic.count()) > 0) {
+ const box = await mic.boundingBox();
+ expect(box?.width ?? 0).toBeGreaterThanOrEqual(36);
+ expect(box?.height ?? 0).toBeGreaterThanOrEqual(36);
+ }
});
test("history sidebar is hidden by default; History toggle is present in the header", async ({ page }) => {
diff --git a/specs/B.2-ade-cockpit.md b/specs/B.2-ade-cockpit.md
index bcf743d1..bf8ffa28 100644
--- a/specs/B.2-ade-cockpit.md
+++ b/specs/B.2-ade-cockpit.md
@@ -105,7 +105,7 @@
## Scratchpad
-The cockpit's scratchpad view renders the per-workspace `scratchpad.md` (see B.7 for the storage format) as a stack of discrete, focusable blocks.
+The cockpit's scratchpad view renders the global `scratchpad.md` (see B.7 for the storage format) as a stack of discrete, focusable blocks. One scratchpad per daemon installation — not per workspace.
[ ] 1. Blocks render as sanitized markdown when not focused (headings, lists, bold/italic, inline + fenced code, links). Raw HTML is sanitized (scripts and inline event handlers removed); ` ` tags are stripped in v1 since block content can originate from external MCP agents.
[ ] 2. Clicking a block enters inline edit mode with a `` containing the raw markdown.
From 7dc9eecb4de9f5eb7ae45df20f3f02db3dd96868 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?=
Date: Mon, 25 May 2026 22:11:06 +0000
Subject: [PATCH 11/13] fix(e2e): mobile redirect interaction with cockpit
tests + deeplink modal mount
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CI surfaced two interactions with the mobile-first scratchpad redirect
that didn't fail locally:
- e2e/operator-cockpit.spec.ts navigates to "/" on the mobile project
and expects the cockpit shell. The new redirect now sends bare-root
mobile loads to /scratchpad, so the test never sees the shell. Swap
both gotos to "/?from=cockpit-test" — any non-bare URL skips the
redirect (isBareRootLanding requires empty search). The cockpit
shell is what the test exercises; the redirect's bare-URL behavior
is covered by scratchpad-mobile.spec.ts.
- The Create Workspace modal is rendered as a child of the Navigator
column. On mobile the column has `display: none` by default
(mobileView starts at 'stage'), so the modal mounts into the DOM
but is invisible — the deeplink test asserted toBeVisible() on the
modal heading and failed. Cockpit's deeplink useEffect now also
calls setMobileView("navigator") when ?modal=new-workspace fires;
desktop is unaffected (mobileView has no visual effect there).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/web/src/cockpit.tsx | 5 +++++
e2e/operator-cockpit.spec.ts | 7 +++++--
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/cockpit.tsx b/apps/web/src/cockpit.tsx
index 9ad15b63..17a4bc61 100644
--- a/apps/web/src/cockpit.tsx
+++ b/apps/web/src/cockpit.tsx
@@ -54,6 +54,11 @@ export function Cockpit() {
if (typeof window === "undefined") return;
if (!shouldOpenNewWorkspaceModal(window.location.search)) return;
setCreateWorkspaceOpen(true);
+ // The Create Workspace modal lives inside the Navigator column. On mobile
+ // the column has `display: none` by default (mobileView starts at 'stage'),
+ // which would hide the modal — switch the mobile view to navigator so the
+ // modal is actually rendered. On desktop this is a no-op.
+ setMobileView("navigator");
consumeNewWorkspaceDeeplink({
pathname: window.location.pathname,
search: window.location.search,
diff --git a/e2e/operator-cockpit.spec.ts b/e2e/operator-cockpit.spec.ts
index 4d12a751..e6d0a066 100644
--- a/e2e/operator-cockpit.spec.ts
+++ b/e2e/operator-cockpit.spec.ts
@@ -13,7 +13,9 @@ const API_BASE =
process.env.CITADEL_API_BASE || `http://127.0.0.1:${process.env.CITADEL_PLAYWRIGHT_DAEMON_PORT || "4012"}`;
test("cockpit renders top bar, navigator, stage, and inspector", async ({ page }, testInfo) => {
- await page.goto("/");
+ // Use a non-bare URL so the mobile-first scratchpad redirect doesn't fire —
+ // this test is about the cockpit shell, not the mobile default landing.
+ await page.goto("/?from=cockpit-test");
await expect(page.locator(".cit-brand")).toContainText("Citadel");
await expect(page.getByRole("button", { name: "Search workspaces" })).toBeVisible();
if (testInfo.project.name === "mobile") {
@@ -70,7 +72,8 @@ test("mobile cockpit toggles between navigator/stage/inspector", async ({ page,
workspaceId = (await createWorkspace(request, repo.id, workspaceName)).workspaceId;
await waitForWorkspace(request, workspaceId, "ready");
- await page.goto("/");
+ // Non-bare URL bypasses the mobile-first scratchpad redirect.
+ await page.goto("/?from=cockpit-test");
const switcher = page.getByRole("navigation", { name: "Workspace layout" });
await switcher.getByRole("button", { name: "Navigator" }).click();
await expect(page.getByRole("button", { name: new RegExp(workspaceName, "i") })).toBeVisible();
From 535c13a46f269aea3ffa515a9854f44ccbb0566b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?=
Date: Tue, 26 May 2026 07:09:14 +0000
Subject: [PATCH 12/13] feat(mac-satellite): native Electron app for
global-shortcut quick-capture
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 2 of the mac-satellite work, originally deferred but folded into
this PR per user request. The native shell now ships alongside the
existing scripts/mac-satellite/ POSIX-shell fallbacks.
apps/mac-satellite/ — Electron app that registers two global shortcuts:
⌘⇧S → Spotlight-shaped frameless BrowserWindow loading the daemon's
existing /quick-capture page. Toggle-like: a second press while
the popup is focused closes it. Esc closes too.
⌘⇧N → opens the cockpit at /?modal=new-workspace in the user's
default browser; the cockpit's deeplink auto-opens the
Create Workspace modal.
Runs as a background utility (app.dock.hide()), single-instance,
re-resolves the daemon target on every press so env-var overrides
take effect after a restart. Liveness probe (2s, fetch with
AbortController) surfaces a native notification if the daemon
isn't reachable rather than silently failing.
No new daemon endpoints — all traffic goes through the surfaces this
PR's earlier commits added (GET /quick-capture, POST /api/scratchpad/
blocks, /?modal=new-workspace deeplink). The shell-script helpers under
scripts/mac-satellite/ remain as the no-Electron path.
Electron over Tauri: keeps CI on its existing pnpm/TS toolchain (no
Rust), the satellite is ~200 LOC, and globalShortcut does exactly what
we need. Pinned electron@33.2.1 in apps/mac-satellite/devDependencies;
added to pnpm-workspace.yaml allowBuilds so the postinstall binary
download runs. apps/mac-satellite/ added to the root tsconfig
project-references list so check:size, typecheck, lint, and test all
sweep it like any other workspace.
Pure helpers (config.ts: daemon URL resolution, env-var precedence,
Spotlight popup geometry with multi-monitor offsets) are unit-tested
in src/config.test.ts. The Electron main process typechecks but is
exercised manually with `pnpm --filter @citadel/mac-satellite dev`
because Electron's runtime is not headless-testable in CI.
apps/mac-satellite/README.md documents the dev flow, configuration,
trust model, and the Electron-vs-Tauri choice. scripts/mac-satellite/
README updated to point at the new app as the recommended path. The
C-technical-stack spec describes both deliveries (Electron app +
shell-script fallback) and which CI gates apply to each.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../plans/scratchpad-capture-voice-mobile.md | 4 +-
apps/mac-satellite/README.md | 47 ++
apps/mac-satellite/package.json | 17 +
apps/mac-satellite/src/config.test.ts | 52 ++
apps/mac-satellite/src/config.ts | 59 +++
apps/mac-satellite/src/main.ts | 172 ++++++
apps/mac-satellite/tsconfig.json | 10 +
pnpm-lock.yaml | 495 ++++++++++++++++++
pnpm-workspace.yaml | 1 +
scripts/mac-satellite/README.md | 9 +-
specs/C-technical-stack.md | 4 +-
tsconfig.json | 3 +-
12 files changed, 867 insertions(+), 6 deletions(-)
create mode 100644 apps/mac-satellite/README.md
create mode 100644 apps/mac-satellite/package.json
create mode 100644 apps/mac-satellite/src/config.test.ts
create mode 100644 apps/mac-satellite/src/config.ts
create mode 100644 apps/mac-satellite/src/main.ts
create mode 100644 apps/mac-satellite/tsconfig.json
diff --git a/.agents/plans/scratchpad-capture-voice-mobile.md b/.agents/plans/scratchpad-capture-voice-mobile.md
index dde318c9..66cad388 100644
--- a/.agents/plans/scratchpad-capture-voice-mobile.md
+++ b/.agents/plans/scratchpad-capture-voice-mobile.md
@@ -21,7 +21,9 @@ Phase-1 operationalised acceptance criteria (what the implementation in this PR
- [ ] AC4a — `scripts/mac-satellite/new-workspace.sh` opens the cockpit at `/?modal=new-workspace`. The cockpit recognises that query parameter on mount and auto-opens the existing Create Workspace modal. After the modal closes (created or cancelled) the query parameter is stripped from the URL so a page refresh doesn't re-open it.
- [ ] AC4b — `scripts/mac-satellite/README.md` documents how to bind each script to a global shortcut via Hammerspoon (recommended) and macOS Shortcuts.app (fallback), and explains the prerequisite that the user's local Citadel daemon is reachable.
-**Out of scope (deferred to a follow-up plan).** Shipping an actual native `.app` shell (Tauri or Electron) that registers global shortcuts itself. The web-served `/quick-capture` page plus thin helper scripts deliver the user-facing UX in this PR without adding a Rust/Electron toolchain to CI. The follow-up will wrap the same `/quick-capture` page in a native shell and reuse the `?modal=new-workspace` deeplink, so this PR's surface area is forward-compatible.
+**Phase 2 follow-up — folded in.** Originally deferred, but added to this PR after the user said "we're not ready without the native Mac .app". Implementation: `apps/mac-satellite/` (Electron). Registers `⌘⇧S` (Spotlight-style quick-capture popup) and `⌘⇧N` (new-workspace deeplink) via `globalShortcut`; loads the existing daemon `/quick-capture` page in a frameless BrowserWindow; opens the cockpit deeplink in the default browser. No new daemon endpoints. The web-served page + shell scripts remain as a no-Electron fallback.
+
+Electron was chosen over Tauri to keep CI on its existing pnpm/TS toolchain (no Rust). Packaging to a signed `.app` is the next follow-up — `pnpm --filter @citadel/mac-satellite dev` is sufficient for personal-machine use right now.
## Context and problem statement
diff --git a/apps/mac-satellite/README.md b/apps/mac-satellite/README.md
new file mode 100644
index 00000000..03a0940d
--- /dev/null
+++ b/apps/mac-satellite/README.md
@@ -0,0 +1,47 @@
+# @citadel/mac-satellite
+
+Native macOS satellite app built on Electron. Registers two global shortcuts and uses the local Citadel daemon's web surfaces — no new endpoints, no shared state, no codesigning required for personal use.
+
+| Shortcut | Action |
+|---|---|
+| `⌘⇧S` | Open a Spotlight-shaped frameless popup loading the daemon's `GET /quick-capture` page. ⌘+Enter saves a new block; Esc closes. |
+| `⌘⇧N` | Open the cockpit at `/?modal=new-workspace` in your default browser — the cockpit auto-opens the Create Workspace modal. |
+
+## Run
+
+```sh
+pnpm --filter @citadel/mac-satellite dev
+```
+
+That launches Electron and registers the shortcuts. The app runs in the background (no Dock icon, no menu-bar item) and stays alive until you `pkill` it or sign out — that's the whole point of a satellite app.
+
+## Configuration
+
+Daemon target defaults to `127.0.0.1:4010` (the long-term systemd daemon). Override via env:
+
+```sh
+CITADEL_HOST=127.0.0.1 CITADEL_PORT=4150 pnpm --filter @citadel/mac-satellite dev
+```
+
+Worktree-isolated daemons (4110+) are intentionally **not** auto-discovered — a globally-bound shortcut can't infer which worktree is "active", and silently picking one would be a footgun. Set `CITADEL_PORT` explicitly if you want the satellite bound to a specific worktree.
+
+## Trust model
+
+Citadel is single-user / localhost-first. The daemon binds `127.0.0.1` by default with CORS allow-all and no per-request authentication. This app does not change that posture — it's a convenience layer over an already-open localhost HTTP surface. If you expose the daemon to a LAN, you own the network gate (SSH tunnel, VPN, auth-adding proxy).
+
+## Why Electron and not Tauri
+
+Tauri would ship a smaller binary, but it needs a Rust toolchain in CI and adds platform-specific build complexity. The satellite is ~200 lines of TypeScript and the Electron `globalShortcut` API does exactly what we need. We chose simplicity. Revisit if startup time or memory footprint becomes a problem.
+
+## Packaging
+
+This package does not yet produce a `.app` bundle — the dev flow above is sufficient for personal use and CI verification (TypeScript compile + unit tests on the config helpers). Adding `electron-builder` for a signed/notarized `.app` is a follow-up; bring it up when the dev flow isn't enough.
+
+## What's tested
+
+- `src/config.ts` — daemon target resolution, env-var precedence, fallback semantics, Spotlight-popup geometry with multi-monitor offsets. See `src/config.test.ts`.
+- `src/main.ts` — Electron-internal; not unit-testable as written. Verify manually via `pnpm --filter @citadel/mac-satellite dev`.
+
+## Shell-script fallback
+
+If you'd rather not run an Electron process: see [`scripts/mac-satellite/`](../../scripts/mac-satellite/). The scripts wrap the same daemon URLs and bind via Hammerspoon or Shortcuts.app.
diff --git a/apps/mac-satellite/package.json b/apps/mac-satellite/package.json
new file mode 100644
index 00000000..92160ede
--- /dev/null
+++ b/apps/mac-satellite/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@citadel/mac-satellite",
+ "version": "0.2.0",
+ "type": "module",
+ "private": true,
+ "description": "macOS satellite app — Spotlight-style quick-capture + new-workspace launcher backed by global shortcuts. Talks to the local Citadel daemon over HTTP.",
+ "main": "dist/main.js",
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "dev": "electron .",
+ "start": "electron ."
+ },
+ "devDependencies": {
+ "electron": "33.2.1",
+ "typescript": "^5.8.3"
+ }
+}
diff --git a/apps/mac-satellite/src/config.test.ts b/apps/mac-satellite/src/config.test.ts
new file mode 100644
index 00000000..71171e66
--- /dev/null
+++ b/apps/mac-satellite/src/config.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_HOST, DEFAULT_PORT, centerOnDisplay, resolveDaemonTarget } from "./config.js";
+
+describe("resolveDaemonTarget", () => {
+ it("defaults to 127.0.0.1:4010 (the systemd long-term daemon)", () => {
+ const target = resolveDaemonTarget({});
+ expect(target.host).toBe(DEFAULT_HOST);
+ expect(target.port).toBe(DEFAULT_PORT);
+ expect(target.origin).toBe("http://127.0.0.1:4010");
+ expect(target.quickCaptureUrl).toBe("http://127.0.0.1:4010/quick-capture");
+ expect(target.newWorkspaceUrl).toBe("http://127.0.0.1:4010/?modal=new-workspace");
+ expect(target.livenessUrl).toBe("http://127.0.0.1:4010/api/scratchpad");
+ });
+
+ it("honors CITADEL_HOST + CITADEL_PORT env vars", () => {
+ const target = resolveDaemonTarget({ CITADEL_HOST: "10.0.0.5", CITADEL_PORT: "4150" });
+ expect(target.host).toBe("10.0.0.5");
+ expect(target.port).toBe(4150);
+ expect(target.quickCaptureUrl).toBe("http://10.0.0.5:4150/quick-capture");
+ });
+
+ it("falls back to defaults when env values are empty / invalid", () => {
+ expect(resolveDaemonTarget({ CITADEL_HOST: " ", CITADEL_PORT: "" }).host).toBe(DEFAULT_HOST);
+ expect(resolveDaemonTarget({ CITADEL_PORT: "not-a-number" }).port).toBe(DEFAULT_PORT);
+ expect(resolveDaemonTarget({ CITADEL_PORT: "0" }).port).toBe(DEFAULT_PORT);
+ expect(resolveDaemonTarget({ CITADEL_PORT: "-1" }).port).toBe(DEFAULT_PORT);
+ });
+});
+
+describe("centerOnDisplay", () => {
+ const display = { workArea: { x: 0, y: 0, width: 1440, height: 900 } };
+
+ it("centers horizontally on the active display work area", () => {
+ const { x, width } = centerOnDisplay(display);
+ expect(width).toBe(640);
+ expect(x).toBe(Math.round((1440 - 640) / 2));
+ });
+
+ it("places the window in the upper third of the display (Spotlight-style)", () => {
+ const { y, height } = centerOnDisplay(display);
+ expect(height).toBe(220);
+ // 28% from the top of a 900px display = 252.
+ expect(y).toBe(252);
+ });
+
+ it("respects workArea offsets (multi-monitor)", () => {
+ const offset = { workArea: { x: 1440, y: 200, width: 1920, height: 1080 } };
+ const { x, y } = centerOnDisplay(offset);
+ expect(x).toBe(1440 + Math.round((1920 - 640) / 2));
+ expect(y).toBe(200 + Math.round(1080 * 0.28));
+ });
+});
diff --git a/apps/mac-satellite/src/config.ts b/apps/mac-satellite/src/config.ts
new file mode 100644
index 00000000..7c673501
--- /dev/null
+++ b/apps/mac-satellite/src/config.ts
@@ -0,0 +1,59 @@
+// Daemon target resolution for the satellite app.
+//
+// We default to the long-term systemd daemon on 127.0.0.1:4010 (per CLAUDE.md
+// and consistent with scripts/mac-satellite/quick-capture.sh). Worktree-isolated
+// daemons (4110+) are deliberately NOT auto-discovered — a global shortcut
+// bound at the OS level can't know which worktree is "active". Users who want
+// a worktree-specific binding set CITADEL_HOST / CITADEL_PORT in the shortcut's
+// environment.
+
+export const DEFAULT_HOST = "127.0.0.1";
+export const DEFAULT_PORT = 4010;
+
+export type DaemonTarget = {
+ host: string;
+ port: number;
+ origin: string;
+ quickCaptureUrl: string;
+ newWorkspaceUrl: string;
+ livenessUrl: string;
+};
+
+export function resolveDaemonTarget(env: NodeJS.ProcessEnv = process.env): DaemonTarget {
+ const host = (env.CITADEL_HOST ?? DEFAULT_HOST).trim() || DEFAULT_HOST;
+ const portRaw = (env.CITADEL_PORT ?? "").trim();
+ const portParsed = Number.parseInt(portRaw, 10);
+ const port = Number.isFinite(portParsed) && portParsed > 0 ? portParsed : DEFAULT_PORT;
+ const origin = `http://${host}:${port}`;
+ return {
+ host,
+ port,
+ origin,
+ quickCaptureUrl: `${origin}/quick-capture`,
+ newWorkspaceUrl: `${origin}/?modal=new-workspace`,
+ livenessUrl: `${origin}/api/scratchpad`,
+ };
+}
+
+// Spotlight-shaped popup geometry. Width keeps the textarea ~60-char wide; the
+// height accommodates the textarea, mic button, and status line without
+// scrolling. Caller centers horizontally on the active display.
+export const QUICK_CAPTURE_WINDOW = {
+ width: 640,
+ height: 220,
+} as const;
+
+export function centerOnDisplay(
+ display: { workArea: { x: number; y: number; width: number; height: number } },
+ win: { width: number; height: number } = QUICK_CAPTURE_WINDOW,
+): { x: number; y: number; width: number; height: number } {
+ const { workArea } = display;
+ return {
+ width: win.width,
+ height: win.height,
+ x: Math.round(workArea.x + (workArea.width - win.width) / 2),
+ // Position slightly above center, Spotlight-style — the eye tracks higher
+ // than dead-center on a screen, and below-content space is desirable.
+ y: Math.round(workArea.y + workArea.height * 0.28),
+ };
+}
diff --git a/apps/mac-satellite/src/main.ts b/apps/mac-satellite/src/main.ts
new file mode 100644
index 00000000..97f021ed
--- /dev/null
+++ b/apps/mac-satellite/src/main.ts
@@ -0,0 +1,172 @@
+// Citadel macOS satellite — Electron main process.
+//
+// Registers two global shortcuts:
+// ⌘⇧S → open the daemon's /quick-capture page in a frameless, always-on-top
+// BrowserWindow sized like Spotlight. Same window is re-shown on
+// subsequent presses (toggle-like UX).
+// ⌘⇧N → open the cockpit at /?modal=new-workspace in the user's default
+// browser. The cockpit auto-opens the Create Workspace modal.
+//
+// Talks to the local daemon over HTTP — same surface as scripts/mac-satellite/
+// shell helpers, no new endpoints. The shell helpers remain as a no-Electron
+// fallback for users who prefer Hammerspoon or Shortcuts.app.
+
+import { BrowserWindow, Notification, app, globalShortcut, screen, shell } from "electron";
+import { type DaemonTarget, QUICK_CAPTURE_WINDOW, centerOnDisplay, resolveDaemonTarget } from "./config.js";
+
+const QUICK_CAPTURE_ACCELERATOR = "CommandOrControl+Shift+S";
+const NEW_WORKSPACE_ACCELERATOR = "CommandOrControl+Shift+N";
+
+// Single instance — re-launching the app brings the existing one to the
+// foreground rather than spawning a second copy. macOS-standard behavior.
+if (!app.requestSingleInstanceLock()) {
+ app.quit();
+}
+
+// Run as a background utility (LSUIElement-style) — no Dock icon, no menu bar
+// item. The app exists to host the global shortcuts; the user shouldn't see a
+// permanent presence in the Dock.
+if (process.platform === "darwin" && app.dock) {
+ app.dock.hide();
+}
+
+let quickCaptureWindow: BrowserWindow | null = null;
+
+function liveTarget(): DaemonTarget {
+ // Re-read on every shortcut so the env override applies if the user
+ // restarts the app with new CITADEL_HOST / CITADEL_PORT values.
+ return resolveDaemonTarget(process.env);
+}
+
+function showFallbackNotification(title: string, body: string): void {
+ if (Notification.isSupported()) {
+ new Notification({ title, body }).show();
+ } else {
+ console.error(`${title}: ${body}`);
+ }
+}
+
+async function probeDaemon(target: DaemonTarget): Promise {
+ // Liveness probe — match the shell helper's behavior (2s max). We don't care
+ // about the response body; any 2xx/4xx means a daemon is answering.
+ try {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 2000);
+ try {
+ const response = await fetch(target.livenessUrl, { signal: controller.signal });
+ return response.ok || response.status >= 400;
+ } finally {
+ clearTimeout(timer);
+ }
+ } catch {
+ return false;
+ }
+}
+
+function buildQuickCaptureWindow(target: DaemonTarget): BrowserWindow {
+ const primary = screen.getPrimaryDisplay();
+ const bounds = centerOnDisplay(primary, QUICK_CAPTURE_WINDOW);
+ const win = new BrowserWindow({
+ ...bounds,
+ frame: false,
+ titleBarStyle: "hidden",
+ resizable: false,
+ minimizable: false,
+ maximizable: false,
+ fullscreenable: false,
+ alwaysOnTop: true,
+ skipTaskbar: true,
+ show: false,
+ backgroundColor: "#0b101a",
+ webPreferences: {
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ },
+ });
+ // alwaysOnTop with visible-on-all-workspaces gives the Spotlight feel — the
+ // window appears over fullscreen apps without forcing a Space switch.
+ win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
+ // Closing the page from inside (window.close() after a successful capture)
+ // emits 'closed'; clear our ref so the next shortcut press builds a fresh
+ // window with re-resolved daemon target.
+ win.on("closed", () => {
+ if (quickCaptureWindow === win) quickCaptureWindow = null;
+ });
+ // Esc closes the popup. The /quick-capture page also calls window.close()
+ // on Esc, but a top-level shortcut is more reliable across focus states.
+ win.webContents.on("before-input-event", (event, input) => {
+ if (input.type === "keyDown" && input.key === "Escape") {
+ event.preventDefault();
+ win.close();
+ }
+ });
+ void win.loadURL(target.quickCaptureUrl);
+ win.once("ready-to-show", () => {
+ win.show();
+ win.focus();
+ });
+ return win;
+}
+
+async function handleQuickCapture(): Promise {
+ const target = liveTarget();
+ if (!(await probeDaemon(target))) {
+ showFallbackNotification(
+ "Citadel Quick Capture",
+ `Daemon not reachable at ${target.host}:${target.port}. Set CITADEL_HOST / CITADEL_PORT if needed.`,
+ );
+ return;
+ }
+ // Toggle: if a popup already exists and is focused, close it instead of
+ // stacking duplicates. Matches Spotlight's ⌘Space behavior.
+ if (quickCaptureWindow && !quickCaptureWindow.isDestroyed()) {
+ if (quickCaptureWindow.isFocused()) {
+ quickCaptureWindow.close();
+ return;
+ }
+ quickCaptureWindow.show();
+ quickCaptureWindow.focus();
+ return;
+ }
+ quickCaptureWindow = buildQuickCaptureWindow(target);
+}
+
+async function handleNewWorkspace(): Promise {
+ const target = liveTarget();
+ if (!(await probeDaemon(target))) {
+ showFallbackNotification("Citadel New Workspace", `Daemon not reachable at ${target.host}:${target.port}.`);
+ return;
+ }
+ // Open in the user's default browser — they likely have a pinned cockpit
+ // tab. The cockpit's ?modal=new-workspace deeplink auto-opens the modal.
+ void shell.openExternal(target.newWorkspaceUrl);
+}
+
+app.whenReady().then(() => {
+ const captureOk = globalShortcut.register(QUICK_CAPTURE_ACCELERATOR, () => {
+ void handleQuickCapture();
+ });
+ const workspaceOk = globalShortcut.register(NEW_WORKSPACE_ACCELERATOR, () => {
+ void handleNewWorkspace();
+ });
+ if (!captureOk || !workspaceOk) {
+ // Another app (or a stale instance) holds the shortcut. Surface to the
+ // user instead of silently failing — they can rebind in System Settings.
+ showFallbackNotification(
+ "Citadel Mac Satellite",
+ "Could not register global shortcuts (⌘⇧S / ⌘⇧N). Another app may have claimed them.",
+ );
+ }
+});
+
+app.on("will-quit", () => {
+ globalShortcut.unregisterAll();
+});
+
+// macOS apps stay running with no windows. The whole point of this app is
+// the global shortcut, so do NOT quit on window-all-closed.
+app.on("window-all-closed", () => {
+ // no-op on macOS — keep running for the shortcut
+ if (process.platform !== "darwin") app.quit();
+});
diff --git a/apps/mac-satellite/tsconfig.json b/apps/mac-satellite/tsconfig.json
new file mode 100644
index 00000000..b0f4e56e
--- /dev/null
+++ b/apps/mac-satellite/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "rootDir": "src",
+ "outDir": "dist",
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a43d860a..d4a3cbc2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -137,6 +137,15 @@ importers:
specifier: ^1.17.16
version: 1.17.17
+ apps/mac-satellite:
+ devDependencies:
+ electron:
+ specifier: 33.2.1
+ version: 33.2.1
+ typescript:
+ specifier: ^5.8.3
+ version: 5.9.3
+
apps/web:
dependencies:
'@citadel/contracts':
@@ -451,6 +460,10 @@ packages:
cpu: [x64]
os: [win32]
+ '@electron/get@2.0.3':
+ resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==}
+ engines: {node: '>=12'}
+
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
@@ -1093,6 +1106,14 @@ packages:
cpu: [x64]
os: [win32]
+ '@sindresorhus/is@4.6.0':
+ resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
+ engines: {node: '>=10'}
+
+ '@szmarczak/http-timer@4.0.6':
+ resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
+ engines: {node: '>=10'}
+
'@tailwindcss/node@4.3.0':
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
@@ -1234,6 +1255,9 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
+ '@types/cacheable-request@6.0.3':
+ resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
+
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@@ -1252,12 +1276,21 @@ packages:
'@types/express@5.0.6':
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
+ '@types/http-cache-semantics@4.2.0':
+ resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==}
+
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/http-proxy@1.17.17':
resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==}
+ '@types/keyv@3.1.4':
+ resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
+
+ '@types/node@20.19.41':
+ resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
+
'@types/node@22.19.19':
resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==}
@@ -1275,6 +1308,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
+ '@types/responselike@1.0.3':
+ resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
+
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
@@ -1287,6 +1323,9 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
+ '@types/yauzl@2.10.3':
+ resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
+
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -1382,6 +1421,10 @@ packages:
resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+ boolean@3.2.0:
+ resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
+ deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
+
brace-expansion@2.1.0:
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
@@ -1394,6 +1437,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
+ buffer-crc32@0.2.13:
+ resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
+
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -1402,6 +1448,14 @@ packages:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
+ cacheable-lookup@5.0.4:
+ resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==}
+ engines: {node: '>=10.6.0'}
+
+ cacheable-request@7.0.4:
+ resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==}
+ engines: {node: '>=8'}
+
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -1424,6 +1478,9 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+ clone-response@1.0.3:
+ resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==}
+
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1484,10 +1541,26 @@ packages:
supports-color:
optional: true
+ decompress-response@6.0.0:
+ resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
+ engines: {node: '>=10'}
+
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
+ defer-to-connect@2.0.1:
+ resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==}
+ engines: {node: '>=10'}
+
+ define-data-property@1.1.4:
+ resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
+ engines: {node: '>= 0.4'}
+
+ define-properties@1.2.1:
+ resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
+ engines: {node: '>= 0.4'}
+
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@@ -1500,6 +1573,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
+ detect-node@2.1.0:
+ resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
+
dompurify@3.4.5:
resolution: {integrity: sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==}
@@ -1516,6 +1592,11 @@ packages:
electron-to-chromium@1.5.357:
resolution: {integrity: sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==}
+ electron@33.2.1:
+ resolution: {integrity: sha512-SG/nmSsK9Qg1p6wAW+ZfqU+AV8cmXMTIklUL18NnOKfZLlum4ZsDoVdmmmlL39ZmeCaq27dr7CgslRPahfoVJg==}
+ engines: {node: '>= 12.20.55'}
+ hasBin: true
+
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -1526,6 +1607,9 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
+ end-of-stream@1.4.5:
+ resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+
enhanced-resolve@5.21.3:
resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==}
engines: {node: '>=10.13.0'}
@@ -1534,6 +1618,10 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
+ env-paths@2.2.1:
+ resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
+ engines: {node: '>=6'}
+
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
@@ -1549,6 +1637,9 @@ packages:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
+ es6-error@4.1.1:
+ resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
+
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -1571,6 +1662,10 @@ packages:
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@@ -1589,6 +1684,14 @@ packages:
resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==}
engines: {node: '>= 0.10.0'}
+ extract-zip@2.0.1:
+ resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
+ engines: {node: '>= 10.17.0'}
+ hasBin: true
+
+ fd-slicer@1.1.0:
+ resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -1623,6 +1726,10 @@ packages:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
+ fs-extra@8.1.0:
+ resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
+ engines: {node: '>=6 <7 || >=8'}
+
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1648,15 +1755,31 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
+ get-stream@5.2.0:
+ resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
+ engines: {node: '>=8'}
+
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
+ global-agent@3.0.0:
+ resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
+ engines: {node: '>=10.0'}
+
+ globalthis@1.0.4:
+ resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
+ engines: {node: '>= 0.4'}
+
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
+ got@11.8.6:
+ resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==}
+ engines: {node: '>=10.19.0'}
+
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -1668,6 +1791,9 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
+ has-property-descriptors@1.0.2:
+ resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
+
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
@@ -1679,6 +1805,9 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+ http-cache-semantics@4.2.0:
+ resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
+
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -1687,6 +1816,10 @@ packages:
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
engines: {node: '>=8.0.0'}
+ http2-wrapper@1.0.3:
+ resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==}
+ engines: {node: '>=10.19.0'}
+
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@@ -1740,11 +1873,23 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ json-buffer@3.0.1:
+ resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+ json-stringify-safe@5.0.1:
+ resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
+
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
hasBin: true
+ jsonfile@4.0.0:
+ resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
+
+ keyv@4.5.4:
+ resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -1822,6 +1967,10 @@ packages:
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+ lowercase-keys@2.0.0:
+ resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
+ engines: {node: '>=8'}
+
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -1848,6 +1997,10 @@ packages:
engines: {node: '>= 18'}
hasBin: true
+ matcher@3.0.0:
+ resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
+ engines: {node: '>=10'}
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1876,6 +2029,14 @@ packages:
engines: {node: '>=4'}
hasBin: true
+ mimic-response@1.0.1:
+ resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
+ engines: {node: '>=4'}
+
+ mimic-response@3.1.0:
+ resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
+ engines: {node: '>=10'}
+
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
@@ -1906,6 +2067,10 @@ packages:
node-releases@2.0.44:
resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==}
+ normalize-url@6.1.0:
+ resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
+ engines: {node: '>=10'}
+
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -1914,10 +2079,21 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
+ object-keys@1.1.1:
+ resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
+ engines: {node: '>= 0.4'}
+
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+ p-cancelable@2.1.1:
+ resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
+ engines: {node: '>=8'}
+
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -1943,6 +2119,9 @@ packages:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
+ pend@1.2.0:
+ resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1964,14 +2143,25 @@ packages:
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
engines: {node: ^10 || ^12 || >=14}
+ progress@2.0.3:
+ resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
+ engines: {node: '>=0.4.0'}
+
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
+ pump@3.0.4:
+ resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
+
qs@6.15.1:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
+ quick-lru@5.1.1:
+ resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
+ engines: {node: '>=10'}
+
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -1996,6 +2186,16 @@ packages:
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
+ resolve-alpn@1.2.1:
+ resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
+
+ responselike@2.0.1:
+ resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==}
+
+ roarr@2.15.4:
+ resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
+ engines: {node: '>=8.0'}
+
rollup@4.60.4:
resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -2010,6 +2210,9 @@ packages:
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+ semver-compare@1.0.0:
+ resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
+
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -2023,6 +2226,10 @@ packages:
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
engines: {node: '>= 0.8.0'}
+ serialize-error@7.0.1:
+ resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==}
+ engines: {node: '>=10'}
+
seroval-plugins@1.5.4:
resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==}
engines: {node: '>=10'}
@@ -2075,6 +2282,9 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ sprintf-js@1.1.3:
+ resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
+
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -2101,6 +2311,10 @@ packages:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
+ sumchecker@3.0.1:
+ resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==}
+ engines: {node: '>= 8.0'}
+
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -2150,6 +2364,10 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
+ type-fest@0.13.1:
+ resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
+ engines: {node: '>=10'}
+
type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
@@ -2162,6 +2380,10 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ universalify@0.1.2:
+ resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
+ engines: {node: '>= 4.0.0'}
+
unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
@@ -2312,6 +2534,9 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
ws@8.20.1:
resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==}
engines: {node: '>=10.0.0'}
@@ -2327,6 +2552,9 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+ yauzl@2.10.0:
+ resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
+
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -2486,6 +2714,20 @@ snapshots:
'@biomejs/cli-win32-x64@1.9.4':
optional: true
+ '@electron/get@2.0.3':
+ dependencies:
+ debug: 4.4.3
+ env-paths: 2.2.1
+ fs-extra: 8.1.0
+ got: 11.8.6
+ progress: 2.0.3
+ semver: 6.3.1
+ sumchecker: 3.0.1
+ optionalDependencies:
+ global-agent: 3.0.0
+ transitivePeerDependencies:
+ - supports-color
+
'@esbuild/aix-ppc64@0.21.5':
optional: true
@@ -2838,6 +3080,12 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.60.4':
optional: true
+ '@sindresorhus/is@4.6.0': {}
+
+ '@szmarczak/http-timer@4.0.6':
+ dependencies:
+ defer-to-connect: 2.0.1
+
'@tailwindcss/node@4.3.0':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -2966,6 +3214,13 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 22.19.19
+ '@types/cacheable-request@6.0.3':
+ dependencies:
+ '@types/http-cache-semantics': 4.2.0
+ '@types/keyv': 3.1.4
+ '@types/node': 22.19.19
+ '@types/responselike': 1.0.3
+
'@types/connect@3.4.38':
dependencies:
'@types/node': 22.19.19
@@ -2991,12 +3246,22 @@ snapshots:
'@types/express-serve-static-core': 5.1.1
'@types/serve-static': 2.2.0
+ '@types/http-cache-semantics@4.2.0': {}
+
'@types/http-errors@2.0.5': {}
'@types/http-proxy@1.17.17':
dependencies:
'@types/node': 22.19.19
+ '@types/keyv@3.1.4':
+ dependencies:
+ '@types/node': 22.19.19
+
+ '@types/node@20.19.41':
+ dependencies:
+ undici-types: 6.21.0
+
'@types/node@22.19.19':
dependencies:
undici-types: 6.21.0
@@ -3013,6 +3278,10 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@types/responselike@1.0.3':
+ dependencies:
+ '@types/node': 22.19.19
+
'@types/send@1.2.1':
dependencies:
'@types/node': 22.19.19
@@ -3029,6 +3298,11 @@ snapshots:
dependencies:
'@types/node': 22.19.19
+ '@types/yauzl@2.10.3':
+ dependencies:
+ '@types/node': 22.19.19
+ optional: true
+
'@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.0))':
dependencies:
'@babel/core': 7.29.0
@@ -3147,6 +3421,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ boolean@3.2.0:
+ optional: true
+
brace-expansion@2.1.0:
dependencies:
balanced-match: 1.0.2
@@ -3163,10 +3440,24 @@ snapshots:
node-releases: 2.0.44
update-browserslist-db: 1.2.3(browserslist@4.28.2)
+ buffer-crc32@0.2.13: {}
+
bytes@3.1.2: {}
cac@6.7.14: {}
+ cacheable-lookup@5.0.4: {}
+
+ cacheable-request@7.0.4:
+ dependencies:
+ clone-response: 1.0.3
+ get-stream: 5.2.0
+ http-cache-semantics: 4.2.0
+ keyv: 4.5.4
+ lowercase-keys: 2.0.0
+ normalize-url: 6.1.0
+ responselike: 2.0.1
+
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -3193,6 +3484,10 @@ snapshots:
dependencies:
clsx: 2.1.1
+ clone-response@1.0.3:
+ dependencies:
+ mimic-response: 1.0.1
+
clsx@2.1.1: {}
color-convert@2.0.1:
@@ -3236,14 +3531,37 @@ snapshots:
dependencies:
ms: 2.1.3
+ decompress-response@6.0.0:
+ dependencies:
+ mimic-response: 3.1.0
+
deep-eql@5.0.2: {}
+ defer-to-connect@2.0.1: {}
+
+ define-data-property@1.1.4:
+ dependencies:
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ gopd: 1.2.0
+ optional: true
+
+ define-properties@1.2.1:
+ dependencies:
+ define-data-property: 1.1.4
+ has-property-descriptors: 1.0.2
+ object-keys: 1.1.1
+ optional: true
+
depd@2.0.0: {}
destroy@1.2.0: {}
detect-libc@2.1.2: {}
+ detect-node@2.1.0:
+ optional: true
+
dompurify@3.4.5:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -3260,12 +3578,24 @@ snapshots:
electron-to-chromium@1.5.357: {}
+ electron@33.2.1:
+ dependencies:
+ '@electron/get': 2.0.3
+ '@types/node': 20.19.41
+ extract-zip: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
encodeurl@2.0.0: {}
+ end-of-stream@1.4.5:
+ dependencies:
+ once: 1.4.0
+
enhanced-resolve@5.21.3:
dependencies:
graceful-fs: 4.2.11
@@ -3273,6 +3603,8 @@ snapshots:
entities@4.5.0: {}
+ env-paths@2.2.1: {}
+
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
@@ -3283,6 +3615,9 @@ snapshots:
dependencies:
es-errors: 1.3.0
+ es6-error@4.1.1:
+ optional: true
+
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -3371,6 +3706,9 @@ snapshots:
escape-html@1.0.3: {}
+ escape-string-regexp@4.0.0:
+ optional: true
+
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
@@ -3417,6 +3755,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ extract-zip@2.0.1:
+ dependencies:
+ debug: 4.4.3
+ get-stream: 5.2.0
+ yauzl: 2.10.0
+ optionalDependencies:
+ '@types/yauzl': 2.10.3
+ transitivePeerDependencies:
+ - supports-color
+
+ fd-slicer@1.1.0:
+ dependencies:
+ pend: 1.2.0
+
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -3444,6 +3796,12 @@ snapshots:
fresh@0.5.2: {}
+ fs-extra@8.1.0:
+ dependencies:
+ graceful-fs: 4.2.11
+ jsonfile: 4.0.0
+ universalify: 0.1.2
+
fsevents@2.3.2:
optional: true
@@ -3472,6 +3830,10 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
+ get-stream@5.2.0:
+ dependencies:
+ pump: 3.0.4
+
glob@10.5.0:
dependencies:
foreground-child: 3.3.1
@@ -3481,8 +3843,38 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
+ global-agent@3.0.0:
+ dependencies:
+ boolean: 3.2.0
+ es6-error: 4.1.1
+ matcher: 3.0.0
+ roarr: 2.15.4
+ semver: 7.8.0
+ serialize-error: 7.0.1
+ optional: true
+
+ globalthis@1.0.4:
+ dependencies:
+ define-properties: 1.2.1
+ gopd: 1.2.0
+ optional: true
+
gopd@1.2.0: {}
+ got@11.8.6:
+ dependencies:
+ '@sindresorhus/is': 4.6.0
+ '@szmarczak/http-timer': 4.0.6
+ '@types/cacheable-request': 6.0.3
+ '@types/responselike': 1.0.3
+ cacheable-lookup: 5.0.4
+ cacheable-request: 7.0.4
+ decompress-response: 6.0.0
+ http2-wrapper: 1.0.3
+ lowercase-keys: 2.0.0
+ p-cancelable: 2.1.1
+ responselike: 2.0.1
+
graceful-fs@4.2.11: {}
happy-dom@15.11.7:
@@ -3493,6 +3885,11 @@ snapshots:
has-flag@4.0.0: {}
+ has-property-descriptors@1.0.2:
+ dependencies:
+ es-define-property: 1.0.1
+ optional: true
+
has-symbols@1.1.0: {}
hasown@2.0.3:
@@ -3501,6 +3898,8 @@ snapshots:
html-escaper@2.0.2: {}
+ http-cache-semantics@4.2.0: {}
+
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -3517,6 +3916,11 @@ snapshots:
transitivePeerDependencies:
- debug
+ http2-wrapper@1.0.3:
+ dependencies:
+ quick-lru: 5.1.1
+ resolve-alpn: 1.2.1
+
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@@ -3564,8 +3968,21 @@ snapshots:
jsesc@3.1.0: {}
+ json-buffer@3.0.1: {}
+
+ json-stringify-safe@5.0.1:
+ optional: true
+
json5@2.2.3: {}
+ jsonfile@4.0.0:
+ optionalDependencies:
+ graceful-fs: 4.2.11
+
+ keyv@4.5.4:
+ dependencies:
+ json-buffer: 3.0.1
+
lightningcss-android-arm64@1.32.0:
optional: true
@@ -3617,6 +4034,8 @@ snapshots:
loupe@3.2.1: {}
+ lowercase-keys@2.0.0: {}
+
lru-cache@10.4.3: {}
lru-cache@5.1.1:
@@ -3643,6 +4062,11 @@ snapshots:
marked@14.1.4: {}
+ matcher@3.0.0:
+ dependencies:
+ escape-string-regexp: 4.0.0
+ optional: true
+
math-intrinsics@1.1.0: {}
media-typer@0.3.0: {}
@@ -3659,6 +4083,10 @@ snapshots:
mime@1.6.0: {}
+ mimic-response@1.0.1: {}
+
+ mimic-response@3.1.0: {}
+
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.6
@@ -3679,14 +4107,25 @@ snapshots:
node-releases@2.0.44: {}
+ normalize-url@6.1.0: {}
+
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
+ object-keys@1.1.1:
+ optional: true
+
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
+ p-cancelable@2.1.1: {}
+
package-json-from-dist@1.0.1: {}
parseurl@1.3.3: {}
@@ -3704,6 +4143,8 @@ snapshots:
pathval@2.0.1: {}
+ pend@1.2.0: {}
+
picocolors@1.1.1: {}
picomatch@4.0.4: {}
@@ -3722,15 +4163,24 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ progress@2.0.3: {}
+
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
ipaddr.js: 1.9.1
+ pump@3.0.4:
+ dependencies:
+ end-of-stream: 1.4.5
+ once: 1.4.0
+
qs@6.15.1:
dependencies:
side-channel: 1.1.0
+ quick-lru@5.1.1: {}
+
range-parser@1.2.1: {}
raw-body@2.5.3:
@@ -3751,6 +4201,22 @@ snapshots:
requires-port@1.0.0: {}
+ resolve-alpn@1.2.1: {}
+
+ responselike@2.0.1:
+ dependencies:
+ lowercase-keys: 2.0.0
+
+ roarr@2.15.4:
+ dependencies:
+ boolean: 3.2.0
+ detect-node: 2.1.0
+ globalthis: 1.0.4
+ json-stringify-safe: 5.0.1
+ semver-compare: 1.0.0
+ sprintf-js: 1.1.3
+ optional: true
+
rollup@4.60.4:
dependencies:
'@types/estree': 1.0.8
@@ -3788,6 +4254,9 @@ snapshots:
scheduler@0.27.0: {}
+ semver-compare@1.0.0:
+ optional: true
+
semver@6.3.1: {}
semver@7.8.0: {}
@@ -3810,6 +4279,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ serialize-error@7.0.1:
+ dependencies:
+ type-fest: 0.13.1
+ optional: true
+
seroval-plugins@1.5.4(seroval@1.5.4):
dependencies:
seroval: 1.5.4
@@ -3867,6 +4341,9 @@ snapshots:
source-map-js@1.2.1: {}
+ sprintf-js@1.1.3:
+ optional: true
+
stackback@0.0.2: {}
statuses@2.0.2: {}
@@ -3893,6 +4370,12 @@ snapshots:
dependencies:
ansi-regex: 6.2.2
+ sumchecker@3.0.1:
+ dependencies:
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -3932,6 +4415,9 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
+ type-fest@0.13.1:
+ optional: true
+
type-is@1.6.18:
dependencies:
media-typer: 0.3.0
@@ -3941,6 +4427,8 @@ snapshots:
undici-types@6.21.0: {}
+ universalify@0.1.2: {}
+
unpipe@1.0.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.2):
@@ -4061,8 +4549,15 @@ snapshots:
string-width: 5.1.2
strip-ansi: 7.2.0
+ wrappy@1.0.2: {}
+
ws@8.20.1: {}
yallist@3.1.1: {}
+ yauzl@2.10.0:
+ dependencies:
+ buffer-crc32: 0.2.13
+ fd-slicer: 1.1.0
+
zod@3.25.76: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 4a046189..391d5677 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -4,3 +4,4 @@ packages:
allowBuilds:
'@biomejs/biome': true
esbuild: true
+ electron: true
diff --git a/scripts/mac-satellite/README.md b/scripts/mac-satellite/README.md
index f6f58330..4e345d19 100644
--- a/scripts/mac-satellite/README.md
+++ b/scripts/mac-satellite/README.md
@@ -1,8 +1,11 @@
# Citadel — Mac satellite shortcuts
-Two tiny shell helpers that turn the local Citadel daemon's web surfaces into
-Spotlight-style global-shortcut targets. Both target the long-term systemd
-daemon on `127.0.0.1:4010` by default.
+There are now **two** ways to bind the satellite shortcuts on macOS:
+
+1. **`apps/mac-satellite/` — the native Electron app (recommended).** Self-contained, registers the global shortcuts itself via Electron's `globalShortcut`, runs without a Dock icon, hosts the quick-capture popup in its own chromeless frameless window. See [`apps/mac-satellite/README.md`](../../apps/mac-satellite/README.md).
+2. **These shell scripts (fallback).** Tiny POSIX-shell helpers that you bind via Hammerspoon or Shortcuts.app. Useful when you don't want another menu-bar app, or when you want a worktree-specific binding via env-var overrides.
+
+Both paths target the long-term systemd daemon on `127.0.0.1:4010` by default and post via the existing daemon HTTP surface — no new endpoints. Pick whichever fits your workflow.
| Script | What it does | Suggested shortcut |
|---|---|---|
diff --git a/specs/C-technical-stack.md b/specs/C-technical-stack.md
index 0af7c29b..3ee9293b 100644
--- a/specs/C-technical-stack.md
+++ b/specs/C-technical-stack.md
@@ -99,7 +99,9 @@
[~] 6. make check is the release-readiness gate.
[ ] 7. make check includes architecture boundaries, file size checks, typecheck, lint, tests, coverage, dependency policy, and production build.
[ ] 8. Performance smoke remains part of release confidence for the ADE cockpit.
-[ ] 9. `scripts/mac-satellite/` hosts macOS-only helper scripts (Spotlight-style quick-capture launcher, new-workspace launcher) that wrap daemon HTTP endpoints; the directory is intentionally outside `make check` because the scripts depend on macOS-only tooling (`open`, `osascript`, AppleScript, Chrome `--app=`) that cannot run in Linux CI.
+[ ] 9. macOS satellite is delivered in two forms, both wrapping the existing daemon HTTP surface (no new endpoints):
+ - `apps/mac-satellite/` — Electron app that registers global shortcuts (`⌘⇧S` for quick-capture, `⌘⇧N` for new-workspace deeplink) and hosts the `/quick-capture` page in a Spotlight-shaped frameless BrowserWindow. The pure helpers (`src/config.ts`) participate in `make check` via Vitest; the main process (`src/main.ts`) typechecks but is exercised manually with `pnpm --filter @citadel/mac-satellite dev` because Electron's runtime is not headless-testable in CI.
+ - `scripts/mac-satellite/` — POSIX-shell fallback helpers for users who don't want an Electron process. Outside `make check` because they depend on macOS-only tooling (`open`, `osascript`, AppleScript, Chrome `--app=`) that cannot run on Linux CI.
## Dependency Policy
diff --git a/tsconfig.json b/tsconfig.json
index 8737e1bf..60737d06 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,6 +15,7 @@
{ "path": "packages/ui" },
{ "path": "apps/daemon" },
{ "path": "apps/web" },
- { "path": "apps/cli" }
+ { "path": "apps/cli" },
+ { "path": "apps/mac-satellite" }
]
}
From 5cbb0d878df30cbdae5b71222e3631e728817171 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?=
Date: Tue, 26 May 2026 13:40:54 +0000
Subject: [PATCH 13/13] chore(lint): biome format nit in
status-monitor-wiring.ts post-merge
After merging origin/main, biome flagged a single-line expression that
status-monitor-wiring.ts's status-monitor-wiring brought in from the
new 2e31415 status-monitor work. One-line format fix; no behavior
change.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/daemon/src/status-monitor-wiring.ts | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/apps/daemon/src/status-monitor-wiring.ts b/apps/daemon/src/status-monitor-wiring.ts
index da8bdf36..d2389d8d 100644
--- a/apps/daemon/src/status-monitor-wiring.ts
+++ b/apps/daemon/src/status-monitor-wiring.ts
@@ -103,8 +103,7 @@ export function buildStatusMonitorDeps(
// tmux session name (e.g., daemon restart re-spawned the session before
// /tmp was cleared). Treat the exit signal as absent so the live agent
// doesn't get marked stopped.
- const liveNewerThanExit =
- liveStat !== null && exitStat !== null && liveStat.mtimeMs > exitStat.mtimeMs;
+ const liveNewerThanExit = liveStat !== null && exitStat !== null && liveStat.mtimeMs > exitStat.mtimeMs;
const exitCode = exitStat && !liveNewerThanExit ? readAgentExitCode(name) : null;
return {
live: liveStat !== null,