diff --git a/apps/suite/e2e/ds-10-sync.spec.ts b/apps/suite/e2e/ds-10-sync.spec.ts
new file mode 100644
index 000000000..e4de34e87
--- /dev/null
+++ b/apps/suite/e2e/ds-10-sync.spec.ts
@@ -0,0 +1,290 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { mkdir } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import AxeBuilder from "@axe-core/playwright";
+import { expect, type Locator, type Page, test } from "@playwright/test";
+
+const directory = fileURLToPath(new URL("../../../artifacts/playwright/DS-10/", import.meta.url));
+async function accessible(page: Page, selector?: string) {
+ const builder = new AxeBuilder({ page });
+ const results = await (selector ? builder.include(selector) : builder).analyze();
+ expect(
+ results.violations.filter((item) => item.impact === "serious" || item.impact === "critical"),
+ ).toEqual([]);
+}
+async function capture(page: Page, state: string, project: string) {
+ await page.evaluate(() => document.fonts.ready);
+ await mkdir(directory, { recursive: true });
+ await page.screenshot({ path: `${directory}/${state}-${project}.png`, fullPage: true });
+}
+async function tabTo(page: Page, target: Locator) {
+ for (let index = 0; index < 60; index++) {
+ await page.keyboard.press("Tab");
+ if (await target.evaluate((element) => element === document.activeElement)) return;
+ }
+ throw new Error("Keyboard path did not reach its target");
+}
+async function reflow(page: Page) {
+ await page.setViewportSize({ width: 320, height: 900 });
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320);
+ for (const dialog of await page.getByRole("dialog").all())
+ expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
+ true,
+ );
+ await accessible(page);
+}
+
+const scenarios = [
+ "Hidden",
+ "Offline",
+ "Reconnecting",
+ "1 waiting",
+ "3 waiting",
+ "Conflict",
+ "3 conflicts",
+ "Offline with changes",
+ "Failed",
+ "Failed without detail",
+ "Not synced yet",
+ "Mixed",
+];
+
+test("pill: every state, icon names, tooltips, axe and reflow", async ({ page }, info) => {
+ await page.goto("/dev/components/offline-pill");
+ await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
+ for (const scenario of scenarios) {
+ const region = page.locator(`[data-fixture="${scenario}"]`);
+ if (["Hidden", "Not synced yet"].includes(scenario))
+ await expect(region.getByRole("button")).toHaveCount(0);
+ else {
+ const icon = region.locator('[data-sync-variant="icon"]');
+ const pill = region.locator('[data-sync-variant="pill"]');
+ await expect(icon).toHaveAttribute(
+ "aria-label",
+ (await pill.getAttribute("aria-label")) ?? "",
+ );
+ // Radix opens tooltips on hover or keyboard focus (not programmatic focus).
+ await icon.hover();
+ await expect(page.locator('[data-slot="tooltip-content"]')).toBeVisible();
+ }
+ await accessible(page, `[data-fixture="${scenario}"]`);
+ await capture(page, `pill-${scenario.replaceAll(" ", "-")}`, info.project.name);
+ await page.keyboard.press("Escape");
+ }
+ await accessible(page);
+ await reflow(page);
+});
+
+test("sheet: every state, retries, conflict actions, axe and screenshots", async ({
+ page,
+}, info) => {
+ await page.goto("/dev/components/sync-sheet");
+ for (const scenario of scenarios) {
+ const trigger = page.getByRole("button", { name: `Open ${scenario}`, exact: true });
+ await trigger.click();
+ const sheet = page.getByRole("dialog", { name: "Sync status" });
+ await expect(sheet).toBeVisible();
+ const box = await sheet.boundingBox();
+ if (!box) throw new Error("Missing sheet geometry");
+ const viewport = page.viewportSize();
+ if (!viewport) throw new Error("Missing viewport");
+ if (viewport.width < 768) expect(box.y + box.height).toBeCloseTo(viewport.height, 0);
+ else {
+ expect(box.x + box.width).toBeCloseTo(viewport.width, 0);
+ expect(box.y).toBe(0);
+ }
+ if (["Hidden", "Offline", "Reconnecting", "Not synced yet"].includes(scenario))
+ await expect(sheet.getByText("Everything is saved")).toBeVisible();
+ if (scenario === "Not synced yet")
+ await expect(sheet.getByText("Not synced yet")).toBeVisible();
+ else await expect(sheet.getByText(/Last synced/)).toBeVisible();
+ if (scenario === "Offline with changes") {
+ await expect(sheet.getByRole("button", { name: "Retry all" })).toBeDisabled();
+ await sheet.getByRole("group", { name: "Reconnect to retry" }).focus();
+ await expect(page.getByRole("tooltip")).toHaveText("Reconnect to retry");
+ await sheet.getByRole("heading", { name: "Sync status", exact: true }).focus();
+ }
+ if (scenario === "Failed") {
+ await sheet.getByRole("button", { name: "Retry Rename project brief" }).click();
+ await expect(page.getByText("Retrying failed-change", { exact: true })).toBeAttached();
+ await sheet.getByRole("button", { name: "Retry all" }).click();
+ }
+ await accessible(page);
+ await capture(page, `sheet-${scenario.replaceAll(" ", "-")}`, info.project.name);
+ await page.keyboard.press("Escape");
+ await expect(sheet).toBeHidden();
+ await expect(trigger).toBeFocused();
+ }
+ await page.getByRole("button", { name: "Open Mixed", exact: true }).click();
+ await reflow(page);
+});
+
+test("sheet: a long queue scrolls inside the sheet to its last action", async ({ page }) => {
+ await page.goto("/dev/components/sync-sheet");
+ await page.getByRole("button", { name: "Open Long queue", exact: true }).click();
+ const sheet = page.getByRole("dialog", { name: "Sync status" });
+ await expect(sheet).toBeVisible();
+ const last = sheet.getByRole("button", { name: "Resolve Last conflict" });
+ await last.scrollIntoViewIfNeeded();
+ const box = await last.boundingBox();
+ const viewport = page.viewportSize();
+ if (!box || !viewport) throw new Error("Missing geometry");
+ expect(box.y).toBeGreaterThanOrEqual(0);
+ expect(box.y + box.height).toBeLessThanOrEqual(viewport.height);
+ // The scroll happened inside the sheet; the locked page did not move.
+ expect(await page.evaluate(() => window.scrollY)).toBe(0);
+ await last.click();
+ const decision = page.getByRole("dialog", { name: "This item changed somewhere else" });
+ await expect(decision).toBeVisible();
+ await expect(decision.getByRole("button", { name: "Keep both" })).toBeFocused();
+});
+
+test("sheet: a long queue with no buttons scrolls from the keyboard", async ({ page }) => {
+ await page.goto("/dev/components/sync-sheet");
+ await page.getByRole("button", { name: "Open Long pending queue", exact: true }).click();
+ const sheet = page.getByRole("dialog", { name: "Sync status" });
+ await expect(sheet).toBeVisible();
+ const region = sheet.getByRole("region", { name: "Sync items" });
+ const scrollTop = () => region.evaluate((node) => node.scrollTop);
+ expect(await scrollTop()).toBe(0);
+ // Tab reaches the scroll region; the arrow and End keys then scroll it.
+ for (let i = 0; i < 5 && !(await region.evaluate((n) => n === document.activeElement)); i++)
+ await page.keyboard.press("Tab");
+ await expect(region).toBeFocused();
+ await page.keyboard.press("End");
+ await expect.poll(scrollTop).toBeGreaterThan(0);
+ await expect(sheet.getByText("Waiting change 24")).toBeInViewport();
+});
+
+test("keyboard: pill opens sheet, Escape restores pill; conflict returns to Resolve", async ({
+ page,
+}) => {
+ await page.goto("/dev/components/offline-pill");
+ const pill = page.locator('[data-fixture="Conflict"] [data-sync-variant="pill"]');
+ await tabTo(page, pill);
+ await page.keyboard.press("Enter");
+ const sheet = page.getByRole("dialog", { name: "Sync status" });
+ await expect(sheet).toBeVisible();
+ await accessible(page);
+ await page.keyboard.press("Escape");
+ await expect(pill).toBeFocused();
+ await page.keyboard.press("Enter");
+ const resolve = sheet.getByRole("button", { name: "Resolve Project brief" });
+ await tabTo(page, resolve);
+ await page.keyboard.press("Enter");
+ await expect(page.getByRole("button", { name: "Keep both" })).toBeFocused();
+ await accessible(page);
+ await page.keyboard.press("Escape");
+ await expect(resolve).toBeFocused();
+ await page.keyboard.press("Escape");
+ await expect(pill).toBeFocused();
+});
+
+test("conflict: equal choices, initial focus, focus order, copy explanation and axe", async ({
+ page,
+}, info) => {
+ await page.goto("/dev/components/conflict-dialog");
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await accessible(page);
+ for (const [choice, strategy] of [
+ ["Keep mine", "mine"],
+ ["Keep theirs", "theirs"],
+ ["Keep both", "both"],
+ ] as const) {
+ const trigger = page.getByRole("button", { name: "Open conflict", exact: true });
+ await trigger.click();
+ const dialog = page.getByRole("dialog");
+ const both = dialog.getByRole("button", { name: "Keep both" });
+ await expect(both).toBeFocused();
+ await expect(both).toHaveAccessibleDescription("Keep both saves your version as a copy.");
+ await page.keyboard.press("Shift+Tab");
+ await expect(dialog.getByRole("button", { name: "Keep theirs" })).toBeFocused();
+ await page.keyboard.press("Shift+Tab");
+ await expect(dialog.getByRole("button", { name: "Keep mine" })).toBeFocused();
+ await page.keyboard.press("Shift+Tab");
+ await expect(dialog.getByRole("button", { name: "Close", exact: true })).toBeFocused();
+ const styles = await dialog.locator(".suite-conflict-actions > button").evaluateAll((buttons) =>
+ buttons.map((button) => {
+ const style = getComputedStyle(button);
+ return [
+ style.backgroundColor,
+ style.color,
+ style.borderColor,
+ style.fontWeight,
+ button.getBoundingClientRect().width,
+ ];
+ }),
+ );
+ expect(styles[0]?.slice(0, 4)).toEqual(styles[1]?.slice(0, 4));
+ expect(styles[1]?.slice(0, 4)).toEqual(styles[2]?.slice(0, 4));
+ expect(Number(styles[0]?.[4])).toBeCloseTo(Number(styles[1]?.[4]), 0);
+ expect(Number(styles[1]?.[4])).toBeCloseTo(Number(styles[2]?.[4]), 0);
+ await accessible(page);
+ await capture(page, `conflict-${strategy}`, info.project.name);
+ await dialog.getByRole("button", { name: choice }).click();
+ await expect(dialog).toBeHidden();
+ await expect(trigger).toBeFocused();
+ await expect(page.getByRole("status")).toHaveText(`Chose ${strategy}`);
+ }
+ await page.getByRole("button", { name: "Open long summary" }).click();
+ await capture(page, "conflict-long-summary", info.project.name);
+ await reflow(page);
+});
+
+test("capabilities: supported, hidden, disabled, note, missing, custom reason and axe", async ({
+ page,
+}, info) => {
+ await page.goto("/dev/components/capability-gate");
+ for (const scenario of [
+ "Supported",
+ "Disabled",
+ "Hidden",
+ "Note",
+ "Custom reason",
+ "Missing capability",
+ "Online requirements",
+ ]) {
+ const region = page.locator(`[data-fixture="${scenario}"]`);
+ if (scenario === "Hidden") await expect(region.getByRole("button")).toHaveCount(0);
+ else if (scenario === "Supported") {
+ await region.getByRole("button").click();
+ await expect(page.getByRole("status")).toHaveText("Moved to trash");
+ } else if (scenario !== "Online requirements") {
+ await expect(region.getByRole("button")).toBeDisabled();
+ await region.getByRole("group").hover();
+ await expect(page.getByRole("tooltip")).toHaveText(
+ scenario === "Custom reason"
+ ? "Your storage does not offer trash."
+ : "Not available on this storage",
+ );
+ }
+ if (scenario === "Note")
+ await expect(
+ region.getByRole("link", { name: "Learn what your storage supports" }),
+ ).toHaveAttribute("href", "/settings/storage");
+ await accessible(page, `[data-fixture="${scenario}"]`);
+ await capture(page, `gate-${scenario.replaceAll(" ", "-")}`, info.project.name);
+ await page.keyboard.press("Escape");
+ }
+ await reflow(page);
+});
+
+test("browser offline transition shows the pill and reconnecting hides it", async ({
+ page,
+ context,
+}, info) => {
+ await page.goto("/dev/components/offline-pill");
+ const region = page.locator('[data-fixture="Browser connection"]');
+ await expect(region.getByRole("status")).toHaveText("Browser is online");
+ await expect(region.getByRole("button")).toHaveCount(0);
+ await context.setOffline(true);
+ await expect(
+ region.getByRole("button", { name: "Offline — showing saved copies" }),
+ ).toBeVisible();
+ await accessible(page);
+ await capture(page, "browser-offline", info.project.name);
+ await context.setOffline(false);
+ await expect(region.getByRole("button")).toHaveCount(0);
+ await expect(region.getByRole("status")).toHaveText("Browser is online");
+ await accessible(page);
+});
diff --git a/apps/suite/e2e/shell/offline.spec.ts b/apps/suite/e2e/shell/offline.spec.ts
new file mode 100644
index 000000000..1e12ae497
--- /dev/null
+++ b/apps/suite/e2e/shell/offline.spec.ts
@@ -0,0 +1,29 @@
+// AUTHORED-BY Claude Opus 5.5
+import AxeBuilder from "@axe-core/playwright";
+import { expect, test } from "@playwright/test";
+
+test("the shell shows the offline pill and its sync sheet while offline", async ({
+ page,
+ context,
+}) => {
+ await page.goto("/signin?next=%2Fsearch");
+ await page.getByRole("button", { name: "Try it with a demo pod" }).click();
+ await expect(page).toHaveURL(/\/search$/);
+ const pill = page.getByRole("button", { name: "Offline — showing saved copies" });
+ await expect(pill).toHaveCount(0);
+ await context.setOffline(true);
+ await expect(pill).toBeVisible();
+ const results = await new AxeBuilder({ page }).analyze();
+ expect(
+ results.violations.filter((v) => v.impact === "serious" || v.impact === "critical"),
+ ).toEqual([]);
+ await pill.click();
+ const sheet = page.getByRole("dialog");
+ await expect(sheet).toContainText("Everything is saved");
+ await page.keyboard.press("Escape");
+ if (await sheet.isVisible()) await page.keyboard.press("Escape");
+ await expect(sheet).toBeHidden();
+ await expect(pill).toBeFocused();
+ await context.setOffline(false);
+ await expect(pill).toHaveCount(0);
+});
diff --git a/apps/suite/e2e/shell/search-shortcuts.spec.ts b/apps/suite/e2e/shell/search-shortcuts.spec.ts
new file mode 100644
index 000000000..fc3335cf0
--- /dev/null
+++ b/apps/suite/e2e/shell/search-shortcuts.spec.ts
@@ -0,0 +1,248 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { mkdir } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import AxeBuilder from "@axe-core/playwright";
+import { expect, type Page, type TestInfo, test } from "@playwright/test";
+
+async function accessible(page: Page) {
+ const result = await new AxeBuilder({ page }).analyze();
+ expect(
+ result.violations.filter(
+ (violation) => violation.impact === "serious" || violation.impact === "critical",
+ ),
+ ).toEqual([]);
+}
+async function capture(page: Page, info: TestInfo, name: string) {
+ await accessible(page);
+ const directory = fileURLToPath(
+ new URL("../../../../artifacts/playwright/shell/", import.meta.url),
+ );
+ await mkdir(directory, { recursive: true });
+ await page.screenshot({ path: `${directory}/${name}-${info.project.name}.png`, fullPage: true });
+}
+async function ready(page: Page, component = "global-search") {
+ await page.goto(`/dev/components/${component}`);
+ await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1);
+ await page.evaluate(() => document.fonts.ready);
+}
+async function searchInput(page: Page) {
+ if ((page.viewportSize()?.width ?? 1280) < 768 && !(await page.getByRole("dialog").count()))
+ await page.getByRole("button", { name: "Search", exact: true }).click();
+ const input = page.getByRole("combobox", { name: "Search", exact: true });
+ await input.focus();
+ return input;
+}
+test.afterEach(async ({ page }) => {
+ if (new URL(page.url()).pathname.startsWith("/dev/components/")) await accessible(page);
+});
+
+test("groups, caps and labels local results; keyboard opens a result", async ({ page }, info) => {
+ await ready(page);
+ const input = await searchInput(page);
+ await page.getByRole("button", { name: "in Drive, remove scope" }).click();
+ await input.fill("project");
+ for (const name of ["Files", "Documents", "Tasks", "People", "Data"])
+ await expect(page.getByRole("group", { name, exact: true })).toBeVisible();
+ await expect(
+ page.getByRole("group", { name: "Files", exact: true }).getByRole("option"),
+ ).toHaveCount(5);
+ await expect(page.getByText("Searching your saved copies", { exact: true })).toBeVisible();
+ await expect(page.getByText("Your storage doesn't offer search", { exact: false })).toBeVisible();
+ await capture(page, info, "search-grouped");
+ await input.press("ArrowDown");
+ const active = await input.getAttribute("aria-activedescendant");
+ expect(active).toBeTruthy();
+ await expect(page.locator(`[id="${active}"]`)).toHaveAttribute("aria-selected", "true");
+ await input.press("Enter");
+ await expect(page.getByText("Opened Project file 1.pdf", { exact: true })).toBeVisible();
+ await expect(page.getByRole("listbox")).toHaveCount(0);
+ if ((page.viewportSize()?.width ?? 1280) < 768)
+ await expect(page.getByRole("button", { name: "Search", exact: true })).toBeFocused();
+});
+
+test("scope chip and primary-modifier shortcut work while typing", async ({ page }, info) => {
+ await ready(page);
+ const input = await searchInput(page);
+ await input.fill("project");
+ await expect(page.getByRole("group", { name: "Files", exact: true })).toBeVisible();
+ await expect(page.getByRole("group", { name: "Documents", exact: true })).toHaveCount(0);
+ await input.press("Control+Shift+F");
+ await expect(page.getByRole("button", { name: "Everywhere, search only in Drive" })).toHaveText(
+ "Everywhere",
+ );
+ await expect(page.getByRole("group", { name: "Documents", exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "Everywhere, search only in Drive" }).click();
+ await expect(page.getByRole("group", { name: "Documents", exact: true })).toHaveCount(0);
+ await capture(page, info, "search-scoped");
+});
+
+test("recent searches and hint dismissal persist and can be cleared", async ({ page }, info) => {
+ await ready(page);
+ let input = await searchInput(page);
+ await input.fill("project");
+ await expect(page.getByText("Searching your saved copies", { exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "Dismiss search hint" }).click();
+ await input.press("Enter");
+ await expect(page.getByText("All results for project in drive", { exact: true })).toBeVisible();
+ await page.reload();
+ input = await searchInput(page);
+ await expect(
+ page
+ .getByRole("listbox", { name: "Recent searches" })
+ .getByRole("option", { name: "project", exact: true }),
+ ).toBeVisible();
+ await capture(page, info, "search-recent");
+ await input.press("ArrowDown");
+ await input.press("Enter");
+ await expect(input).toHaveValue("project");
+ await expect(page.getByText("Searching your saved copies", { exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Dismiss search hint" })).toHaveCount(0);
+ await input.fill("");
+ await page.getByRole("button", { name: "Clear recent searches" }).click();
+ expect(await page.evaluate(() => localStorage.getItem("suite.recentSearches.v1"))).toBeNull();
+ await expect(page.getByRole("listbox")).toHaveCount(0);
+});
+
+test("loading, aborted stale searches, errors and empty states", async ({ page }, info) => {
+ await ready(page);
+ await page.getByLabel("Fixture state").selectOption("slow");
+ let input = await searchInput(page);
+ await input.fill("project");
+ await expect(page.getByText("Searching…", { exact: true })).toBeVisible();
+ await capture(page, info, "search-loading");
+ await input.fill("unmatched");
+ await expect(page.getByText("No results for “unmatched”", { exact: true })).toBeVisible();
+ await expect(page.getByRole("option", { name: /Project file/ })).toHaveCount(0);
+ await input.press("Escape");
+ await page.getByLabel("Fixture state").selectOption("error");
+ input = await searchInput(page);
+ await input.fill("project");
+ await expect(
+ page.getByText("Search couldn't finish. Try another search.", { exact: true }),
+ ).toBeVisible();
+ await capture(page, info, "search-error");
+ await input.press("Escape");
+ await page.getByLabel("Fixture state").selectOption("empty");
+ input = await searchInput(page);
+ await input.fill("nothing");
+ await expect(page.getByText("No results for “nothing”", { exact: true })).toBeVisible();
+ await capture(page, info, "search-empty");
+});
+
+test("search closes on Escape and reflows at 320px", async ({ page }, info) => {
+ await ready(page);
+ await page.setViewportSize({ width: 320, height: 900 });
+ const input = await searchInput(page);
+ await input.fill("project");
+ await expect(page.getByRole("listbox")).toBeVisible();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320);
+ const dialog = page.getByRole("dialog");
+ expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
+ await capture(page, info, "search-320");
+ await input.press("Escape");
+ await expect(dialog).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Search", exact: true })).toBeFocused();
+});
+
+test("palette filters actions, creates, searches and restores focus", async ({ page }, info) => {
+ await ready(page, "command-palette");
+ const trigger = page.getByRole("button", { name: "Open command palette" });
+ await trigger.focus();
+ await page.keyboard.press("Control+k");
+ const dialog = page.getByRole("dialog", { name: "Command palette" });
+ await expect(dialog).toBeVisible();
+ await expect(dialog.getByRole("group", { name: "Go to" })).toBeVisible();
+ await expect(dialog.getByRole("group", { name: "Create" })).toBeVisible();
+ await expect(dialog.getByRole("group", { name: "Recent" })).toBeVisible();
+ await expect(dialog.getByRole("option", { name: /Calendar/ })).toHaveCount(0);
+ await capture(page, info, "palette");
+ const input = dialog.getByRole("combobox");
+ await input.fill("New document");
+ await input.press("Enter");
+ await expect(page.getByText("Created a document", { exact: true })).toBeVisible();
+ await expect(trigger).toBeFocused();
+ await page.keyboard.press("Control+k");
+ await input.fill("a unique search");
+ await input.press("Enter");
+ await expect(page.getByText("Palette search for a unique search", { exact: true })).toBeVisible();
+ await trigger.click();
+ await page.keyboard.press("Escape");
+ await expect(dialog).toHaveCount(0);
+ await expect(trigger).toBeFocused();
+});
+
+test("palette Enter navigates to Drive", async ({ page }) => {
+ await ready(page, "command-palette");
+ await page.getByLabel("Typing area").focus();
+ await page.keyboard.press("Control+k");
+ const dialog = page.getByRole("dialog", { name: "Command palette" });
+ await dialog.getByRole("combobox").fill("drive");
+ await expect(dialog.getByRole("option", { name: /Drive/ })).toBeVisible();
+ await accessible(page);
+ await dialog.getByRole("combobox").press("Enter");
+ await expect(page).toHaveURL(/\/drive(?:\/my)?$/);
+});
+
+test("help groups and filters shortcuts, lists frame keys and restores focus", async ({
+ page,
+}, info) => {
+ await ready(page, "shortcut-help");
+ const trigger = page.getByRole("button", { name: "Show keyboard shortcuts" });
+ await trigger.focus();
+ await page.keyboard.press("?");
+ const dialog = page.getByRole("dialog", { name: "Keyboard shortcuts" });
+ await expect(dialog).toBeVisible();
+ await expect(dialog.getByText("Toggle sidebar", { exact: true })).toBeVisible();
+ await expect(dialog.getByText("Toggle details pane", { exact: true })).toBeVisible();
+ await capture(page, info, "shortcut-help");
+ await dialog.getByLabel("Find a shortcut").fill("Drive");
+ await expect(dialog.getByText("Go to Drive", { exact: true })).toBeVisible();
+ await expect(dialog.getByText("Go to Home", { exact: true })).toHaveCount(0);
+ await page.keyboard.press("Escape");
+ await expect(trigger).toBeFocused();
+ await page.keyboard.press("x");
+ await expect(page.getByRole("status").filter({ hasText: "Editor action" })).toHaveCount(1);
+});
+
+test("single keys stay in editable controls and g d navigates after blur", async ({ page }) => {
+ await ready(page);
+ const input = page.getByLabel("Typing area");
+ await input.fill("");
+ await input.pressSequentially("gd/?D");
+ await expect(input).toHaveValue("gd/?D");
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await page.getByLabel("Shortcuts disabled", { exact: true }).pressSequentially("gd?");
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await accessible(page);
+ await page.getByRole("heading", { level: 1 }).click();
+ await page.keyboard.press("g");
+ await page.keyboard.press("d");
+ await expect(page).toHaveURL(/\/drive(?:\/my)?$/);
+});
+
+test("slash focuses responsive search and Shift+D toggles theme", async ({ page }) => {
+ await ready(page);
+ const previous = await page.locator("html").getAttribute("class");
+ await page.keyboard.press("Shift+D");
+ await expect.poll(() => page.locator("html").getAttribute("class")).not.toBe(previous);
+ await page.keyboard.press("/");
+ await expect(page.getByRole("combobox", { name: "Search", exact: true })).toBeFocused();
+});
+
+test("results body filters and error recovery", async ({ page }, info) => {
+ await ready(page);
+ await page.getByLabel("Fixture state").selectOption("results");
+ await expect(page.getByText("12 results", { exact: true })).toBeVisible();
+ await page.getByLabel("Type", { exact: true }).selectOption("document");
+ await expect(page.getByText("1 results", { exact: true })).toBeVisible();
+ await page.getByLabel("App", { exact: true }).selectOption("drive");
+ await expect(page.getByText("No results for 'project'", { exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "Clear filters", exact: true }).first().click();
+ await page.getByLabel("Owner", { exact: true }).selectOption("me");
+ await expect(page.getByText("8 results", { exact: true })).toBeVisible();
+ await capture(page, info, "search-results");
+ await page.getByLabel("Fixture state").selectOption("results-error");
+ await expect(page.getByRole("alert").filter({ hasText: /\S/ })).toBeVisible();
+ await page.getByRole("button", { name: "Retry", exact: true }).click();
+ await expect(page.getByRole("alert").filter({ hasText: /\S/ })).toHaveCount(0);
+});
diff --git a/apps/suite/e2e/shell/shortcut-switch.spec.ts b/apps/suite/e2e/shell/shortcut-switch.spec.ts
new file mode 100644
index 000000000..827364394
--- /dev/null
+++ b/apps/suite/e2e/shell/shortcut-switch.spec.ts
@@ -0,0 +1,59 @@
+// AUTHORED-BY Claude Opus 5.5
+import AxeBuilder from "@axe-core/playwright";
+import { expect, type Page, test } from "@playwright/test";
+
+async function accessible(page: Page) {
+ const result = await new AxeBuilder({ page }).analyze();
+ expect(
+ result.violations.filter((v) => v.impact === "serious" || v.impact === "critical"),
+ ).toEqual([]);
+}
+
+test("single-key shortcuts can be turned off, and the switch stays reachable", async ({ page }) => {
+ await page.goto("/signin?next=%2Fsearch");
+ await page.getByRole("button", { name: "Try it with a demo pod" }).click();
+ await expect(page).toHaveURL(/\/search$/);
+ await page.getByRole("heading", { level: 1 }).click();
+
+ const help = page.getByRole("dialog", { name: "Keyboard shortcuts" });
+ const toggle = page.getByRole("checkbox", { name: /Use single-key shortcuts/ });
+ const sidebar = page.getByRole("button", { name: "Toggle sidebar" });
+ await expect(sidebar).toHaveAttribute("aria-keyshortcuts", "[");
+
+ await page.keyboard.press("?");
+ await expect(help).toBeVisible();
+ await toggle.uncheck();
+ await accessible(page);
+ await page.keyboard.press("Escape");
+ if (await help.isVisible()) await page.keyboard.press("Escape");
+ await expect(help).toBeHidden();
+
+ // Off: no single key acts, including the frame's own [ and ] keys.
+ await page.getByRole("heading", { level: 1 }).click();
+ const expanded = await sidebar.getAttribute("aria-expanded");
+ await page.keyboard.press("[");
+ await page.keyboard.press("?");
+ await expect(sidebar).toHaveAttribute("aria-expanded", expanded ?? "");
+ await expect(sidebar).not.toHaveAttribute("aria-keyshortcuts", /./);
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+
+ // The palette (a modifier shortcut) still reaches the help sheet and its switch,
+ // and closing help returns focus to where the person was before the palette.
+ await sidebar.focus();
+ await page.keyboard.press("ControlOrMeta+k");
+ const palette = page.getByRole("dialog", { name: "Command palette" });
+ await expect(palette).toBeVisible();
+ await expect(palette.getByText("g d", { exact: true })).toHaveCount(0);
+ await page.keyboard.type("Keyboard shortcuts");
+ await page.keyboard.press("Enter");
+ await expect(help).toBeVisible();
+ await expect(toggle).not.toBeChecked();
+ await toggle.check();
+ await page.keyboard.press("Escape");
+ if (await help.isVisible()) await page.keyboard.press("Escape");
+ await expect(help).toBeHidden();
+ await expect(sidebar).toBeFocused();
+ await page.getByRole("heading", { level: 1 }).click();
+ await page.keyboard.press("?");
+ await expect(help).toBeVisible();
+});
diff --git a/apps/suite/e2e/shell/sign-in.spec.ts b/apps/suite/e2e/shell/sign-in.spec.ts
new file mode 100644
index 000000000..643840704
--- /dev/null
+++ b/apps/suite/e2e/shell/sign-in.spec.ts
@@ -0,0 +1,233 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { mkdir } from "node:fs/promises";
+import AxeBuilder from "@axe-core/playwright";
+import { expect, type Page, test } from "@playwright/test";
+
+async function accessible(page: Page) {
+ const results = await new AxeBuilder({ page }).analyze();
+ expect(
+ results.violations.filter(
+ (violation) => violation.impact === "serious" || violation.impact === "critical",
+ ),
+ ).toEqual([]);
+ await expect(page.locator("h1")).toHaveCount(1);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+}
+async function screenshot(page: Page, name: string, project: string) {
+ const directory = "../../artifacts/playwright/shell";
+ await mkdir(directory, { recursive: true });
+ await page.screenshot({ path: `${directory}/${name}-${project}.png`, fullPage: true });
+}
+
+test("signed-out entry shows welcome, sign-in links and an optional demo", async ({
+ page,
+}, info) => {
+ await page.goto("/");
+ await expect(page).toHaveURL(/\/welcome$/);
+ await expect(page.getByRole("heading", { level: 1 })).toHaveText(
+ "Your documents, files and tasks — kept in a pod you control",
+ );
+ await expect(page.getByRole("link", { name: "Sign in", exact: true })).toHaveAttribute(
+ "href",
+ "/signin",
+ );
+ await expect(page.getByRole("link", { name: "Create account", exact: true })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Try it with a demo pod" })).toBeVisible();
+ await accessible(page);
+ await screenshot(page, "welcome", info.project.name);
+});
+
+test("different-account validation and the provider preview seam", async ({ page }, info) => {
+ await page.goto("/signin");
+ const address = page.getByRole("textbox", { name: "Your WebID" });
+ await expect(address).toBeVisible();
+ await accessible(page);
+ await screenshot(page, "signin", info.project.name);
+ await address.fill("notaurl");
+ await page.getByRole("button", { name: "Continue", exact: true }).click();
+ await expect(page.getByRole("alert").filter({ hasText: /\S/ })).toHaveText(
+ "We couldn't find a sign-in service at that address",
+ );
+ await expect(address).toHaveAttribute("aria-invalid", "true");
+ await accessible(page);
+ await address.fill("https://example.org/profile/card#me");
+ await address.press("Enter");
+ await expect(page.getByRole("alert").filter({ hasText: /\S/ })).toHaveText(
+ "Signing in with a Solid provider isn't switched on in this preview yet.",
+ );
+ const remembered = () =>
+ page.evaluate(() => JSON.parse(localStorage.getItem("suite.recentProviders.v1") ?? "[]"));
+ // A WebID is never remembered as a provider.
+ expect(await remembered()).toEqual([]);
+ await accessible(page);
+ // The provider field keeps its intent, even for a provider that lives under a path.
+ await page.getByRole("button", { name: "Another provider" }).click();
+ const provider = page.getByRole("combobox", { name: "Provider address" });
+ await provider.fill("https://login.example.org/realms/solid");
+ await provider.press("Enter");
+ await expect(page.getByRole("alert").filter({ hasText: /\S/ })).toHaveText(
+ "Signing in with a Solid provider isn't switched on in this preview yet.",
+ );
+ expect(await remembered()).toEqual(["https://login.example.org/realms/solid"]);
+ await accessible(page);
+ // Provider buttons: the known community providers, each a real button.
+ await expect(page.getByRole("list", { name: "Providers" }).getByRole("button")).not.toHaveCount(
+ 0,
+ );
+});
+
+test("pod explainer opens with the keyboard and returns focus", async ({ page }) => {
+ await page.goto("/signin");
+ const trigger = page.getByRole("button", { name: "What is a pod?" });
+ await trigger.focus();
+ await page.keyboard.press("Enter");
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await expect(page.getByRole("dialog")).toContainText(
+ "A pod is a place to keep your data online.",
+ );
+ await accessible(page);
+ // Focus starts on Close, whose tooltip takes the first Escape (WCAG 1.4.13 dismissible).
+ await page.keyboard.press("Escape");
+ if (await page.getByRole("dialog").isVisible()) await page.keyboard.press("Escape");
+ await expect(page.getByRole("dialog")).toBeHidden();
+ await expect(trigger).toBeFocused();
+});
+
+test("demo lands on the start page and the shell keeps the session", async ({ page }, info) => {
+ await page.goto("/signin");
+ await page.getByRole("button", { name: "Try it with a demo pod" }).click();
+ await expect(page).toHaveURL(/\/drive(\/my)?$/);
+ await page.goto("/search");
+ await expect(page.getByRole("heading", { name: "Search", exact: true, level: 1 })).toBeVisible();
+ await expect(
+ page.getByRole("link", { name: /^Account: / }).filter({ visible: true }),
+ ).toBeVisible();
+ await accessible(page);
+ await screenshot(page, "search", info.project.name);
+ await page.reload();
+ await expect(
+ page.getByRole("link", { name: /^Account: / }).filter({ visible: true }),
+ ).toBeVisible();
+ await accessible(page);
+ await page.goto("/");
+ await expect(page).toHaveURL(/\/drive(\/my)?$/);
+});
+
+test("session guard keeps a safe return route", async ({ page }) => {
+ await page.goto("/search");
+ await expect(page).toHaveURL(/\/signin\?next=%2Fsearch$/);
+ await accessible(page);
+ await page.getByRole("button", { name: "Try it with a demo pod" }).click();
+ await expect(page).toHaveURL(/\/search$/);
+ await accessible(page);
+});
+
+test("unsafe next cannot redirect the demo outside the suite", async ({ page }) => {
+ await page.goto("/signin?next=//evil.example");
+ await accessible(page);
+ await page.getByRole("button", { name: "Try it with a demo pod" }).click();
+ await expect(page).toHaveURL(/\/drive(\/my)?$/);
+ await accessible(page);
+});
+
+test("expired sessions keep content inert until reauthentication or signout", async ({ page }) => {
+ await page.goto("/signin");
+ await page.getByRole("textbox", { name: "Your WebID" }).fill("https://example.org/");
+ await page.getByRole("button", { name: "Continue", exact: true }).click();
+ await expect(page.getByRole("alert").filter({ hasText: /\S/ })).toBeVisible();
+ await page.evaluate(() =>
+ localStorage.setItem(
+ "suite.accounts.v1",
+ JSON.stringify({
+ activeId: "example",
+ accounts: [
+ {
+ id: "example",
+ name: "Alex Example",
+ webId: "https://example.org/profile#me",
+ storageUrl: "https://example.org/storage/",
+ issuer: "https://id.example.org/",
+ kind: "solid",
+ },
+ ],
+ }),
+ ),
+ );
+ await page.goto("/search");
+ const dialog = page.getByRole("alertdialog");
+ await expect(dialog).toContainText("Your session ended — sign in again to continue");
+ await expect(page.locator("[inert] #suite-page-title")).toHaveText("Search");
+ await page.keyboard.press("Escape");
+ await expect(dialog).toBeVisible();
+ await page.mouse.click(1, 1);
+ await expect(dialog).toBeVisible();
+ // The hidden, inert page still owns its one h1 while the dialog has its own h2.
+ await accessible(page);
+ await dialog.getByRole("button", { name: "Sign in again" }).click();
+ await expect(dialog.getByRole("alert")).toHaveText(
+ "Signing in with a Solid provider isn't switched on in this preview yet.",
+ );
+ await dialog.getByRole("button", { name: "Sign out", exact: true }).click();
+ await expect(page).toHaveURL(/\/signin\?next=%2Fsearch$/);
+ await accessible(page);
+});
+
+test("invalid Drive reference renders the not-found page", async ({ page }) => {
+ await page.goto("/drive/f/not-a-ref");
+ await expect(
+ page.getByRole("heading", { name: "We couldn't find that page", exact: true }),
+ ).toBeVisible();
+ await expect(page.getByRole("link", { name: "Go to Drive" })).toHaveAttribute("href", "/drive");
+ await expect(page.getByRole("link", { name: "Search", exact: true })).toHaveAttribute(
+ "href",
+ "/search",
+ );
+ await accessible(page);
+});
+
+test("client identity and callback bridge are served", async ({ page, request, baseURL }) => {
+ const clientUrl = `${baseURL}/.well-known/solid-client-id.jsonld`;
+ const client = await request.get(clientUrl);
+ expect(client.ok()).toBe(true);
+ expect(client.headers()["content-type"]).toContain("application/ld+json");
+ // Unconfigured local server: answered for loopback only, never cached.
+ expect(client.headers()["cache-control"]).toBe("no-store");
+ const document = await client.json();
+ const origin = new URL(document.client_id).origin;
+ expect(new URL(origin).hostname).toMatch(/^(localhost|127\.0\.0\.1)$/);
+ expect(document.client_id).toBe(`${origin}/.well-known/solid-client-id.jsonld`);
+ expect(document.redirect_uris).toEqual([`${origin}/auth/callback.html`]);
+ const forged = await request.get(clientUrl, {
+ headers: { "x-forwarded-host": "attacker.example" },
+ });
+ expect((await forged.json()).client_id).toBe(document.client_id);
+ const callback = await request.get("/auth/callback.html");
+ expect(callback.ok()).toBe(true);
+ expect(await callback.text()).toContain("Signing you in…");
+ await page.goto("/auth/callback.html?code=example&state=test");
+ await expect(page).toHaveURL(/\/signin\?callback=1$/);
+ expect(await page.evaluate(() => sessionStorage.getItem("suite.auth.callback"))).toBe(
+ `${baseURL}/auth/callback.html?code=example&state=test`,
+ );
+ await accessible(page);
+});
+
+test("same-origin popup sends its callback only to its opener", async ({ page, baseURL }) => {
+ await page.goto("/signin");
+ await page.evaluate(() => {
+ window.addEventListener("message", (event) => {
+ if (event.origin === location.origin)
+ sessionStorage.setItem("test.callback", JSON.stringify(event.data));
+ });
+ window.open("/auth/callback.html?code=example&state=popup");
+ });
+ await expect
+ .poll(() => page.evaluate(() => sessionStorage.getItem("test.callback")))
+ .toBe(
+ JSON.stringify({
+ type: "solid-suite:auth-callback",
+ url: `${baseURL}/auth/callback.html?code=example&state=popup`,
+ }),
+ );
+ await accessible(page);
+});
diff --git a/apps/suite/public/auth/callback.html b/apps/suite/public/auth/callback.html
new file mode 100644
index 000000000..601a9fa2e
--- /dev/null
+++ b/apps/suite/public/auth/callback.html
@@ -0,0 +1,19 @@
+
+
+
+
Signing in · Solid Suite
+Signing you in…
+
diff --git a/apps/suite/public/icon.svg b/apps/suite/public/icon.svg
new file mode 100644
index 000000000..08449f55d
--- /dev/null
+++ b/apps/suite/public/icon.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/apps/suite/src/app/(public)/layout.tsx b/apps/suite/src/app/(public)/layout.tsx
new file mode 100644
index 000000000..c4621bc06
--- /dev/null
+++ b/apps/suite/src/app/(public)/layout.tsx
@@ -0,0 +1,15 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import type { ReactNode } from "react";
+export default function PublicLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ Solid Suite
+
+ {children}
+
+ );
+}
diff --git a/apps/suite/src/app/(public)/signin/page.tsx b/apps/suite/src/app/(public)/signin/page.tsx
new file mode 100644
index 000000000..5f33a1519
--- /dev/null
+++ b/apps/suite/src/app/(public)/signin/page.tsx
@@ -0,0 +1,164 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+// AUTHORED-BY Claude Opus 5.5
+"use client";
+/**
+ * /signin (02-suite-shell §8.1) on the suite-ui SignInCard: Account ID first,
+ * then provider buttons (home provider first), another provider, and the
+ * accounts used on this device. Until pod-auth's provider directory lands,
+ * input is mapped to a WebID hint when it names a document, else an issuer.
+ */
+import { Button } from "@jeswr/suite-ui";
+import {
+ hostOf,
+ SignInCard,
+ type SignInProvider,
+ type SignInRecentAccount,
+} from "@jeswr/suite-ui/sign-in";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Suspense, useEffect, useMemo, useState } from "react";
+import { useSolidExtension } from "@/lib/session/extension-session";
+import { useSuiteEnvironment } from "@/shell/environment";
+import { safeNext } from "@/shell/lib/next";
+import { RECENT_PROVIDERS_KEY, recentProviders } from "@/shell/lib/preferences";
+import { hintForField, rememberedIssuer } from "@/shell/lib/sign-in-hint";
+import { DemoButton } from "@/shell/public/demo-button";
+import { PodExplainer } from "@/shell/public/pod-explainer";
+import { useAccounts } from "@/shell/session/accounts-provider";
+import { SignInError, type SignInHint } from "@/shell/session/types";
+
+export default function Page() {
+ return (
+
+
+
+ );
+}
+
+type Pending = { kind: "webid" | "provider" | "account" | "custom"; key: string } | null;
+
+function SignIn() {
+ const env = useSuiteEnvironment();
+ const { signIn, signInWith, backendKinds, accounts, active } = useAccounts();
+ const extension = useSolidExtension();
+ const router = useRouter();
+ const params = useSearchParams();
+ const next = params.get("next");
+ const adding = params.get("add") === "1" && active;
+ const [recent, setRecent] = useState([]);
+ const [error, setError] = useState(null);
+ const [pending, setPending] = useState(null);
+ useEffect(() => {
+ try {
+ setRecent(recentProviders(localStorage.getItem(RECENT_PROVIDERS_KEY)));
+ } catch {
+ /* Known providers remain available. */
+ }
+ }, []);
+ const providers = useMemo(() => {
+ const issuers = [
+ ...(env.NEXT_PUBLIC_SUITE_IDP ? [env.NEXT_PUBLIC_SUITE_IDP] : []),
+ ...env.NEXT_PUBLIC_SUITE_KNOWN_PROVIDERS,
+ ];
+ return [...new Set(issuers)].map((issuer, index) => ({
+ id: issuer,
+ issuer,
+ name:
+ index === 0 && env.NEXT_PUBLIC_SUITE_IDP ? env.NEXT_PUBLIC_SUITE_IDP_NAME : hostOf(issuer),
+ }));
+ }, [env]);
+ const recentAccounts: SignInRecentAccount[] = accounts
+ .filter((account) => account.kind === "solid" && account.id !== active?.id)
+ .map((account) => ({
+ webId: account.webId,
+ name: account.name,
+ ...(account.photoUrl ? { photoUrl: account.photoUrl } : {}),
+ }));
+ const remember = (provider: string) => {
+ const updated = recentProviders(JSON.stringify([provider, ...recent]));
+ setRecent(updated);
+ try {
+ localStorage.setItem(RECENT_PROVIDERS_KEY, JSON.stringify(updated));
+ } catch {
+ /* Memory-only. */
+ }
+ };
+ const submit = async (hint: SignInHint, state: NonNullable) => {
+ setPending(state);
+ setError(null);
+ try {
+ await signIn(hint);
+ router.replace(safeNext(next));
+ } catch (cause) {
+ if (cause instanceof SignInError) {
+ setError(cause.message);
+ const issuer = rememberedIssuer(hint);
+ if (cause.code === "unavailable" && issuer) remember(issuer);
+ } else setError("We couldn't sign you in. Please try again.");
+ } finally {
+ setPending(null);
+ }
+ };
+ const useExtension = extension.present && backendKinds.includes("extension");
+ return (
+ <>
+ Sign in to Solid Suite
+ {adding && (
+
+ Adding another account — you stay signed in as {active.name}.
+
+ )}
+
+ void submit(hintForField("webid", input), { kind: "webid", key: input })
+ }
+ onCustomProvider={(input) =>
+ void submit(hintForField("provider", input), { kind: "custom", key: input })
+ }
+ onProvider={(provider) =>
+ void submit({ issuer: provider.issuer }, { kind: "provider", key: provider.id })
+ }
+ onAccount={(account) =>
+ void submit({ webId: account.webId }, { kind: "account", key: account.webId })
+ }
+ footer={
+
+ {useExtension && (
+
+ )}
+
+
+ {env.SUITE_TEST_CAPS.selfRegistration === true && (
+ <>
+
+ New here? Create an account
+
+ ·
+ >
+ )}
+
+
+
+ }
+ />
+ >
+ );
+}
diff --git a/apps/suite/src/app/(public)/welcome/page.tsx b/apps/suite/src/app/(public)/welcome/page.tsx
new file mode 100644
index 000000000..dba502e2f
--- /dev/null
+++ b/apps/suite/src/app/(public)/welcome/page.tsx
@@ -0,0 +1,36 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import { Button } from "@jeswr/suite-ui";
+import { useSearchParams } from "next/navigation";
+import { Suspense } from "react";
+import { useSuiteEnvironment } from "@/shell/environment";
+import { DemoButton } from "@/shell/public/demo-button";
+export default function Page() {
+ return (
+
+
+
+ );
+}
+function Welcome() {
+ const env = useSuiteEnvironment();
+ const next = useSearchParams().get("next");
+ return (
+ <>
+ Your documents, files and tasks — kept in a pod you control
+ One place for your data, with you in charge of who can use it.
+
+ Works with your own pod
+ >
+ );
+}
diff --git a/apps/suite/src/app/(suite)/search/page.tsx b/apps/suite/src/app/(suite)/search/page.tsx
new file mode 100644
index 000000000..203c47b6d
--- /dev/null
+++ b/apps/suite/src/app/(suite)/search/page.tsx
@@ -0,0 +1,83 @@
+// AUTHORED-BY Claude Opus 5.5
+"use client";
+/**
+ * Global search results (02-suite-shell §5). The query and scope come from the
+ * URL; results come from the same source the TopBar search uses.
+ */
+import { type AppSlug, EmptyState, suiteApps } from "@jeswr/suite-ui";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Suspense, useEffect, useState } from "react";
+import type { SearchResult } from "@/shell/lib/search-results";
+import { runSearch } from "@/shell/lib/search-run";
+import { SearchResultsView } from "@/shell/search/results-view";
+import { useShellSearchSource } from "@/shell/search/search-context";
+import { useAccounts } from "@/shell/session/accounts-provider";
+import { SuiteShell } from "@/shell/suite-shell";
+
+function scopeOf(value: string | null): AppSlug | undefined {
+ return suiteApps.find((app) => app.slug === value)?.slug;
+}
+
+function SearchPage() {
+ const params = useSearchParams();
+ const router = useRouter();
+ const query = params.get("q")?.trim() ?? "";
+ const scope = scopeOf(params.get("scope"));
+ const { active } = useAccounts();
+ const source = useShellSearchSource();
+ const [state, setState] = useState<{
+ results: readonly SearchResult[];
+ loading: boolean;
+ error?: "unknown";
+ }>({ results: [], loading: false });
+ const [attempt, setAttempt] = useState(0);
+ // biome-ignore lint/correctness/useExhaustiveDependencies: attempt re-runs the search on Retry
+ useEffect(() => {
+ if (!query) {
+ setState({ results: [], loading: false });
+ return;
+ }
+ const controller = new AbortController();
+ setState((current) => ({ results: current.results, loading: true }));
+ void runSearch(
+ source,
+ query,
+ { ...(scope ? { scope } : {}), signal: controller.signal },
+ (outcome) =>
+ setState(
+ outcome.kind === "results"
+ ? { results: outcome.results, loading: false }
+ : { results: [], loading: false, error: "unknown" },
+ ),
+ );
+ return () => controller.abort();
+ }, [query, scope, source, attempt]);
+ return (
+
+ {!query ? (
+
+ ) : (
+ setAttempt((value) => value + 1)}
+ {...(active ? { accountId: active.webId } : {})}
+ onOpen={(result) => router.push(result.href)}
+ />
+ )}
+
+ );
+}
+
+export default function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/suite/src/app/.well-known/solid-client-id.jsonld/route.ts b/apps/suite/src/app/.well-known/solid-client-id.jsonld/route.ts
new file mode 100644
index 000000000..df12e8e04
--- /dev/null
+++ b/apps/suite/src/app/.well-known/solid-client-id.jsonld/route.ts
@@ -0,0 +1,24 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+// AUTHORED-BY Claude Opus 5.5
+import { buildClientId, trustedClientOrigin } from "@/shell/lib/client-id";
+
+/**
+ * The document's client_id and redirect URIs come only from deployment
+ * configuration (NEXT_PUBLIC_SUITE_ORIGIN, or the Vercel-provided host), never
+ * from request headers, so a forged Host cannot poison a cached copy. Without
+ * configuration only a loopback request is answered (local development), and
+ * that answer is never cached.
+ */
+export function GET(request: Request): Response {
+ const origin = trustedClientOrigin(process.env, request.url);
+ if (!origin.origin)
+ return new Response("Set NEXT_PUBLIC_SUITE_ORIGIN to serve the Client Identifier Document.", {
+ status: 404,
+ });
+ return Response.json(buildClientId(request.url, origin.origin), {
+ headers: {
+ "content-type": "application/ld+json",
+ "cache-control": origin.cacheable ? "public, max-age=300" : "no-store",
+ },
+ });
+}
diff --git a/apps/suite/src/app/dev/components/command-palette/page.tsx b/apps/suite/src/app/dev/components/command-palette/page.tsx
new file mode 100644
index 000000000..2a8d62708
--- /dev/null
+++ b/apps/suite/src/app/dev/components/command-palette/page.tsx
@@ -0,0 +1,5 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { ShellSearchFixture } from "../global-search/fixture";
+export default function Page() {
+ return ;
+}
diff --git a/apps/suite/src/app/dev/components/fixture-catalog.tsx b/apps/suite/src/app/dev/components/fixture-catalog.tsx
index d63a141a6..807767992 100644
--- a/apps/suite/src/app/dev/components/fixture-catalog.tsx
+++ b/apps/suite/src/app/dev/components/fixture-catalog.tsx
@@ -7,6 +7,8 @@ import { type ReactNode, useId, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { atomNames, type FixtureName, primitiveNames } from "./fixture-names";
+import { ShellSearchFixture } from "./global-search/fixture";
+import { SyncFixtures } from "./sync-fixtures";
function Field({ label, children }: { label: string; children: (id: string) => ReactNode }) {
const id = useId();
@@ -681,6 +683,15 @@ function StateExamples() {
);
}
export function FixtureCatalog({ name }: { name: FixtureName }) {
+ if (name === "global-search" || name === "command-palette" || name === "shortcut-help")
+ return ;
+ if (
+ name === "offline-pill" ||
+ name === "sync-sheet" ||
+ name === "conflict-dialog" ||
+ name === "capability-gate"
+ )
+ return ;
const names = name === "primitives" ? primitiveNames : name === "atoms" ? atomNames : [name];
return (
diff --git a/apps/suite/src/app/dev/components/fixture-names.ts b/apps/suite/src/app/dev/components/fixture-names.ts
index baba50a8d..925a1ee96 100644
--- a/apps/suite/src/app/dev/components/fixture-names.ts
+++ b/apps/suite/src/app/dev/components/fixture-names.ts
@@ -45,5 +45,21 @@ export const atomNames = [
"status-dot",
"count-badge",
] as const;
-export const fixtureNames = ["primitives", "atoms", ...primitiveNames, ...atomNames] as const;
+export const syncFixtureNames = [
+ "offline-pill",
+ "sync-sheet",
+ "conflict-dialog",
+ "capability-gate",
+] as const;
+export type SyncFixtureName = (typeof syncFixtureNames)[number];
+export const fixtureNames = [
+ "primitives",
+ "atoms",
+ "global-search",
+ "command-palette",
+ "shortcut-help",
+ ...primitiveNames,
+ ...atomNames,
+ ...syncFixtureNames,
+] as const;
export type FixtureName = (typeof fixtureNames)[number];
diff --git a/apps/suite/src/app/dev/components/global-search/fixture.tsx b/apps/suite/src/app/dev/components/global-search/fixture.tsx
new file mode 100644
index 000000000..41314919f
--- /dev/null
+++ b/apps/suite/src/app/dev/components/global-search/fixture.tsx
@@ -0,0 +1,202 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import { Button, Input, Label, TooltipProvider } from "@jeswr/suite-ui";
+import { useMemo, useState } from "react";
+import type { SearchResult, SearchSource } from "../../../../shell/lib/search-results";
+import { CommandPalette } from "../../../../shell/search/command-palette";
+import { GlobalSearch } from "../../../../shell/search/global-search";
+import { createLocalSearchSource } from "../../../../shell/search/local-source";
+import { SearchResultsView } from "../../../../shell/search/results-view";
+import { GlobalShortcuts } from "../../../../shell/shortcuts/global-shortcuts";
+import { ShortcutHelpSheet, ShortcutsTable } from "../../../../shell/shortcuts/help-sheet";
+import { ShortcutsProvider, useShortcut } from "../../../../shell/shortcuts/provider";
+
+const syntheticResults: SearchResult[] = [
+ ...Array.from(
+ { length: 8 },
+ (_, index): SearchResult => ({
+ id: `file-${index}`,
+ title: `Project file ${index + 1}.pdf`,
+ type: "file",
+ app: "drive",
+ href: `/drive?file=${index}`,
+ parent: "My Drive / Projects",
+ modified: new Date("2026-10-29T12:00:00Z"),
+ owner: "https://example.org/account",
+ }),
+ ),
+ {
+ id: "document",
+ title: "Project plan",
+ type: "document",
+ app: "docs",
+ href: "/docs",
+ parent: "Documents",
+ },
+ {
+ id: "task",
+ title: "Project checklist",
+ type: "task",
+ app: "tasks",
+ href: "/tasks",
+ parent: "Inbox",
+ },
+ {
+ id: "person",
+ title: "Project contact: Rowan Vale",
+ type: "person",
+ app: "contacts",
+ href: "/contacts",
+ parent: "Contacts",
+ },
+ {
+ id: "data",
+ title: "Project data",
+ type: "data",
+ app: "home",
+ href: "/home",
+ parent: "My data",
+ },
+];
+function ScopedShortcut({ enabled }: { enabled: boolean }) {
+ const [message, setMessage] = useState("");
+ useShortcut({
+ id: "fixture.app",
+ keys: "x",
+ label: "App action",
+ group: "Drive",
+ when: () => enabled,
+ run: () => setMessage("App action"),
+ });
+ useShortcut({
+ id: "fixture.editor",
+ keys: "x",
+ label: "Editor action",
+ group: "Drive",
+ scope: "editor",
+ when: () => enabled,
+ run: () => setMessage("Editor action"),
+ });
+ return {message}
;
+}
+function FixtureContent({
+ component,
+}: {
+ component: "global-search" | "command-palette" | "shortcut-help";
+}) {
+ const [state, setState] = useState("ready");
+ const [palette, setPalette] = useState(false);
+ const [help, setHelp] = useState(false);
+ const [message, setMessage] = useState("");
+ const source = useMemo(() => {
+ const local = createLocalSearchSource(syntheticResults);
+ return {
+ async search(query, options) {
+ if (state === "slow")
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(resolve, 1200);
+ options.signal.addEventListener(
+ "abort",
+ () => {
+ clearTimeout(timer);
+ reject(new DOMException("Aborted", "AbortError"));
+ },
+ { once: true },
+ );
+ });
+ options.signal.throwIfAborted();
+ if (state === "error") throw new Error("Synthetic search failure");
+ if (state === "empty") return { results: [], source: "local", serverSearch: false };
+ return local.search(query, options);
+ },
+ };
+ }, [state]);
+ return (
+
+
+ {component === "global-search"
+ ? "Global search"
+ : component === "command-palette"
+ ? "Command palette"
+ : "Keyboard shortcuts"}
+
+ All component fixtures
+ setPalette((value) => !value)}
+ onOpenHelp={() => setHelp(true)}
+ />
+
+
+
+
setMessage(`Opened ${result.title}`)}
+ onSeeAll={(query, scope) =>
+ setMessage(`All results for ${query}${scope ? ` in ${scope}` : " everywhere"}`)
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+ {message}
+
+ {component === "shortcut-help" && }
+ {(state === "results" || state === "results-error") && (
+ setState("results") }
+ : {})}
+ onOpen={(result) => setMessage(`Opened ${result.title}`)}
+ />
+ )}
+
+ setMessage("Created a document"),
+ },
+ ]}
+ recent={[{ id: "recent-plan", title: "Planning notes", href: "/docs", app: "docs" }]}
+ onSearch={(query) => setMessage(`Palette search for ${query}`)}
+ />
+
+
+ );
+}
+export function ShellSearchFixture({
+ component = "global-search",
+}: {
+ component?: "global-search" | "command-palette" | "shortcut-help";
+}) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/suite/src/app/dev/components/global-search/page.tsx b/apps/suite/src/app/dev/components/global-search/page.tsx
new file mode 100644
index 000000000..abf19c77e
--- /dev/null
+++ b/apps/suite/src/app/dev/components/global-search/page.tsx
@@ -0,0 +1,5 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { ShellSearchFixture } from "./fixture";
+export default function Page() {
+ return ;
+}
diff --git a/apps/suite/src/app/dev/components/shortcut-help/page.tsx b/apps/suite/src/app/dev/components/shortcut-help/page.tsx
new file mode 100644
index 000000000..e9f2a2ae1
--- /dev/null
+++ b/apps/suite/src/app/dev/components/shortcut-help/page.tsx
@@ -0,0 +1,5 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { ShellSearchFixture } from "../global-search/fixture";
+export default function Page() {
+ return ;
+}
diff --git a/apps/suite/src/app/dev/components/sync-fixtures.tsx b/apps/suite/src/app/dev/components/sync-fixtures.tsx
new file mode 100644
index 000000000..645f044f4
--- /dev/null
+++ b/apps/suite/src/app/dev/components/sync-fixtures.tsx
@@ -0,0 +1,315 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+
+import {
+ Button,
+ CapabilityGate,
+ CapabilityProvider,
+ ConflictDialog,
+ OfflinePill,
+ type OutboxItem,
+ type SyncConflict,
+ SyncSheet,
+ type SyncSnapshot,
+ useRequiresOnline,
+} from "@jeswr/suite-ui";
+import { type ReactNode, useEffect, useState } from "react";
+import type { SyncFixtureName } from "./fixture-names";
+
+const now = new Date("2026-10-20T12:00:00Z");
+const conflict: SyncConflict = {
+ id: "project-brief",
+ name: "Project brief",
+ where: "My Drive / Work",
+ summary: "You changed the title. Another device changed the introduction.",
+ changedAt: new Date("2026-10-20T11:56:00Z"),
+};
+const saved: SyncSnapshot = {
+ online: true,
+ reconnecting: false,
+ pending: [],
+ conflicts: [],
+ lastSyncedAt: new Date("2026-10-20T11:55:00Z"),
+};
+const failed: OutboxItem = {
+ id: "failed-change",
+ label: "Rename project brief",
+ where: "My Drive / Work",
+ queuedAt: new Date("2026-10-20T11:59:00Z"),
+ state: "failed",
+};
+const pending = ["Rename project brief", "Save meeting notes", "Move holiday plans"].map(
+ (label, i) => ({
+ id: `change-${i}`,
+ label,
+ where: "My Drive / Work",
+ queuedAt: new Date(now.getTime() - (i + 1) * 60000),
+ state: "pending" as const,
+ }),
+);
+export const syncScenarios: { name: string; snapshot: SyncSnapshot }[] = [
+ { name: "Hidden", snapshot: saved },
+ { name: "Offline", snapshot: { ...saved, online: false } },
+ { name: "Reconnecting", snapshot: { ...saved, reconnecting: true } },
+ { name: "1 waiting", snapshot: { ...saved, pending: pending.slice(0, 1) } },
+ { name: "3 waiting", snapshot: { ...saved, pending } },
+ { name: "Conflict", snapshot: { ...saved, conflicts: [conflict] } },
+ {
+ name: "3 conflicts",
+ snapshot: {
+ ...saved,
+ conflicts: [
+ conflict,
+ { ...conflict, id: "notes", name: "Meeting notes" },
+ { ...conflict, id: "plans", name: "Holiday plans" },
+ ],
+ },
+ },
+ { name: "Offline with changes", snapshot: { ...saved, online: false, pending } },
+ {
+ name: "Failed",
+ snapshot: {
+ ...saved,
+ pending: [{ ...failed, error: "Your storage could not save this change." }],
+ },
+ },
+ {
+ name: "Failed without detail",
+ snapshot: { ...saved, pending: [failed] },
+ },
+ {
+ name: "Not synced yet",
+ snapshot: { online: true, reconnecting: false, pending: [], conflicts: [] },
+ },
+ { name: "Mixed", snapshot: { ...saved, pending, conflicts: [conflict] } },
+ {
+ // Taller than any viewport: the sheet body must scroll to its last action.
+ name: "Long queue",
+ snapshot: {
+ ...saved,
+ pending: Array.from({ length: 24 }, (_, i) => ({
+ id: `long-${i}`,
+ label: `Queued change ${i + 1}`,
+ where: "My Drive / Work",
+ queuedAt: new Date(now.getTime() - (i + 1) * 60000),
+ state: "pending" as const,
+ })),
+ conflicts: [{ ...conflict, id: "last", name: "Last conflict" }],
+ },
+ },
+ {
+ // Nothing focusable inside: only the region itself can take keyboard scrolling.
+ name: "Long pending queue",
+ snapshot: {
+ ...saved,
+ pending: Array.from({ length: 24 }, (_, i) => ({
+ id: `pending-${i}`,
+ label: `Waiting change ${i + 1}`,
+ where: "My Drive / Work",
+ queuedAt: new Date(now.getTime() - (i + 1) * 60000),
+ state: "pending" as const,
+ })),
+ },
+ },
+];
+
+function Frame({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
+function BrowserOnlineDemo() {
+ const [online, setOnline] = useState(true);
+ const [open, setOpen] = useState(false);
+ useEffect(() => {
+ const update = () => setOnline(navigator.onLine);
+ update();
+ window.addEventListener("online", update);
+ window.addEventListener("offline", update);
+ return () => {
+ window.removeEventListener("online", update);
+ window.removeEventListener("offline", update);
+ };
+ }, []);
+ return (
+
+ Browser connection
+ {online ? "Browser is online" : "Browser is offline"}
+ setOpen(true)} />
+ {}}
+ onRetry={() => {}}
+ onResolve={() => {}}
+ />
+
+ );
+}
+function OnlineControl({ online }: { online: boolean }) {
+ const { disabled, reason } = useRequiresOnline(online, "change sharing");
+ return (
+
+
+ {disabled &&
{reason}
}
+
+ );
+}
+export function SyncFixtures({ name }: { name: SyncFixtureName }) {
+ const [sheet, setSheet] = useState(null);
+ const [decision, setDecision] = useState(null);
+ const [message, setMessage] = useState("");
+ const [dialogOpen, setDialogOpen] = useState(false);
+ if (name === "capability-gate")
+ return (
+
+ {(
+ [
+ "Supported",
+ "Disabled",
+ "Hidden",
+ "Note",
+ "Custom reason",
+ "Missing capability",
+ ] as const
+ ).map((scenario) => (
+
+ {scenario}
+
+
+
+
+
+
+ ))}
+
+ Online requirements
+
+
+
+ {message}
+
+ );
+ return (
+
+ {name === "offline-pill" ? (
+ <>
+ {syncScenarios.map(({ name: scenario, snapshot }) => (
+
+ {scenario}
+ setSheet(snapshot)} />
+ setSheet(snapshot)} variant="icon" />
+
+ ))}
+
+ >
+ ) : name === "sync-sheet" ? (
+ syncScenarios.map(({ name: scenario, snapshot }) => (
+
+ ))
+ ) : (
+ <>
+
+
+ The dialog stays hidden until a conflict is opened.
+ >
+ )}
+ {message}
+ {
+ if (!open) setSheet(null);
+ }}
+ snapshot={sheet ?? saved}
+ now={now}
+ locale="en-GB"
+ onRetryAll={() => setMessage("Retrying all changes")}
+ onRetry={(id) => setMessage(`Retrying ${id}`)}
+ onResolve={(id) => {
+ setDecision(sheet?.conflicts.find((item) => item.id === id) ?? null);
+ setDialogOpen(true);
+ }}
+ />
+ {
+ setMessage(`Chose ${strategy}`);
+ setDialogOpen(false);
+ }}
+ />
+
+ );
+}
diff --git a/apps/suite/src/app/error.tsx b/apps/suite/src/app/error.tsx
new file mode 100644
index 000000000..c5fa079b2
--- /dev/null
+++ b/apps/suite/src/app/error.tsx
@@ -0,0 +1,43 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import { Button } from "@jeswr/suite-ui";
+import { usePathname } from "next/navigation";
+import { useEffect, useState } from "react";
+import { reportProblemUrl } from "@/shell/lib/report-problem";
+import { appForPath } from "@/shell/registry";
+export default function ErrorPage({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ const route = usePathname();
+ const [errorId, setErrorId] = useState();
+ useEffect(() => {
+ const id = crypto.randomUUID();
+ setErrorId(id);
+ console.error(id, error);
+ }, [error]);
+ return (
+
+ Something went wrong
+
+
+ );
+}
diff --git a/apps/suite/src/app/global-error.tsx b/apps/suite/src/app/global-error.tsx
new file mode 100644
index 000000000..a7206f8fd
--- /dev/null
+++ b/apps/suite/src/app/global-error.tsx
@@ -0,0 +1,33 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+export default function GlobalError({
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ return (
+
+
+
+ Something went wrong
+
+
+
+
+ );
+}
diff --git a/apps/suite/src/app/globals.css b/apps/suite/src/app/globals.css
index e62be11c3..60317d605 100644
--- a/apps/suite/src/app/globals.css
+++ b/apps/suite/src/app/globals.css
@@ -11,6 +11,9 @@
@import "@jeswr/suite-ui/styles/collections.css";
@import "@jeswr/suite-ui/styles/sharing.css";
@import "../components/interim-shell/shell.css";
+@import "@jeswr/suite-ui/styles/sync.css";
+@import "@jeswr/suite-ui/styles/sign-in.css";
+@import "../shell/shell.css";
@source "../../../../packages/suite-ui/src";
* {
@@ -30,8 +33,11 @@ body {
outline-offset: 2px;
scroll-margin: var(--space-4);
}
-a {
+/* Button-styled links keep their variant colours (unlayered rules beat Tailwind layers). */
+a:where(:not([data-slot="button"])) {
color: var(--primary);
+}
+a {
display: inline-flex;
align-items: center;
min-height: 24px;
diff --git a/apps/suite/src/app/layout.tsx b/apps/suite/src/app/layout.tsx
index a56f6e3a6..9e5f0e990 100644
--- a/apps/suite/src/app/layout.tsx
+++ b/apps/suite/src/app/layout.tsx
@@ -1,10 +1,11 @@
// AUTHORED-BY Codex GPT-6.1-Sol
import { onest, sourceCode, sourceSerif } from "@jeswr/suite-ui/fonts";
import type { Metadata, Viewport } from "next";
+import { suiteEnv } from "@/shell/lib/env";
import { Providers } from "./providers";
import "./globals.css";
export const viewport: Viewport = { colorScheme: "light dark" };
-export const metadata: Metadata = { title: "Solid Suite · Component fixtures" };
+export const metadata: Metadata = { title: "Solid Suite", icons: { icon: "/icon.svg" } };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
+
Skip to main content
diff --git a/apps/suite/src/app/not-found.tsx b/apps/suite/src/app/not-found.tsx
new file mode 100644
index 000000000..383859d6e
--- /dev/null
+++ b/apps/suite/src/app/not-found.tsx
@@ -0,0 +1,19 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { AppIcon, Button } from "@jeswr/suite-ui";
+import { START_PAGE, START_PAGE_NAME } from "@/shell/lib/next";
+export default function NotFound() {
+ return (
+
+
+ We couldn't find that page
+
+
+ );
+}
diff --git a/apps/suite/src/app/page.tsx b/apps/suite/src/app/page.tsx
new file mode 100644
index 000000000..865de8600
--- /dev/null
+++ b/apps/suite/src/app/page.tsx
@@ -0,0 +1,19 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import { LoadingSkeleton } from "@jeswr/suite-ui";
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+import { START_PAGE } from "@/shell/lib/next";
+import { useAccounts } from "@/shell/session/accounts-provider";
+export default function Page() {
+ const { status } = useAccounts();
+ const router = useRouter();
+ useEffect(() => {
+ if (status !== "loading") router.replace(status === "signed-out" ? "/welcome" : START_PAGE);
+ }, [status, router]);
+ return (
+
+
+
+ );
+}
diff --git a/apps/suite/src/app/providers.tsx b/apps/suite/src/app/providers.tsx
index 63086d59f..b5cbe8e10 100644
--- a/apps/suite/src/app/providers.tsx
+++ b/apps/suite/src/app/providers.tsx
@@ -2,6 +2,10 @@
"use client";
import { ThemeProvider, useTheme } from "next-themes";
import { useEffect } from "react";
+import { SuiteEnvironmentContext } from "@/shell/environment";
+import type { SuiteEnv } from "@/shell/lib/env";
+import { ShellSearchProvider } from "@/shell/search/search-context";
+import { AccountsProvider } from "@/shell/session/accounts-provider";
function ThemeAttribute() {
const { theme } = useTheme();
@@ -10,11 +14,21 @@ function ThemeAttribute() {
}, [theme]);
return null;
}
-export function Providers({ children }: { children: React.ReactNode }) {
+export function Providers({
+ children,
+ environment,
+}: {
+ children: React.ReactNode;
+ environment: SuiteEnv;
+}) {
return (
- {children}
+
+
+ {children}
+
+
);
}
diff --git a/apps/suite/src/shell/environment.tsx b/apps/suite/src/shell/environment.tsx
new file mode 100644
index 000000000..a323957eb
--- /dev/null
+++ b/apps/suite/src/shell/environment.tsx
@@ -0,0 +1,10 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import { createContext, useContext } from "react";
+import { type SuiteEnv, suiteEnv } from "./lib/env";
+
+// The server passes capability overrides explicitly: SUITE_TEST_CAPS is never a public env variable.
+export const SuiteEnvironmentContext = createContext(null);
+export function useSuiteEnvironment(): SuiteEnv {
+ return useContext(SuiteEnvironmentContext) ?? suiteEnv();
+}
diff --git a/apps/suite/src/shell/errors/states.tsx b/apps/suite/src/shell/errors/states.tsx
new file mode 100644
index 000000000..d5d6babea
--- /dev/null
+++ b/apps/suite/src/shell/errors/states.tsx
@@ -0,0 +1,35 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import { ErrorState } from "@jeswr/suite-ui";
+export function ForbiddenState({
+ onRequestAccess,
+ onSwitchAccount,
+}: {
+ onRequestAccess?: () => void;
+ onSwitchAccount: () => void;
+}) {
+ return (
+
+ );
+}
+export function UnreachableState({
+ onRetry,
+ onShowSaved,
+}: {
+ onRetry: () => void;
+ onShowSaved?: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/suite/src/shell/lib/accounts-store.ts b/apps/suite/src/shell/lib/accounts-store.ts
new file mode 100644
index 000000000..3676a46c4
--- /dev/null
+++ b/apps/suite/src/shell/lib/accounts-store.ts
@@ -0,0 +1,77 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { z } from "zod";
+import type { SuiteAccount } from "../session/types";
+
+export const ACCOUNTS_KEY = "suite.accounts.v1";
+const publicAccount = z.object({
+ id: z.string().min(1),
+ webId: z.url({ protocol: /^https?$/ }),
+ name: z.string().min(1),
+ photoUrl: z.url({ protocol: /^https?$/ }).optional(),
+ storageUrl: z.url({ protocol: /^https?$/ }),
+ issuer: z.url({ protocol: /^https?$/ }).optional(),
+ kind: z.enum(["demo", "solid", "extension"]),
+});
+export interface AccountsState {
+ accounts: SuiteAccount[];
+ activeId: string | null;
+}
+export const emptyAccounts: AccountsState = { accounts: [], activeId: null };
+export type AccountsAction =
+ | { type: "remember"; account: SuiteAccount }
+ | { type: "switch"; id: string }
+ | { type: "remove"; id: string }
+ | { type: "clear" };
+
+export function accountsReducer(state: AccountsState, action: AccountsAction): AccountsState {
+ switch (action.type) {
+ case "remember":
+ return {
+ accounts: [...state.accounts.filter((a) => a.id !== action.account.id), action.account],
+ activeId: action.account.id,
+ };
+ case "switch":
+ return state.accounts.some((a) => a.id === action.id)
+ ? { ...state, activeId: action.id }
+ : state;
+ case "remove": {
+ const accounts = state.accounts.filter((a) => a.id !== action.id);
+ return {
+ accounts,
+ activeId: state.activeId === action.id ? (accounts[0]?.id ?? null) : state.activeId,
+ };
+ }
+ case "clear":
+ return { accounts: [], activeId: null };
+ }
+}
+
+export function parseAccounts(value: string | null): AccountsState {
+ try {
+ const parsed: unknown = JSON.parse(value ?? "null");
+ if (
+ !parsed ||
+ typeof parsed !== "object" ||
+ !("accounts" in parsed) ||
+ !Array.isArray(parsed.accounts)
+ )
+ return { ...emptyAccounts };
+ const accounts: SuiteAccount[] = [];
+ for (const item of parsed.accounts) {
+ const account = publicAccount.safeParse(item);
+ if (account.success && !accounts.some((a) => a.id === account.data.id))
+ accounts.push(account.data as SuiteAccount);
+ }
+ const activeId =
+ "activeId" in parsed && accounts.some((a) => a.id === parsed.activeId)
+ ? String(parsed.activeId)
+ : null;
+ return { accounts, activeId };
+ } catch {
+ return { ...emptyAccounts };
+ }
+}
+/** Round-tripping through the allowlist drops tokens and future private fields. */
+export function serializeAccounts(state: AccountsState): string {
+ return JSON.stringify(parseAccounts(JSON.stringify(state)));
+}
diff --git a/apps/suite/src/shell/lib/client-id.ts b/apps/suite/src/shell/lib/client-id.ts
new file mode 100644
index 000000000..fee1170b3
--- /dev/null
+++ b/apps/suite/src/shell/lib/client-id.ts
@@ -0,0 +1,51 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+export function buildClientId(requestUrl: string, configuredOrigin?: string) {
+ const request = new URL(requestUrl);
+ const origin = new URL(configuredOrigin ?? request.origin).origin;
+ return {
+ "@context": ["https://www.w3.org/ns/solid/oidc-context.jsonld"],
+ client_id: origin + request.pathname,
+ client_name: "Solid Suite",
+ logo_uri: `${origin}/icon.svg`,
+ redirect_uris: [`${origin}/auth/callback.html`],
+ post_logout_redirect_uris: [`${origin}/welcome`],
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ scope: "openid profile offline_access webid",
+ token_endpoint_auth_method: "none",
+ application_type: "web",
+ dpop_bound_access_tokens: true,
+ };
+}
+
+const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
+
+/**
+ * Where the suite says it lives: configured origin first, then the host Vercel
+ * assigns the deployment (trusted environment, not request input), then a
+ * loopback request URL for local development only.
+ */
+export function trustedClientOrigin(
+ env: Partial>,
+ requestUrl: string,
+): { origin?: string; cacheable: boolean } {
+ const parse = (value: string | undefined) => {
+ if (!value) return undefined;
+ try {
+ const url = new URL(value.includes("://") ? value : `https://${value}`);
+ return url.protocol === "https:" || LOOPBACK.has(url.hostname) ? url.origin : undefined;
+ } catch {
+ return undefined;
+ }
+ };
+ const configured =
+ parse(env.NEXT_PUBLIC_SUITE_ORIGIN) ??
+ (env.VERCEL_ENV === "production" ? parse(env.VERCEL_PROJECT_PRODUCTION_URL) : undefined) ??
+ parse(env.VERCEL_BRANCH_URL) ??
+ parse(env.VERCEL_URL);
+ if (configured) return { origin: configured, cacheable: true };
+ const request = new URL(requestUrl);
+ return LOOPBACK.has(request.hostname)
+ ? { origin: request.origin, cacheable: false }
+ : { cacheable: false };
+}
diff --git a/apps/suite/src/shell/lib/env.ts b/apps/suite/src/shell/lib/env.ts
new file mode 100644
index 000000000..722a2af53
--- /dev/null
+++ b/apps/suite/src/shell/lib/env.ts
@@ -0,0 +1,88 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { z } from "zod";
+
+const https = z.url({ protocol: /^https$/ });
+const capabilities = z.strictObject({
+ selfRegistration: z.boolean().optional(),
+ publicLinks: z.boolean().optional(),
+ authenticatedAgentGrants: z.boolean().optional(),
+ grantExpiry: z.boolean().optional(),
+ grantPurpose: z.boolean().optional(),
+ accessRequests: z.boolean().optional(),
+ liveUpdates: z.boolean().optional(),
+ serverSearch: z.boolean().optional(),
+ notifyAgents: z.boolean().optional(),
+});
+const jsonCapabilities = z
+ .string()
+ .transform((value, ctx) => {
+ try {
+ return JSON.parse(value) as unknown;
+ } catch {
+ ctx.addIssue({ code: "custom", message: "Expected a JSON capability object" });
+ return z.NEVER;
+ }
+ })
+ .pipe(capabilities);
+
+export const suiteEnvSchema = z.object({
+ NEXT_PUBLIC_SUITE_IDP: https.optional(),
+ NEXT_PUBLIC_SUITE_IDP_NAME: z.string().trim().min(1).default("your provider"),
+ NEXT_PUBLIC_SUITE_IDP_ACCOUNT_URL: https.optional(),
+ NEXT_PUBLIC_SHOW_BACKLOG_APPS: z
+ .enum(["0", "1"])
+ .default("0")
+ .transform((v) => v === "1"),
+ NEXT_PUBLIC_SUITE_DEMO: z
+ .enum(["0", "1"])
+ .default("1")
+ .transform((v) => v !== "0"),
+ NEXT_PUBLIC_SUITE_KNOWN_PROVIDERS: z
+ .string()
+ .default("https://solidcommunity.net/,https://login.inrupt.com/,https://teamid.live/")
+ .transform((value) => value.split(",").map((url) => url.trim()))
+ .pipe(z.array(https).min(1)),
+ SUITE_TEST_CAPS: jsonCapabilities.prefault("{}"),
+});
+export type SuiteEnv = z.output;
+
+/** Strict pure parser; callers can distinguish invalid configuration without exposing its values. */
+export class SuiteEnvError extends Error {
+ readonly fields: string[];
+ constructor(fields: string[]) {
+ super(`Invalid suite environment: ${fields.join(", ")}`);
+ this.name = "SuiteEnvError";
+ this.fields = fields;
+ }
+}
+export function parseSuiteEnv(record: Record): SuiteEnv {
+ const result = suiteEnvSchema.safeParse(record);
+ if (!result.success)
+ throw new SuiteEnvError([
+ ...new Set(result.error.issues.map((issue) => String(issue.path[0]))),
+ ]);
+ return result.data;
+}
+
+let cached: SuiteEnv | undefined;
+/** Next replaces only literal public env references. Invalid fields fall back independently. */
+export function suiteEnv(): SuiteEnv {
+ if (cached) return cached;
+ const record: Record = {
+ NEXT_PUBLIC_SUITE_IDP: process.env.NEXT_PUBLIC_SUITE_IDP,
+ NEXT_PUBLIC_SUITE_IDP_NAME: process.env.NEXT_PUBLIC_SUITE_IDP_NAME,
+ NEXT_PUBLIC_SUITE_IDP_ACCOUNT_URL: process.env.NEXT_PUBLIC_SUITE_IDP_ACCOUNT_URL,
+ NEXT_PUBLIC_SHOW_BACKLOG_APPS: process.env.NEXT_PUBLIC_SHOW_BACKLOG_APPS,
+ NEXT_PUBLIC_SUITE_DEMO: process.env.NEXT_PUBLIC_SUITE_DEMO,
+ NEXT_PUBLIC_SUITE_KNOWN_PROVIDERS: process.env.NEXT_PUBLIC_SUITE_KNOWN_PROVIDERS,
+ SUITE_TEST_CAPS: process.env.SUITE_TEST_CAPS,
+ };
+ const result = suiteEnvSchema.safeParse(record);
+ if (!result.success) {
+ const fields = [...new Set(result.error.issues.map((issue) => String(issue.path[0])))];
+ console.warn(new SuiteEnvError(fields).message);
+ for (const field of fields) delete record[field];
+ }
+ cached = parseSuiteEnv(record);
+ return cached;
+}
diff --git a/apps/suite/src/shell/lib/next.ts b/apps/suite/src/shell/lib/next.ts
new file mode 100644
index 000000000..865be7919
--- /dev/null
+++ b/apps/suite/src/shell/lib/next.ts
@@ -0,0 +1,30 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+/**
+ * Where the suite opens after sign-in. Drive until the Home app (W4) lands on main,
+ * then "/home" (02-suite-shell §1).
+ */
+export const START_PAGE = "/drive";
+/** The start page's app name, for "Go to …" links (server components cannot read the registry). */
+export const START_PAGE_NAME = "Drive";
+
+/** Validate both the supplied path and its decoded form before handing it to the router. */
+export function safeNext(next: string | null | undefined): string {
+ if (!next) return START_PAGE;
+ let decoded: string;
+ try {
+ decoded = decodeURIComponent(next);
+ } catch {
+ return START_PAGE;
+ }
+ for (const value of [next, decoded]) {
+ // Encoded delimiters and dot segments can become a network-path reference after normalisation.
+ if (
+ !value.startsWith("/") ||
+ value.includes("//") ||
+ /[\\\p{Cc}]/u.test(value) ||
+ /%/u.test(decoded)
+ )
+ return START_PAGE;
+ }
+ return next;
+}
diff --git a/apps/suite/src/shell/lib/preferences.ts b/apps/suite/src/shell/lib/preferences.ts
new file mode 100644
index 000000000..a2f309f22
--- /dev/null
+++ b/apps/suite/src/shell/lib/preferences.ts
@@ -0,0 +1,36 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { type AppSlug, defaultRailPins, type RailPreferences, suiteApps } from "@jeswr/suite-ui";
+
+export function parseRailPreferences(value: unknown): RailPreferences {
+ if (!value || typeof value !== "object" || !("pinned" in value) || !Array.isArray(value.pinned))
+ return { pinned: [...defaultRailPins] };
+ return {
+ pinned: [
+ ...new Set(
+ value.pinned.filter((slug): slug is AppSlug => suiteApps.some((app) => app.slug === slug)),
+ ),
+ ].slice(0, 6),
+ };
+}
+export const RECENT_PROVIDERS_KEY = "suite.recentProviders.v1";
+export function recentProviders(value: string | null): string[] {
+ try {
+ const parsed: unknown = JSON.parse(value ?? "null");
+ if (!Array.isArray(parsed)) return [];
+ return [
+ ...new Set(
+ parsed.filter((url): url is string => {
+ if (typeof url !== "string") return false;
+ try {
+ const provider = new URL(url);
+ return /^https?:$/.test(provider.protocol) && !provider.username && !provider.password;
+ } catch {
+ return false;
+ }
+ }),
+ ),
+ ].slice(0, 5);
+ } catch {
+ return [];
+ }
+}
diff --git a/apps/suite/src/shell/lib/registry.ts b/apps/suite/src/shell/lib/registry.ts
new file mode 100644
index 000000000..829e2d357
--- /dev/null
+++ b/apps/suite/src/shell/lib/registry.ts
@@ -0,0 +1,48 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { APP_ICONS, type AppSlug, type CreateAction, suiteApps } from "@jeswr/suite-ui";
+import type { SidebarSection } from "@jeswr/suite-ui/shell";
+
+export interface AppDefinition {
+ slug: AppSlug;
+ name: string;
+ hue: number;
+ basePath: string;
+ status: "first-cut" | "parity" | "backlog";
+ sidebar?: (ctx: { pathname: string }) => SidebarSection[];
+ create?: readonly CreateAction[];
+ search?: { scopeLabel: string; types: string[] };
+ shortcuts?: { keys: string; label: string }[];
+}
+
+export const appRegistry: readonly AppDefinition[] = suiteApps.map((app) => ({
+ ...app,
+ hue: APP_ICONS[app.slug].hue,
+ ...(app.status !== "backlog"
+ ? {
+ sidebar: ({ pathname }: { pathname: string }): SidebarSection[] => [
+ {
+ id: app.slug,
+ label: app.name,
+ items: [
+ {
+ id: app.slug,
+ label: app.name,
+ href: app.basePath,
+ current: pathname === app.basePath,
+ },
+ ],
+ },
+ ],
+ }
+ : {}),
+}));
+
+export function appForPath(pathname: string, apps = appRegistry): AppDefinition | undefined {
+ return apps
+ .filter((app) => pathname === app.basePath || pathname.startsWith(`${app.basePath}/`))
+ .sort((a, b) => b.basePath.length - a.basePath.length)[0];
+}
+
+export function visibleApps(showBacklog: boolean): readonly AppDefinition[] {
+ return appRegistry.filter((app) => showBacklog || app.status !== "backlog");
+}
diff --git a/apps/suite/src/shell/lib/report-problem.ts b/apps/suite/src/shell/lib/report-problem.ts
new file mode 100644
index 000000000..48e204721
--- /dev/null
+++ b/apps/suite/src/shell/lib/report-problem.ts
@@ -0,0 +1,55 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+/** Redact before truncating, so a cut-off URL cannot escape the scrubber. */
+export function redact(text: string): string {
+ return (
+ text
+ .replace(/\b(?:https?|wss?|file):[^\s<>"'`]+/giu, "")
+ .replace(
+ /(?:"(?:[^"\\\r\n]|\\.)+"|[\p{L}\p{N}.!#$%&'*+/=?^_`{|}~-]+)@(?:[\p{L}\p{N}.-]+|\[[^\]\s]+\])/giu,
+ "",
+ )
+ .replace(/#[\p{L}\p{N}_-]+/gu, "")
+ // Credentials: bearer/DPoP headers, token-like key=value pairs and JWT-shaped strings.
+ .replace(/\b(Bearer|DPoP|Basic)\s+\S+/giu, "$1 ")
+ .replace(
+ /\b([\w-]*(?:token|secret|password|code|key|session|cookie|authorization)[\w-]*)\s*[:=]\s*\S+/giu,
+ "$1=",
+ )
+ .replace(/\beyJ[\w-]+\.[\w-]+(?:\.[\w-]*)?/gu, "")
+ // Percent-encoded identifiers (an encoded WebID or URL) and long opaque strings.
+ .replace(/\S*%[0-9a-f]{2}\S*/giu, "")
+ .replace(/[\w+/=-]{32,}/gu, "")
+ );
+}
+
+/** Only a fixed-shape error type reaches the report; messages may carry private data. */
+function errorType(error: string | Error | undefined): string {
+ const name = error instanceof Error ? error.name : undefined;
+ return name && /^[A-Za-z]{1,40}$/u.test(name) ? name : "Not available";
+}
+export function routePattern(route: string): string {
+ const path = route.split(/[?#]/u, 1).join("");
+ return path.replace(/\/(f|p|d|i|a)\/[^/]+/gu, "/$1/[ref]");
+}
+export function reportProblemUrl({
+ app,
+ route,
+ error,
+ errorId,
+}: {
+ app: string;
+ route: string;
+ error?: string | Error;
+ errorId?: string;
+}): string {
+ const url = new URL("https://github.com/jeswr/solid-sdk/issues/new");
+ // Allowlisted fields only (no error message, URL, query or account data), then redacted again.
+ const id = errorId && /^[\w-]{1,64}$/u.test(errorId) ? errorId : "Not available";
+ const context = redact(
+ `App: ${app}\nRoute: ${routePattern(route)}\nError ID: ${id}\nError type: ${errorType(error)}\n\nWhat were you doing when this happened?\n`,
+ );
+ url.searchParams.set("labels", "suite");
+ url.searchParams.set("title", redact(`Solid Suite: problem in ${app}`).slice(0, 160));
+ url.searchParams.set("body", context.slice(0, 2000));
+ return url.href;
+}
diff --git a/apps/suite/src/shell/lib/search-filters.ts b/apps/suite/src/shell/lib/search-filters.ts
new file mode 100644
index 000000000..25a6a3102
--- /dev/null
+++ b/apps/suite/src/shell/lib/search-filters.ts
@@ -0,0 +1,33 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import type { AppSlug } from "@jeswr/suite-ui";
+import type { SearchResult } from "./search-results";
+
+export interface SearchFilters {
+ type: SearchResult["type"] | "any";
+ app: AppSlug | "any";
+ modified: "any" | "7" | "30" | "year";
+ owner: "anyone" | "me";
+}
+export const defaultSearchFilters: SearchFilters = {
+ type: "any",
+ app: "any",
+ modified: "any",
+ owner: "anyone",
+};
+export function filterSearchResults(
+ results: readonly SearchResult[],
+ filters: SearchFilters,
+ { now = Date.now(), accountId }: { now?: number; accountId?: string } = {},
+): SearchResult[] {
+ const days = filters.modified === "year" ? 365 : Number(filters.modified);
+ return results.filter(
+ (result) =>
+ (filters.type === "any" || result.type === filters.type) &&
+ (filters.app === "any" || result.app === filters.app) &&
+ (filters.modified === "any" ||
+ (!!result.modified &&
+ result.modified.getTime() >= now - days * 86400000 &&
+ result.modified.getTime() <= now)) &&
+ (filters.owner === "anyone" || (!!accountId && result.owner === accountId)),
+ );
+}
diff --git a/apps/suite/src/shell/lib/search-results.ts b/apps/suite/src/shell/lib/search-results.ts
new file mode 100644
index 000000000..fff0e8b4c
--- /dev/null
+++ b/apps/suite/src/shell/lib/search-results.ts
@@ -0,0 +1,69 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import type { AppSlug } from "@jeswr/suite-ui";
+
+export interface SearchResult {
+ id: string;
+ title: string;
+ type: "file" | "document" | "task" | "person" | "data";
+ href: string;
+ parent?: string;
+ modified?: Date;
+ app: AppSlug;
+ /** Optional ownership metadata; unknown ownership is excluded by the Me filter. */
+ owner?: string;
+}
+export interface SearchResponse {
+ results: SearchResult[];
+ source: "server" | "local";
+ serverSearch: boolean;
+}
+export interface SearchSource {
+ search(query: string, options: { scope?: AppSlug; signal: AbortSignal }): Promise;
+}
+export const searchGroups = [
+ ["file", "Files"],
+ ["document", "Documents"],
+ ["task", "Tasks"],
+ ["person", "People"],
+ ["data", "Data"],
+] as const;
+export const recentSearchesKey = "suite.recentSearches.v1";
+export const searchHintKey = "suite.searchHint.v1";
+
+/** Preserve the source's relevance order within each group, independent of request timing. */
+export function groupSearchResults(results: readonly SearchResult[], cap = 5) {
+ return searchGroups
+ .map(([type, label]) => ({
+ type,
+ label,
+ results: results.filter((result) => result.type === type).slice(0, Math.max(0, cap)),
+ }))
+ .filter((group) => group.results.length > 0);
+}
+export function updateRecentSearches(recent: readonly string[], query: string): string[] {
+ const trimmed = query.trim();
+ const all = trimmed ? [trimmed, ...recent] : recent;
+ const seen = new Set();
+ return all
+ .filter((item) => {
+ const key = item.trim().toLowerCase();
+ if (!key || seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ })
+ .slice(0, 8)
+ .map((item) => item.trim());
+}
+export function readRecentSearches(json: string | null): string[] {
+ try {
+ const value: unknown = JSON.parse(json ?? "null");
+ return Array.isArray(value)
+ ? updateRecentSearches(
+ value.filter((item): item is string => typeof item === "string"),
+ "",
+ )
+ : [];
+ } catch {
+ return [];
+ }
+}
diff --git a/apps/suite/src/shell/lib/search-run.ts b/apps/suite/src/shell/lib/search-run.ts
new file mode 100644
index 000000000..1747b9b3b
--- /dev/null
+++ b/apps/suite/src/shell/lib/search-run.ts
@@ -0,0 +1,28 @@
+// AUTHORED-BY Claude Opus 5.5
+import type { AppSlug } from "@jeswr/suite-ui";
+import type { SearchResult, SearchSource } from "./search-results";
+
+export type SearchOutcome =
+ | { kind: "results"; results: readonly SearchResult[] }
+ | { kind: "error" };
+
+/**
+ * Runs one search and reports its outcome only while `signal` is live. A slow request
+ * that was superseded (and so aborted) must never overwrite a newer one's results,
+ * whether it resolves or rejects after the abort.
+ */
+export async function runSearch(
+ source: SearchSource,
+ query: string,
+ options: { scope?: AppSlug; signal: AbortSignal },
+ report: (outcome: SearchOutcome) => void,
+): Promise {
+ let outcome: SearchOutcome;
+ try {
+ const { results } = await source.search(query, options);
+ outcome = { kind: "results", results };
+ } catch {
+ outcome = { kind: "error" };
+ }
+ if (!options.signal.aborted) report(outcome);
+}
diff --git a/apps/suite/src/shell/lib/shortcut-settings.ts b/apps/suite/src/shell/lib/shortcut-settings.ts
new file mode 100644
index 000000000..4d311ecc6
--- /dev/null
+++ b/apps/suite/src/shell/lib/shortcut-settings.ts
@@ -0,0 +1,35 @@
+// AUTHORED-BY Claude Opus 5.5
+/**
+ * WCAG 2.1.4 (character key shortcuts): one suite-wide switch for single-key
+ * shortcuts. Apps that bind their own single keys (Home 1–9, Drive c, …) call
+ * `characterShortcutsEnabled()` before acting; Cmd/Ctrl shortcuts are unaffected.
+ */
+export const SHORTCUTS_SETTING_KEY = "suite.settings.shortcuts";
+export const SHORTCUTS_SETTING_EVENT = "suite:shortcuts-setting";
+
+/** The choice made on this page when storage refused it; it wins until a write succeeds. */
+let unsaved: boolean | undefined;
+
+export function characterShortcutsEnabled(storage?: Pick): boolean {
+ if (unsaved !== undefined) return unsaved;
+ try {
+ return (storage ?? localStorage).getItem(SHORTCUTS_SETTING_KEY) !== "off";
+ } catch {
+ return true;
+ }
+}
+
+export function setCharacterShortcutsEnabled(
+ on: boolean,
+ storage?: Pick,
+ target?: Pick,
+): void {
+ try {
+ (storage ?? localStorage).setItem(SHORTCUTS_SETTING_KEY, on ? "on" : "off");
+ unsaved = undefined;
+ } catch {
+ // Memory-only for this page, so re-reading storage cannot undo the person's choice.
+ unsaved = on;
+ }
+ (target ?? globalThis).dispatchEvent?.(new Event(SHORTCUTS_SETTING_EVENT));
+}
diff --git a/apps/suite/src/shell/lib/shortcuts.ts b/apps/suite/src/shell/lib/shortcuts.ts
new file mode 100644
index 000000000..3b699d6ef
--- /dev/null
+++ b/apps/suite/src/shell/lib/shortcuts.ts
@@ -0,0 +1,131 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+export type ShortcutScope = "global" | "app" | "editor";
+export interface KeyStroke {
+ key: string;
+ meta: boolean;
+ ctrl: boolean;
+ alt: boolean;
+ shift: boolean;
+ cmd: boolean;
+}
+export interface ShortcutBinding {
+ id: string;
+ keys: string;
+ scope?: ShortcutScope;
+}
+export interface ShortcutEvent {
+ key: string;
+ meta: boolean;
+ ctrl: boolean;
+ alt: boolean;
+ shift: boolean;
+ editable: boolean;
+ now: number;
+}
+
+/** Cmd/Ctrl and Cmd are the platform's primary modifier; Meta and Ctrl are literal. */
+export function parseKeys(keys: string): KeyStroke[] {
+ if (!keys.trim()) throw new Error("Shortcut keys must not be empty");
+ return keys
+ .trim()
+ .split(/\s+/)
+ .map((part) => {
+ const tokens = part.toLowerCase().split("+");
+ const key = tokens.pop();
+ if (
+ !key ||
+ tokens.some((token) => !["cmd/ctrl", "cmd", "meta", "ctrl", "alt", "shift"].includes(token))
+ ) {
+ throw new Error("Invalid shortcut keys");
+ }
+ const stroke = {
+ key,
+ cmd: tokens.includes("cmd") || tokens.includes("cmd/ctrl"),
+ meta: tokens.includes("meta"),
+ ctrl: tokens.includes("ctrl"),
+ alt: tokens.includes("alt"),
+ shift: tokens.includes("shift"),
+ };
+ if ((stroke.cmd || stroke.meta || stroke.ctrl) && ["t", "w", "n", "l", "r"].includes(key)) {
+ throw new Error("Browser shortcuts cannot be registered");
+ }
+ return stroke;
+ });
+}
+
+/** Duck typing also handles elements from another window and text-node event targets. */
+export function isEditableTarget(target: EventTarget | null): boolean {
+ if (!target || typeof target !== "object") return false;
+ const element = target as Element;
+ if (typeof element.closest !== "function") {
+ return isEditableTarget((target as Node).parentElement);
+ }
+ return !!element.closest(
+ 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"], [data-shortcuts="off"]',
+ );
+}
+
+const scopeRank = { global: 0, app: 1, editor: 2 };
+
+/** Narrower scopes win; the latest registration wins within a scope. No timers are needed. */
+export function createSequenceMatcher(
+ bindings: readonly ShortcutBinding[],
+ { timeoutMs = 1000, platform = "" }: { timeoutMs?: number; platform?: string } = {},
+) {
+ const mac = /mac|iphone|ipad/i.test(platform);
+ const registered = bindings
+ .map((binding, order) => ({
+ ...binding,
+ order,
+ strokes: parseKeys(binding.keys),
+ }))
+ .sort(
+ (a, b) =>
+ scopeRank[b.scope ?? "global"] - scopeRank[a.scope ?? "global"] || b.order - a.order,
+ );
+ let pending: typeof registered = [];
+ let position = 0;
+ let previousTime = 0;
+ const reset = () => {
+ pending = [];
+ position = 0;
+ };
+ return {
+ reset,
+ feed(event: ShortcutEvent): { matched?: string; pending: boolean } {
+ if (event.now - previousTime >= timeoutMs || event.now < previousTime) reset();
+ const candidates = position ? pending : registered;
+ const matches = ({ strokes }: (typeof registered)[number]) => {
+ const stroke = strokes[position] as KeyStroke;
+ const primary = stroke.cmd || stroke.meta || stroke.ctrl;
+ return (
+ !(event.editable && !primary) &&
+ stroke.key === event.key.toLowerCase() &&
+ event.meta === (stroke.meta || (stroke.cmd && mac)) &&
+ event.ctrl === (stroke.ctrl || (stroke.cmd && !mac)) &&
+ event.alt === stroke.alt &&
+ (stroke.key === "?" || event.shift === stroke.shift)
+ );
+ };
+ let matching = candidates.filter(matches);
+ // Primary-modifier commands interrupt an unrelated navigation prefix.
+ if (position && !matching.length && (event.meta || event.ctrl)) {
+ reset();
+ matching = registered.filter(matches);
+ }
+ const complete = matching.find(({ strokes }) => strokes.length === position + 1);
+ if (complete) {
+ reset();
+ return { matched: complete.id, pending: false };
+ }
+ if (!matching.length) {
+ reset();
+ return { pending: false };
+ }
+ pending = matching;
+ position++;
+ previousTime = event.now;
+ return { pending: true };
+ },
+ };
+}
diff --git a/apps/suite/src/shell/lib/sign-in-hint.ts b/apps/suite/src/shell/lib/sign-in-hint.ts
new file mode 100644
index 000000000..6a49ce375
--- /dev/null
+++ b/apps/suite/src/shell/lib/sign-in-hint.ts
@@ -0,0 +1,17 @@
+// AUTHORED-BY Claude Opus 5.5
+import type { SignInHint } from "../session/types";
+
+/**
+ * The sign-in card's fields say what the person typed: the WebID field names a profile,
+ * the provider field names an issuer. Never re-guess from the URL's shape: a provider
+ * can live under a path (`/realms/solid`) and a WebID can be a bare origin.
+ */
+export function hintForField(field: "webid" | "provider", input: string): SignInHint {
+ const value = input.trim();
+ return field === "webid" ? { webId: value } : { issuer: value };
+}
+
+/** Only issuers belong in the remembered-provider list, never a person's WebID. */
+export function rememberedIssuer(hint: SignInHint): string | null {
+ return "issuer" in hint ? hint.issuer : null;
+}
diff --git a/apps/suite/src/shell/public/demo-button.tsx b/apps/suite/src/shell/public/demo-button.tsx
new file mode 100644
index 000000000..f272e91dd
--- /dev/null
+++ b/apps/suite/src/shell/public/demo-button.tsx
@@ -0,0 +1,38 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import { Button } from "@jeswr/suite-ui";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { useSuiteEnvironment } from "../environment";
+import { safeNext } from "../lib/next";
+import { useAccounts } from "../session/accounts-provider";
+export function DemoButton({ next }: { next?: string | null }) {
+ const { signInDemo } = useAccounts();
+ const router = useRouter();
+ const env = useSuiteEnvironment();
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ if (!env.NEXT_PUBLIC_SUITE_DEMO) return null;
+ return (
+ <>
+
+ {error && {error}
}
+ >
+ );
+}
diff --git a/apps/suite/src/shell/public/pod-explainer.tsx b/apps/suite/src/shell/public/pod-explainer.tsx
new file mode 100644
index 000000000..b288826e7
--- /dev/null
+++ b/apps/suite/src/shell/public/pod-explainer.tsx
@@ -0,0 +1,30 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import {
+ Button,
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@jeswr/suite-ui";
+export function PodExplainer() {
+ return (
+
+
+
+
+
+
+ What is a pod?
+
+ A pod is a place to keep your data online. You choose which apps and people can use it.
+ Your files stay with your chosen provider, and you can change who has access at any
+ time.
+
+
+
+
+ );
+}
diff --git a/apps/suite/src/shell/registry.ts b/apps/suite/src/shell/registry.ts
new file mode 100644
index 000000000..606784b89
--- /dev/null
+++ b/apps/suite/src/shell/registry.ts
@@ -0,0 +1,2 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+export { type AppDefinition, appForPath, appRegistry, visibleApps } from "./lib/registry";
diff --git a/apps/suite/src/shell/search/README.md b/apps/suite/src/shell/search/README.md
new file mode 100644
index 000000000..938dfd489
--- /dev/null
+++ b/apps/suite/src/shell/search/README.md
@@ -0,0 +1,23 @@
+
+
+Mount `ShortcutsProvider` once around the shell and `GlobalShortcuts` once inside it.
+`GlobalShortcuts` owns the palette and help dialog by default; `onOpenPalette` and
+`onOpenHelp` let the shell own those dialogs instead. `useShortcut` accepts an optional
+`scope`: editor beats app, app beats global, and later registration wins within a scope.
+Non-Global groups default to app scope. `when` is evaluated on each key event, and disabled
+registrations do not shadow enabled ones. Unmounting removes registrations.
+`ShortcutsTable` is also the read-only Settings shortcuts view. The frame owns `[` and `]`.
+
+`GlobalSearch` accepts an injectable `SearchSource`, optional current-app scope, and callbacks
+for opening a result and the results page. Exported source/result types live in
+`../lib/search-results.ts` (also re-exported by `global-search.tsx`). The parent owns routing;
+this lane does not mount `/search`. Render `SearchResultsView` inside that route's page, which
+owns its heading and request lifecycle. Pass `accountId` and result `owner` metadata to support
+the Me filter; unknown owners are excluded. Results preserve relevance order. The list renders
+an overscanned window of fixed-height rows and exposes native links and a keyboard-scrollable region.
+
+`createLocalSearchSource(items)` indexes titles and optional body text with `src/lib/search`'s
+BM25 index and preserves navigation metadata. Create and populate it per account from opened or
+synced items; discard it when the account changes. It performs no storage reads itself.
+The fixtures use this same adapter for synthetic examples of all five result types, with eight
+files to exercise capping. Slow mode adds an abortable deterministic 1200 ms delay.
diff --git a/apps/suite/src/shell/search/command-palette.tsx b/apps/suite/src/shell/search/command-palette.tsx
new file mode 100644
index 000000000..86392eb0c
--- /dev/null
+++ b/apps/suite/src/shell/search/command-palette.tsx
@@ -0,0 +1,216 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import {
+ AppGlyph,
+ type AppSlug,
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+ Kbd,
+ suiteApps,
+} from "@jeswr/suite-ui";
+import { useRouter } from "next/navigation";
+import { type MouseEvent, useRef, useState } from "react";
+import { useShortcutSettings } from "../shortcuts/provider";
+
+export interface PaletteCreateAction {
+ id: string;
+ label: string;
+ run: () => void;
+ keys?: string;
+}
+export interface PaletteRecent {
+ id: string;
+ title: string;
+ href: string;
+ app: AppSlug;
+ keys?: string;
+}
+export interface CommandPaletteProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ createActions?: readonly PaletteCreateAction[];
+ recent?: readonly PaletteRecent[];
+ onSearch: (query: string) => void;
+ /**
+ * Opens the keyboard-shortcut help, which holds the single-key on/off switch. Gets the
+ * element focused before the palette opened, so help can return focus there.
+ */
+ onOpenHelp?: (returnTo: HTMLElement | null) => void;
+ showBacklog?: boolean;
+}
+export const appShortcutKeys: Partial> = {
+ home: "g h",
+ drive: "g d",
+ docs: "g o",
+ tasks: "g t",
+ calendar: "g c",
+ permissions: "g p",
+};
+
+function preserveModifiedNavigation(event: MouseEvent) {
+ // Keep native new-tab/window navigation from also running cmdk's selection handler.
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) event.stopPropagation();
+}
+
+export function CommandPalette({
+ open,
+ onOpenChange,
+ createActions = [],
+ recent = [],
+ onSearch,
+ onOpenHelp,
+ showBacklog = process.env.NEXT_PUBLIC_SHOW_BACKLOG_APPS === "1",
+}: CommandPaletteProps) {
+ const router = useRouter();
+ // Single-key hints are noise (and wrong) once the user has turned those keys off.
+ const { characterKeys } = useShortcutSettings();
+ const [query, setQuery] = useState("");
+ const returnFocus = useRef(null);
+ const select = (run: () => void) => {
+ onOpenChange(false);
+ setQuery("");
+ run();
+ };
+ const navigation = [
+ ...suiteApps
+ .filter((app) => app.status !== "backlog" || showBacklog)
+ .map((app) => ({
+ id: app.slug,
+ label: app.name,
+ href: app.basePath,
+ keys: appShortcutKeys[app.slug],
+ app: app.slug,
+ })),
+ { id: "settings", label: "Settings", href: "/settings", keys: "g s", app: undefined },
+ {
+ id: "notifications",
+ label: "Notifications",
+ href: "/notifications",
+ keys: "g n",
+ app: undefined,
+ },
+ ];
+ return (
+
+ );
+}
diff --git a/apps/suite/src/shell/search/demo-items.ts b/apps/suite/src/shell/search/demo-items.ts
new file mode 100644
index 000000000..f7e116f3b
--- /dev/null
+++ b/apps/suite/src/shell/search/demo-items.ts
@@ -0,0 +1,36 @@
+// AUTHORED-BY Claude Opus 5.5
+/**
+ * Search items for the demo account, derived from the demo pod's seed so the
+ * TopBar search and /search show real results before SDK-APP-09 lands.
+ */
+import { DEMO_ROOT, DEMO_WEBID } from "../../lib/demo-pod/seed";
+import { encodeRef } from "../../lib/refs";
+import type { LocalSearchItem } from "./local-source";
+
+interface SeedEntry {
+ readonly path: string;
+ readonly contentType?: string;
+ readonly modified?: number;
+}
+
+export function demoSearchItems(seed: readonly SeedEntry[]): LocalSearchItem[] {
+ return seed
+ .filter((file) => file.path.startsWith("drive/") && file.path !== "drive/")
+ .map((file) => {
+ const url = DEMO_ROOT + file.path;
+ const folder = file.path.endsWith("/");
+ const segments = file.path.replace(/\/$/u, "").split("/");
+ const title = decodeURIComponent(segments.at(-1) ?? file.path);
+ const parent = ["My Drive", ...segments.slice(1, -1).map(decodeURIComponent)].join(" / ");
+ return {
+ id: url,
+ title,
+ type: "file" as const,
+ app: "drive" as const,
+ href: folder ? `/drive/f/${encodeRef(url)}` : `/drive/p/${encodeRef(url)}`,
+ parent,
+ owner: DEMO_WEBID,
+ ...(file.modified ? { modified: new Date(file.modified) } : {}),
+ };
+ });
+}
diff --git a/apps/suite/src/shell/search/global-search.tsx b/apps/suite/src/shell/search/global-search.tsx
new file mode 100644
index 000000000..2d4a498fa
--- /dev/null
+++ b/apps/suite/src/shell/search/global-search.tsx
@@ -0,0 +1,438 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import {
+ type AppSlug,
+ Button,
+ IconButton,
+ Input,
+ Label,
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetTitle,
+ SheetTrigger,
+} from "@jeswr/suite-ui";
+import { Search } from "lucide-react";
+import { type KeyboardEvent, useEffect, useId, useRef, useState } from "react";
+import {
+ groupSearchResults,
+ readRecentSearches,
+ recentSearchesKey,
+ type SearchResponse,
+ type SearchResult,
+ type SearchSource,
+ searchHintKey,
+ updateRecentSearches,
+} from "../lib/search-results";
+import { useShortcut } from "../shortcuts/provider";
+import { SearchResultRow } from "./result-row";
+import styles from "./search.module.css";
+
+export type { SearchResult, SearchSource } from "../lib/search-results";
+export interface GlobalSearchProps {
+ source: SearchSource;
+ scope?: { app: AppSlug; label: string };
+ onOpen: (result: SearchResult) => void;
+ onSeeAll: (query: string, scope?: AppSlug) => void;
+}
+
+function SearchBox({
+ source,
+ scope,
+ onOpen,
+ onSeeAll,
+ scopeEnabled,
+ setScopeEnabled,
+ onClose,
+}: GlobalSearchProps & {
+ scopeEnabled: boolean;
+ setScopeEnabled: (enabled: boolean) => void;
+ onClose?: () => void;
+}) {
+ const [query, setQuery] = useState("");
+ const [expanded, setExpanded] = useState(false);
+ const [recent, setRecent] = useState([]);
+ const [hintDismissed, setHintDismissed] = useState(true);
+ const [response, setResponse] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [failed, setFailed] = useState(false);
+ const [active, setActive] = useState(-1);
+ const root = useRef(null);
+ const input = useRef(null);
+ const id = useId();
+ const term = query.trim();
+ const app = scopeEnabled ? scope?.app : undefined;
+ const groups = groupSearchResults(response?.results ?? []);
+ const results = groups.flatMap((group) => group.results);
+ const count = term ? results.length + 1 : recent.length;
+ const open = expanded && (!!term || recent.length > 0);
+ useEffect(() => {
+ try {
+ setRecent(readRecentSearches(localStorage.getItem(recentSearchesKey)));
+ setHintDismissed(localStorage.getItem(searchHintKey) === "dismissed");
+ } catch {
+ setHintDismissed(false);
+ }
+ }, []);
+ useEffect(() => {
+ const controller = new AbortController();
+ setResponse(null);
+ setFailed(false);
+ setActive(-1);
+ setLoading(!!term);
+ if (!term) return () => controller.abort();
+ const timer = setTimeout(() => {
+ void Promise.resolve()
+ .then(() =>
+ source.search(term, { ...(app ? { scope: app } : {}), signal: controller.signal }),
+ )
+ .then((value) => {
+ if (!controller.signal.aborted) {
+ setResponse(value);
+ setLoading(false);
+ }
+ })
+ .catch(() => {
+ if (!controller.signal.aborted) {
+ setFailed(true);
+ setLoading(false);
+ }
+ });
+ }, 250);
+ return () => {
+ clearTimeout(timer);
+ controller.abort();
+ };
+ }, [source, term, app]);
+ useEffect(() => {
+ if (active >= 0)
+ document.getElementById(`${id}-option-${active}`)?.scrollIntoView({ block: "nearest" });
+ }, [active, id]);
+ const remember = () => {
+ const updated = updateRecentSearches(recent, term);
+ setRecent(updated);
+ try {
+ localStorage.setItem(recentSearchesKey, JSON.stringify(updated));
+ } catch {
+ /* Search still works without browser storage. */
+ }
+ };
+ const close = () => {
+ setExpanded(false);
+ setActive(-1);
+ onClose?.();
+ };
+ const seeAll = () => {
+ remember();
+ close();
+ onSeeAll(term, app);
+ };
+ const selectResult = (result: SearchResult) => {
+ remember();
+ close();
+ onOpen(result);
+ };
+ const selectRecent = (value: string) => {
+ setQuery(value);
+ setActive(-1);
+ input.current?.focus();
+ };
+ const keydown = (event: KeyboardEvent) => {
+ if (event.isDefaultPrevented() || event.nativeEvent.isComposing) return;
+ if (event.key === "Escape") {
+ event.preventDefault();
+ close();
+ input.current?.blur();
+ } else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
+ event.preventDefault();
+ setExpanded(true);
+ if (count)
+ setActive((current) =>
+ event.key === "ArrowDown"
+ ? (current + 1) % count
+ : current <= 0
+ ? count - 1
+ : current - 1,
+ );
+ } else if (event.key === "Enter" && term) {
+ event.preventDefault();
+ const result = results[active];
+ if (result) selectResult(result);
+ else seeAll();
+ } else if (event.key === "Enter" && recent[active]) {
+ event.preventDefault();
+ selectRecent(recent[active]);
+ }
+ };
+ let index = 0;
+ return (
+ {
+ if (!event.currentTarget.contains(event.relatedTarget)) setExpanded(false);
+ }}
+ >
+
+
+ {scope ? (
+
+ ) : (
+ Everywhere
+ )}
+ = 0 && active < count ? `${id}-option-${active}` : undefined
+ }
+ value={query}
+ autoComplete="off"
+ placeholder="Search your storage"
+ onChange={(event) => {
+ setQuery(event.target.value);
+ setExpanded(true);
+ setActive(-1);
+ }}
+ onFocus={() => setExpanded(true)}
+ onKeyDown={keydown}
+ />
+
+ {open && (
+
+ {response && (
+
+ {results.length} search suggestions
+
+ )}
+ {loading && (
+
+ Searching…
+
+ )}
+ {failed && (
+
+ Search couldn't finish. Try another search.
+
+ )}
+ {!loading && !failed && term && results.length === 0 && (
+
+ No results for “{term}”
+
+ )}
+
+ {!term && (
+
+ )}
+ {response?.source === "local" && (
+
+
Searching your saved copies
+ {!response.serverSearch && !hintDismissed && (
+ <>
+
+ Your storage doesn't offer search, so results come from items you've opened or
+ synced.
+
+
+ >
+ )}
+
+ )}
+
+ )}
+
+ );
+}
+
+export function GlobalSearch(props: GlobalSearchProps) {
+ const [mobile, setMobile] = useState(false);
+ const [sheetOpen, setSheetOpen] = useState(false);
+ const [scopeEnabled, setScopeEnabled] = useState(true);
+ const trigger = useRef(null);
+ useEffect(() => {
+ const media = matchMedia("(max-width: 767px)");
+ const update = () => {
+ setMobile(media.matches);
+ if (!media.matches) setSheetOpen(false);
+ };
+ update();
+ media.addEventListener("change", update);
+ return () => media.removeEventListener("change", update);
+ }, []);
+ useShortcut({
+ id: "global.search.scope",
+ keys: "Cmd/Ctrl+Shift+F",
+ label: "Toggle search scope",
+ group: "Global",
+ when: () => !!props.scope,
+ run: () => setScopeEnabled((value) => !value),
+ });
+ const box = (
+ setSheetOpen(false) } : {})}
+ />
+ );
+ return mobile ? (
+
+
+
+
+
+
+ {
+ event.preventDefault();
+ requestAnimationFrame(() =>
+ document
+ .querySelector('[role="dialog"] input[data-global-search]')
+ ?.focus(),
+ );
+ }}
+ onCloseAutoFocus={(event) => {
+ event.preventDefault();
+ trigger.current?.focus();
+ }}
+ >
+ Search your storage
+ Find files, documents, tasks, people, and data.
+ {box}
+
+
+ ) : (
+ box
+ );
+}
diff --git a/apps/suite/src/shell/search/local-source.ts b/apps/suite/src/shell/search/local-source.ts
new file mode 100644
index 000000000..6eab3eae9
--- /dev/null
+++ b/apps/suite/src/shell/search/local-source.ts
@@ -0,0 +1,24 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { SearchIndex } from "../../lib/search";
+import type { SearchResult, SearchSource } from "../lib/search-results";
+
+export type LocalSearchItem = SearchResult & { body?: string };
+/** One source/index per account. Populate from opened or synced items; discard on account switch. */
+export function createLocalSearchSource(items: readonly LocalSearchItem[]): SearchSource {
+ const index = new SearchIndex();
+ const metadata = new Map();
+ for (const { body = "", ...result } of items) {
+ index.add({ id: result.id, title: result.title, body });
+ metadata.set(result.id, result);
+ }
+ return {
+ async search(query, { scope, signal }) {
+ signal.throwIfAborted();
+ const results = index
+ .search(query)
+ .map((hit) => ({ ...(metadata.get(hit.document.id) as SearchResult) }))
+ .filter((result) => !scope || result.app === scope);
+ return { results, source: "local", serverSearch: false };
+ },
+ };
+}
diff --git a/apps/suite/src/shell/search/result-row.tsx b/apps/suite/src/shell/search/result-row.tsx
new file mode 100644
index 000000000..4baae31a1
--- /dev/null
+++ b/apps/suite/src/shell/search/result-row.tsx
@@ -0,0 +1,32 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import { AppGlyph, FileTypeIcon } from "@jeswr/suite-ui";
+import type { SearchResult } from "../lib/search-results";
+
+export function SearchResultRow({ result }: { result: SearchResult }) {
+ return (
+
+ {result.type === "file" ? (
+
+ ) : (
+
+ )}
+
+ {result.title}
+ {result.parent && (
+ {result.parent}
+ )}
+
+ {result.modified && (
+
+ )}
+
+ );
+}
diff --git a/apps/suite/src/shell/search/results-view.tsx b/apps/suite/src/shell/search/results-view.tsx
new file mode 100644
index 000000000..1bfa018c4
--- /dev/null
+++ b/apps/suite/src/shell/search/results-view.tsx
@@ -0,0 +1,176 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import { Button, EmptyState, ErrorState, Label, LoadingSkeleton, suiteApps } from "@jeswr/suite-ui";
+import { useEffect, useId, useRef, useState } from "react";
+import {
+ defaultSearchFilters,
+ filterSearchResults,
+ type SearchFilters,
+} from "../lib/search-filters";
+import { type SearchResult, searchGroups } from "../lib/search-results";
+import { SearchResultRow } from "./result-row";
+import styles from "./search.module.css";
+
+export interface SearchResultsViewProps {
+ query: string;
+ results: readonly SearchResult[];
+ loading?: boolean;
+ error?: "unreachable" | "forbidden" | "unknown";
+ onRetry?: () => void;
+ accountId?: string;
+ onOpen?: (result: SearchResult) => void;
+}
+/** Page body only: the route owns its h1, query/source lifecycle and account boundary. */
+export function SearchResultsView({
+ query,
+ results,
+ loading,
+ error,
+ onRetry,
+ accountId,
+ onOpen,
+}: SearchResultsViewProps) {
+ const [filters, setFilters] = useState(defaultSearchFilters);
+ const [scrollTop, setScrollTop] = useState(0);
+ const viewport = useRef(null);
+ const id = useId();
+ const filtered = filterSearchResults(results, filters, accountId ? { accountId } : {});
+ const start = Math.max(0, Math.min(Math.floor(scrollTop / 80) - 3, filtered.length - 1));
+ const visible = filtered.slice(start, start + 18);
+ // A new query or result set starts at the top; a stale offset would window past the new list.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: query/results identity is the trigger
+ useEffect(() => {
+ setScrollTop(0);
+ viewport.current?.scrollTo?.(0, 0);
+ }, [query, results]);
+ const update = (key: K, value: SearchFilters[K]) => {
+ setFilters((current) => ({ ...current, [key]: value }));
+ setScrollTop(0);
+ viewport.current?.scrollTo(0, 0);
+ };
+ const clear = () => {
+ setFilters(defaultSearchFilters);
+ setScrollTop(0);
+ viewport.current?.scrollTo(0, 0);
+ };
+ return (
+
+
+
+ Results for “{query}”
+ {error ? (
+
+ ) : loading ? (
+
+ ) : !filtered.length ? (
+
+ ) : (
+ <>
+
+ {filtered.length} results
+
+ setScrollTop(event.currentTarget.scrollTop)}
+ >
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/suite/src/shell/search/search-context.tsx b/apps/suite/src/shell/search/search-context.tsx
new file mode 100644
index 000000000..86e5cf1a4
--- /dev/null
+++ b/apps/suite/src/shell/search/search-context.tsx
@@ -0,0 +1,46 @@
+// AUTHORED-BY Claude Opus 5.5
+"use client";
+/**
+ * One search source per signed-in account, shared by the TopBar search and the
+ * /search page. The demo account is indexed from its seed; other accounts start
+ * empty until SDK-APP-09 (`useSearch`) supplies server or synced results.
+ */
+import { createContext, type ReactNode, useContext, useEffect, useState } from "react";
+import type { SearchSource } from "../lib/search-results";
+import { useAccounts } from "../session/accounts-provider";
+import { demoSearchItems } from "./demo-items";
+import { createLocalSearchSource } from "./local-source";
+
+const SearchSourceContext = createContext(null);
+
+export function ShellSearchProvider({ children }: { children: ReactNode }) {
+ const { active } = useAccounts();
+ const [source, setSource] = useState(() => createLocalSearchSource([]));
+ const accountId = active?.id;
+ const demo = active?.kind === "demo";
+ // biome-ignore lint/correctness/useExhaustiveDependencies: a different account gets a fresh index
+ useEffect(() => {
+ let cancelled = false;
+ // A new account never sees the previous account's index.
+ setSource(createLocalSearchSource([]));
+ if (demo)
+ void import("../../lib/demo-pod/seed")
+ .then(({ buildDemoSeed }) => buildDemoSeed(Date.now()))
+ .then((seed) => {
+ if (!cancelled) setSource(createLocalSearchSource(demoSearchItems(seed)));
+ })
+ .catch(() => {
+ /* The empty index stays; search still works, with no demo results. */
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [accountId, demo]);
+ return {children};
+}
+
+export function useShellSearchSource(): SearchSource {
+ const source = useContext(SearchSourceContext);
+ if (!source) throw new Error("useShellSearchSource requires ShellSearchProvider");
+ return source;
+}
diff --git a/apps/suite/src/shell/search/search.module.css b/apps/suite/src/shell/search/search.module.css
new file mode 100644
index 000000000..b824b8dd8
--- /dev/null
+++ b/apps/suite/src/shell/search/search.module.css
@@ -0,0 +1,84 @@
+/* AUTHORED-BY Codex GPT-6.1-Sol */
+.root {
+ position: relative;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 8px;
+ min-width: min(280px, 100%);
+ width: 400px;
+ max-width: 100%;
+}
+.root:focus-within {
+ width: 560px;
+}
+.field {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px;
+}
+.field input {
+ flex: 1;
+ min-width: 100px;
+}
+.panel {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ width: max(100%, 340px);
+ max-width: calc(100vw - 32px);
+ z-index: 40;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--popover);
+ color: var(--popover-foreground);
+ box-shadow: var(--shadow-md);
+}
+.list {
+ max-height: 52dvh;
+ overflow: auto;
+ padding: 4px;
+}
+.option {
+ display: block;
+ padding: 4px 8px;
+ border-radius: 4px;
+ min-height: 32px;
+ scroll-margin: 8px;
+ text-decoration: none;
+}
+.option[aria-selected="true"] {
+ background: var(--accent);
+ color: var(--accent-foreground);
+}
+.footer {
+ border-top: 1px solid var(--border);
+ padding: 12px;
+ font-size: 12px;
+}
+.mobile .root {
+ display: block;
+ min-width: 0;
+ width: 100%;
+ max-width: 100%;
+}
+.mobile .panel {
+ position: static;
+ width: 100%;
+ max-width: 100%;
+ box-shadow: none;
+}
+.mobile .list {
+ max-height: 58dvh;
+}
+.results {
+ display: grid;
+ grid-template-columns: 200px minmax(0, 1fr);
+ gap: 24px;
+}
+@media (max-width: 767px) {
+ .results {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
diff --git a/apps/suite/src/shell/session/accounts-provider.tsx b/apps/suite/src/shell/session/accounts-provider.tsx
new file mode 100644
index 000000000..c2702e7f8
--- /dev/null
+++ b/apps/suite/src/shell/session/accounts-provider.tsx
@@ -0,0 +1,234 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import {
+ ACCOUNTS_KEY,
+ type AccountsAction,
+ type AccountsState,
+ accountsReducer,
+ emptyAccounts,
+ parseAccounts,
+ serializeAccounts,
+} from "../lib/accounts-store";
+import { suiteEnv } from "../lib/env";
+import { DemoBackend, demoAccount } from "./demo-backend";
+import { SolidBackend } from "./solid-backend";
+import {
+ type SessionBackend,
+ type SessionStatus,
+ SignInError,
+ type SignInHint,
+ type SuiteAccount,
+} from "./types";
+
+export interface AccountsContextValue {
+ status: SessionStatus;
+ active: SuiteAccount | null;
+ accounts: SuiteAccount[];
+ signIn(hint: SignInHint): Promise;
+ signInDemo(): Promise;
+ /** Sign in through a specific backend (e.g. "extension"); rejects when none is registered. */
+ signInWith(kind: SuiteAccount["kind"], hint: SignInHint): Promise;
+ /** Backend kinds available in this deployment. */
+ backendKinds: readonly SuiteAccount["kind"][];
+ add(hint: SignInHint): Promise;
+ switchTo(id: string): void;
+ signOut(id?: string): Promise;
+ signOutAll(): Promise;
+ reauth(): Promise;
+ fetch: typeof fetch;
+}
+const AccountsContext = createContext(null);
+
+export function AccountsProvider({
+ children,
+ backends: supplied,
+}: {
+ children: ReactNode;
+ backends?: readonly SessionBackend[];
+}) {
+ const [backends] = useState(() => supplied ?? [new DemoBackend(), new SolidBackend()]);
+ const [state, setState] = useState(emptyAccounts);
+ const [status, setStatus] = useState("loading");
+ const current = useRef(state);
+ const currentStatus = useRef(status);
+ const sessions = useRef(new Map());
+ const generation = useRef(0);
+ const signInAttempt = useRef(0);
+ const changeStatus = useCallback((value: SessionStatus) => {
+ currentStatus.current = value;
+ setStatus(value);
+ }, []);
+ const publish = useCallback(
+ (value: AccountsState) => {
+ current.current = value;
+ setState(value);
+ try {
+ localStorage.setItem(ACCOUNTS_KEY, serializeAccounts(value));
+ } catch {
+ /* Memory-only when browser storage is blocked. */
+ }
+ changeStatus(
+ !value.activeId ? "signed-out" : sessions.current.has(value.activeId) ? "ready" : "expired",
+ );
+ },
+ [changeStatus],
+ );
+ const dispatch = (action: AccountsAction) => publish(accountsReducer(current.current, action));
+
+ useEffect(() => {
+ let cancelled = false;
+ const revision = ++generation.current;
+ let remembered = emptyAccounts;
+ try {
+ remembered = parseAccounts(localStorage.getItem(ACCOUNTS_KEY));
+ } catch {
+ /* No remembered session. */
+ }
+ if (!suiteEnv().NEXT_PUBLIC_SUITE_DEMO) {
+ for (const account of remembered.accounts)
+ if (account.kind === "demo")
+ remembered = accountsReducer(remembered, { type: "remove", id: account.id });
+ }
+ void Promise.all(
+ backends.map(async (backend) => {
+ if (backend.kind === "demo" && !suiteEnv().NEXT_PUBLIC_SUITE_DEMO) return null;
+ try {
+ return { backend, account: await backend.restore() };
+ } catch {
+ return null;
+ }
+ }),
+ ).then((restored) => {
+ if (cancelled || revision !== generation.current) return;
+ let next = remembered;
+ for (const result of restored) {
+ if (!result?.account) continue;
+ sessions.current.set(result.account.id, result.backend);
+ next = accountsReducer(next, { type: "remember", account: result.account });
+ }
+ // Demo sessions never expire. Keep an inactive remembered demo usable when switching accounts.
+ const demo = backends.find((backend) => backend.kind === "demo");
+ if (
+ demo &&
+ suiteEnv().NEXT_PUBLIC_SUITE_DEMO &&
+ next.accounts.some((account) => account.id === demoAccount.id && account.kind === "demo")
+ )
+ sessions.current.set(demoAccount.id, demo);
+ // restore() may complete in any order; the remembered active pointer wins.
+ if (remembered.activeId)
+ next = accountsReducer(next, { type: "switch", id: remembered.activeId });
+ publish(next);
+ });
+ const unsubscribe = backends.map((backend) =>
+ backend.onExpired(() => {
+ const activeExpired =
+ current.current.activeId !== null &&
+ sessions.current.get(current.current.activeId) === backend;
+ for (const [id, owner] of sessions.current)
+ if (owner === backend) sessions.current.delete(id);
+ if (activeExpired) changeStatus("expired");
+ }),
+ );
+ return () => {
+ cancelled = true;
+ ++generation.current;
+ for (const stop of unsubscribe) stop();
+ };
+ }, [backends, changeStatus, publish]);
+
+ const signInWith = async (kind: SuiteAccount["kind"], hint: SignInHint) => {
+ const revision = generation.current;
+ const attempt = ++signInAttempt.current;
+ const backend = backends.find((item) => item.kind === kind);
+ if (!backend || (kind === "demo" && !suiteEnv().NEXT_PUBLIC_SUITE_DEMO))
+ throw new SignInError("unavailable");
+ const account = await backend.signIn(hint);
+ if (revision !== generation.current || attempt !== signInAttempt.current)
+ throw new Error("The account changed while signing in. Please try again.");
+ ++generation.current;
+ sessions.current.set(account.id, backend);
+ dispatch({ type: "remember", account });
+ return account;
+ };
+ const signIn = (hint: SignInHint) => signInWith("solid", hint);
+ const signInDemo = () => signInWith("demo", { webId: demoAccount.webId });
+ const signOut = async (id = current.current.activeId ?? "") => {
+ ++generation.current;
+ const account = current.current.accounts.find((item) => item.id === id);
+ if (!account) return;
+ const backend = sessions.current.get(id) ?? backends.find((item) => item.kind === account.kind);
+ sessions.current.delete(id);
+ dispatch({ type: "remove", id });
+ await backend?.signOut(account);
+ };
+ const signOutAll = async () => {
+ ++generation.current;
+ const previous = current.current.accounts.map((account) => ({
+ account,
+ backend: sessions.current.get(account.id) ?? backends.find((b) => b.kind === account.kind),
+ }));
+ sessions.current.clear();
+ dispatch({ type: "clear" });
+ const results = await Promise.allSettled(
+ previous.map(({ account, backend }) => backend?.signOut(account)),
+ );
+ const failure = results.find((result) => result.status === "rejected");
+ if (failure?.status === "rejected") throw failure.reason;
+ };
+ const active = state.accounts.find((a) => a.id === state.activeId) ?? null;
+ const reauth = () => {
+ const account = current.current.accounts.find((a) => a.id === current.current.activeId);
+ if (!account) return Promise.reject(new SignInError("unavailable"));
+ return signInWith(
+ account.kind,
+ account.issuer ? { issuer: account.issuer } : { webId: account.webId },
+ );
+ };
+ const authenticatedFetch: typeof fetch = async (input, init) => {
+ const account = current.current.accounts.find((a) => a.id === current.current.activeId);
+ const backend = account && sessions.current.get(account.id);
+ if (!account || !backend || currentStatus.current !== "ready")
+ throw new Error("Sign in to continue");
+ return backend.fetchFor(account)(input, init);
+ };
+ return (
+ backend.kind),
+ add: signIn,
+ switchTo: (id) => {
+ ++generation.current;
+ dispatch({ type: "switch", id });
+ },
+ signOut,
+ signOutAll,
+ reauth,
+ fetch: authenticatedFetch,
+ }}
+ >
+ {children}
+
+ );
+}
+
+export function useAccounts(): AccountsContextValue {
+ const value = useContext(AccountsContext);
+ if (!value) throw new Error("useAccounts requires AccountsProvider");
+ return value;
+}
diff --git a/apps/suite/src/shell/session/demo-backend.ts b/apps/suite/src/shell/session/demo-backend.ts
new file mode 100644
index 000000000..96b009293
--- /dev/null
+++ b/apps/suite/src/shell/session/demo-backend.ts
@@ -0,0 +1,35 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { getDemoPod } from "../../lib/demo-pod/browser";
+import { DEMO_NAME, DEMO_ROOT, DEMO_WEBID } from "../../lib/demo-pod/seed";
+import { ACCOUNTS_KEY, parseAccounts } from "../lib/accounts-store";
+import type { SessionBackend, SuiteAccount } from "./types";
+
+export const demoAccount: SuiteAccount = {
+ id: "demo",
+ webId: DEMO_WEBID,
+ name: DEMO_NAME,
+ storageUrl: DEMO_ROOT,
+ kind: "demo",
+};
+
+export class DemoBackend implements SessionBackend {
+ readonly kind = "demo";
+ async restore(): Promise {
+ try {
+ const state = parseAccounts(localStorage.getItem(ACCOUNTS_KEY));
+ return state.activeId === demoAccount.id ? { ...demoAccount } : null;
+ } catch {
+ return null;
+ }
+ }
+ async signIn(): Promise {
+ return { ...demoAccount };
+ }
+ async signOut(): Promise {}
+ fetchFor(): typeof fetch {
+ return async (input, init) => (await getDemoPod()).fetch(input, init);
+ }
+ onExpired(): () => void {
+ return () => {};
+ }
+}
diff --git a/apps/suite/src/shell/session/reauth-dialog.tsx b/apps/suite/src/shell/session/reauth-dialog.tsx
new file mode 100644
index 000000000..ecaf97ea6
--- /dev/null
+++ b/apps/suite/src/shell/session/reauth-dialog.tsx
@@ -0,0 +1,51 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import {
+ AlertDialog,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ Button,
+} from "@jeswr/suite-ui";
+import { useState } from "react";
+import { useAccounts } from "./accounts-provider";
+
+export function ReauthDialog() {
+ const { reauth, signOut } = useAccounts();
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+ const act = async (action: () => Promise) => {
+ setBusy(true);
+ setError("");
+ try {
+ await action();
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : "Please try again.");
+ } finally {
+ setBusy(false);
+ }
+ };
+ return (
+
+ event.preventDefault()}>
+
+ Your session ended — sign in again to continue
+
+ Your work will stay here while you sign in.
+
+
+ {error && {error}
}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/suite/src/shell/session/session-guard.tsx b/apps/suite/src/shell/session/session-guard.tsx
new file mode 100644
index 000000000..ed893c7c7
--- /dev/null
+++ b/apps/suite/src/shell/session/session-guard.tsx
@@ -0,0 +1,30 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import { LoadingSkeleton } from "@jeswr/suite-ui";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { type ReactNode, useEffect } from "react";
+import { useAccounts } from "./accounts-provider";
+import { ReauthDialog } from "./reauth-dialog";
+
+export function SessionGuard({ children }: { children: ReactNode }) {
+ const { status } = useAccounts();
+ const pathname = usePathname();
+ const search = useSearchParams().toString();
+ const router = useRouter();
+ useEffect(() => {
+ if (status === "signed-out")
+ router.replace(`/signin?next=${encodeURIComponent(pathname + (search ? `?${search}` : ""))}`);
+ }, [status, pathname, search, router]);
+ if (status === "loading" || status === "signed-out")
+ return (
+
+
+
+ );
+ return (
+ <>
+ {children}
+ {status === "expired" && }
+ >
+ );
+}
diff --git a/apps/suite/src/shell/session/solid-backend.ts b/apps/suite/src/shell/session/solid-backend.ts
new file mode 100644
index 000000000..35b008de8
--- /dev/null
+++ b/apps/suite/src/shell/session/solid-backend.ts
@@ -0,0 +1,29 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { type SessionBackend, SignInError, type SignInHint, type SuiteAccount } from "./types";
+
+/** Integration seam: replace with one pod-auth session per account when the SDK manager lands.
+ * This preview validates discovery input only; it never starts OAuth or sends a request.
+ */
+export class SolidBackend implements SessionBackend {
+ readonly kind = "solid";
+ async restore(): Promise {
+ return null;
+ }
+ async signIn(hint: SignInHint): Promise {
+ const value = "issuer" in hint ? hint.issuer : hint.webId;
+ try {
+ const url = new URL(value);
+ if (!/^https?:$/.test(url.protocol) || url.username || url.password) throw new Error();
+ } catch {
+ throw new SignInError("invalid-url");
+ }
+ throw new SignInError("unavailable");
+ }
+ async signOut(): Promise {}
+ fetchFor(): typeof fetch {
+ return globalThis.fetch;
+ }
+ onExpired(): () => void {
+ return () => {};
+ }
+}
diff --git a/apps/suite/src/shell/session/types.ts b/apps/suite/src/shell/session/types.ts
new file mode 100644
index 000000000..e8815dbc3
--- /dev/null
+++ b/apps/suite/src/shell/session/types.ts
@@ -0,0 +1,33 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+export interface SuiteAccount {
+ id: string;
+ webId: string;
+ name: string;
+ photoUrl?: string;
+ storageUrl: string;
+ issuer?: string;
+ /** "extension": the session comes from the Solid browser extension (window.solid). */
+ kind: "demo" | "solid" | "extension";
+}
+export type SessionStatus = "loading" | "signed-out" | "ready" | "expired";
+export type SignInHint = { issuer: string } | { webId: string };
+export interface SessionBackend {
+ kind: SuiteAccount["kind"];
+ restore(): Promise;
+ signIn(hint: SignInHint): Promise;
+ signOut(account: SuiteAccount): Promise;
+ fetchFor(account: SuiteAccount): typeof fetch;
+ onExpired(cb: () => void): () => void;
+}
+export class SignInError extends Error {
+ readonly code: "invalid-url" | "unavailable";
+ constructor(code: "invalid-url" | "unavailable") {
+ super(
+ code === "invalid-url"
+ ? "We couldn't find a sign-in service at that address"
+ : "Signing in with a Solid provider isn't switched on in this preview yet.",
+ );
+ this.name = "SignInError";
+ this.code = code;
+ }
+}
diff --git a/apps/suite/src/shell/shell.css b/apps/suite/src/shell/shell.css
new file mode 100644
index 000000000..9d522b2b1
--- /dev/null
+++ b/apps/suite/src/shell/shell.css
@@ -0,0 +1,105 @@
+/* AUTHORED-BY Codex GPT-6.1-Sol */
+.shell-public {
+ min-height: 100dvh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-8);
+ padding: var(--space-6) var(--space-4);
+ background: radial-gradient(ellipse at top, var(--primary-soft), transparent 70%);
+}
+.shell-brand {
+ gap: var(--space-2);
+ font-weight: 600;
+ text-decoration: none;
+}
+.shell-public-card {
+ width: min(100%, 420px);
+ min-width: 0;
+ display: grid;
+ gap: var(--space-6);
+ padding: var(--space-6);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-xl);
+ background: var(--surface);
+ box-shadow: var(--shadow-1);
+}
+.shell-public-card p {
+ color: var(--foreground-muted);
+}
+.shell-public-card [role="alert"] {
+ color: var(--destructive);
+ overflow-wrap: anywhere;
+}
+.shell-public-actions {
+ display: grid;
+ gap: var(--space-3);
+ min-width: 0;
+}
+.shell-public-actions input {
+ width: 100%;
+ min-width: 0;
+}
+.shell-public-subtitle {
+ font-size: var(--text-base);
+ margin: 0;
+}
+.shell-public-footer,
+.shell-error-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+}
+.shell-loading,
+.shell-error-page {
+ min-height: 100dvh;
+ padding: var(--space-6);
+}
+.shell-error-page {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-6);
+ text-align: center;
+}
+.shell-search {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+}
+.shell-search input {
+ width: 100%;
+ min-width: 0;
+}
+.shell-mobile-account {
+ display: none;
+}
+@media (max-width: 767px) {
+ .shell-mobile-account {
+ display: block;
+ }
+ .shell-search .suite-button {
+ display: none;
+ }
+ .shell-search input {
+ padding-inline: var(--space-2);
+ }
+ .shell-public-card {
+ padding: var(--space-4);
+ }
+}
+
+/* The SignInCard is its own card: the public layout's card frame steps back. */
+.shell-public-card:has(.su-sign-in-card) {
+ padding: 0;
+ border: 0;
+ background: none;
+ box-shadow: none;
+}
+.shell-public-note {
+ margin: 0;
+}
diff --git a/apps/suite/src/shell/shortcuts/global-shortcuts.tsx b/apps/suite/src/shell/shortcuts/global-shortcuts.tsx
new file mode 100644
index 000000000..82fdd399d
--- /dev/null
+++ b/apps/suite/src/shell/shortcuts/global-shortcuts.tsx
@@ -0,0 +1,112 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import { useRouter } from "next/navigation";
+import { useTheme } from "next-themes";
+import { useState } from "react";
+import {
+ CommandPalette,
+ type PaletteCreateAction,
+ type PaletteRecent,
+} from "../search/command-palette";
+import { ShortcutHelpSheet } from "./help-sheet";
+import { useShortcut } from "./provider";
+
+const destinations = [
+ ["h", "Home", "/home"],
+ ["d", "Drive", "/drive"],
+ ["o", "Docs", "/docs"],
+ ["t", "Tasks", "/tasks"],
+ ["c", "Calendar", "/calendar"],
+ ["p", "Permissions", "/permissions"],
+ ["n", "Notifications", "/notifications"],
+ ["s", "Settings", "/settings"],
+] as const;
+function GoToShortcut({ letter, name, href }: { letter: string; name: string; href: string }) {
+ const router = useRouter();
+ useShortcut({
+ id: `global.go.${letter}`,
+ keys: `g ${letter}`,
+ label: `Go to ${name}`,
+ group: "Global",
+ run: () => router.push(href),
+ });
+ return null;
+}
+export function GlobalShortcuts({
+ onOpenPalette,
+ onOpenHelp,
+ createActions,
+ recent,
+ onSearch,
+}: {
+ onOpenPalette?: () => void;
+ onOpenHelp?: () => void;
+ createActions?: readonly PaletteCreateAction[];
+ recent?: readonly PaletteRecent[];
+ onSearch?: (query: string) => void;
+} = {}) {
+ const [paletteOpen, setPaletteOpen] = useState(false);
+ const [helpOpen, setHelpOpen] = useState(false);
+ const { resolvedTheme, setTheme } = useTheme();
+ const router = useRouter();
+ const [helpReturn, setHelpReturn] = useState(null);
+ const openHelp = (returnTo: HTMLElement | null = null) => {
+ setHelpReturn(returnTo);
+ if (onOpenHelp) onOpenHelp();
+ else setHelpOpen(true);
+ };
+ useShortcut({
+ id: "global.search",
+ keys: "/",
+ label: "Focus global search",
+ group: "Global",
+ run: () => {
+ const target = document.querySelector("[data-global-search]");
+ if (target?.tagName === "BUTTON") target.click();
+ else target?.focus();
+ },
+ });
+ useShortcut({
+ id: "global.help",
+ keys: "?",
+ label: "Keyboard shortcuts",
+ group: "Global",
+ run: () => openHelp(),
+ });
+ useShortcut({
+ id: "global.theme",
+ keys: "Shift+D",
+ label: "Toggle dark mode",
+ group: "Global",
+ run: () => setTheme(resolvedTheme === "dark" ? "light" : "dark"),
+ });
+ useShortcut({
+ id: "global.palette",
+ keys: "Cmd/Ctrl+K",
+ label: "Command palette",
+ group: "Global",
+ run: () => (onOpenPalette ? onOpenPalette() : setPaletteOpen((value) => !value)),
+ });
+ return (
+ <>
+ {destinations.map(([letter, name, href]) => (
+
+ ))}
+ {!onOpenHelp && (
+
+ )}
+ {!onOpenPalette && (
+ router.push(`/search?q=${encodeURIComponent(query)}`))}
+ />
+ )}
+ >
+ );
+}
diff --git a/apps/suite/src/shell/shortcuts/help-sheet.tsx b/apps/suite/src/shell/shortcuts/help-sheet.tsx
new file mode 100644
index 000000000..6b95cf42e
--- /dev/null
+++ b/apps/suite/src/shell/shortcuts/help-sheet.tsx
@@ -0,0 +1,113 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+ Input,
+ Kbd,
+ Label,
+} from "@jeswr/suite-ui";
+import { useId, useRef, useState } from "react";
+import { type RegisteredShortcut, useRegisteredShortcuts, useShortcutSettings } from "./provider";
+
+const frameShortcuts: RegisteredShortcut[] = [
+ { id: "frame.sidebar", keys: "[", label: "Toggle sidebar", group: "Global" },
+ { id: "frame.details", keys: "]", label: "Toggle details pane", group: "Global" },
+];
+export function ShortcutsTable() {
+ const shortcuts = useRegisteredShortcuts();
+ const [query, setQuery] = useState("");
+ const id = useId();
+ const rows = [
+ ...shortcuts,
+ ...frameShortcuts.filter((row) => !shortcuts.some((shortcut) => shortcut.keys === row.keys)),
+ ].filter((row) =>
+ `${row.label} ${row.keys} ${row.group}`.toLowerCase().includes(query.toLowerCase()),
+ );
+ const groups = [...new Set(rows.map((row) => row.group))];
+ return (
+
+
+
setQuery(event.target.value)} />
+ {groups.map((group) => (
+
+ {group}
+
+
+ | Action |
+ Keys |
+
+
+
+ {rows
+ .filter((row) => row.group === group)
+ .map((row) => (
+
+ |
+ {row.label}
+ |
+
+ {row.keys}
+ |
+
+ ))}
+
+
+ ))}
+ {!rows.length &&
No matching shortcuts
}
+
+ );
+}
+/** WCAG 2.1.4 character key shortcuts: a persistent way to turn them off. */
+export function CharacterKeysToggle() {
+ const { characterKeys, setCharacterKeys } = useShortcutSettings();
+ return (
+
+ );
+}
+export function ShortcutHelpSheet({
+ open,
+ onOpenChange,
+ returnFocusTo,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /**
+ * Where focus goes on close when the sheet was opened from a surface that is itself
+ * closing (the command palette): its input is gone by then, so it can't be the target.
+ */
+ returnFocusTo?: HTMLElement | null;
+}) {
+ const returnFocus = useRef(null);
+ return (
+
+ );
+}
diff --git a/apps/suite/src/shell/shortcuts/provider.tsx b/apps/suite/src/shell/shortcuts/provider.tsx
new file mode 100644
index 000000000..d26bbcf81
--- /dev/null
+++ b/apps/suite/src/shell/shortcuts/provider.tsx
@@ -0,0 +1,146 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from "react";
+import {
+ characterShortcutsEnabled,
+ SHORTCUTS_SETTING_EVENT,
+ setCharacterShortcutsEnabled,
+} from "../lib/shortcut-settings";
+import {
+ createSequenceMatcher,
+ isEditableTarget,
+ parseKeys,
+ type ShortcutScope,
+} from "../lib/shortcuts";
+
+export interface RegisteredShortcut {
+ id: string;
+ keys: string;
+ label: string;
+ group: string;
+ scope?: ShortcutScope;
+}
+export interface Shortcut extends RegisteredShortcut {
+ run: () => void;
+ when?: () => boolean;
+}
+type Entry = Shortcut & { token: string };
+const Context = createContext<{
+ shortcuts: readonly Entry[];
+ register: (shortcut: Shortcut) => () => void;
+ characterKeys: boolean;
+ setCharacterKeys: (on: boolean) => void;
+} | null>(null);
+
+const hasPrimaryModifier = (keys: string) => /\b(cmd|ctrl|meta|alt)(\/ctrl)?\+/iu.test(keys);
+
+/** editor > app > global; later registrations win within the same scope. */
+export function ShortcutsProvider({
+ children,
+ platform,
+}: {
+ children: ReactNode;
+ platform?: string;
+}) {
+ const [shortcuts, setShortcuts] = useState([]);
+ const [characterKeys, setCharacterKeysState] = useState(true);
+ useEffect(() => {
+ const sync = () => setCharacterKeysState(characterShortcutsEnabled());
+ sync();
+ addEventListener(SHORTCUTS_SETTING_EVENT, sync);
+ addEventListener("storage", sync);
+ return () => {
+ removeEventListener(SHORTCUTS_SETTING_EVENT, sync);
+ removeEventListener("storage", sync);
+ };
+ }, []);
+ const setCharacterKeys = (on: boolean) => {
+ setCharacterKeysState(on);
+ setCharacterShortcutsEnabled(on);
+ };
+ const [register] = useState(() => {
+ let serial = 0;
+ return (shortcut: Shortcut) => {
+ parseKeys(shortcut.keys);
+ const token = `shortcut-${serial++}`;
+ setShortcuts((current) => [...current, { ...shortcut, token }]);
+ return () => setShortcuts((current) => current.filter((entry) => entry.token !== token));
+ };
+ });
+ useEffect(() => {
+ let signature = "";
+ let matcher = createSequenceMatcher([]);
+ const keydown = (event: KeyboardEvent) => {
+ if (event.defaultPrevented || event.isComposing || event.repeat) return;
+ const active = shortcuts.filter(
+ (shortcut) =>
+ (characterKeys || hasPrimaryModifier(shortcut.keys)) &&
+ (!shortcut.when || shortcut.when()),
+ );
+ const nextSignature = active.map((shortcut) => shortcut.token).join(",");
+ if (signature !== nextSignature) {
+ signature = nextSignature;
+ matcher = createSequenceMatcher(
+ active.map((shortcut) => ({
+ id: shortcut.token,
+ keys: shortcut.keys,
+ scope: shortcut.scope ?? (shortcut.group === "Global" ? "global" : "app"),
+ })),
+ { platform: platform ?? navigator.platform },
+ );
+ }
+ const result = matcher.feed({
+ key: event.key,
+ meta: event.metaKey,
+ ctrl: event.ctrlKey,
+ alt: event.altKey,
+ shift: event.shiftKey,
+ editable: isEditableTarget(event.target),
+ now: event.timeStamp,
+ });
+ if (result.pending || result.matched) event.preventDefault();
+ if (result.matched) active.find((shortcut) => shortcut.token === result.matched)?.run();
+ };
+ document.addEventListener("keydown", keydown);
+ return () => document.removeEventListener("keydown", keydown);
+ }, [shortcuts, platform, characterKeys]);
+ return (
+
+ {children}
+
+ );
+}
+export function useShortcut(shortcut: Shortcut) {
+ const context = useContext(Context);
+ const latest = useRef(shortcut);
+ latest.current = shortcut;
+ const register = context?.register;
+ const { id, keys, label, group, scope } = shortcut;
+ useEffect(
+ () =>
+ register?.({
+ id,
+ keys,
+ label,
+ group,
+ ...(scope ? { scope } : {}),
+ run: () => latest.current.run(),
+ when: () => latest.current.when?.() ?? true,
+ }),
+ [register, id, keys, label, group, scope],
+ );
+}
+export function useRegisteredShortcuts(): readonly RegisteredShortcut[] {
+ return useContext(Context)?.shortcuts ?? [];
+}
+
+export function useShortcutSettings(): {
+ characterKeys: boolean;
+ setCharacterKeys: (on: boolean) => void;
+} {
+ const context = useContext(Context);
+ return {
+ characterKeys: context?.characterKeys ?? true,
+ setCharacterKeys: context?.setCharacterKeys ?? (() => {}),
+ };
+}
diff --git a/apps/suite/src/shell/suite-shell.tsx b/apps/suite/src/shell/suite-shell.tsx
new file mode 100644
index 000000000..81ce6a730
--- /dev/null
+++ b/apps/suite/src/shell/suite-shell.tsx
@@ -0,0 +1,187 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+"use client";
+import {
+ AppRail,
+ type AppSlug,
+ Avatar,
+ AvatarFallback,
+ BottomTabBar,
+ type RailPreferences,
+ RailPreferencesProvider,
+} from "@jeswr/suite-ui";
+import {
+ defaultShellPreferences,
+ parseShellPreferences,
+ ShellFrame,
+ type ShellFrameProps,
+ type ShellPreferences,
+} from "@jeswr/suite-ui/shell";
+import { OfflinePill, SyncSheet } from "@jeswr/suite-ui/sync";
+import { usePathname, useRouter } from "next/navigation";
+import { type ReactNode, Suspense, useEffect, useState } from "react";
+import { useSuiteEnvironment } from "./environment";
+import { parseRailPreferences } from "./lib/preferences";
+import type { SearchSource } from "./lib/search-results";
+import { appRegistry, visibleApps } from "./registry";
+import { GlobalSearch } from "./search/global-search";
+import { useShellSearchSource } from "./search/search-context";
+import { useAccounts } from "./session/accounts-provider";
+import { SessionGuard } from "./session/session-guard";
+import { GlobalShortcuts } from "./shortcuts/global-shortcuts";
+import { ShortcutsProvider, useShortcutSettings } from "./shortcuts/provider";
+import { useSyncSnapshot } from "./sync/online";
+
+export interface SuiteShellProps {
+ app: AppSlug;
+ pageTitle: string;
+ sidebar?: ShellFrameProps["sidebar"];
+ context?: ReactNode;
+ actions?: ReactNode;
+ details?: ShellFrameProps["details"];
+ /** Overrides the account's shared search source (ShellSearchProvider) for this page. */
+ searchSource?: SearchSource;
+ children: ReactNode;
+}
+
+function Frame({
+ app: slug,
+ pageTitle,
+ sidebar,
+ context,
+ actions,
+ details,
+ searchSource,
+ children,
+}: SuiteShellProps) {
+ const pathname = usePathname();
+ const router = useRouter();
+ const shellSource = useShellSearchSource();
+ const sync = useSyncSnapshot();
+ const [syncOpen, setSyncOpen] = useState(false);
+ const { active } = useAccounts();
+ const { characterKeys } = useShortcutSettings();
+ const env = useSuiteEnvironment();
+ const app = appRegistry.find((item) => item.slug === slug);
+ const [preferences, setPreferences] = useState(defaultShellPreferences);
+ const [rail, setRail] = useState(() => parseRailPreferences(null));
+ useEffect(() => {
+ try {
+ setPreferences(
+ parseShellPreferences(JSON.parse(localStorage.getItem("suite.shell.v1") ?? "null")),
+ );
+ } catch {
+ /* Defaults. */
+ }
+ try {
+ setRail(parseRailPreferences(JSON.parse(localStorage.getItem("suite.rail.v1") ?? "null")));
+ } catch {
+ /* Defaults. */
+ }
+ }, []);
+ if (!app) return null;
+ const updateShell = (value: ShellPreferences) => {
+ setPreferences(value);
+ try {
+ localStorage.setItem("suite.shell.v1", JSON.stringify(value));
+ } catch {
+ /* Memory-only preferences. */
+ }
+ };
+ const updateRail = (value: RailPreferences) => {
+ const valid = parseRailPreferences(value);
+ setRail(valid);
+ try {
+ localStorage.setItem("suite.rail.v1", JSON.stringify(valid));
+ } catch {
+ /* Memory-only preferences. */
+ }
+ };
+ const account = active && (
+
+ Account: {active.name}
+ {/* Initials are decoration; the link's name is the sentence above (WCAG 2.5.3). */}
+
+
+
+ {active.name
+ .split(/\s+/u)
+ .map((part) => part[0])
+ .slice(0, 2)
+ .join("")}
+
+
+
+
+ );
+ const apps = visibleApps(env.NEXT_PUBLIC_SHOW_BACKLOG_APPS);
+ return (
+ {} }}
+ >
+ }
+ tabBar={}
+ sidebar={
+ sidebar ?? { label: `${app.name} sections`, sections: app.sidebar?.({ pathname }) ?? [] }
+ }
+ {...(details ? { details } : {})}
+ topBar={{
+ appName: app.name,
+ context,
+ actions,
+ account: {account}
,
+ offlinePill: setSyncOpen(true)} />,
+ search: (
+ router.push(result.href)}
+ onSeeAll={(query, scope) =>
+ router.push(
+ `/search?q=${encodeURIComponent(query)}${scope ? `&scope=${scope}` : ""}`,
+ )
+ }
+ />
+ ),
+ }}
+ >
+ {children}
+
+ {}}
+ onRetry={() => {}}
+ onResolve={() => {}}
+ />
+
+ );
+}
+
+/**
+ * Global shortcuts (and the palette and help sheet they open) exist only while the
+ * session is usable: an expired session's re-auth dialog must stay the only layer.
+ */
+function ReadyShortcuts() {
+ const { status } = useAccounts();
+ return status === "ready" ? : null;
+}
+
+export function SuiteShell(props: SuiteShellProps) {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/suite/src/shell/sync/online.ts b/apps/suite/src/shell/sync/online.ts
new file mode 100644
index 000000000..e721eec0c
--- /dev/null
+++ b/apps/suite/src/shell/sync/online.ts
@@ -0,0 +1,29 @@
+// AUTHORED-BY Claude Opus 5.5
+"use client";
+/**
+ * Browser connectivity for the shell's OfflinePill (02-suite-shell §10). The
+ * outbox (pending changes, conflicts) joins this when SDK-APP-08 lands; until
+ * then the snapshot reports connectivity only.
+ */
+import type { SyncSnapshot } from "@jeswr/suite-ui/sync";
+import { useSyncExternalStore } from "react";
+
+function subscribe(onChange: () => void) {
+ addEventListener("online", onChange);
+ addEventListener("offline", onChange);
+ return () => {
+ removeEventListener("online", onChange);
+ removeEventListener("offline", onChange);
+ };
+}
+
+const online: SyncSnapshot = { online: true, reconnecting: false, pending: [], conflicts: [] };
+const offline: SyncSnapshot = { online: false, reconnecting: false, pending: [], conflicts: [] };
+
+export function useSyncSnapshot(): SyncSnapshot {
+ return useSyncExternalStore(
+ subscribe,
+ () => (navigator.onLine ? online : offline),
+ () => online,
+ );
+}
diff --git a/apps/suite/test/shell-accounts-store.test.ts b/apps/suite/test/shell-accounts-store.test.ts
new file mode 100644
index 000000000..b243e49f2
--- /dev/null
+++ b/apps/suite/test/shell-accounts-store.test.ts
@@ -0,0 +1,91 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { describe, expect, it } from "vitest";
+import {
+ ACCOUNTS_KEY,
+ accountsReducer,
+ emptyAccounts,
+ parseAccounts,
+ serializeAccounts,
+} from "../src/shell/lib/accounts-store";
+import type { SuiteAccount } from "../src/shell/session/types";
+
+const first: SuiteAccount = {
+ id: "a",
+ name: "Alex Example",
+ webId: "https://example.org/alex#me",
+ storageUrl: "https://example.org/alex/",
+ issuer: "https://id.example.org/",
+ photoUrl: "https://example.org/avatar.png",
+ kind: "solid",
+};
+const second: SuiteAccount = {
+ id: "b",
+ name: "Robin Example",
+ webId: "https://example.com/robin#me",
+ storageUrl: "https://example.com/robin/",
+ kind: "demo",
+};
+describe("remembered accounts", () => {
+ it("remembers, updates, and switches without duplicates", () => {
+ expect(ACCOUNTS_KEY).toBe("suite.accounts.v1");
+ let state = accountsReducer(emptyAccounts, { type: "remember", account: first });
+ state = accountsReducer(state, { type: "remember", account: second });
+ state = accountsReducer(state, { type: "remember", account: { ...first, name: "Updated" } });
+ expect(state.accounts).toEqual([second, { ...first, name: "Updated" }]);
+ expect(state.activeId).toBe("a");
+ expect(accountsReducer(state, { type: "switch", id: "missing" })).toBe(state);
+ expect(accountsReducer(state, { type: "switch", id: "b" }).activeId).toBe("b");
+ });
+ it("chooses the next account on active signout, keeps the active on other signout, and clears all", () => {
+ const state = { accounts: [first, second], activeId: "a" };
+ expect(accountsReducer(state, { type: "remove", id: "b" })).toEqual({
+ accounts: [first],
+ activeId: "a",
+ });
+ expect(accountsReducer(state, { type: "remove", id: "a" })).toEqual({
+ accounts: [second],
+ activeId: "b",
+ });
+ expect(
+ accountsReducer({ accounts: [first], activeId: "a" }, { type: "remove", id: "a" }),
+ ).toEqual(emptyAccounts);
+ expect(accountsReducer(state, { type: "clear" })).toEqual(emptyAccounts);
+ });
+ it.each([
+ null,
+ "{",
+ "null",
+ "1",
+ "[]",
+ "{}",
+ '{"accounts":{}}',
+ '{"accounts":[null,{}, {"id":"x","kind":"alien"}]}',
+ ])("tolerates corrupt storage %j", (value) =>
+ expect(parseAccounts(value)).toEqual(emptyAccounts));
+ it("filters invalid entries and duplicates, and validates the active pointer", () => {
+ expect(
+ parseAccounts(
+ JSON.stringify({
+ accounts: [first, null, first, { ...second, storageUrl: "file:///private" }, second],
+ activeId: "b",
+ }),
+ ),
+ ).toEqual({ accounts: [first, second], activeId: "b" });
+ expect(
+ parseAccounts(JSON.stringify({ accounts: [first], activeId: "unknown" })).activeId,
+ ).toBeNull();
+ expect(parseAccounts(JSON.stringify({ accounts: [first] })).activeId).toBeNull();
+ });
+ it("serializes only allowlisted public metadata, never credentials", () => {
+ const serialized = serializeAccounts({
+ accounts: [
+ { ...first, accessToken: "SECRET", refreshToken: "SECRET", dpopKey: "SECRET" },
+ second,
+ ] as SuiteAccount[],
+ activeId: "a",
+ });
+ expect(serialized).not.toContain("SECRET");
+ expect(JSON.parse(serialized)).toEqual({ accounts: [first, second], activeId: "a" });
+ expect(emptyAccounts).toEqual({ accounts: [], activeId: null });
+ });
+});
diff --git a/apps/suite/test/shell-backends.test.ts b/apps/suite/test/shell-backends.test.ts
new file mode 100644
index 000000000..725e54507
--- /dev/null
+++ b/apps/suite/test/shell-backends.test.ts
@@ -0,0 +1,54 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ACCOUNTS_KEY } from "../src/shell/lib/accounts-store";
+import { DemoBackend, demoAccount } from "../src/shell/session/demo-backend";
+import { SolidBackend } from "../src/shell/session/solid-backend";
+import { SignInError } from "../src/shell/session/types";
+
+afterEach(() => vi.unstubAllGlobals());
+describe("session backend seam", () => {
+ it.each([
+ "notaurl",
+ "javascript:alert(1)",
+ "file:///tmp/profile",
+ "https://user:pass@example.org/",
+ ])("rejects invalid discovery URL %s", async (issuer) => {
+ await expect(new SolidBackend().signIn({ issuer })).rejects.toMatchObject({
+ code: "invalid-url",
+ message: "We couldn't find a sign-in service at that address",
+ });
+ });
+ it("validates URL hints without making discovery requests in this preview", async () => {
+ const backend = new SolidBackend();
+ const fetch = vi.fn();
+ vi.stubGlobal("fetch", fetch);
+ await expect(backend.signIn({ webId: "https://example.org/profile#me" })).rejects.toEqual(
+ new SignInError("unavailable"),
+ );
+ await expect(backend.signIn({ issuer: "http://localhost:3000/" })).rejects.toMatchObject({
+ code: "unavailable",
+ });
+ expect(fetch).not.toHaveBeenCalled();
+ expect(await backend.restore()).toBeNull();
+ expect(backend.fetchFor()).toBe(fetch);
+ expect(backend.kind).toBe("solid");
+ await backend.signOut();
+ backend.onExpired()();
+ });
+ it("signs into demo instantly and restores only its active pointer", async () => {
+ const backend = new DemoBackend();
+ expect(await backend.signIn()).toEqual(demoAccount);
+ expect(await backend.restore()).toBeNull();
+ const getItem = vi.fn(() => JSON.stringify({ accounts: [demoAccount], activeId: "demo" }));
+ vi.stubGlobal("localStorage", { getItem });
+ expect(await backend.restore()).toEqual(demoAccount);
+ expect(getItem).toHaveBeenCalledWith(ACCOUNTS_KEY);
+ getItem.mockReturnValue(JSON.stringify({ accounts: [demoAccount], activeId: null }));
+ expect(await backend.restore()).toBeNull();
+ expect(backend.kind).toBe("demo");
+ const response = await backend.fetchFor()(demoAccount.webId);
+ expect(response.status).toBe(200);
+ await backend.signOut();
+ backend.onExpired()();
+ });
+});
diff --git a/apps/suite/test/shell-callback.test.ts b/apps/suite/test/shell-callback.test.ts
new file mode 100644
index 000000000..a2b3753c9
--- /dev/null
+++ b/apps/suite/test/shell-callback.test.ts
@@ -0,0 +1,67 @@
+// AUTHORED-BY Codex GPT-6.1-Sol
+import { readFileSync } from "node:fs";
+import { runInNewContext } from "node:vm";
+import { describe, expect, it, vi } from "vitest";
+
+const html = readFileSync(new URL("../public/auth/callback.html", import.meta.url), "utf8");
+const script = html.match(/