diff --git a/apps/web/e2e/browser-lock.spec.ts b/apps/web/e2e/browser-lock.spec.ts
index 3106fe9d..80621173 100644
--- a/apps/web/e2e/browser-lock.spec.ts
+++ b/apps/web/e2e/browser-lock.spec.ts
@@ -327,7 +327,7 @@ test("lock broadcast in one tab locks another open tab", async ({
await context.close();
});
-test("shell loading state is text-first, not the large six-dot grip", async ({
+test("shell loading state is a quiet skeleton, not the large six-dot grip", async ({
page,
}) => {
await mockApi(page, { userDelayMs: 1_000 });
@@ -336,7 +336,7 @@ test("shell loading state is text-first, not the large six-dot grip", async ({
await page.goto(`${APP}/app/project`, { waitUntil: "domcontentloaded" });
await expect(
- page.getByRole("status").filter({ hasText: "Loading" }),
+ page.getByRole("status", { name: "Loading projects" }),
).toBeVisible();
await expect(page.locator('svg[aria-label="Loading"]')).toHaveCount(0);
});
@@ -417,26 +417,22 @@ test("unsaved edits survive tab switches and project switches go through the gua
await page.getByRole("textbox", { name: "Passphrase" }).fill(passphrase);
await page.getByRole("button", { name: "Unlock" }).click();
+ // Raw .env view: the editor lives in Monaco there (the table is the default view).
+ await page.getByRole("radio", { name: ".env" }).click();
const editor = page.locator(".monaco-editor .view-lines");
await editor.click();
await page.keyboard.press("ControlOrMeta+End");
await page.keyboard.press("Enter");
await page.keyboard.type('UNSAVED="1"');
- await expect(
- page.getByRole("button", { name: "Save", exact: true }),
- ).toBeEnabled();
-
- await page
- .getByRole("button", { name: "History", exact: true })
- .first()
- .click();
- await page
- .getByRole("button", { name: "Editor", exact: true })
- .first()
- .click();
+ await expect(page.getByRole("button", { name: /^Save/ })).toBeEnabled();
+
+ const tabs = page.getByRole("navigation", { name: "Project" });
+ await tabs.getByRole("link", { name: "History" }).click();
+ await expect(page).toHaveURL(/project-eval\/history$/);
+ await tabs.getByRole("link", { name: "Secrets" }).click();
await expect(editor).toContainText("UNSAVED");
- await page.locator("aside nav a", { hasText: "Second Project" }).click();
+ await page.getByRole("link", { name: /Second Project/ }).click();
const guard = page.getByRole("dialog", { name: "Save your changes?" });
await expect(guard).toBeVisible();
await expect(page).toHaveURL(/project-eval$/);
diff --git a/apps/web/e2e/visual-parity.spec.ts b/apps/web/e2e/visual-parity.spec.ts
index 07b1b1e0..95c30380 100644
--- a/apps/web/e2e/visual-parity.spec.ts
+++ b/apps/web/e2e/visual-parity.spec.ts
@@ -14,9 +14,6 @@ import { PNG } from "pngjs";
const REACT = process.env.REACT_URL ?? "http://127.0.0.1:5173";
const SVELTE = process.env.SVELTE_URL ?? "http://127.0.0.1:9090";
-const PROJECT_ID = process.env.PARITY_PROJECT_ID ?? "69e4efc7ed94cab2c9b88c9b";
-const REACT_STORAGE_STATE = process.env.PARITY_REACT_STORAGE_STATE;
-const SVELTE_STORAGE_STATE = process.env.PARITY_SVELTE_STORAGE_STATE;
test.beforeAll(async ({ request }, testInfo) => {
const check = async (url: string) => {
@@ -97,56 +94,3 @@ for (const route of Object.keys(THRESHOLDS)) {
).toBeLessThanOrEqual(threshold);
});
}
-
-test("authenticated history tab visual parity", async ({
- browser,
-}, testInfo) => {
- if (!REACT_STORAGE_STATE || !SVELTE_STORAGE_STATE) {
- testInfo.skip(
- true,
- "History parity needs authenticated storage states. Provide PARITY_REACT_STORAGE_STATE and PARITY_SVELTE_STORAGE_STATE.",
- );
- }
-
- const route = `/app/project/${PROJECT_ID}`;
- const reactContext = await browser.newContext({
- storageState: REACT_STORAGE_STATE,
- viewport: { width: 1440, height: 900 },
- deviceScaleFactor: 1,
- colorScheme: "dark",
- });
- const svelteContext = await browser.newContext({
- storageState: SVELTE_STORAGE_STATE,
- viewport: { width: 1440, height: 900 },
- deviceScaleFactor: 1,
- colorScheme: "dark",
- });
-
- const reactPage = await reactContext.newPage();
- await reactPage.goto(`${REACT}${route}`, { waitUntil: "load" });
- await reactPage.getByRole("button", { name: "History" }).click();
- await expect(
- reactPage.getByPlaceholder("Search edits — author, email, diff content…"),
- ).toBeVisible();
- await reactPage.waitForTimeout(1200);
-
- const sveltePage = await svelteContext.newPage();
- await sveltePage.goto(`${SVELTE}${route}`, { waitUntil: "load" });
- await sveltePage.getByRole("button", { name: "History" }).click();
- await expect(
- sveltePage.getByPlaceholder("Search edits — author, email, diff content…"),
- ).toBeVisible();
- await sveltePage.waitForTimeout(1200);
-
- const reactShot = await reactPage.locator("main").screenshot();
- const svelteShot = await sveltePage.locator("main").screenshot();
-
- await reactContext.close();
- await svelteContext.close();
-
- const ratio = mismatchRatio(reactShot, svelteShot);
- expect(
- ratio,
- `History tab pixel mismatch ${(ratio * 100).toFixed(2)}% for ${route}.`,
- ).toBeLessThanOrEqual(0.03);
-});
diff --git a/apps/web/package.json b/apps/web/package.json
index 3a017997..935cad9b 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -20,7 +20,8 @@
},
"dependencies": {
"@fontsource-variable/funnel-display": "^5.2.8",
- "@monaco-editor/loader": "^1.5.0",
+ "@fontsource-variable/geist": "^5.3.0",
+ "@fontsource-variable/geist-mono": "^5.3.0",
"@packages/backend-sdk": "workspace:*",
"@tabler/icons-svelte": "^3.34.1",
"clsx": "^2.1.1",
diff --git a/apps/web/src/app.css b/apps/web/src/app.css
index 13433a40..ff5b698a 100644
--- a/apps/web/src/app.css
+++ b/apps/web/src/app.css
@@ -1,9 +1,13 @@
@import "@fontsource-variable/funnel-display";
+@import "@fontsource-variable/geist";
+@import "@fontsource-variable/geist-mono";
@import "tailwindcss";
@plugin "daisyui" {
logs: false;
}
@import "tw-animate-css";
+@import "./lib/appearance/greys.css";
+@import "./lib/appearance/theme.css";
@custom-variant dark (&:is(.dark *));
@@ -50,6 +54,54 @@
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-editor: #1e1e1e;
+ /* App design system (lib/appearance/theme.css). */
+ --font-ui: "Geist Variable", system-ui, sans-serif;
+ --font-code: "Geist Mono Variable", ui-monospace, monospace;
+ --color-root: var(--root);
+ --color-root-hover: var(--root-hover);
+ --color-root-sel: var(--root-sel);
+ --color-s50: var(--s50);
+ --color-s50-hover: var(--s50-hover);
+ --color-s50-sel: var(--s50-sel);
+ --color-s50-border: var(--s50-border);
+ --color-s100: var(--s100);
+ --color-s100-hover: var(--s100-hover);
+ --color-s100-sel: var(--s100-sel);
+ --color-s100-border: var(--s100-border);
+ --color-s150: var(--s150);
+ --color-s150-hover: var(--s150-hover);
+ --color-s150-border: var(--s150-border);
+ --color-elev: var(--elev);
+ --color-elev-hover: var(--elev-hover);
+ --color-elev-border: var(--elev-border);
+ --color-field: var(--field);
+ --color-field-border: var(--field-border);
+ --color-fg: var(--fg);
+ --color-fg2: var(--fg2);
+ --color-fg3: var(--fg3);
+ --color-ph: var(--ph);
+ --color-faint: var(--faint);
+ --color-btn: var(--btn);
+ --color-btn-hover: var(--btn-hover);
+ --color-btn-border: var(--btn-border);
+ --color-btn-primary: var(--btn-primary);
+ --color-btn-primary-hover: var(--btn-primary-hover);
+ --color-btn-primary-fg: var(--btn-primary-fg);
+ --color-success: var(--success);
+ --color-warning: var(--warning);
+ --color-danger: var(--danger);
+ --color-danger-bg: var(--danger-bg);
+ --color-danger-bg-hover: var(--danger-bg-hover);
+ --radius-panel: 24px;
+ --radius-well: 18px;
+ --radius-card: 12px;
+ --radius-row: 6px;
+ --shadow-raise: var(--raise-shadow);
+ --shadow-panel: inset 0 0 0 0.5px var(--s50-border);
+ --shadow-card: inset 0 0 0 0.5px var(--s100-border), var(--raise-shadow);
+ --shadow-btn: inset 0 0 0 0.5px var(--btn-border), var(--raise-shadow);
+ --shadow-field: inset 0 0 0 0.5px var(--field-border);
+ --shadow-elev: 0 0 0 0.5px var(--elev-border), var(--elev-shadow);
--animate-marquee: marquee var(--duration) infinite linear;
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
@keyframes marquee {
@@ -202,6 +254,15 @@ body {
min-height: 100%;
}
+/* Monospace for keys and values. Ligatures off so "==" or "->" never render as one glyph. */
+@utility mono {
+ font-family: var(--font-code);
+ font-variant-ligatures: none;
+ font-feature-settings:
+ "liga" 0,
+ "calt" 0;
+}
+
body {
margin: 0;
}
diff --git a/apps/web/src/app.html b/apps/web/src/app.html
index 44290495..b97a44a7 100644
--- a/apps/web/src/app.html
+++ b/apps/web/src/app.html
@@ -9,6 +9,40 @@
/>
+
diff --git a/apps/web/src/lib/account/ui/AccountSettingsPage.svelte b/apps/web/src/lib/account/ui/AccountSettingsPage.svelte
new file mode 100644
index 00000000..e3d0fa79
--- /dev/null
+++ b/apps/web/src/lib/account/ui/AccountSettingsPage.svelte
@@ -0,0 +1,340 @@
+
+
+
+
+{#snippet copyButton(text: string, label: string)}
+ void copy(text)}>
+ {#if copied === text}
+
+ {:else}
+
+ {/if}
+
+{/snippet}
+
+{#snippet swatch(name: GreyPreset)}
+ {@const on = appearance.greys === name}
+ {@const [dark, mid, light] = GREY_SWATCHES[name]}
+
+{/snippet}
+
+
+ {#if user?.displayName}
+ {user.displayName}
+ /
+ {/if}
+ Settings
+
+
+
+
+
+ {#if renaming}
+
+ {:else}
+ {user?.displayName}
+ {/if}
+ {#snippet actions()}
+ {#if renaming}
+
+
+ {:else}
+
+ {/if}
+ {/snippet}
+
+
+ {#if user}
+ {PROVIDERS[user.authMethod]}
+ {#if user.email}
+ {user.email}
+ {/if}
+ {/if}
+ {#snippet actions()}
+
+ {/snippet}
+
+
+
+
+
+ {themeNote}
+ {#snippet actions()}
+ appearance.theme, setTheme}
+ />
+ {/snippet}
+
+
+
+ {#each GREY_PRESETS as name (name)}
+ {@render swatch(name)}
+ {/each}
+
+
+
+
+
+
+
+
+
+ Cryptly never sees it and cannot reset it.
+ {#snippet actions()}
+
+ {/snippet}
+
+
+
+
+
+ {device}
+ {keyAuth.hasMasterKey ? "Unlocked" : "Locked"}
+ {#snippet actions()}
+ {#if keyAuth.hasMasterKey}
+
+ {/if}
+ {/snippet}
+
+
+ {INSTALL}
+ {#snippet actions()}
+ {@render copyButton(INSTALL, "Copy install command")}
+ {/snippet}
+
+
+ {LOGIN}
+ {#snippet actions()}
+ {@render copyButton(LOGIN, "Copy login command")}
+ {/snippet}
+
+
+
+
+
diff --git a/apps/web/src/lib/account/ui/AppPreview.svelte b/apps/web/src/lib/account/ui/AppPreview.svelte
new file mode 100644
index 00000000..e724e888
--- /dev/null
+++ b/apps/web/src/lib/account/ui/AppPreview.svelte
@@ -0,0 +1,249 @@
+
+
+
+
+
+
+
+
+
+
+
Projects
+
/
+
checkout-api
+
+ {#each ["Secrets", "History", "Settings"] as tab, i (tab)}
+ {tab}
+ {/each}
+
+
+
+ Filter {total} secrets
+ /
+
+
Saved
+
+
+
+
+
+ {#each COLUMNS as column, c (c)}
+
+ {#each column as [group, rows] (group)}
+
+ {#snippet trail()}
+
+ {/snippet}
+ {#each rows as [key, head, tail] (key)}
+
+ {key}
+ {head}{head ? "••••••" : "••••••••"}{tail}
+
+ {/each}
+
+ {/each}
+
+ {/each}
+
+
+
+
diff --git a/apps/web/src/lib/account/ui/ChangePassphraseDialog.svelte b/apps/web/src/lib/account/ui/ChangePassphraseDialog.svelte
new file mode 100644
index 00000000..bfdd3e6e
--- /dev/null
+++ b/apps/web/src/lib/account/ui/ChangePassphraseDialog.svelte
@@ -0,0 +1,154 @@
+
+
+
diff --git a/apps/web/src/lib/account/ui/PassphraseField.svelte b/apps/web/src/lib/account/ui/PassphraseField.svelte
new file mode 100644
index 00000000..fc85683a
--- /dev/null
+++ b/apps/web/src/lib/account/ui/PassphraseField.svelte
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+ (shown = !shown)}
+ >
+ {#if shown}
+
+ {:else}
+
+ {/if}
+
+
+
diff --git a/apps/web/src/lib/api/integrations.api.ts b/apps/web/src/lib/api/integrations.api.ts
index 611b163c..430869db 100644
--- a/apps/web/src/lib/api/integrations.api.ts
+++ b/apps/web/src/lib/api/integrations.api.ts
@@ -129,11 +129,17 @@ export class IntegrationsApi {
}
}
- static acknowledgeSecretsPushed(jwtToken: string, projectId: string) {
+ /** Records the push (analytics + `lastPushedAt` on these integrations for the sync status). */
+ static acknowledgeSecretsPushed(
+ jwtToken: string,
+ projectId: string,
+ integrationIds: string[],
+ ) {
return unwrap(
backend.POST("/projects/{projectId}/analytics/secrets-pushed", {
params: { path: { projectId } },
headers: bearer(jwtToken),
+ body: { integrationIds },
}),
"Failed to acknowledge secrets push",
);
@@ -144,8 +150,36 @@ export class IntegrationsApi {
integrations: Integration[],
content: string,
): Promise {
- if (publicEnv.githubLocalMock) return;
+ const targets = integrations.filter(
+ (integration) =>
+ integration.repositoryData?.owner && integration.repositoryData.name,
+ );
+ // The local GitHub mock never sends secrets anywhere; it still records the push below so
+ // the sync status works locally.
+ if (!publicEnv.githubLocalMock) {
+ await this.pushToRepositories(jwtToken, targets, content);
+ }
+
+ const projectId = integrations[0]?.projectId;
+ if (projectId) {
+ try {
+ await this.acknowledgeSecretsPushed(
+ jwtToken,
+ projectId,
+ targets.map((integration) => integration.id),
+ );
+ } catch {
+ // the push itself succeeded; a missing record only leaves the sync status stale
+ }
+ }
+ }
+
+ private static async pushToRepositories(
+ jwtToken: string,
+ integrations: Integration[],
+ content: string,
+ ): Promise {
const secrets = parseDotenv(content);
await Promise.all(
integrations.map(async (integration) => {
@@ -175,14 +209,6 @@ export class IntegrationsApi {
);
}),
);
- const pid = integrations[0]?.projectId;
- if (pid) {
- try {
- await this.acknowledgeSecretsPushed(jwtToken, pid);
- } catch {
- // analytics must not fail the push flow
- }
- }
}
}
diff --git a/apps/web/src/lib/appearance/appearance.svelte.ts b/apps/web/src/lib/appearance/appearance.svelte.ts
new file mode 100644
index 00000000..1df5cbd4
--- /dev/null
+++ b/apps/web/src/lib/appearance/appearance.svelte.ts
@@ -0,0 +1,78 @@
+import { browser } from "$app/environment";
+import { GREY_PRESETS, type GreyPreset } from "./greys";
+
+export type ThemeChoice = "system" | "light" | "dark";
+
+/** Must match the key read by the pre-paint script in app.html. */
+const STORAGE_KEY = "cryptly.appearance";
+const DEFAULT_GREYS: GreyPreset = "Stone";
+
+function read(): { theme: ThemeChoice; greys: GreyPreset } {
+ let saved: { theme?: unknown; greys?: unknown } = {};
+ if (browser) {
+ try {
+ saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") ?? {};
+ } catch {
+ saved = {};
+ }
+ }
+ const theme =
+ saved.theme === "light" || saved.theme === "dark" ? saved.theme : "system";
+ const greys = GREY_PRESETS.includes(saved.greys as GreyPreset)
+ ? (saved.greys as GreyPreset)
+ : DEFAULT_GREYS;
+ return { theme, greys };
+}
+
+const systemLight = browser
+ ? matchMedia("(prefers-color-scheme: light)")
+ : null;
+
+export const appearance = $state({
+ ...read(),
+ /** The theme in effect after resolving "system". */
+ resolved: "dark" as "light" | "dark",
+});
+
+function apply() {
+ if (!browser) return;
+ appearance.resolved =
+ appearance.theme === "system"
+ ? systemLight?.matches
+ ? "light"
+ : "dark"
+ : appearance.theme;
+ document.documentElement.dataset.theme = appearance.resolved;
+ document.documentElement.dataset.greys = appearance.greys;
+}
+
+function save() {
+ if (!browser) return;
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({ theme: appearance.theme, greys: appearance.greys }),
+ );
+}
+
+export function setTheme(theme: ThemeChoice) {
+ appearance.theme = theme;
+ save();
+ apply();
+}
+
+export function setGreys(greys: GreyPreset) {
+ appearance.greys = greys;
+ save();
+ apply();
+}
+
+if (browser) {
+ apply();
+ systemLight?.addEventListener("change", apply);
+ // Keep other tabs in sync.
+ window.addEventListener("storage", (event) => {
+ if (event.key !== STORAGE_KEY) return;
+ Object.assign(appearance, read());
+ apply();
+ });
+}
diff --git a/apps/web/src/lib/appearance/greys.css b/apps/web/src/lib/appearance/greys.css
new file mode 100644
index 00000000..4b11fbab
--- /dev/null
+++ b/apps/web/src/lib/appearance/greys.css
@@ -0,0 +1,222 @@
+/* Grey ramps for the app UI. One preset is active per html[data-greys]; themes map semantic tokens onto these steps. */
+html[data-greys="Neutral"] {
+ --g50: #fbfbfb;
+ --g100: #f5f5f5;
+ --g150: #f0f0f0;
+ --g200: #eaeaea;
+ --g250: #dedede;
+ --g300: #d8d8d8;
+ --g350: #cdcdcd;
+ --g400: #b6b6b6;
+ --g450: #979797;
+ --g500: #828282;
+ --g550: #717171;
+ --g600: #666666;
+ --g650: #4f4f4f;
+ --g700: #404040;
+ --g750: #373737;
+ --g800: #2f2f2f;
+ --g850: #292929;
+ --g900: #1f1f1f;
+ --g925: #1a1a1a;
+ --g950: #141414;
+}
+:root,
+html[data-greys="Stone"] {
+ --g50: #fcfbfb;
+ --g100: #f7f5f4;
+ --g150: #f2efee;
+ --g200: #ece9e7;
+ --g250: #e1dddb;
+ --g300: #dbd7d4;
+ --g350: #d1ccc9;
+ --g400: #b9b5b2;
+ --g450: #9b9693;
+ --g500: #86817e;
+ --g550: #75706d;
+ --g600: #6a6562;
+ --g650: #534f4c;
+ --g700: #44403d;
+ --g750: #393633;
+ --g800: #322f2d;
+ --g850: #2b2826;
+ --g900: #211f1d;
+ --g925: #1c1a18;
+ --g950: #161413;
+}
+html[data-greys="Paper"] {
+ --g50: #fcfbfa;
+ --g100: #f6f5f3;
+ --g150: #f1f0ed;
+ --g200: #eceae6;
+ --g250: #e0ded9;
+ --g300: #dad7d2;
+ --g350: #d0cdc7;
+ --g400: #b8b5af;
+ --g450: #9a9790;
+ --g500: #85827c;
+ --g550: #74716b;
+ --g600: #696660;
+ --g650: #524f4a;
+ --g700: #43403b;
+ --g750: #393632;
+ --g800: #312f2b;
+ --g850: #2b2925;
+ --g900: #211f1c;
+ --g925: #1b1a17;
+ --g950: #151412;
+}
+html[data-greys="Sand"] {
+ --g50: #fcfbfa;
+ --g100: #f7f5f2;
+ --g150: #f3efeb;
+ --g200: #eee9e4;
+ --g250: #e3ddd7;
+ --g300: #ddd7cf;
+ --g350: #d3ccc4;
+ --g400: #bcb4ac;
+ --g450: #9d968d;
+ --g500: #898178;
+ --g550: #777067;
+ --g600: #6c655d;
+ --g650: #554e47;
+ --g700: #463f38;
+ --g750: #3b362f;
+ --g800: #342f29;
+ --g850: #2d2823;
+ --g900: #231e1a;
+ --g925: #1d1916;
+ --g950: #171411;
+}
+html[data-greys="Clay"] {
+ --g50: #fdfbfa;
+ --g100: #f8f5f3;
+ --g150: #f4eeed;
+ --g200: #efe8e6;
+ --g250: #e4dcd9;
+ --g300: #dfd6d2;
+ --g350: #d5cbc7;
+ --g400: #beb3b0;
+ --g450: #9f9491;
+ --g500: #8a807c;
+ --g550: #796e6b;
+ --g600: #6e6460;
+ --g650: #564d4a;
+ --g700: #473e3b;
+ --g750: #3c3532;
+ --g800: #352e2b;
+ --g850: #2e2725;
+ --g900: #231e1c;
+ --g925: #1e1917;
+ --g950: #171312;
+}
+html[data-greys="Rose"] {
+ --g50: #fcfbfb;
+ --g100: #f7f4f5;
+ --g150: #f3eeef;
+ --g200: #eee8e9;
+ --g250: #e3dcdd;
+ --g300: #ddd6d6;
+ --g350: #d3cbcc;
+ --g400: #bcb3b4;
+ --g450: #9d9495;
+ --g500: #898081;
+ --g550: #776e6f;
+ --g600: #6c6465;
+ --g650: #554d4e;
+ --g700: #463e3f;
+ --g750: #3b3535;
+ --g800: #342e2e;
+ --g850: #2d2828;
+ --g900: #231e1e;
+ --g925: #1d1919;
+ --g950: #171314;
+}
+html[data-greys="Plum"] {
+ --g50: #fcfbfc;
+ --g100: #f7f5f7;
+ --g150: #f2eff2;
+ --g200: #ece9ed;
+ --g250: #e1dce1;
+ --g300: #dbd6db;
+ --g350: #d1cbd1;
+ --g400: #bab3ba;
+ --g450: #9b959b;
+ --g500: #868086;
+ --g550: #756f75;
+ --g600: #6a646a;
+ --g650: #534d53;
+ --g700: #443f44;
+ --g750: #3a353a;
+ --g800: #322e32;
+ --g850: #2b282c;
+ --g900: #211e21;
+ --g925: #1c191c;
+ --g950: #161416;
+}
+html[data-greys="Sage"] {
+ --g50: #fbfcfb;
+ --g100: #f4f6f4;
+ --g150: #eef1ee;
+ --g200: #e8ebe8;
+ --g250: #dbdfdb;
+ --g300: #d4d9d4;
+ --g350: #c9cfc9;
+ --g400: #b2b8b2;
+ --g450: #939993;
+ --g500: #7e847e;
+ --g550: #6d736d;
+ --g600: #626862;
+ --g650: #4c514c;
+ --g700: #3d423d;
+ --g750: #343834;
+ --g800: #2d312d;
+ --g850: #272a27;
+ --g900: #1d201d;
+ --g925: #181b18;
+ --g950: #131513;
+}
+html[data-greys="Slate"] {
+ --g50: #fafbfd;
+ --g100: #f4f6f8;
+ --g150: #edf0f3;
+ --g200: #e7ebef;
+ --g250: #dadfe4;
+ --g300: #d3d8de;
+ --g350: #c8ced4;
+ --g400: #b0b7bd;
+ --g450: #91989f;
+ --g500: #7d838a;
+ --g550: #6b7278;
+ --g600: #61676d;
+ --g650: #4b5056;
+ --g700: #3c4147;
+ --g750: #32373c;
+ --g800: #2c3035;
+ --g850: #262a2e;
+ --g900: #1c2023;
+ --g925: #181a1e;
+ --g950: #121517;
+}
+html[data-greys="Ink"] {
+ --g50: #fafbfe;
+ --g100: #f3f5fa;
+ --g150: #ecf0f7;
+ --g200: #e6eaf3;
+ --g250: #d9dee9;
+ --g300: #d2d8e5;
+ --g350: #c6cddb;
+ --g400: #afb6c5;
+ --g450: #9097a6;
+ --g500: #7b8291;
+ --g550: #6a717f;
+ --g600: #5f6674;
+ --g650: #49505d;
+ --g700: #3b414d;
+ --g750: #313742;
+ --g800: #2b303a;
+ --g850: #252932;
+ --g900: #1c1f27;
+ --g925: #171a21;
+ --g950: #12141a;
+}
diff --git a/apps/web/src/lib/appearance/greys.ts b/apps/web/src/lib/appearance/greys.ts
new file mode 100644
index 00000000..1d0b3eae
--- /dev/null
+++ b/apps/web/src/lib/appearance/greys.ts
@@ -0,0 +1,29 @@
+/** Grey presets, in display order. Each maps to an html[data-greys] block in greys.css. */
+export const GREY_PRESETS = [
+ "Neutral",
+ "Stone",
+ "Paper",
+ "Sand",
+ "Clay",
+ "Rose",
+ "Plum",
+ "Sage",
+ "Slate",
+ "Ink",
+] as const;
+
+export type GreyPreset = (typeof GREY_PRESETS)[number];
+
+/** Swatch colors for the Appearance picker: dark, mid and light steps of each ramp. */
+export const GREY_SWATCHES: Record = {
+ Neutral: ["#141414", "#828282", "#f0f0f0"],
+ Stone: ["#161413", "#86817e", "#f2efee"],
+ Paper: ["#151412", "#85827c", "#f1f0ed"],
+ Sand: ["#171411", "#898178", "#f3efeb"],
+ Clay: ["#171312", "#8a807c", "#f4eeed"],
+ Rose: ["#171314", "#898081", "#f3eeef"],
+ Plum: ["#161416", "#868086", "#f2eff2"],
+ Sage: ["#131513", "#7e847e", "#eef1ee"],
+ Slate: ["#121517", "#7d838a", "#edf0f3"],
+ Ink: ["#12141a", "#7b8291", "#ecf0f7"],
+};
diff --git a/apps/web/src/lib/appearance/theme.css b/apps/web/src/lib/appearance/theme.css
new file mode 100644
index 00000000..2564c7f3
--- /dev/null
+++ b/apps/web/src/lib/appearance/theme.css
@@ -0,0 +1,86 @@
+/*
+ * App design tokens. One grey ramp (greys.css) feeds both themes; each theme maps the same
+ * semantic tokens onto different steps. The pre-paint script in app.html sets data-theme and
+ * data-greys before first paint. Surfaces nest root → s50 (panel) → root (well) → s100 (card).
+ */
+html[data-theme="dark"] {
+ --scheme: dark;
+ --root: var(--g950);
+ --root-hover: var(--g900);
+ --root-sel: var(--g850);
+ --s50: var(--g925);
+ --s50-hover: var(--g900);
+ --s50-sel: var(--g850);
+ --s50-border: var(--g850);
+ --s100: var(--g850);
+ --s100-hover: var(--g800);
+ --s100-sel: var(--g750);
+ --s100-border: var(--g800);
+ --s150: var(--g800);
+ --s150-hover: var(--g750);
+ --s150-border: var(--g700);
+ --elev: var(--g800);
+ --elev-hover: var(--g750);
+ --elev-border: var(--g700);
+ --field: var(--g950);
+ --field-border: var(--g800);
+ --fg: var(--g100);
+ --fg2: var(--g400);
+ --fg3: var(--g500);
+ --ph: var(--g550);
+ --faint: var(--g650);
+ --btn: var(--g800);
+ --btn-hover: var(--g750);
+ --btn-border: var(--g700);
+ --btn-primary: var(--g50);
+ --btn-primary-hover: var(--g200);
+ --btn-primary-fg: var(--g950);
+ --success: #4ade80;
+ --warning: #fbbf24;
+ --danger: #f87171;
+ --danger-bg: #3b1d1c;
+ --danger-bg-hover: #4a2321;
+ --raise-shadow: 0 1px 2px #0000004d;
+ --elev-shadow: 0 12px 32px #00000066;
+}
+
+html[data-theme="light"] {
+ --scheme: light;
+ --root: var(--g200);
+ --root-hover: var(--g250);
+ --root-sel: var(--g300);
+ --s50: var(--g150);
+ --s50-hover: var(--g200);
+ --s50-sel: var(--g250);
+ --s50-border: var(--g250);
+ --s100: var(--g50);
+ --s100-hover: var(--g150);
+ --s100-sel: var(--g200);
+ --s100-border: var(--g250);
+ --s150: var(--g50);
+ --s150-hover: var(--g100);
+ --s150-border: var(--g250);
+ --elev: var(--g50);
+ --elev-hover: var(--g150);
+ --elev-border: var(--g250);
+ --field: var(--g100);
+ --field-border: var(--g250);
+ --fg: var(--g900);
+ --fg2: var(--g700);
+ --fg3: var(--g600);
+ --ph: var(--g500);
+ --faint: var(--g400);
+ --btn: var(--g50);
+ --btn-hover: var(--g150);
+ --btn-border: var(--g250);
+ --btn-primary: var(--g950);
+ --btn-primary-hover: var(--g800);
+ --btn-primary-fg: var(--g50);
+ --success: #16a34a;
+ --warning: #c27803;
+ --danger: #d82d2a;
+ --danger-bg: #fde8e7;
+ --danger-bg-hover: #fbd5d3;
+ --raise-shadow: 0 1px 2px #0000000d;
+ --elev-shadow: 0 12px 32px #0000001f;
+}
diff --git a/apps/web/src/lib/auth/change-passphrase.ts b/apps/web/src/lib/auth/change-passphrase.ts
new file mode 100644
index 00000000..c6e167c0
--- /dev/null
+++ b/apps/web/src/lib/auth/change-passphrase.ts
@@ -0,0 +1,24 @@
+import { broadcastPassphraseChanged } from "$lib/stores/key-state.svelte";
+import { decryptPrivateKey, encryptPrivateKey } from "./private-key";
+import { UserApi } from "./user.api";
+
+export { IncorrectPassphraseError } from "./private-key";
+
+/**
+ * Changes the account passphrase by re-encrypting the private key in the browser. The key pair
+ * does not change, so project keys, this unlocked browser and logged-in CLIs keep working.
+ * Only the re-encrypted key is sent; the passphrases and the decrypted key never leave this
+ * function. Nothing is written when `current` is wrong (IncorrectPassphraseError).
+ */
+export async function changePassphrase(
+ jwtToken: string,
+ current: string,
+ next: string,
+): Promise {
+ const privateKeyEncrypted = await encryptPrivateKey(
+ await decryptPrivateKey(current),
+ next,
+ );
+ await UserApi.updateMe(jwtToken, { privateKeyEncrypted });
+ broadcastPassphraseChanged();
+}
diff --git a/apps/web/src/lib/auth/private-key.ts b/apps/web/src/lib/auth/private-key.ts
new file mode 100644
index 00000000..65f98ec5
--- /dev/null
+++ b/apps/web/src/lib/auth/private-key.ts
@@ -0,0 +1,63 @@
+import { auth, setUserData } from "$lib/stores/auth.svelte";
+import { SymmetricCrypto } from "./symmetric-crypto";
+import { UserApi } from "./user.api";
+
+/**
+ * The account private key (PKCS#8, base64) is stored on the server encrypted with AES-GCM keyed
+ * by SHA-256 of the passphrase. Every path that opens or writes it goes through this module, so
+ * setup, unlock, device approval, CLI login and passphrase change share one format.
+ */
+
+export class IncorrectPassphraseError extends Error {
+ constructor() {
+ super("Incorrect passphrase");
+ this.name = "IncorrectPassphraseError";
+ }
+}
+
+/**
+ * The server's current encrypted private key. A copy loaded earlier may predate a passphrase
+ * change (in another tab or browser), and would still open with the old passphrase. Falls back
+ * to the loaded copy when the API is unreachable.
+ */
+async function currentPrivateKeyEncrypted(): Promise {
+ const jwt = auth.jwtToken;
+ if (jwt) {
+ try {
+ const me = await UserApi.getMe(jwt);
+ if (auth.jwtToken === jwt) setUserData(me);
+ return me.privateKeyEncrypted;
+ } catch {
+ // Offline or API error: use the copy we have.
+ }
+ }
+ return auth.userData?.privateKeyEncrypted;
+}
+
+/** Opens the account private key with `passphrase`. Throws IncorrectPassphraseError. */
+export async function decryptPrivateKey(passphrase: string): Promise {
+ const encrypted = await currentPrivateKeyEncrypted();
+ if (!encrypted) throw new Error("This account has no passphrase yet");
+ const key = await SymmetricCrypto.deriveBase64KeyFromPassphrase(passphrase);
+ try {
+ return await SymmetricCrypto.decrypt(encrypted, key);
+ } catch {
+ throw new IncorrectPassphraseError();
+ }
+}
+
+/**
+ * Encrypts the private key with `passphrase`, then opens the result again before it can leave
+ * the browser: a blob the passphrase can't open would lock the account out for good.
+ */
+export async function encryptPrivateKey(
+ privateKey: string,
+ passphrase: string,
+): Promise {
+ const key = await SymmetricCrypto.deriveBase64KeyFromPassphrase(passphrase);
+ const encrypted = await SymmetricCrypto.encrypt(privateKey, key);
+ if ((await SymmetricCrypto.decrypt(encrypted, key)) !== privateKey) {
+ throw new Error("Could not verify the encrypted private key");
+ }
+ return encrypted;
+}
diff --git a/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte b/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte
index e26b1645..692c1fc5 100644
--- a/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte
+++ b/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte
@@ -18,6 +18,7 @@
type CliSessionInfo,
} from "$lib/auth/cli-flow.api";
import { randomBytes, u8ToBase64 } from "$lib/auth/crypto.utils";
+ import { decryptPrivateKey } from "$lib/auth/private-key";
import { SymmetricCrypto } from "$lib/auth/symmetric-crypto";
import { auth, loadUserData } from "$lib/stores/auth.svelte";
@@ -134,16 +135,8 @@
let ephemeralKey: string | null = null;
try {
- const base64Key =
- await SymmetricCrypto.deriveBase64KeyFromPassphrase(passphrase);
- try {
- rawPrivateKey = await SymmetricCrypto.decrypt(
- auth.userData.privateKeyEncrypted,
- base64Key,
- );
- } catch {
- throw new Error("Incorrect passphrase");
- }
+ // Throws "Incorrect passphrase"; checked against the server's current key.
+ rawPrivateKey = await decryptPrivateKey(passphrase);
ephemeralKey = u8ToBase64(randomBytes(32));
const wrappedKey = await AsymmetricCrypto.encrypt(
diff --git a/apps/web/src/lib/device-flow/ui/DeviceFlowApproverDialog.svelte b/apps/web/src/lib/device-flow/ui/DeviceFlowApproverDialog.svelte
index e7da834b..ed14923a 100644
--- a/apps/web/src/lib/device-flow/ui/DeviceFlowApproverDialog.svelte
+++ b/apps/web/src/lib/device-flow/ui/DeviceFlowApproverDialog.svelte
@@ -1,13 +1,15 @@
-{#if msg?.type === "request"}
-
+
diff --git a/apps/web/src/lib/history/diff.ts b/apps/web/src/lib/history/diff.ts
new file mode 100644
index 00000000..19d265a8
--- /dev/null
+++ b/apps/web/src/lib/history/diff.ts
@@ -0,0 +1,184 @@
+import { diffArrays } from "diff";
+import type { ProjectRevealOn } from "$lib/auth/domain/project-settings";
+import { oneLine, parseEnv as parseEnvDoc, peek } from "$lib/secrets/env-doc";
+
+/** One secret of a decrypted .env document, with the group it sits in on the Secrets page. */
+export interface EnvSecret {
+ key: string;
+ value: string;
+ group: string;
+ /** Index of the group block, so two "Untitled" groups stay apart. */
+ block: number;
+}
+
+export interface SecretChange {
+ kind: "added" | "changed" | "removed";
+ key: string;
+ before?: string;
+ after?: string;
+}
+
+export interface SecretDiff {
+ /** Changed secrets grouped like the Secrets page, in the newer document's group order. */
+ groups: { name: string; changes: SecretChange[] }[];
+ added: number;
+ changed: number;
+ removed: number;
+ unchanged: number;
+ unchangedGroups: number;
+}
+
+/** Secrets with their groups, parsed exactly like the Secrets page (`env-doc.ts`). */
+export function parseEnv(text: string): EnvSecret[] {
+ return parseEnvDoc(text).groups.flatMap((g) =>
+ g.secrets.map((s) => ({
+ key: s.key,
+ value: s.value,
+ group: g.name ?? "Untitled",
+ block: g.index,
+ })),
+ );
+}
+
+/**
+ * Per-secret diff by key. Moving a secret to another group or reordering it is not a change;
+ * a key defined twice counts with its last value, like dotenv.
+ */
+export function diffSecrets(
+ before: EnvSecret[],
+ after: EnvSecret[],
+): SecretDiff {
+ const old = new Map(before.map((s) => [s.key, s]));
+ const now = new Map(after.map((s) => [s.key, s]));
+ const order = new Map
();
+ for (const s of [...now.values(), ...old.values()])
+ if (!order.has(s.group)) order.set(s.group, order.size);
+
+ const byGroup = new Map();
+ const push = (group: string, change: SecretChange) =>
+ byGroup.set(group, [...(byGroup.get(group) ?? []), change]);
+ let added = 0;
+ let changed = 0;
+ let removed = 0;
+ let unchanged = 0;
+ const unchangedBlocks = new Set();
+
+ for (const s of now.values()) {
+ const prev = old.get(s.key);
+ if (!prev) {
+ added++;
+ push(s.group, { kind: "added", key: s.key, after: s.value });
+ } else if (prev.value !== s.value) {
+ changed++;
+ push(s.group, {
+ kind: "changed",
+ key: s.key,
+ before: prev.value,
+ after: s.value,
+ });
+ } else {
+ unchanged++;
+ unchangedBlocks.add(s.block);
+ }
+ }
+ for (const s of old.values()) {
+ if (now.has(s.key)) continue;
+ removed++;
+ push(s.group, { kind: "removed", key: s.key, before: s.value });
+ }
+
+ const groups = [...byGroup]
+ .map(([name, changes]) => ({ name, changes }))
+ .sort((a, b) => order.get(a.name)! - order.get(b.name)!);
+ return {
+ groups,
+ added,
+ changed,
+ removed,
+ unchanged,
+ unchangedGroups: unchangedBlocks.size,
+ };
+}
+
+/**
+ * A value as the project's Values setting shows it, as [head, mask, tail]: Peek keeps up to 4
+ * characters at each end around a fixed mask, Hidden is a fixed mask, Visible is the value.
+ * Never reveals the length unless the value is Visible.
+ */
+export function shownValue(
+ value: string,
+ revealOn: ProjectRevealOn,
+): [string, string, string] {
+ if (revealOn === "always") return [oneLine(value), "", ""];
+ const p = revealOn === "hover" ? peek(value) : null;
+ return p ? [p.head, "••••••", p.tail] : ["", "••••••••", ""];
+}
+
+/** One line of the document; a secret, multiline or not, is one line showing its value as set. */
+interface DiffLine {
+ /** Exact source text, what the diff compares. */
+ real: string;
+ shown: string;
+}
+
+function diffLines(text: string, revealOn: ProjectRevealOn): DiffLine[] {
+ const doc = parseEnvDoc(text);
+ const secretAt = new Map(doc.secrets.map((s) => [s.start, s]));
+ const out: DiffLine[] = [];
+ for (let i = 0; i < doc.lines.length; i++) {
+ const line = doc.lines[i]!;
+ const s = secretAt.get(i);
+ if (!s) {
+ out.push({ real: line, shown: line });
+ continue;
+ }
+ const real = doc.lines.slice(s.start, s.end + 1).join("\n");
+ out.push({
+ real,
+ shown:
+ revealOn === "always"
+ ? real
+ : line.slice(0, line.indexOf("=") + 1) +
+ shownValue(s.value, revealOn).join(""),
+ });
+ i = s.end;
+ }
+ return out;
+}
+
+/**
+ * Line diff for the ".env diff" view, `+`/`-`/` ` prefixed with 4 lines of context and "⋯" for
+ * the gaps. Lines are compared by their real text but show values per the Values setting, so a
+ * masked line never depends on the value's length and a change hidden by the mask still shows.
+ */
+export function envDiff(
+ before: string | null,
+ after: string,
+ revealOn: ProjectRevealOn,
+): string {
+ const next = diffLines(after, revealOn);
+ const rows =
+ before === null
+ ? next.map((l) => ({ mark: "+", text: l.shown }))
+ : diffArrays(diffLines(before, revealOn), next, {
+ comparator: (a, b) => a.real === b.real,
+ }).flatMap((c) =>
+ c.value.map((l) => ({
+ mark: c.added ? "+" : c.removed ? "-" : " ",
+ text: l.shown,
+ })),
+ );
+ const changed = rows.map((r) => r.mark !== " ");
+ const out: string[] = [];
+ let gap = false;
+ rows.forEach((row, i) => {
+ if (!changed.slice(Math.max(0, i - 4), i + 5).some(Boolean)) {
+ gap = out.length > 0;
+ return;
+ }
+ if (gap) out.push(" ⋯");
+ gap = false;
+ for (const line of row.text.split("\n")) out.push(row.mark + line);
+ });
+ return out.join("\n");
+}
diff --git a/apps/web/src/lib/history/format.ts b/apps/web/src/lib/history/format.ts
new file mode 100644
index 00000000..0c5c8a76
--- /dev/null
+++ b/apps/web/src/lib/history/format.ts
@@ -0,0 +1,54 @@
+/** Local calendar day, `YYYY-MM-DD`. */
+export function dayKey(value: string | Date): string {
+ const d = new Date(value);
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+}
+
+function fromKey(key: string) {
+ const [y, m, d] = key.split("-").map(Number);
+ return new Date(y!, m! - 1, d!);
+}
+
+/** "Oct 8", with the year when it isn't this year. */
+export function shortDay(key: string): string {
+ const date = fromKey(key);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year:
+ date.getFullYear() === new Date().getFullYear() ? undefined : "numeric",
+ });
+}
+
+/** "Today", "Yesterday", else `shortDay`. */
+export function dayLabel(key: string, now: number): string {
+ if (key === dayKey(new Date(now))) return "Today";
+ const yesterday = new Date(now);
+ yesterday.setDate(yesterday.getDate() - 1);
+ return key === dayKey(yesterday) ? "Yesterday" : shortDay(key);
+}
+
+/** "now", "12m", "2h" today; "18:42" before. */
+export function shortTime(value: string, now: number): string {
+ if (dayKey(value) !== dayKey(new Date(now))) return clock(value);
+ const minutes = Math.floor((now - Date.parse(value)) / 60_000);
+ if (minutes < 1) return "now";
+ return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`;
+}
+
+/** "Oct 12, 2026 · 11:01". */
+export function fullDate(value: string): string {
+ const date = new Date(value).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ return `${date} · ${clock(value)}`;
+}
+
+function clock(value: string) {
+ return new Date(value).toLocaleTimeString("en-GB", {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
diff --git a/apps/web/src/lib/history/history.svelte.ts b/apps/web/src/lib/history/history.svelte.ts
new file mode 100644
index 00000000..20f2a3eb
--- /dev/null
+++ b/apps/web/src/lib/history/history.svelte.ts
@@ -0,0 +1,149 @@
+import { browser } from "$app/environment";
+import { SymmetricCrypto } from "$lib/auth/symmetric-crypto";
+import { getUnlockedProjectKey } from "$lib/projects/project-key";
+import { ProjectsApi, type Project } from "$lib/projects/projects.api";
+import { auth } from "$lib/stores/auth.svelte";
+import { keyAuth } from "$lib/stores/key.svelte";
+import { diffSecrets, parseEnv, type SecretDiff } from "./diff";
+
+export interface HistoryVersion {
+ id: string;
+ createdAt: string;
+ updatedAt: string;
+ /** Missing on the version Cryptly created together with the project. */
+ author?: {
+ id: string;
+ displayName: string;
+ avatarUrl?: string;
+ email?: string;
+ };
+ /** Decrypted .env text of this version. In memory only, never sent anywhere. */
+ content: string;
+ /** Decrypted text of the version before it; null for the oldest kept version. */
+ previous: string | null;
+ /** Per-secret changes against `previous` (everything added for the oldest version). */
+ diff: SecretDiff;
+}
+
+class HistoryStore {
+ projectId = $state(null);
+ /** Newest first. Raw: replaced as a whole, never mutated in place. */
+ versions = $state.raw([]);
+ loading = $state(false);
+ /** The browser is locked, so nothing can be decrypted. */
+ locked = $state(false);
+ message = $state(null);
+}
+
+/**
+ * Decrypted version history of the open project, shared by the History tab, the GitHub sync
+ * status in Settings, and the Secrets tab (which reloads it after a save). Decryption happens
+ * in the browser; the plaintext is dropped as soon as the browser locks.
+ */
+export const history = new HistoryStore();
+
+let seq = 0;
+
+export async function loadHistory(project: Project): Promise {
+ const mySeq = ++seq;
+ const revision = keyAuth.revision;
+ const current = () =>
+ mySeq === seq && revision === keyAuth.revision && keyAuth.hasMasterKey;
+
+ if (history.projectId !== project.id) {
+ history.projectId = project.id;
+ history.versions = [];
+ }
+ if (!keyAuth.hasMasterKey) {
+ dropDecrypted();
+ return;
+ }
+ history.loading = true;
+ history.locked = false;
+ history.message = null;
+
+ try {
+ const jwt = auth.jwtToken;
+ const userId = auth.userData?.id;
+ if (!jwt || !userId) throw new Error("Signed out");
+ if (!project.encryptedSecretsKeys?.[userId]) {
+ history.versions = [];
+ history.message = "You don't have a key for this project.";
+ return;
+ }
+ const encrypted = await ProjectsApi.getProjectVersions(jwt, project.id);
+ if (!current()) return;
+ const key = await getUnlockedProjectKey(project);
+ if (!current()) return;
+ if (!key) {
+ dropDecrypted();
+ return;
+ }
+ const contents = await Promise.all(
+ encrypted.map((v) =>
+ SymmetricCrypto.decryptWithKey(v.encryptedSecrets, key).catch(() => ""),
+ ),
+ );
+ if (!current()) return;
+
+ // Oldest first, so each version diffs against the one before it.
+ const chronological = encrypted
+ .map((v, i) => ({ ...v, content: contents[i]! }))
+ .reverse();
+ const parsed = chronological.map((v) => parseEnv(v.content));
+ history.versions = chronological
+ .map(
+ (v, i): HistoryVersion => ({
+ id: v.id,
+ createdAt: v.createdAt,
+ updatedAt: v.updatedAt,
+ author:
+ i === 0 && isCreation(v.createdAt, project) ? undefined : v.author,
+ content: v.content,
+ previous: chronological[i - 1]?.content ?? null,
+ diff: diffSecrets(parsed[i - 1] ?? [], parsed[i]!),
+ }),
+ )
+ .reverse();
+ } catch {
+ if (!current()) return;
+ history.versions = [];
+ history.message = "Check your connection and try again.";
+ } finally {
+ if (mySeq === seq) history.loading = false;
+ }
+}
+
+export function clearHistory() {
+ seq++;
+ history.projectId = null;
+ history.versions = [];
+ history.loading = false;
+ history.locked = false;
+ history.message = null;
+}
+
+function dropDecrypted() {
+ seq++;
+ history.versions = [];
+ history.loading = false;
+ history.locked = true;
+ history.message = null;
+}
+
+/**
+ * The oldest version is the project's first one (shown as created by Cryptly) unless older
+ * versions were pruned (the backend keeps the last 100), which shows as a later timestamp.
+ */
+function isCreation(createdAt: string, project: Project) {
+ return Date.parse(createdAt) - Date.parse(project.createdAt) < 60_000;
+}
+
+if (browser) {
+ // Plaintext must not outlive the unlocked browser, whichever tab is open.
+ $effect.root(() => {
+ $effect(() => {
+ if (!keyAuth.hasMasterKey || !auth.jwtToken) dropDecrypted();
+ });
+ });
+}
diff --git a/apps/web/src/lib/history/ui/ActivityHeatmap.svelte b/apps/web/src/lib/history/ui/ActivityHeatmap.svelte
new file mode 100644
index 00000000..8c2c7de6
--- /dev/null
+++ b/apps/web/src/lib/history/ui/ActivityHeatmap.svelte
@@ -0,0 +1,96 @@
+
+
+
+ {#snippet trail()}
+ {#if day}
+
+ {/if}
+ {/snippet}
+ {#each cells as cell (cell.key)}
+ {#if cell.count > 0}
+
+ {:else}
+ (hovered = cell.future ? null : cell)}
+ onpointerleave={() => (hovered = null)}
+ >
+ {/if}
+ {/each}
+
diff --git a/apps/web/src/lib/history/ui/HistoryView.svelte b/apps/web/src/lib/history/ui/HistoryView.svelte
new file mode 100644
index 00000000..7804a598
--- /dev/null
+++ b/apps/web/src/lib/history/ui/HistoryView.svelte
@@ -0,0 +1,520 @@
+
+
+
+
+{#snippet who(v: HistoryVersion)}
+ {#if v.author}
+
+ {v.author.displayName}
+ {#if v.author.id === me}
+ you
+ {/if}
+ {:else}
+ C
+ Cryptly
+ {/if}
+{/snippet}
+
+{#snippet counts(diff: SecretDiff)}
+
+ {#if diff.added}+{diff.added}{/if}
+ {#if diff.changed}~{diff.changed}{/if}
+ {#if diff.removed}−{diff.removed}{/if}
+ {#if !diff.added && !diff.changed && !diff.removed}±0{/if}
+
+{/snippet}
+
+{#snippet secretValue(value: string)}
+ {@const [head, mask, tail] = shownValue(value, revealOn)}
+ {head}{mask}{tail}
+{/snippet}
+
+{#snippet message(title: string, text?: string, retry = false)}
+
+
+
{title}
+ {#if text}
{text}
{/if}
+ {#if retry}
+
+ {/if}
+
+
+{/snippet}
+
+{#snippet filters(className: string)}
+
+
+
+{/snippet}
+
+
+ {@render filters("")}
+
+
+ {@render filters("w-auto flex-1")}
+
+
+{#if locked}
+ {@render message(
+ "Unlock to view history",
+ "Versions are encrypted. Unlock this browser to decrypt them here.",
+ )}
+{:else if history.message && history.projectId === project.id}
+ {@render message("Couldn't load the history", history.message, true)}
+{:else if loading}
+ {@render message("Loading history…")}
+{:else if versions.length === 0}
+ {@render message("No versions yet", "Save secrets to start the history.")}
+{:else}
+
+
+
+ {#if selected}
+
+
+
+ {@render who(selected)}
+
+ {fullDate(selected.createdAt)}
+ {@render counts(selected.diff)}
+
+ localStorage.setItem(DIFF_MODE_KEY, mode)}
+ />
+ {#if !readOnly && selected.id !== versions[0]?.id}
+
+ {/if}
+
+
+ {#if envText !== null}
+
+
+
+ {:else}
+
+ {/if}
+
+ {/if}
+
+{/if}
+
+
diff --git a/apps/web/src/lib/integrations/ui/GithubSyncToast.svelte b/apps/web/src/lib/integrations/ui/GithubSyncToast.svelte
deleted file mode 100644
index 29f548b4..00000000
--- a/apps/web/src/lib/integrations/ui/GithubSyncToast.svelte
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
- {#if ok}
-
- {:else}
-
- {/if}
-
- {ok ? "Synced with" : "Failed to sync with"}
-
-
- GitHub
-
-
-
-
-
diff --git a/apps/web/src/lib/invitations/ui/PersonalInvitationsInbox.svelte b/apps/web/src/lib/invitations/ui/PersonalInvitationsInbox.svelte
deleted file mode 100644
index dc03d141..00000000
--- a/apps/web/src/lib/invitations/ui/PersonalInvitationsInbox.svelte
+++ /dev/null
@@ -1,127 +0,0 @@
-
-
-{#if shouldShow && invitations.length > 0}
-
-
-
Project invitations
-
- You have pending direct invitations.
-
-
-
- {#each invitations as invitation (invitation.id)}
-
-
-
-
- {invitation.projectName}
-
-
- Invited by {invitation.author.displayName ||
- invitation.author.email ||
- "a teammate"} as {invitation.role}
-
-
-
-
-
-
-
-
- {/each}
-
-
-{/if}
diff --git a/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte b/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte
index 35a6281f..de9eda20 100644
--- a/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte
+++ b/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte
@@ -1,14 +1,6 @@
-{#if open}
-
-