diff --git a/apps/web/e2e/browser-lock.spec.ts b/apps/web/e2e/browser-lock.spec.ts
index 80621173..3106fe9d 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 a quiet skeleton, not the large six-dot grip", async ({
+test("shell loading state is text-first, not the large six-dot grip", async ({
page,
}) => {
await mockApi(page, { userDelayMs: 1_000 });
@@ -336,7 +336,7 @@ test("shell loading state is a quiet skeleton, not the large six-dot grip", asyn
await page.goto(`${APP}/app/project`, { waitUntil: "domcontentloaded" });
await expect(
- page.getByRole("status", { name: "Loading projects" }),
+ page.getByRole("status").filter({ hasText: "Loading" }),
).toBeVisible();
await expect(page.locator('svg[aria-label="Loading"]')).toHaveCount(0);
});
@@ -417,22 +417,26 @@ 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/ })).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(
+ 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(editor).toContainText("UNSAVED");
- await page.getByRole("link", { name: /Second Project/ }).click();
+ await page.locator("aside nav a", { hasText: "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 95c30380..07b1b1e0 100644
--- a/apps/web/e2e/visual-parity.spec.ts
+++ b/apps/web/e2e/visual-parity.spec.ts
@@ -14,6 +14,9 @@ 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) => {
@@ -94,3 +97,56 @@ 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 935cad9b..3a017997 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -20,8 +20,7 @@
},
"dependencies": {
"@fontsource-variable/funnel-display": "^5.2.8",
- "@fontsource-variable/geist": "^5.3.0",
- "@fontsource-variable/geist-mono": "^5.3.0",
+ "@monaco-editor/loader": "^1.5.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 ff5b698a..13433a40 100644
--- a/apps/web/src/app.css
+++ b/apps/web/src/app.css
@@ -1,13 +1,9 @@
@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 *));
@@ -54,54 +50,6 @@
--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 {
@@ -254,15 +202,6 @@ 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 b97a44a7..44290495 100644
--- a/apps/web/src/app.html
+++ b/apps/web/src/app.html
@@ -9,40 +9,6 @@
/>
-
diff --git a/apps/web/src/lib/account/ui/AccountSettingsPage.svelte b/apps/web/src/lib/account/ui/AccountSettingsPage.svelte
deleted file mode 100644
index e3d0fa79..00000000
--- a/apps/web/src/lib/account/ui/AccountSettingsPage.svelte
+++ /dev/null
@@ -1,340 +0,0 @@
-
-
-
-
-{#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
deleted file mode 100644
index e724e888..00000000
--- a/apps/web/src/lib/account/ui/AppPreview.svelte
+++ /dev/null
@@ -1,249 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
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
deleted file mode 100644
index bfdd3e6e..00000000
--- a/apps/web/src/lib/account/ui/ChangePassphraseDialog.svelte
+++ /dev/null
@@ -1,154 +0,0 @@
-
-
-
diff --git a/apps/web/src/lib/account/ui/PassphraseField.svelte b/apps/web/src/lib/account/ui/PassphraseField.svelte
deleted file mode 100644
index fc85683a..00000000
--- a/apps/web/src/lib/account/ui/PassphraseField.svelte
+++ /dev/null
@@ -1,58 +0,0 @@
-
-
-
-
-
-
- (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 430869db..611b163c 100644
--- a/apps/web/src/lib/api/integrations.api.ts
+++ b/apps/web/src/lib/api/integrations.api.ts
@@ -129,17 +129,11 @@ export class IntegrationsApi {
}
}
- /** Records the push (analytics + `lastPushedAt` on these integrations for the sync status). */
- static acknowledgeSecretsPushed(
- jwtToken: string,
- projectId: string,
- integrationIds: string[],
- ) {
+ static acknowledgeSecretsPushed(jwtToken: string, projectId: string) {
return unwrap(
backend.POST("/projects/{projectId}/analytics/secrets-pushed", {
params: { path: { projectId } },
headers: bearer(jwtToken),
- body: { integrationIds },
}),
"Failed to acknowledge secrets push",
);
@@ -150,36 +144,8 @@ export class IntegrationsApi {
integrations: Integration[],
content: string,
): Promise {
- const targets = integrations.filter(
- (integration) =>
- integration.repositoryData?.owner && integration.repositoryData.name,
- );
+ if (publicEnv.githubLocalMock) return;
- // 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) => {
@@ -209,6 +175,14 @@ 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
deleted file mode 100644
index 1df5cbd4..00000000
--- a/apps/web/src/lib/appearance/appearance.svelte.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-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
deleted file mode 100644
index 4b11fbab..00000000
--- a/apps/web/src/lib/appearance/greys.css
+++ /dev/null
@@ -1,222 +0,0 @@
-/* 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
deleted file mode 100644
index 1d0b3eae..00000000
--- a/apps/web/src/lib/appearance/greys.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-/** 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
deleted file mode 100644
index 2564c7f3..00000000
--- a/apps/web/src/lib/appearance/theme.css
+++ /dev/null
@@ -1,86 +0,0 @@
-/*
- * 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
deleted file mode 100644
index c6e167c0..00000000
--- a/apps/web/src/lib/auth/change-passphrase.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-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
deleted file mode 100644
index 65f98ec5..00000000
--- a/apps/web/src/lib/auth/private-key.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-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 692c1fc5..e26b1645 100644
--- a/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte
+++ b/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte
@@ -18,7 +18,6 @@
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";
@@ -135,8 +134,16 @@
let ephemeralKey: string | null = null;
try {
- // Throws "Incorrect passphrase"; checked against the server's current key.
- rawPrivateKey = await decryptPrivateKey(passphrase);
+ const base64Key =
+ await SymmetricCrypto.deriveBase64KeyFromPassphrase(passphrase);
+ try {
+ rawPrivateKey = await SymmetricCrypto.decrypt(
+ auth.userData.privateKeyEncrypted,
+ base64Key,
+ );
+ } catch {
+ throw new Error("Incorrect 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 ed14923a..e7da834b 100644
--- a/apps/web/src/lib/device-flow/ui/DeviceFlowApproverDialog.svelte
+++ b/apps/web/src/lib/device-flow/ui/DeviceFlowApproverDialog.svelte
@@ -1,15 +1,13 @@
-
+{/if}
diff --git a/apps/web/src/lib/history/diff.ts b/apps/web/src/lib/history/diff.ts
deleted file mode 100644
index 19d265a8..00000000
--- a/apps/web/src/lib/history/diff.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-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
deleted file mode 100644
index 0c5c8a76..00000000
--- a/apps/web/src/lib/history/format.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/** 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
deleted file mode 100644
index 20f2a3eb..00000000
--- a/apps/web/src/lib/history/history.svelte.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-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
deleted file mode 100644
index 8c2c7de6..00000000
--- a/apps/web/src/lib/history/ui/ActivityHeatmap.svelte
+++ /dev/null
@@ -1,96 +0,0 @@
-
-
-
- {#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
deleted file mode 100644
index 7804a598..00000000
--- a/apps/web/src/lib/history/ui/HistoryView.svelte
+++ /dev/null
@@ -1,520 +0,0 @@
-
-
-
-
-{#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
new file mode 100644
index 00000000..29f548b4
--- /dev/null
+++ b/apps/web/src/lib/integrations/ui/GithubSyncToast.svelte
@@ -0,0 +1,33 @@
+
+
+
+
+ {#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
new file mode 100644
index 00000000..dc03d141
--- /dev/null
+++ b/apps/web/src/lib/invitations/ui/PersonalInvitationsInbox.svelte
@@ -0,0 +1,127 @@
+
+
+{#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 de9eda20..35a6281f 100644
--- a/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte
+++ b/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte
@@ -1,6 +1,14 @@
-