): Plugin {
+ const docName = env.VITE_DOC_NAME || "Gray Paper";
+ return {
+ name: "generate-webmanifest",
+ generateBundle() {
+ this.emitFile({
+ type: "asset",
+ fileName: "site.webmanifest",
+ source: JSON.stringify({
+ name: `${docName} Reader`,
+ short_name: docName,
+ icons: [
+ { src: "android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
+ { src: "android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
+ ],
+ theme_color: "#ffffff",
+ background_color: "#ffffff",
+ display: "standalone",
+ }),
+ });
+ },
+ };
+}
diff --git a/src/devtools/featureFlags.ts b/src/devtools/featureFlags.ts
new file mode 100644
index 00000000..4175d01b
--- /dev/null
+++ b/src/devtools/featureFlags.ts
@@ -0,0 +1,164 @@
+import { DOC_CONFIG } from "../config/documentConfig";
+
+export interface FeatureFlag {
+ key: string;
+ name: string;
+ description: string;
+ defaultValue: boolean;
+}
+
+export const FEATURE_FLAGS = {
+ DEBUG_DRAW_BLOCKS: {
+ key: "debug_draw_blocks",
+ name: "Debug Draw Blocks",
+ description: "Draws visual rectangles around synctex blocks for debugging",
+ defaultValue: false,
+ },
+} as const;
+
+export type FeatureFlagKey = keyof typeof FEATURE_FLAGS;
+
+class FeatureFlagsManager {
+ private static STORAGE_PREFIX = `${DOC_CONFIG.localStoragePrefix}_feature_flag_`;
+
+ private isDevelopment(): boolean {
+ return window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1";
+ }
+
+ private getStorageKey(flagKey: string): string {
+ return `${FeatureFlagsManager.STORAGE_PREFIX}${flagKey}`;
+ }
+
+ isEnabled(flagKey: FeatureFlagKey): boolean {
+ if (!this.isDevelopment()) {
+ return false;
+ }
+
+ const flag = FEATURE_FLAGS[flagKey];
+ const storedValue = sessionStorage.getItem(this.getStorageKey(flag.key));
+
+ if (storedValue !== null) {
+ return storedValue === "true";
+ }
+
+ return flag.defaultValue;
+ }
+
+ toggle(flagKey: FeatureFlagKey): boolean {
+ if (!this.isDevelopment()) {
+ console.warn("Feature flags are only available in development environment");
+ return false;
+ }
+
+ const flag = FEATURE_FLAGS[flagKey];
+ const currentValue = this.isEnabled(flagKey);
+ const newValue = !currentValue;
+
+ sessionStorage.setItem(this.getStorageKey(flag.key), String(newValue));
+
+ console.log(`🚩 Feature flag "${flag.name}" is now ${newValue ? "ENABLED ✅" : "DISABLED ❌"}`);
+
+ return newValue;
+ }
+
+ enable(flagKey: FeatureFlagKey): void {
+ if (!this.isDevelopment()) {
+ console.warn("Feature flags are only available in development environment");
+ return;
+ }
+
+ const flag = FEATURE_FLAGS[flagKey];
+ sessionStorage.setItem(this.getStorageKey(flag.key), "true");
+ console.log(`🚩 Feature flag "${flag.name}" is now ENABLED ✅`);
+ }
+
+ disable(flagKey: FeatureFlagKey): void {
+ if (!this.isDevelopment()) {
+ console.warn("Feature flags are only available in development environment");
+ return;
+ }
+
+ const flag = FEATURE_FLAGS[flagKey];
+ sessionStorage.setItem(this.getStorageKey(flag.key), "false");
+ console.log(`🚩 Feature flag "${flag.name}" is now DISABLED ❌`);
+ }
+
+ reset(flagKey: FeatureFlagKey): void {
+ if (!this.isDevelopment()) {
+ console.warn("Feature flags are only available in development environment");
+ return;
+ }
+
+ const flag = FEATURE_FLAGS[flagKey];
+ sessionStorage.removeItem(this.getStorageKey(flag.key));
+ console.log(`🚩 Feature flag "${flag.name}" reset to default: ${flag.defaultValue ? "ENABLED ✅" : "DISABLED ❌"}`);
+ }
+
+ resetAll(): void {
+ if (!this.isDevelopment()) {
+ console.warn("Feature flags are only available in development environment");
+ return;
+ }
+
+ for (const [, flag] of Object.entries(FEATURE_FLAGS)) {
+ sessionStorage.removeItem(this.getStorageKey(flag.key));
+ }
+
+ console.log("🚩 All feature flags have been reset to defaults");
+ this.listFlags();
+ }
+
+ listFlags(): void {
+ if (!this.isDevelopment()) {
+ console.warn("Feature flags are only available in development environment");
+ return;
+ }
+
+ console.group("🚩 Available Feature Flags");
+
+ for (const [key, flag] of Object.entries(FEATURE_FLAGS)) {
+ const isEnabled = this.isEnabled(key as FeatureFlagKey);
+ const status = isEnabled ? "✅ ENABLED" : "❌ DISABLED";
+
+ console.log(
+ `%c${status}%c ${flag.name} (${key})\n ${flag.description}`,
+ `color: ${isEnabled ? "green" : "gray"}; font-weight: bold`,
+ "color: inherit; font-weight: normal",
+ );
+ }
+
+ console.groupEnd();
+
+ console.log("\n💡 Use window.gpDev to interact with feature flags");
+ }
+
+ help(): void {
+ if (!this.isDevelopment()) {
+ console.warn("Feature flags are only available in development environment");
+ return;
+ }
+
+ console.group("📚 Feature Flags Help");
+ console.log("%cAvailable commands:", "font-weight: bold; font-size: 14px");
+ console.log(" gpDev.listFlags() - List all available feature flags");
+ console.log(' gpDev.toggle("FLAG_KEY") - Toggle a specific flag on/off');
+ console.log(' gpDev.enable("FLAG_KEY") - Enable a specific flag');
+ console.log(' gpDev.disable("FLAG_KEY") - Disable a specific flag');
+ console.log(' gpDev.reset("FLAG_KEY") - Reset flag to default value');
+ console.log(" gpDev.resetAll() - Reset all flags to defaults");
+ console.log(" gpDev.help() - Show this help message");
+ console.log("\n%cAvailable flag keys:", "font-weight: bold; font-size: 14px");
+
+ for (const [key, flag] of Object.entries(FEATURE_FLAGS)) {
+ console.log(` "${key}" - ${flag.name}`);
+ }
+
+ console.groupEnd();
+ }
+}
+
+export const featureFlags = new FeatureFlagsManager();
+
+export function isFeatureEnabled(flagKey: FeatureFlagKey): boolean {
+ return featureFlags.isEnabled(flagKey);
+}
diff --git a/src/devtools/index.ts b/src/devtools/index.ts
new file mode 100644
index 00000000..c2aae294
--- /dev/null
+++ b/src/devtools/index.ts
@@ -0,0 +1,8 @@
+export type { FeatureFlag, FeatureFlagKey } from "./featureFlags";
+export { FEATURE_FLAGS, featureFlags, isFeatureEnabled } from "./featureFlags";
+export { initDevTools } from "./initDevTools";
+export { useFeatureFlag, withFeatureFlag } from "./useFeatureFlag";
+
+export function isDevelopment(): boolean {
+ return window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1";
+}
diff --git a/src/devtools/initDevTools.ts b/src/devtools/initDevTools.ts
new file mode 100644
index 00000000..0a41098c
--- /dev/null
+++ b/src/devtools/initDevTools.ts
@@ -0,0 +1,46 @@
+import { type FeatureFlagKey, featureFlags } from "./featureFlags";
+
+declare global {
+ interface Window {
+ gpDev: {
+ listFlags: () => void;
+ toggle: (flagKey: FeatureFlagKey) => boolean;
+ enable: (flagKey: FeatureFlagKey) => void;
+ disable: (flagKey: FeatureFlagKey) => void;
+ reset: (flagKey: FeatureFlagKey) => void;
+ resetAll: () => void;
+ help: () => void;
+ };
+ }
+}
+
+export function initDevTools(): void {
+ if (window.location.hostname !== "localhost" && window.location.hostname !== "127.0.0.1") {
+ return;
+ }
+
+ window.gpDev = {
+ listFlags: () => featureFlags.listFlags(),
+ toggle: (flagKey: FeatureFlagKey) => featureFlags.toggle(flagKey),
+ enable: (flagKey: FeatureFlagKey) => featureFlags.enable(flagKey),
+ disable: (flagKey: FeatureFlagKey) => featureFlags.disable(flagKey),
+ reset: (flagKey: FeatureFlagKey) => featureFlags.reset(flagKey),
+ resetAll: () => featureFlags.resetAll(),
+ help: () => featureFlags.help(),
+ };
+
+ console.log(
+ "%c🛠️ Graypaper Reader Dev Tools Loaded",
+ "background: #4a5568; color: white; padding: 4px 8px; border-radius: 4px; font-weight: bold; font-size: 14px",
+ );
+ console.log(
+ "💡 Type %cgpDev.help()%c to see available commands",
+ "font-family: monospace; background: #edf2f7; padding: 2px 4px; border-radius: 2px",
+ "",
+ );
+ console.log(
+ "🚩 Type %cgpDev.listFlags()%c to see all feature flags",
+ "font-family: monospace; background: #edf2f7; padding: 2px 4px; border-radius: 2px",
+ "",
+ );
+}
diff --git a/src/devtools/useFeatureFlag.tsx b/src/devtools/useFeatureFlag.tsx
new file mode 100644
index 00000000..4ceb1d09
--- /dev/null
+++ b/src/devtools/useFeatureFlag.tsx
@@ -0,0 +1,47 @@
+import { useEffect, useState } from "react";
+import { type FeatureFlagKey, isFeatureEnabled } from "./featureFlags";
+
+export function useFeatureFlag(flagKey: FeatureFlagKey): boolean {
+ const [enabled, setEnabled] = useState(() => isFeatureEnabled(flagKey));
+
+ useEffect(() => {
+ const handleStorageChange = (e: StorageEvent) => {
+ if (e.storageArea === sessionStorage && e.key?.startsWith("gp_feature_flag_")) {
+ setEnabled(isFeatureEnabled(flagKey));
+ }
+ };
+
+ const checkFlagState = () => {
+ const currentState = isFeatureEnabled(flagKey);
+ if (currentState !== enabled) {
+ setEnabled(currentState);
+ }
+ };
+
+ const intervalId = setInterval(checkFlagState, 1000);
+
+ window.addEventListener("storage", handleStorageChange);
+
+ return () => {
+ clearInterval(intervalId);
+ window.removeEventListener("storage", handleStorageChange);
+ };
+ }, [flagKey, enabled]);
+
+ return enabled;
+}
+
+export function withFeatureFlag(
+ flagKey: FeatureFlagKey,
+ Component: React.ComponentType
,
+): React.ComponentType
{
+ return function FeatureFlagWrapper(props: P) {
+ const isEnabled = useFeatureFlag(flagKey);
+
+ if (!isEnabled) {
+ return null;
+ }
+
+ return ;
+ };
+}
diff --git a/src/font.css b/src/font.css
index 1437d1dd..5d158fc3 100644
--- a/src/font.css
+++ b/src/font.css
@@ -1,4 +1,25 @@
+@font-face {
+ font-family: ff2;
+ src: url("data:application/font-woff;base64,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")
+ format("woff");
+}
+.ff2 {
+ font-family: ff2;
+ line-height: 0.903;
+ font-style: normal;
+ font-weight: normal;
+ visibility: visible;
+}
-@font-face{font-family:ff2;src:url('data:application/font-woff;base64,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')format("woff");}.ff2{font-family:ff2;line-height:0.903000;font-style:normal;font-weight:normal;visibility:visible;}
-
-@font-face{font-family:ff7;src:url('data:application/font-woff;base64,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')format("woff");}.ff7{font-family:ff7;line-height:1.000000;font-style:normal;font-weight:normal;visibility:visible;}
+@font-face {
+ font-family: ff7;
+ src: url("data:application/font-woff;base64,d09GRgABAAAAACOwAA4AAAAAN1wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABGRlRNAAAjlAAAABoAAAAcoQicj0dERUYAACN4AAAAHAAAAB4AJwBhT1MvMgAAAbQAAABIAAAAVlXp1OFjbWFwAAAC8AAAAQAAAAHC36098mN2dCAAAAPwAAAABAAAAAQAIQJ5Z2FzcAAAI3AAAAAIAAAACP//AANnbHlmAAAErAAAG6oAACwQSshVrGhlYWQAAAFEAAAAMAAAADYou+IxaGhlYQAAAXQAAAAgAAAAJAbWA01obXR4AAAB/AAAAPEAAAFqyGEOD2xvY2EAAAP0AAAAuAAAALjQ+9ycbWF4cAAAAZQAAAAfAAAAIAChAI5uYW1lAAAgWAAAAmAAAARc09CfgnBvc3QAACK4AAAAtwAAAQCLjElZeJxjYGQAg+0/vR/E89t8ZZBnfgHiP7rkvwZG/7/5n42Fh+kdkMvBwAQSBQCloQ7LeJxjYGRgYHr3n42BgUXh/83/Z1h4GIAiKCAKAJMdBil4nGNgZGBgiGaIZWBhAAEmIGZkAIk5MOiBBAAWKQEwAHicY2Bk3MM4gYGVgYGpi2kPAwNDD4RmfMBgyMgEFGVgZWYAgwYGBmYBBgQISHNNAVIKz+qY3v1nA6p8x/ALyGcEyQEAGYYM6nicTZCxTcRAEEX/7DpDINA5ILCXsw/MISdIRLY7OIkY6yRCMjI6oIITEjEd0AAV0AEUcK6AHN6uHGDp63/Nn/mzY3tUJT67BaeSH9X5M7W20wPo0Z0FjXang8jOqQNrcA9uQAvKf7qbuU1zIGXsyfWq3Zeu3IQ+IeNDjXtT4y+T19iTKnesa/ob90z9XW2sx3l614l/mBvgSRfJ88qzpRbwIRB7K/xgn+waVMOl9ej4xkEr3nNu/e+3C/SguXcV67MfqNf2gvcK77RIXlDOvhw+ijpmpqx9urXMMv5d0CbdO8GTtm5U4WfQW6j4Aw3BKh4AAAB4nGNgYGBmgGAZBkYGENgD5DGC+SwMC4C0CoMCkMUCJNUYNBmsGWwZ7BnCGSIZ4hmqGLYy7Gf4piCiIKkg+6zu/3+gagUGVQYNBm2wKkewqkS4KgkFmWdV////f/z//v97/+/+v/P/9v9b/2/+v/L/8v+Z/yf8j3pg+8DqgYXkHahLCAJGNga4UkYmIMGErgDiNQYWVgY2dgYOTi5uHl4+fgFBIWERUTFxCQZJBikGaRlZOXkFRSVlFVU1dQ1NLW0dXT19A0MGI7AJxgwmpmbmFpZW1ja2dvYOjk7OLq5u7h6eXt4+vgRcF4Bbyg/B9EeXCwwJDQomYDQSAAAEZz2OACECeQAAACoAKgAqACoApAEaAUIBbAGQAbIBwAHSAfYCNAJYApoC8gMiA3QDugPmBC4EeASYBMoE6gUsBW4FtAX8BjQGdgayBw4HXgeIB8AIEghECJoI5AkSCVQJtgocCooKyAsKC0ALmgvaC+gMNgxuDKYM4g0eDVYNwg4EDi4OYg6kDsQPJA9kD5AP0hAMEEgQrBDWEQoRPhGYEe4SPhJuEqwS8BMyE0ATZhOME9IUGBR4FPIVaBW+Fgh4nI1aC5gb1XWee0fSSBpJo9dIGr1Ho9dqtZJ29VqtdiXt+/2yvS/ba3vt2AYMix8YCK/Y2HEhmNQQGick0ObV0jgtgYY0NG1CqBtMmtIPQklIab+kidsEWhLqEr4Cq+29dyTtrmM3+LPXM6PVPef855z/nHPvUJASKQrE4MMUTTFU4glAJTufZBTUf7U9oVK+1vkkDdEl9QSNHyvx4ycZFXi/80mAn6dNoimSNkki0Pziuefgwyv7RDhPUQqqsHqB9sI3qRDVSvVSM0iClQEqKRDOZvIggi4iNt4q3+fSbXZbui2P/rdZ1p7ZQCYsBQw0b/XS6bbcxjt4Qb3ynk6r0WvABdA5NDlinWgvbt/WGRZnl28b0urU+ln5QftE1V1u0phcFo6zuEyaaAVc2HgPf8SwK2ajTgvN8A+AGGvt3HL08PzsEc/0Tk638rh8cwR0jexT8lJ/MtEv8cq91ac33FLoD001r74O34Dfp9qpPmoaWazigIrxQqRuPm0AyGTZthLIo781I3mrATA2e64McllkYdbelg9lElAKqHirPWcJR8IRA+StCB/4xmxTU3/aE8gIsUHgBjPFnbPzu8dvMPMuUfQ3zedSsUCstfmOlpasTgp8uFuq/h6tdVsDMe8BR2tT0RX02jO5WXAGVvoOFrxedzFbnhodnas4mwV3QBSv3TU2P9Pi7cnklx9OjM5ZWLPgo+nPaBxxd0/L7JYD1b7WwnRPIFiM5HZSgNq7yoAZ5F87RSmtUiABItizJWQsb2VUJqPdBmZ06sy4qzIkDGUYnercY5o32fiu9nv/5M/PtC40syrVF6rfrX7nYRZjB6gOtF4MreekqJCVMSAA0HI5E4ICL5xu8wK0Yqy+VqCj1aWRl3/vcbIcrflDUPrQlw/2OjQqIgatWVj9IrwIp7GOIaskmpCeWZOIFk6LJrQkbxLb4EWp+jPeVv03SQJe3gY80ru8fGPjgVeSqv+G1plfLVP/SF1P8RSVtxpoOYhzWVUgTFz4jyaHy6pQqhVjXJvHI3glKFZagia1QqkcOmL0x/15tAZLPQ081DsoSqiQmBWBp/omMD9dIrajhKHOo/UZispZyapt59HX/PEx/MNPfqewqgIXED4WOZMwQCSVMOI2cEFZ/QJNs4xarwSL8gX8EeTen1WrGRTYHP1lfEFRkIquvg5eRbnOURGyEs5EYg5OQiYTSaA4VTE4+HCelWA+B14N5u860t4Syt95ez48y+oHQtKAXqdlfGGfQe+O+hktqHx86ta9mx99cOrWm6d/7/58KZ0q5j/x+8VozqFR23LRouzjRfTjq0g28oeIkpnJoszImlRSti2PEsFktRUB+GrwxN1Bf483N3V7ycJ4e/w07bN4odLpq6gtKy96LWidNFrnBbROC8KSETMod0oQJw9SXE4pFQ7IRn5BDmB2yYMX3NXjtPovpViqrz8+bVKo7R7RorY6ljcv3mntLY/ueXJBpWOfeMKsVX83nN6bLO3LtgGPXXApXfbBG5dnwsXxju4dGsMI0gHlOngF6VDGsVXjLCI6QhIeqdMMiBL5tjxGMZuRkUXpoVr7ZcYLwCt3bZ+7TYz7Qy7reMKcHCqVk1qNyqQVrPawwadPRqIT5g533HPNZ28c6B1a/PLE4W29HrfX4fQrxAgzPje6FahULIRAaeOT/e5ExO9ts7ndjsLEVCG/JBgxOUHKgzD7OfwS5aK8SGOJx2DzYdkHXUDMghLAaJnSkokGP98nZG3lh8u2rFD9FWhnVBq9at9X3/nSrNeMOMFn9s76wEusQmuqdoDD1Y9j36YRHtgnxXV41HwR8RL8sxnkcvKJqZNIw6RfBggQnFIRHiECXvjotqkDXgvv9pkNptlkf8R9YHNSo9YqaKWC1nb39/XqWaMU75ZakunPH76w/e6Dgy7BaXcIMNvR1bL4rR693gjCakZDK10upV5h97gshr/LHKcIBnWf+alUTcsEbKiJNU6H5eDHj1SZCC44AGcC1h+8cudsZKA3+qVrK73OmJB1lttGLOH43Myt2yz+7pZOm1WXCfdKf790zDd439Kjz5QTNqfTmoh2hYc/tvPYt58Nl0qj4aWkJ1mp5fPr4BL8IvIHykJUJzwAsxOqDWWAYWKkfBoFErikOT2aTBj6i7teWmRYg1t0Q7bYCU/a2W/v+2VroT2262vTJ1Ta6Rd1rKZeg4iNDkqikmu+yMsELduK6o0tQvKFt/pAzpJJAmynDVfZV+6anzn2iRaHLnzDxNDB20XLnrHpjuzm7G0fsg3fle/e0VPe9djU8tLYkQG32ZAo9O/e1LuraNWX38sMdSRaA2Md1Rc7UxPu+Uxp88B6zMNUBlu6IVMkJLFEdwFcG2XlvHQEwR4g/COr85n9vWMI7iCr8Axao6mgw9jdNnPr8XlPvhBzLZr9p8I8gr9SBsOPPFtsw5A77XRIF+48MGCLFI7t3HXMlBy+zbCYmqyEdy+FSzcinQjfgscpDWWiKEsjH40buFf+Cz5s8/ttdp+v+td1LoakHjxHvu9f/33L+tpQt6i2GG20CWZaqVZXWF7weO0dawvfFOiWq4VmeL8GPyIyCtRH4EVwM5JBGF+SyxZKlvoVvCiufFIMwd2h0MqjIRFeJ4JdPG/7qc3Gy9+nwDn4HYR7QuZ5DwG/C+AinSUdBtO2vgWzwZr54FzHRxRaw3ZWp1OaWyM7wh6702px75q8ZtsDxBhYKDfNf9TLaY3uh3ewprCLd4WtFa/J6hQc2anplddkpCAVQIr8BPVDISqKGD+CyKYti2JNxO612Xn8ADVClhLE1d3iBUUQYgH8yYndLZuUuepKUhEMH6qEM63sDxW0xqhR0D9Te1tM1f+9+WZEQl6l/uxxrcbsB5iSQMgHN2kZpYpRr9xv4/4dDFafJvnQhHR4FJ5D+RCsZ70o1x3M0FmxTsM0IWyEEvrMgq9JOUKN5qMndlcP2nOCkLODm5ZO7BjdcvjOod67A9wj/dud4xN/wwVOjix8fWgZsSLUI37c25ef4zR7gCYza3Bdmqu+VdhXfctl2C7nPPwK8kl6A0d6oT2Pogf5B+cBylNcKBqNLmmp4FfO3vzgyy8/GOxqE8xptVEBGLWKgcB0YspTGuuIRjvGyp7Z+1QAVQLDL3b+4Q8+ffZlk583jepY7nWdRqlQqY/cxsUH7picumMgzt1yQKVU4l4AYfNDhI0ZYUNy8zJkSDuAUZFrM+Jw+MOn766erqNx4ht7is7F7gMBjgtcf0tfHkydPl+H4fu/f3YikDW4Vi66DAsDcv0PI3mPwMcwLwF6gyy7lKAb8GfRPLHmAVQ84SPNK1+ThcKy281Ymoc7TgW4xYGo2x0dWOQCp8tTSR6FTrWKxWuR+J8WetJWg+tXqf7qO/2p6m9cBqEy1FPT4QjSIY50uJLQWmFsdCeyfvCImzE3DRbuCnBbUVFyR/q3+ip+6cT1LTW1WsFzhe4Mkvh+a1/1nb7Wt70WuVISbWq+v4R834d8H5DZHnX24AMFAeZIogeekuClVhurjult+/sf+sHVQ6Kpc6LkYR3tTSLqoaQ7K92XDL6vCDFrOPnyp64WHNN39scNwGBvC5SJ+nWfvYZipIh7tjoooml9+1ADydaW5yVsmphdfy/HDnyt5fsYq+r3EFryZeMC5NY/I6gBZfW9eq9Bwolc/fKX9auVS16zHFN4dp1G+rnW6/fb8sF00782Ce1OZ7vQuLhs9bU1U6hm/TNaE2cFWYAxgPUNHpCF4NwgLgH/3OoC2uV7dvhS3oDbXPbHSppYMiAd6+mBp3We6rUD6v7JybzT67MLftrlnewBz/FmLUG4jvElJK9Q74sRTUZohr86yLwJj4sRPC3yaMpE3JmGl9I9O+MF0zdvuwLC4+rmOzo9mzq/+bojkkDWegx2oD107rchrp4HGVbng/hO2f5n1a9J1jUdZ5GO4uW5iwFYwxnFah7Oulf+syb++hOSv+LjAnPp0ahTrainaE2exbvyXy6DNzLU0oRlNCPCvhvJ6NowH0RoPKPQjMmKGiI5MxEp1KSjumV3oW4SAdXQgb67PLWpE5v9rlqnV/8GKEwtwemgtDnYYlIgpR6zalU/+YlSZ35sXcTBDrByj9kGrwcrv+TMbgNWz+A2c+C/kaoMt/IPMK9XNqJkLS/acIygCd1EEjcCalrWlazrCPAkK8kJ/BrWA6WrVl2dI6oNbcJPnCn3Y5xGBQ7gZ1sCwWl4Giuw8mNWw+jg7pX316sFXYyP1qy8A87VnpKajzjmIuIYjdxVqvw4Zv2IxhMQq4g0QzrBi89X//3554Hz+X3J0cmJSdRejkxumhxJgM1nX3rp7KdeeeXeuZMDgcDAybntp8dDofHTpB/BteI4ybMkWbvBlBuJEheNhFKuGZi44PH79v/J+oAE+/fftw+odI5ALrArwJ1J9vrNELw1csMbG4JxecxgDjp8Fr3BVb0k+NIuXMsLqyX4BrIviDKlD+874Ck4oahtqyBL5VIuj63pNrput81e22XKMWQIq22nsI5c0C4JRgOjSrXeTyC5fZaPhrQ20UFDrf7axMj0lumRxI5xXWtwJHbXw2wk5BZd/pTG3l6J6bzOrC9gIIh96uhfBNMppanJy4LBhXvGQqGxe+ePfd5jdUht183sTbYFHZaIPIOg+Rv+mvDpCO4CsAURPCaGI+GrQkrMUthtpCWuWYY6TcRKGGT4a7Wl2TOYFDkNH+u5FWGcOp+sgV1efnRpzJ8QTEqa05gCzpAC6gw/84b7moJZjYG56WsLOn/UFXKWugpRZ+zdDS64bqR9jtd63XGXcMgWao6bwOnmopeDkOb46mpthoEA+WOy1s9EDDQZ8FC1wg0l2UHAk3ASkIJWG0DsiCLwx3JDGo7UXeIDqAjaIDi8cPMQq6cVSjWjVii0GgWgU0O9gcqYSaNxBMuL7WLmyOw1xfW/ooS6xa1d4+n8fLQpY215evMdAyabklHpWdN3TKxa37E5apxrd7ayrMBxNrs/MzlxZP1vaLjZGyey0REDr9GLznZsWwg56xk0LzfJfUINbStuUvmwHzuoBLCHyJAI0bgmwmfcKq6pXGzRGhh1Ih0JVl8IRtIJtUqvTRXLTZzKbYVHwPnevoQeQ7hyiTfqMNw6Iw/1PEdDfaKv96eEWxCumFsSDW6Rx+bLypq80XA5sRzfd92nGwkXLGSck7esJxSLsfqpLZ8+cz24uUH5J8vbms237wbbGlSCdLAiHd5EOvixDgyNC2CERq4iJSeAxjMX2bAkJQi+ucn1C4XGplH+t1bMH1tqmVJe+LYyKB0sS/B078pTOrVGD/c69Viel9FVVeCiTs27QV2WZfV1hQ7J6sB7pgxNooIFqoAskBQYWWKeREvjiRfw67RQ6CbszzIcGgK+/KeMl/kHjZgqlXa2bFF+4X6FVaP2N5E7QH/xs4rm5pvLPngaDKw8bdVw4N0qarTgNS4WK+hT6qsO8Ji+T69jyD1UMqDKgx8YoFUEjZqtR/HxNtK5Wdb5sv0UmvQGG6Ei0MG352PfbaToa4ZA3HTymqY+zV+fYz3OQxgv0Ff93t96ar55FszZCOcLSq76GtAb9WYeqwAoavUoTa8+gPcTlWJWpOn3V2j6PjfhGHH1bfAWeIZqofL1SSNS2xLL5/Jkp4zxwvpGFMpIL0DJ2NYF0ky2sRHwVtHjiylii+GB9h1Dd3Q39wWVnOCMuha6p3M+w5D7zw/sz4Vi4wuZ5hZxM5qYphMBpdjVLQGFw2J1Om3JYsqdTT9e6cor9ig2tQdTJaKbd/Vt6IBfl2cOmTUYyYTRyZswKzBk24WMqzb5VAA67t2+JR1yO9MP5bdsPT7rbuobTbAKbbR3uNn/o10nhrrglpDFx1a/Wzq5OJ/oKRmtf49m065yK/ZTAGFxDmERJmyL5/PGvhQZ1cP1eiBPWuCcWpAqka0nj+/s2+qyWlxN0Up+ZqEQGzIqoFJluEXhjEjOk9+6N9+C9xoiatfSdXv3JFNaFS3zO5IH3cg+G5l5mbWNAHtWNhLJimRl3qhvcyAb3Wfz01uP37NjshWZ6UyfpTUxbB6ydTjOsZXKRxd3niz+EzZz5XMhy8L3DFpdsdy6DZlb4eX9vZqdXryvK9eVdVtwIpEjl0V5tIAMOGcND8YWT925sG1RY67+LNA0yDqTMR3NGvpAb4oT4376Fm00ETi1tOPU4S2cam9bl9KUlNgPz29ifSM5OQ/QjA+WUQ2Iru3uEEzRUMVL8phABn9EVFI2XTvYWRasFsHJccH2W25yZKwfCU1lhMnJpXI0C2+zCoLFp1UNVQ75qt+WOUovgl7fkcH5GIp2z2obuAT+DkV2hZpYJzPAyD6NqOS48dKNvY28fOZS7wHI3p4MftqeaQaB2vlKDlwy6zhOX4w2LQ3kUganTZ0p5vtynlM7dp1IR5s5HZ1YGqjEE2WX6+tb407LwvOf2e6NmZg9w4MfAp/gjBbWrA7Gs51zPmsKFVqaC3T6C8Oj2UBW7ynyTWa6udg5F0z6f/NrydVx6/ubrD6bJtZZ2xuXkBMlFDfJxgyAaCRDgETK1dpe0hPgIEo3shZ3/tKk1DyYEYayM0JmKoAvURCBtqXxgFVtdcoI/scNW0a+LurlG8iQhPnTbU2O7MAznAHKsYsyklqBD6DcdFJU+jKRSIv6DtFKa0dzb0YQUmf4VLsv4fMlZCfpQma06gNeA9yMH/rQmquvrqapH6M1XdhC+4adYYaEZG39TG3tH2/qTPQ7eavT7/A4/DqFu/BZsumU6ltoDvEo5QJanbdVNIH7sAnfqMkBeFcb6hB+LQS/2k4THqHSfGS9GfnG3GQADN7ZhTpP1/UBx1JFyNx4I0HufsG1HJGS01s+7oh4kWU2TjyR23sZdtItNjQt4EcwM/wd0Yh1wPOiknBbw4fr0APK4WhsgEgQMuuWWjkr6hsxQHeDv6KGN8RA+PIguNKzmhw+TTbW10cH3S1HhxjNzAqZSSk+UL+uh8oDwDyeP2pp2zPms2sb8ZLPbR5tBMzGO8BivXdHO3buDDpzQ7UAqsUw0v8DxTD/u2P4AdC696oxLCuxtNQIYZkDf444kMXzz/pTiHpRqdMt+PlHdy6ewv9mHZHJ8YjgCo+PRYV/WjxxYnHpY/cubIv39DTtWIpXeuJyzSpBB7LLR06c5NOlCH8Fk2RZ5BChtoGNi5cvI5ixURh5IfNQx5aFj8xOdQQHHfpYd9c8qmC81fylhlkvi0YfWy6f3LHppnD0Nd7adbRWV0rQjXQQML8rG+iulZd8GgcZnc00KhouK7K/89MLd9+zbS4TxvIHcsWF6faxOPcsEfjVgZOI3+bnV14U9aDlY8lth2e2HB111XIKHEUyY2s8i3g0GzDQ2KHrmH3NoTZw1GUyOzUM9LcKnKJO7s70x0FkVoyBO3iHw8qaGQD88ULY9MkGwRNvXnNNqF6zDyM/DtU7BNXaWIHHBHIwidIYdYOwPlTkNs4UtHyATk6ND0/P9LXo1BYVnhV0NFBx3mxQ50x6Jb7fL8aMobGFQkTF6uvjBqKNtnAps6dSsMeOFTbFdToryx3jWMQvzkjKqTBEvSG3Sa9iLJzUkhpzK1kdy+0yahnWKMSEuLfdorPY46SvfRvMwv2Ue+20CRVBVIXTPCmOpFVMg9lcyh5rds4lSg7gfuCBXk/YfUdhgnW3R4FVAKXpxDd81TeuK2RzJMdQf4FzTCLdhaQi4yNhBMwyZCRo3CHGlh4C0a2DER9nLZxVGkPeXB9qLpxpdvNml6NY/GSfA1HZUw6P1zDb+wThtUdCllr//zbUgMcpX60nv7wZt8hESuYOTbfzHrWfeVDriwYriUpeefgaZSLWkvSCM7BU/YbFAvY4SW/t1Girv3rfoInSjT4ay3kRyemU5ZB5mAVyitUk1gSWAemaLlMhXNPhxS77LTStYTj1/n1qjtFCxXGNMxwo3dCh2rlJaVTp0v7OQqKg3Ltd6fZEM1IUnKHL1W8yKFOBo/oLnlFpwJKDDGQuFVd9/6cGRTPD4Vu3Sl99810da/aA2p6oASWkUdZZlA8xcLGRT81rlFAmO7i1OoNP2Wpwyft1VjzrpqFxd6JsMlkmKyznTqXaPWOZpf2SL707UzCaNRMVgzt60DOcnWVOaP0OJz7k4DRq295xg9nkwgD6SkePtxjJ6YdBq9LtmTQZnQpy23Et1PyxXg8JvmmoRrpmkeKo6aM3HM3KFbgMNgItH1bXcFV7Ygb1U62bI3mHxewQWLvNHvIYWeFRtS8U6bwxrzq4V03b+FhL3A/OAKfdUl04lApY7XaLzQo4PuZzFcERi5Z4nzFU333TqFcZ+LU5Cp9LP4L0c5OzfHlGIQflaKJuw0MKg3db8+ARU/UrrIbRAOfB6bTSzYJhfMqrqf7H0bms+lvfAoyaYcFNHdNfBJBRqtjq/f2Lck1CvCmBp/F+fz1f5KZgY3m+UuI0LSQlR/ZwqifrEDJnFcaQL9uPLoQMyh79UUkmbAlcCFtR3vyl4PIbZvr/jNz8Udgq78elUU/+PxSDKyJVa3Dqbzjlc/I2CaYsHDnyaU/j3Scn6XqeODS7bartgEJriER4oDEHhMQ8b/Hj8zjwMjlmWznfMTrVXo4unPShoce7sJzzdlhsfpPB77MLVqeA38UidRGeJnVRwhPr/1cb602erX4ceOVa+ZQ76vFEXyA/r1Q3wVv4Ezf53I3nVaqqoIG9/v6Lgn5vRUFXBeKjEXAPdR4+hd8LMTfqjNwiqniZ2c97BF+opZlRqpUTEEIjL2D7O1K5pTh+z2Z4DwegnrxLA+6lXkI9mB2fzNbOV1BA10oIsfilDet48MpwbMNCCNcO5D+kFzhN9EpeXTPbB9b44gexwX8Fq3AsbQX3gs8Su5JXt8x+lecv/7bFFz4QCL+tG9YF5So8hWa+Iu5Uf+fUZ9rQKNWHQDKHSbXv4G9nM/BUbSwMtd96FHcO4cmM8IqQkdvmyclTM5ukZCmW+R9/vGPxVdp51Vmx+jeNbqrXd2hkLhaLmQb6l4/wOtYjyvrTBaT/NLV9Y6dOzhzXK/v/6M6vdUQ1K9pyZfJ9EJaNQh/SBVl5S+BzQmYk0jS40aAtm4NDu4pjLt7iVtrMvnxu6Tajwni3kKnu8l+361W3bIPeb1tv0eXW5XLFZjQY8WqzBXr7B5Yh0FQfFPXwgLdvcPm4zhcj5+B1m4vUKDW33urLbby6yYgf5EFmzWsJkAQZZXbN/XWDX7mSuTNbxcredIAFIWThgj/evvNVhU4PAMjY7R6pc/EKBl5ubDqtV6jwxARvxS49ZNMrrGq1+C9f0DtdOvVg3zLmAByfbmRr+Xd4F9tWS9ur+RK6ZTOIA+uGDCxlr+S0K3rLdyg9Friig+S8rusaxe/HXN0rdTfUXECvg7yu4jqkT26tofzQVYH1HRqer0EJzqxDj/o//Xz9cwAAeJydUstO20AUPXGAPhaIRT/g7gAVO5FhASxBBIFCkEhAqDuTjB+K8UQeRwF1120/oWpVqfv+Sj+om56ZDJUKu3rkmXNf5947cwGs4ydaWH6/Wt89buF18MnjAK+CLx638T747fEKNtpnHq/ibXvm8Rr1X+nZWnlDom8uyuIWNoKZxwHWg88et3ET/PB4BdLe9HgV79ofPF6j/iOOoTHDI2oUyJCjgSBGl2sXO3/xAdERIu7nSDDGlFELGJ4FdQkqTJzNely4feA8lr7WZ4tyxfMOilkSlEgpabePqLvlOSejYiWGOOOpqZlR2naMI8YVzragpSaveLlmVMk/cfETx2QrslzCjnIiwSmuMSSLoOdqsZ32yTCmtXKRgpBLuKxkZXsfDWs4RIerIW/GWmz2jHLqeQxx+Q+T1TzlC5nvkvdhUR9nvPETSkPuEWMfXB2p4xRXc8M+CvIZ2nGsZ491keWNxN3u7o7dD+QokvNkPNULMy0kqSZyHslFJAO9oLaQLV3JncqTMhWdykjdytyo2khW6/nMbEcyygsjC11PhWetSpUYNZF5NVG1NLmS0+vhSHq6aqRfjFVllIShiFFK8qaZHXY6zTyLdJ11UvqYTrl0Mh0bF/YuB6Owf3Z8MhieRM1DI6muZaKapCgNG+pzPq7Y7b2bmgMqLq70fVIRXbH5jFdc0lRTVNm8TAienqvnL165sYw4lsKHkReU4TOmpVdM/T5/O9Ax9kjL4nvsQkkcdeVQngoJfWKq4nA/jLvx3suyn+fA82jgxs9y4SfflosbPkTB9+nK/3D+AT6T1hZ4nG3ON04DUQAE0PfXgEEggslRCJmckw2IBuMl5xwPQENHxyHo6LkC6XiwQlsy0uh1oxH5y8+LIf/lNmkQyahWo1adeg0aNclp1qJVm3YdOnXp1qNXn34D8gaTxWEjRo0ZN2HSlGkzZs2Zt2BRQdGSZStWlZTFNmzasm3Hrj37Dhw6cuzEqTPnLly6cu0m+XXn3qs3z959+fbhM0QhEypCZajKPj0+xIXiWmopdT21nBr/AvTeIZwAAAAAAf//AAJ4nGNgZGBg4AFiMSBmYmAEwiggZgHzGAAHfACJeJxjYGBgZACC28l/zUD0o0v+a2A0AFISB9EAAA==")
+ format("woff");
+}
+.ff7 {
+ font-family: ff7;
+ line-height: 1;
+ font-style: normal;
+ font-weight: normal;
+ visibility: visible;
+}
diff --git a/src/hooks/useKeyboardShortcut.ts b/src/hooks/useKeyboardShortcut.ts
new file mode 100644
index 00000000..21718b69
--- /dev/null
+++ b/src/hooks/useKeyboardShortcut.ts
@@ -0,0 +1,63 @@
+import { useEffect } from "react";
+
+interface UseKeyboardShortcutOptions {
+ key: string;
+ onKeyPress: () => void;
+ enabled?: boolean;
+ preventDefault?: boolean;
+ requiresNoModifiers?: boolean;
+ ignoreWhenTyping?: boolean;
+}
+
+export function useKeyboardShortcut({
+ key,
+ onKeyPress,
+ enabled = true,
+ preventDefault = true,
+ requiresNoModifiers = true,
+ ignoreWhenTyping = true,
+}: UseKeyboardShortcutOptions) {
+ useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ // Check if user is typing in an input or textarea
+ if (ignoreWhenTyping) {
+ const activeElement = document.activeElement;
+ const isTyping =
+ activeElement?.tagName === "INPUT" ||
+ activeElement?.tagName === "TEXTAREA" ||
+ activeElement?.getAttribute("contenteditable") === "true";
+
+ if (isTyping) {
+ return;
+ }
+ }
+
+ // Check if the pressed key matches
+ if (event.key.toLowerCase() !== key.toLowerCase()) {
+ return;
+ }
+
+ // Check modifiers if required
+ if (requiresNoModifiers) {
+ if (event.ctrlKey || event.metaKey || event.altKey) {
+ return;
+ }
+ }
+
+ // Prevent default behavior if needed
+ if (preventDefault) {
+ event.preventDefault();
+ }
+
+ // Execute the callback
+ onKeyPress();
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [key, onKeyPress, enabled, preventDefault, requiresNoModifiers, ignoreWhenTyping]);
+}
diff --git a/src/hooks/useLatestCallback.ts b/src/hooks/useLatestCallback.ts
new file mode 100644
index 00000000..9a32757b
--- /dev/null
+++ b/src/hooks/useLatestCallback.ts
@@ -0,0 +1,8 @@
+import { type RefObject, useRef } from "react";
+
+// biome-ignore lint/suspicious/noExplicitAny: generic callback type requires any
+export function useLatestCallback any>(callback: T): RefObject {
+ const ref = useRef(callback);
+ ref.current = callback;
+ return ref;
+}
diff --git a/src/hooks/usePrevious.ts b/src/hooks/usePrevious.ts
index 0eff9fc9..f943727c 100644
--- a/src/hooks/usePrevious.ts
+++ b/src/hooks/usePrevious.ts
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
// biome-ignore lint/suspicious/noExplicitAny: this hook can apply to any value
export function usePrevious(value: any) {
- const ref = useRef();
+ const ref = useRef(undefined);
useEffect(() => {
ref.current = value;
diff --git a/src/hooks/useScrollSync.spec.tsx b/src/hooks/useScrollSync.spec.tsx
new file mode 100644
index 00000000..3bf02d4b
--- /dev/null
+++ b/src/hooks/useScrollSync.spec.tsx
@@ -0,0 +1,232 @@
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { useScrollSyncConsumer, useScrollSyncPublisher } from "./useScrollSync";
+
+function createMockViewer(opts: { pagesCount?: number; scrollTop?: number } = {}) {
+ const { pagesCount = 3, scrollTop = 0 } = opts;
+ const container = document.createElement("div");
+ Object.defineProperty(container, "scrollTop", {
+ get: () => scrollTop,
+ set: vi.fn(),
+ configurable: true,
+ });
+ Object.defineProperty(container, "scrollHeight", { value: 3000, configurable: true });
+ Object.defineProperty(container, "clientHeight", { value: 500, configurable: true });
+
+ const pages = Array.from({ length: pagesCount }, (_, i) => ({
+ div: {
+ offsetTop: i * 1000,
+ offsetHeight: 1000,
+ },
+ }));
+
+ return {
+ container,
+ pagesCount,
+ getPageView: (i: number) => pages[i],
+ };
+}
+
+describe("useScrollSyncPublisher", () => {
+ it("returns a syncing ref", () => {
+ const setTarget = vi.fn();
+ const viewer = createMockViewer();
+ const { result } = renderHook(() => useScrollSyncPublisher(viewer as never, "left", true, setTarget));
+
+ expect(result.current).toHaveProperty("current");
+ expect(result.current.current).toBe(false);
+ });
+
+ it("attaches scroll listener when enabled", () => {
+ const setTarget = vi.fn();
+ const viewer = createMockViewer();
+ const addSpy = vi.spyOn(viewer.container, "addEventListener");
+
+ renderHook(() => useScrollSyncPublisher(viewer as never, "left", true, setTarget));
+
+ expect(addSpy).toHaveBeenCalledWith("scroll", expect.any(Function), { passive: true });
+ });
+
+ it("does not attach listener when disabled", () => {
+ const setTarget = vi.fn();
+ const viewer = createMockViewer();
+ const addSpy = vi.spyOn(viewer.container, "addEventListener");
+
+ renderHook(() => useScrollSyncPublisher(viewer as never, "left", false, setTarget));
+
+ expect(addSpy).not.toHaveBeenCalled();
+ });
+
+ it("publishes scroll position on scroll event", () => {
+ const setTarget = vi.fn();
+ const viewer = createMockViewer({ scrollTop: 1500 });
+
+ renderHook(() => useScrollSyncPublisher(viewer as never, "left", true, setTarget));
+
+ viewer.container.dispatchEvent(new Event("scroll"));
+
+ expect(setTarget).toHaveBeenCalledWith({
+ page: 2,
+ yFraction: 0.5,
+ sourcePane: "left",
+ });
+ });
+
+ it("suppresses scroll events when isSyncing is true", () => {
+ const setTarget = vi.fn();
+ const viewer = createMockViewer({ scrollTop: 500 });
+
+ const { result } = renderHook(() => useScrollSyncPublisher(viewer as never, "left", true, setTarget));
+
+ result.current.current = true;
+ viewer.container.dispatchEvent(new Event("scroll"));
+
+ expect(setTarget).not.toHaveBeenCalled();
+ });
+
+ it("removes listener on cleanup", () => {
+ const setTarget = vi.fn();
+ const viewer = createMockViewer();
+ const removeSpy = vi.spyOn(viewer.container, "removeEventListener");
+
+ const { unmount } = renderHook(() => useScrollSyncPublisher(viewer as never, "left", true, setTarget));
+
+ unmount();
+
+ expect(removeSpy).toHaveBeenCalledWith("scroll", expect.any(Function));
+ });
+});
+
+describe("useScrollSyncConsumer", () => {
+ it("ignores targets from the same pane", () => {
+ const viewer = createMockViewer();
+ const scrollTopSetter = vi.fn();
+ Object.defineProperty(viewer.container, "scrollTop", {
+ get: () => 0,
+ set: scrollTopSetter,
+ configurable: true,
+ });
+ const isSyncingRef = { current: false };
+
+ renderHook(() =>
+ useScrollSyncConsumer(
+ viewer as never,
+ "left",
+ true,
+ { page: 2, yFraction: 0.5, sourcePane: "left" },
+ isSyncingRef,
+ ),
+ );
+
+ expect(scrollTopSetter).not.toHaveBeenCalled();
+ });
+
+ it("scrolls to target position from other pane", () => {
+ const viewer = createMockViewer();
+ const scrollTopSetter = vi.fn();
+ Object.defineProperty(viewer.container, "scrollTop", {
+ get: () => 0,
+ set: scrollTopSetter,
+ configurable: true,
+ });
+ const isSyncingRef = { current: false };
+
+ renderHook(() =>
+ useScrollSyncConsumer(
+ viewer as never,
+ "left",
+ true,
+ { page: 2, yFraction: 0.5, sourcePane: "right" },
+ isSyncingRef,
+ ),
+ );
+
+ // page 2 → index 1 → offsetTop=1000, offsetHeight=1000
+ // targetScroll = 1000 + 0.5 * 1000 = 1500
+ expect(scrollTopSetter).toHaveBeenCalledWith(1500);
+ });
+
+ it("sets isSyncing flag to prevent feedback loops", () => {
+ const viewer = createMockViewer();
+ Object.defineProperty(viewer.container, "scrollTop", {
+ get: () => 0,
+ set: vi.fn(),
+ configurable: true,
+ });
+ const isSyncingRef = { current: false };
+
+ renderHook(() =>
+ useScrollSyncConsumer(
+ viewer as never,
+ "left",
+ true,
+ { page: 1, yFraction: 0, sourcePane: "right" },
+ isSyncingRef,
+ ),
+ );
+
+ // The flag should be set synchronously
+ expect(isSyncingRef.current).toBe(true);
+ });
+
+ it("does nothing when disabled", () => {
+ const viewer = createMockViewer();
+ const scrollTopSetter = vi.fn();
+ Object.defineProperty(viewer.container, "scrollTop", {
+ get: () => 0,
+ set: scrollTopSetter,
+ configurable: true,
+ });
+ const isSyncingRef = { current: false };
+
+ renderHook(() =>
+ useScrollSyncConsumer(
+ viewer as never,
+ "left",
+ false,
+ { page: 2, yFraction: 0.5, sourcePane: "right" },
+ isSyncingRef,
+ ),
+ );
+
+ expect(scrollTopSetter).not.toHaveBeenCalled();
+ });
+
+ it("does nothing when target is null", () => {
+ const viewer = createMockViewer();
+ const scrollTopSetter = vi.fn();
+ Object.defineProperty(viewer.container, "scrollTop", {
+ get: () => 0,
+ set: scrollTopSetter,
+ configurable: true,
+ });
+ const isSyncingRef = { current: false };
+
+ renderHook(() => useScrollSyncConsumer(viewer as never, "left", true, null, isSyncingRef));
+
+ expect(scrollTopSetter).not.toHaveBeenCalled();
+ });
+
+ it("ignores out-of-range page indices", () => {
+ const viewer = createMockViewer({ pagesCount: 3 });
+ const scrollTopSetter = vi.fn();
+ Object.defineProperty(viewer.container, "scrollTop", {
+ get: () => 0,
+ set: scrollTopSetter,
+ configurable: true,
+ });
+ const isSyncingRef = { current: false };
+
+ renderHook(() =>
+ useScrollSyncConsumer(
+ viewer as never,
+ "left",
+ true,
+ { page: 10, yFraction: 0.5, sourcePane: "right" },
+ isSyncingRef,
+ ),
+ );
+
+ expect(scrollTopSetter).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/hooks/useScrollSync.ts b/src/hooks/useScrollSync.ts
new file mode 100644
index 00000000..a8f41cc3
--- /dev/null
+++ b/src/hooks/useScrollSync.ts
@@ -0,0 +1,99 @@
+import type * as pdfJsViewer from "pdfjs-dist/web/pdf_viewer.mjs";
+import { useEffect, useRef } from "react";
+import type { ScrollSyncTarget } from "../components/SplitScreenProvider/SplitScreenProvider";
+
+/**
+ * Publishes scroll position from a PDF viewer as a normalized target.
+ */
+export function useScrollSyncPublisher(
+ viewer: pdfJsViewer.PDFViewer | undefined,
+ paneId: "left" | "right",
+ isEnabled: boolean,
+ setScrollSyncTarget: (target: ScrollSyncTarget) => void,
+) {
+ // Guard against re-entrant scroll events caused by the consumer
+ const isSyncing = useRef(false);
+
+ useEffect(() => {
+ if (!viewer?.container || !isEnabled) return;
+
+ const container = viewer.container;
+
+ const handleScroll = () => {
+ if (isSyncing.current) return;
+
+ const scrollTop = container.scrollTop;
+ const scrollHeight = container.scrollHeight;
+ const clientHeight = container.clientHeight;
+
+ if (scrollHeight <= clientHeight) return;
+
+ // Find the page that's at the top of the viewport
+ let currentPage = 1;
+ let pageTop = 0;
+ let pageHeight = 0;
+
+ for (let i = 0; i < viewer.pagesCount; i++) {
+ const pageView = viewer.getPageView(i);
+ if (!pageView?.div) continue;
+
+ const div = pageView.div;
+ pageTop = div.offsetTop;
+ pageHeight = div.offsetHeight;
+
+ if (pageTop + pageHeight > scrollTop) {
+ currentPage = i + 1;
+ break;
+ }
+ }
+
+ const yFraction = pageHeight > 0 ? (scrollTop - pageTop) / pageHeight : 0;
+
+ setScrollSyncTarget({
+ page: currentPage,
+ yFraction,
+ sourcePane: paneId,
+ });
+ };
+
+ container.addEventListener("scroll", handleScroll, { passive: true });
+ return () => container.removeEventListener("scroll", handleScroll);
+ }, [viewer, isEnabled, paneId, setScrollSyncTarget]);
+
+ return isSyncing;
+}
+
+/**
+ * Consumes scroll sync target and scrolls the viewer to the matching position.
+ * Ignores targets that originated from this pane.
+ */
+export function useScrollSyncConsumer(
+ viewer: pdfJsViewer.PDFViewer | undefined,
+ paneId: "left" | "right",
+ isEnabled: boolean,
+ scrollSyncTarget: ScrollSyncTarget,
+ isSyncingRef: React.MutableRefObject,
+) {
+ useEffect(() => {
+ if (!isEnabled || !viewer?.container || !scrollSyncTarget) return;
+ // Only react to targets from the *other* pane
+ if (scrollSyncTarget.sourcePane === paneId) return;
+
+ const pageIndex = scrollSyncTarget.page - 1;
+ if (pageIndex < 0 || pageIndex >= viewer.pagesCount) return;
+
+ const pageView = viewer.getPageView(pageIndex);
+ if (!pageView?.div) return;
+
+ const pageTop = pageView.div.offsetTop;
+ const pageHeight = pageView.div.offsetHeight;
+ const targetScroll = pageTop + scrollSyncTarget.yFraction * pageHeight;
+
+ // Suppress our own scroll handler while we programmatically scroll
+ isSyncingRef.current = true;
+ viewer.container.scrollTop = targetScroll;
+ requestAnimationFrame(() => {
+ isSyncingRef.current = false;
+ });
+ }, [isEnabled, viewer, paneId, scrollSyncTarget, isSyncingRef]);
+}
diff --git a/src/hooks/useStateSyncecWthLocalStorage.ts b/src/hooks/useStateSyncecWthLocalStorage.ts
new file mode 100644
index 00000000..5efb3bba
--- /dev/null
+++ b/src/hooks/useStateSyncecWthLocalStorage.ts
@@ -0,0 +1,23 @@
+import { useCallback, useState } from "react";
+
+export function useStateSyncedWithLocalStorage(key: string, initialValue: T): [T, (value: T) => void] {
+ const [state, setState] = useState(() => {
+ const storedValue = localStorage.getItem(key);
+ try {
+ return storedValue ? JSON.parse(storedValue) : initialValue;
+ } catch (error) {
+ console.error(`Error parsing localStorage item "${key}":`, error);
+ return initialValue;
+ }
+ });
+
+ const setSyncedState = useCallback(
+ (value: T) => {
+ setState(value);
+ localStorage.setItem(key, JSON.stringify(value));
+ },
+ [key],
+ );
+
+ return [state, setSyncedState];
+}
diff --git a/src/index.css b/src/index.css
index 867f34df..fdcb5da0 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,10 +1,17 @@
:root {
- font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ color-scheme: light;
+}
+
+:root.dark {
+ color-scheme: dark;
+}
+
+:root {
+ font-family: Poppins, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
- font-size: 12px;
+ font-size: 14px;
- color-scheme: light dark;
color: light-dark(#213547, rgba(200, 200, 200, 0.87));
background-color: light-dark(#fff, #242424);
@@ -17,19 +24,19 @@
height: 100%;
}
-a {
+.default-link {
font-weight: 500;
color: light-dark(#59b7fe, #7eb6e0);
text-decoration: inherit;
}
-a:hover {
+.default-link:hover {
color: light-dark(#81c5f9, #3875a3);
}
body {
margin: 0;
- min-width: 100vw;
- min-height: 100vh;
+ min-width: 100svw;
+ min-height: 100svh;
overflow: hidden;
}
@@ -38,25 +45,28 @@ h1 {
line-height: 1.1;
}
-button {
+.default-button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
- background-color: light-dark(#f9f9f9, #1a1a1a);
+ background-color: light-dark(#e7e7e7, #1a1a1a);
cursor: pointer;
transition: border-color 0.25s;
}
-button:hover {
+.default-button:disabled {
+ opacity: 0.5;
+}
+.default-button:hover:not(:disabled) {
border-color: #646cff;
}
-button:focus,
-button:focus-visible {
+.default-button:focus,
+.default-button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
-button.toggled {
+.default-button.toggled {
border-color: #fff874;
}
diff --git a/src/main.tsx b/src/main.tsx
index 70d2042e..bad03b13 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,19 +1,37 @@
import React from "react";
import ReactDOM from "react-dom/client";
-import Modal from "react-modal";
-import { App } from "./App.tsx";
-import "./index.css";
-import "./font.css";
import { LocationProvider } from "./components/LocationProvider/LocationProvider.tsx";
import { MetadataProvider } from "./components/MetadataProvider/MetadataProvider.tsx";
+import { migrateLocalStorageKeys } from "./config/localStorageMigration";
+import { initDevTools } from "./devtools/initDevTools";
+import { installReadableStreamAsyncIteratorPolyfill } from "./utils/readableStreamAsyncIteratorPolyfill";
+
+// pdf.js text extraction uses ReadableStream async iteration, which is missing in some Safari versions.
+// Install the polyfill before anything renders so search works there (issue #446).
+installReadableStreamAsyncIteratorPolyfill();
+
+import "./tailwind.css";
+import "./variables.css";
+import "./index.css";
+import "./font.css";
+
+import { App } from "./App.tsx";
+import { VersionProvider } from "./components/LocationProvider/VersionProvider.tsx";
-Modal.setAppElement("#root");
+try {
+ migrateLocalStorageKeys();
+} catch (e) {
+ console.warn("Local storage migration skipped", e);
+}
+initDevTools();
ReactDOM.createRoot(document.getElementById("root") ?? document.body).render(
-
+
+
+
,
diff --git a/src/tailwind.css b/src/tailwind.css
new file mode 100644
index 00000000..58f62799
--- /dev/null
+++ b/src/tailwind.css
@@ -0,0 +1,18 @@
+@import "tailwindcss/preflight" layer(base);
+@import "@fluffylabs/shared-ui/theme.css";
+@import "@fluffylabs/shared-ui/style.css";
+@import "tailwindcss/theme" layer(theme);
+@import "tailwindcss/utilities" layer(utilities);
+
+@theme {
+ --animate-fade-in: fade-in 0.75s ease-in forwards;
+
+ @keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+}
diff --git a/src/utils/readableStreamAsyncIteratorPolyfill.spec.ts b/src/utils/readableStreamAsyncIteratorPolyfill.spec.ts
new file mode 100644
index 00000000..603a8e0c
--- /dev/null
+++ b/src/utils/readableStreamAsyncIteratorPolyfill.spec.ts
@@ -0,0 +1,75 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { installReadableStreamAsyncIteratorPolyfill } from "./readableStreamAsyncIteratorPolyfill";
+
+const readableStreamPrototype = ReadableStream.prototype as ReadableStream & {
+ values?: (options?: { preventCancel?: boolean }) => AsyncIterableIterator;
+ [Symbol.asyncIterator]?: () => AsyncIterableIterator;
+};
+
+const originalValues = Object.getOwnPropertyDescriptor(readableStreamPrototype, "values");
+const originalAsyncIterator = Object.getOwnPropertyDescriptor(readableStreamPrototype, Symbol.asyncIterator);
+
+function restoreProperty(property: "values" | typeof Symbol.asyncIterator, descriptor: PropertyDescriptor | undefined) {
+ if (descriptor) {
+ Object.defineProperty(readableStreamPrototype, property, descriptor);
+ } else {
+ delete readableStreamPrototype[property];
+ }
+}
+
+async function collectStreamValues(stream: ReadableStream): Promise {
+ const values: T[] = [];
+ for await (const value of stream) {
+ values.push(value);
+ }
+ return values;
+}
+
+describe("installReadableStreamAsyncIteratorPolyfill", () => {
+ afterEach(() => {
+ restoreProperty("values", originalValues);
+ restoreProperty(Symbol.asyncIterator, originalAsyncIterator);
+ });
+
+ it("adds async iteration support when ReadableStream.values and Symbol.asyncIterator are missing", async () => {
+ Reflect.deleteProperty(readableStreamPrototype, "values");
+ Reflect.deleteProperty(readableStreamPrototype, Symbol.asyncIterator);
+
+ installReadableStreamAsyncIteratorPolyfill();
+
+ expect(typeof readableStreamPrototype.values).toBe("function");
+ expect(typeof readableStreamPrototype[Symbol.asyncIterator]).toBe("function");
+ expect(
+ await collectStreamValues(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue("hello");
+ controller.enqueue("safari");
+ controller.close();
+ },
+ }),
+ ),
+ ).toEqual(["hello", "safari"]);
+ });
+
+ it("does not overwrite native implementations", () => {
+ const values = function* values() {};
+ const asyncIterator = function* asyncIterator() {};
+
+ Object.defineProperty(readableStreamPrototype, "values", {
+ configurable: true,
+ writable: true,
+ value: values,
+ });
+ Object.defineProperty(readableStreamPrototype, Symbol.asyncIterator, {
+ configurable: true,
+ writable: true,
+ value: asyncIterator,
+ });
+
+ installReadableStreamAsyncIteratorPolyfill();
+
+ expect(readableStreamPrototype.values).toBe(values);
+ expect(readableStreamPrototype[Symbol.asyncIterator]).toBe(asyncIterator);
+ });
+});
diff --git a/src/utils/readableStreamAsyncIteratorPolyfill.ts b/src/utils/readableStreamAsyncIteratorPolyfill.ts
new file mode 100644
index 00000000..a202aca1
--- /dev/null
+++ b/src/utils/readableStreamAsyncIteratorPolyfill.ts
@@ -0,0 +1,86 @@
+type ReadableStreamIteratorOptions = {
+ preventCancel?: boolean;
+};
+
+type ReadableStreamPrototypeWithIterator = ReadableStream & {
+ values?: (this: ReadableStream, options?: ReadableStreamIteratorOptions) => AsyncIterableIterator;
+ [Symbol.asyncIterator]?: (this: ReadableStream) => AsyncIterableIterator;
+};
+
+function readableStreamValues(
+ this: ReadableStream,
+ options: ReadableStreamIteratorOptions = {},
+): AsyncIterableIterator {
+ const reader = this.getReader();
+ let isDone = false;
+
+ const iterator: AsyncIterableIterator = {
+ async next() {
+ if (isDone) {
+ return { done: true, value: undefined as T };
+ }
+
+ const result = await reader.read();
+ if (result.done) {
+ isDone = true;
+ reader.releaseLock();
+ return { done: true, value: undefined as T };
+ }
+
+ return { done: false, value: result.value };
+ },
+
+ async return(value?: unknown) {
+ if (!isDone) {
+ isDone = true;
+ try {
+ if (!options.preventCancel) {
+ await reader.cancel(value);
+ }
+ } finally {
+ reader.releaseLock();
+ }
+ }
+
+ return { done: true, value: value as T };
+ },
+
+ [Symbol.asyncIterator]() {
+ return this;
+ },
+ };
+
+ return iterator;
+}
+
+/**
+ * Polyfill `ReadableStream` async iteration for Safari versions that expose streams but not
+ * `ReadableStream.prototype[Symbol.asyncIterator]` / `.values()`.
+ *
+ * pdf.js' `PDFPageProxy.getTextContent` consumes text streams with `for await (... of stream)`.
+ * Without this API Safari throws `TypeError: undefined is not a function` and search indexing
+ * fails with "Unable to get text content for page ...".
+ */
+export function installReadableStreamAsyncIteratorPolyfill(): void {
+ if (typeof ReadableStream === "undefined" || typeof Symbol.asyncIterator === "undefined") {
+ return;
+ }
+
+ const prototype = ReadableStream.prototype as ReadableStreamPrototypeWithIterator;
+
+ if (typeof prototype.values !== "function") {
+ Object.defineProperty(prototype, "values", {
+ configurable: true,
+ writable: true,
+ value: readableStreamValues,
+ });
+ }
+
+ if (typeof prototype[Symbol.asyncIterator] !== "function") {
+ Object.defineProperty(prototype, Symbol.asyncIterator, {
+ configurable: true,
+ writable: true,
+ value: prototype.values,
+ });
+ }
+}
diff --git a/src/utils/subtractBorder.ts b/src/utils/subtractBorder.ts
index 1743e45d..79e769f2 100644
--- a/src/utils/subtractBorder.ts
+++ b/src/utils/subtractBorder.ts
@@ -1,9 +1,9 @@
export function subtractBorder(rect: DOMRect, element: HTMLElement): DOMRect {
const borderWidth = {
- left: Number.parseInt(getComputedStyle(element).getPropertyValue("border-left-width")),
- top: Number.parseInt(getComputedStyle(element).getPropertyValue("border-top-width")),
- right: Number.parseInt(getComputedStyle(element).getPropertyValue("border-right-width")),
- bottom: Number.parseInt(getComputedStyle(element).getPropertyValue("border-bottom-width")),
+ left: Number.parseInt(getComputedStyle(element).getPropertyValue("border-left-width"), 10),
+ top: Number.parseInt(getComputedStyle(element).getPropertyValue("border-top-width"), 10),
+ right: Number.parseInt(getComputedStyle(element).getPropertyValue("border-right-width"), 10),
+ bottom: Number.parseInt(getComputedStyle(element).getPropertyValue("border-bottom-width"), 10),
};
return new DOMRect(
diff --git a/src/variables.css b/src/variables.css
new file mode 100644
index 00000000..0f307c2c
--- /dev/null
+++ b/src/variables.css
@@ -0,0 +1,10 @@
+:root {
+ --header-height: 87px;
+ --smaller-font-size: 12px;
+}
+
+@media (max-width: 640px) {
+ :root {
+ --header-height: 77px;
+ }
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index b888dc13..ece532f9 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -1,5 +1,22 @@
///
+interface ImportMetaEnv {
+ readonly VITE_DOC_ID: string;
+ readonly VITE_DOC_NAME: string;
+ readonly VITE_DOC_BASE_PATH: string;
+ readonly VITE_DOC_METADATA_HOST: string;
+ readonly VITE_DOC_READER_DOMAIN: string;
+ readonly VITE_DOC_LEGACY_READER_HOST: string;
+ readonly VITE_DOC_PDF_FILE_PREFIX: string;
+ readonly VITE_DOC_LOCAL_STORAGE_PREFIX: string;
+ readonly VITE_DOC_SEARCH_PLACEHOLDER: string;
+ readonly VITE_DOC_DISCLAIMER_SUBJECT: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
+
declare module "*?prefetch" {
export default string;
}
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 00000000..ed06844f
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,13 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ "node_modules/@fluffylabs/shared-ui/dist/*.{js,ts,jsx,tsx}",
+ "node_modules/@fluffylabs/shared-ui/lib/components/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
diff --git a/tools/links-check/index.ts b/tools/links-check/index.ts
index 18d17b9f..f4191730 100755
--- a/tools/links-check/index.ts
+++ b/tools/links-check/index.ts
@@ -1,12 +1,12 @@
import * as fs from "node:fs";
import * as path from "node:path";
-import { type Metadata, fetchMetadata } from "@fluffylabs/links-metadata";
+import { fetchMetadata, type Metadata } from "@fluffylabs/links-metadata";
import { program } from "commander";
import fastGlob from "fast-glob";
import ignore from "ignore";
import { performMigrations } from "./migrate";
import { generateNotes } from "./notes";
-import { type Report, printReport } from "./report";
+import { printReport, type Report } from "./report";
import { scan } from "./scan";
const TIME_GLOB = "resolving glob patterns";
diff --git a/tools/links-check/notes.ts b/tools/links-check/notes.ts
index 8519214b..733e7c49 100644
--- a/tools/links-check/notes.ts
+++ b/tools/links-check/notes.ts
@@ -1,4 +1,4 @@
-import type { INoteV3, INotesEnvelopeV3, Link } from "@fluffylabs/links-metadata";
+import type { INotesEnvelopeV3, INoteV3, Link } from "@fluffylabs/links-metadata";
import type { Report } from "./report";
export function generateNotes(report: Report, label?: string): INotesEnvelopeV3 {
diff --git a/tools/links-check/package.json b/tools/links-check/package.json
index 734284e4..f14bd0bc 100644
--- a/tools/links-check/package.json
+++ b/tools/links-check/package.json
@@ -1,6 +1,11 @@
{
"name": "@fluffylabs/links-check",
- "version": "0.3.1",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/FluffyLabs/graypaper-reader.git",
+ "directory": "tools/links-check"
+ },
+ "version": "0.4.0",
"description": "Check list of files for Gray Paper Reader links and make sure they are up to date.",
"main": "./dist/index.js",
"bin": {
@@ -15,21 +20,23 @@
"start": "ts-node index.ts",
"prepublish": "npm run build"
},
- "files": ["bin/", "dist/"],
+ "files": [
+ "bin/",
+ "dist/"
+ ],
"author": "Fluffy Labs",
"license": "MPL-2.0",
"devDependencies": {
- "@biomejs/biome": "^1.8.3",
- "@fluffylabs/types": "^1.0.0",
- "@tsconfig/node20": "^20.1.4",
- "@types/node": "^22.13.14",
+ "@biomejs/biome": "^2.5.1",
+ "@tsconfig/node20": "^20.1.9",
+ "@types/node": "^26.0.0",
"ts-node": "^10.9.2",
- "typescript": "^5.6.3"
+ "typescript": "^6.0.2"
},
"dependencies": {
- "@fluffylabs/links-metadata": "^1.2.0",
- "commander": "^13.1.0",
+ "@fluffylabs/links-metadata": "^1.4.0",
+ "commander": "^14.0.3",
"fast-glob": "^3.3.3",
- "ignore": "^7.0.3"
+ "ignore": "^7.0.5"
}
}
diff --git a/tools/links-check/scan.ts b/tools/links-check/scan.ts
index 10aaee94..87470b47 100644
--- a/tools/links-check/scan.ts
+++ b/tools/links-check/scan.ts
@@ -1,8 +1,8 @@
import fs from "node:fs";
import path from "node:path";
-import { SynctexStore, TexStore, findLinks, parseAndMigrateLink } from "@fluffylabs/links-metadata";
import type { Metadata } from "@fluffylabs/links-metadata";
-import { type FileReport, type Path, type Report, printFileReport } from "./report";
+import { findLinks, parseAndMigrateLink, SynctexStore, TexStore } from "@fluffylabs/links-metadata";
+import { type FileReport, type Path, printFileReport, type Report } from "./report";
class Timer {
data = new Map();
diff --git a/tools/matrix-bot/Dockerfile b/tools/matrix-bot/Dockerfile
index d6562bc0..5a988ede 100644
--- a/tools/matrix-bot/Dockerfile
+++ b/tools/matrix-bot/Dockerfile
@@ -1,5 +1,5 @@
# Use the official Node.js image from Docker Hub
-FROM node:22-slim
+FROM node:24-slim
# Set the working directory inside the container
WORKDIR /usr/src/app
diff --git a/tools/matrix-bot/convert-to-notes.ts b/tools/matrix-bot/convert-to-notes.ts
index da54b723..d5ac13ed 100644
--- a/tools/matrix-bot/convert-to-notes.ts
+++ b/tools/matrix-bot/convert-to-notes.ts
@@ -1,6 +1,6 @@
import fs, { readFileSync } from "node:fs";
import path from "node:path";
-import type { INoteV3, INotesEnvelopeV3, Metadata } from "@fluffylabs/links-metadata";
+import type { INotesEnvelopeV3, INoteV3, Metadata } from "@fluffylabs/links-metadata";
import { fetchMetadata, findLinkToLatestVersion } from "@fluffylabs/links-metadata";
/**
diff --git a/tools/matrix-bot/index.ts b/tools/matrix-bot/index.ts
index 559bcb16..e625641f 100644
--- a/tools/matrix-bot/index.ts
+++ b/tools/matrix-bot/index.ts
@@ -1,6 +1,7 @@
import * as dotenv from "dotenv";
import { MessagesLogger } from "./logger";
import { listenToMessages } from "./server";
+
dotenv.config();
const homeserverUrl = "https://matrix.org";
diff --git a/tools/matrix-bot/logger.ts b/tools/matrix-bot/logger.ts
index 74eea41e..b8ebb1bf 100644
--- a/tools/matrix-bot/logger.ts
+++ b/tools/matrix-bot/logger.ts
@@ -44,7 +44,7 @@ export class MessagesLogger {
// read previous log file
try {
messages = JSON.parse(readFileSync(path.resolve(outputFilename), "utf-8"));
- } catch (e) {}
+ } catch (_e) {}
// append the new message and save.
messages.push(newMessage);
diff --git a/tools/matrix-bot/package-lock.json b/tools/matrix-bot/package-lock.json
index 7e034ee3..5e65aeae 100644
--- a/tools/matrix-bot/package-lock.json
+++ b/tools/matrix-bot/package-lock.json
@@ -10,15 +10,15 @@
"license": "MPL-2.0",
"dependencies": {
"@fluffylabs/links-metadata": "^1.3.0",
- "dotenv": "^16.4.5",
- "matrix-js-sdk": "^36.2.0",
- "tsx": "^4.19.3"
+ "dotenv": "^17.3.1",
+ "matrix-js-sdk": "^41.7.0",
+ "tsx": "^4.22.4"
},
"devDependencies": {
- "@biomejs/biome": "^1.8.3",
- "@tsconfig/node20": "^20.1.4",
- "@types/node": "^22.7.5",
- "typescript": "^5.6.3"
+ "@biomejs/biome": "^2.4.13",
+ "@tsconfig/node20": "^20.1.9",
+ "@types/node": "^25.5.2",
+ "typescript": "^6.0.2"
}
},
"node_modules/@babel/runtime": {
@@ -34,11 +34,10 @@
}
},
"node_modules/@biomejs/biome": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz",
- "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==",
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.0.tgz",
+ "integrity": "sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw==",
"dev": true,
- "hasInstallScript": true,
"license": "MIT OR Apache-2.0",
"bin": {
"biome": "bin/biome"
@@ -51,20 +50,20 @@
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
- "@biomejs/cli-darwin-arm64": "1.9.4",
- "@biomejs/cli-darwin-x64": "1.9.4",
- "@biomejs/cli-linux-arm64": "1.9.4",
- "@biomejs/cli-linux-arm64-musl": "1.9.4",
- "@biomejs/cli-linux-x64": "1.9.4",
- "@biomejs/cli-linux-x64-musl": "1.9.4",
- "@biomejs/cli-win32-arm64": "1.9.4",
- "@biomejs/cli-win32-x64": "1.9.4"
+ "@biomejs/cli-darwin-arm64": "2.5.0",
+ "@biomejs/cli-darwin-x64": "2.5.0",
+ "@biomejs/cli-linux-arm64": "2.5.0",
+ "@biomejs/cli-linux-arm64-musl": "2.5.0",
+ "@biomejs/cli-linux-x64": "2.5.0",
+ "@biomejs/cli-linux-x64-musl": "2.5.0",
+ "@biomejs/cli-win32-arm64": "2.5.0",
+ "@biomejs/cli-win32-x64": "2.5.0"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz",
- "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==",
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.0.tgz",
+ "integrity": "sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw==",
"cpu": [
"arm64"
],
@@ -79,9 +78,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz",
- "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==",
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.0.tgz",
+ "integrity": "sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ==",
"cpu": [
"x64"
],
@@ -96,13 +95,16 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz",
- "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==",
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.0.tgz",
+ "integrity": "sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw==",
"cpu": [
"arm64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -113,13 +115,16 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz",
- "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==",
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.0.tgz",
+ "integrity": "sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g==",
"cpu": [
"arm64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -130,13 +135,16 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz",
- "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==",
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.0.tgz",
+ "integrity": "sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA==",
"cpu": [
"x64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -147,13 +155,16 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz",
- "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==",
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.0.tgz",
+ "integrity": "sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg==",
"cpu": [
"x64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -164,9 +175,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz",
- "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==",
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.0.tgz",
+ "integrity": "sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw==",
"cpu": [
"arm64"
],
@@ -181,9 +192,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz",
- "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==",
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.0.tgz",
+ "integrity": "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw==",
"cpu": [
"x64"
],
@@ -198,9 +209,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz",
- "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -214,9 +225,9 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz",
- "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -230,9 +241,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz",
- "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -246,9 +257,9 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz",
- "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -262,9 +273,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz",
- "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -278,9 +289,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz",
- "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -294,9 +305,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz",
- "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -310,9 +321,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz",
- "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -326,9 +337,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz",
- "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -342,9 +353,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz",
- "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -358,9 +369,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz",
- "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -374,9 +385,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz",
- "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -390,9 +401,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz",
- "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -406,9 +417,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz",
- "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -422,9 +433,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz",
- "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -438,9 +449,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz",
- "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -454,9 +465,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz",
- "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -470,9 +481,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz",
- "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -486,9 +497,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz",
- "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -502,9 +513,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz",
- "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -518,9 +529,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz",
- "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -533,10 +544,26 @@
"node": ">=18"
}
},
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/sunos-x64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz",
- "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -550,9 +577,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz",
- "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -566,9 +593,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz",
- "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -582,9 +609,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz",
- "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -607,24 +634,18 @@
}
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
- "version": "13.0.0",
- "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-13.0.0.tgz",
- "integrity": "sha512-2gtpjnxL42sdJAgkwitpMMI4cw7Gcjf5sW0MXoe+OAlXPlxIzyM+06F5JJ8ENvBeHkuV2RqtFIRrh8i90HLsMw==",
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz",
+ "integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
"license": "Apache-2.0",
"engines": {
"node": ">= 18"
}
},
- "node_modules/@matrix-org/olm": {
- "version": "3.2.15",
- "resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz",
- "integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==",
- "license": "Apache-2.0"
- },
"node_modules/@tsconfig/node20": {
- "version": "20.1.4",
- "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz",
- "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==",
+ "version": "20.1.9",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.9.tgz",
+ "integrity": "sha512-IjlTv1RsvnPtUcjTqtVsZExKVq+KQx4g5pCP5tI7rAs6Xesl2qFwSz/tPDBC4JajkL/MlezBu3gPUwqRHl+RIg==",
"dev": true,
"license": "MIT"
},
@@ -635,21 +656,15 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.13.10",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
- "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
+ "version": "25.5.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz",
+ "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~6.20.0"
+ "undici-types": "~7.18.0"
}
},
- "node_modules/@types/retry": {
- "version": "0.12.0",
- "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
- "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
- "license": "MIT"
- },
"node_modules/another-json": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz",
@@ -672,18 +687,22 @@
}
},
"node_modules/content-type": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
- "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
- "node": ">= 0.6"
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/dotenv": {
- "version": "16.4.7",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
- "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
+ "version": "17.4.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
+ "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -693,9 +712,9 @@
}
},
"node_modules/esbuild": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz",
- "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -705,31 +724,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.1",
- "@esbuild/android-arm": "0.25.1",
- "@esbuild/android-arm64": "0.25.1",
- "@esbuild/android-x64": "0.25.1",
- "@esbuild/darwin-arm64": "0.25.1",
- "@esbuild/darwin-x64": "0.25.1",
- "@esbuild/freebsd-arm64": "0.25.1",
- "@esbuild/freebsd-x64": "0.25.1",
- "@esbuild/linux-arm": "0.25.1",
- "@esbuild/linux-arm64": "0.25.1",
- "@esbuild/linux-ia32": "0.25.1",
- "@esbuild/linux-loong64": "0.25.1",
- "@esbuild/linux-mips64el": "0.25.1",
- "@esbuild/linux-ppc64": "0.25.1",
- "@esbuild/linux-riscv64": "0.25.1",
- "@esbuild/linux-s390x": "0.25.1",
- "@esbuild/linux-x64": "0.25.1",
- "@esbuild/netbsd-arm64": "0.25.1",
- "@esbuild/netbsd-x64": "0.25.1",
- "@esbuild/openbsd-arm64": "0.25.1",
- "@esbuild/openbsd-x64": "0.25.1",
- "@esbuild/sunos-x64": "0.25.1",
- "@esbuild/win32-arm64": "0.25.1",
- "@esbuild/win32-ia32": "0.25.1",
- "@esbuild/win32-x64": "0.25.1"
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/events": {
@@ -764,16 +784,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
- "node_modules/get-tsconfig": {
- "version": "4.10.0",
- "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz",
- "integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==",
+ "node_modules/is-network-error": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz",
+ "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==",
"license": "MIT",
- "dependencies": {
- "resolve-pkg-maps": "^1.0.0"
+ "engines": {
+ "node": ">=16"
},
"funding": {
- "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/jwt-decode": {
@@ -805,35 +825,33 @@
"license": "Apache-2.0"
},
"node_modules/matrix-js-sdk": {
- "version": "36.2.0",
- "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-36.2.0.tgz",
- "integrity": "sha512-pP44qfqLA9tiJjx5YjxBPPkUmNsA2G0nb04ZUTuPbtQFmfK5cEQgIpvoCq69oqU6aulufeYpxJmd9yNffOvF9g==",
+ "version": "41.7.0",
+ "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz",
+ "integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
- "@matrix-org/matrix-sdk-crypto-wasm": "^13.0.0",
- "@matrix-org/olm": "3.2.15",
+ "@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
- "content-type": "^1.0.4",
+ "content-type": "^2.0.0",
"jwt-decode": "^4.0.0",
- "loglevel": "^1.7.1",
+ "loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1",
- "matrix-widget-api": "^1.10.0",
+ "matrix-widget-api": "^1.16.1",
"oidc-client-ts": "^3.0.1",
- "p-retry": "4",
- "sdp-transform": "^2.14.1",
- "unhomoglyph": "^1.0.6",
- "uuid": "11"
+ "p-retry": "8",
+ "sdp-transform": "^3.0.0",
+ "unhomoglyph": "^1.0.6"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
}
},
"node_modules/matrix-widget-api": {
- "version": "1.13.1",
- "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz",
- "integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==",
+ "version": "1.17.0",
+ "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz",
+ "integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==",
"license": "Apache-2.0",
"dependencies": {
"@types/events": "^3.0.0",
@@ -853,16 +871,18 @@
}
},
"node_modules/p-retry": {
- "version": "4.6.2",
- "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
- "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.0.tgz",
+ "integrity": "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==",
"license": "MIT",
"dependencies": {
- "@types/retry": "0.12.0",
- "retry": "^0.13.1"
+ "is-network-error": "^1.3.0"
},
"engines": {
- "node": ">=8"
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/regenerator-runtime": {
@@ -871,41 +891,22 @@
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
- "node_modules/resolve-pkg-maps": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
- "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
- }
- },
- "node_modules/retry": {
- "version": "0.13.1",
- "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
- "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
"node_modules/sdp-transform": {
- "version": "2.15.0",
- "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
- "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",
+ "integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/tsx": {
- "version": "4.19.3",
- "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz",
- "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==",
+ "version": "4.22.4",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
+ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"license": "MIT",
"dependencies": {
- "esbuild": "~0.25.0",
- "get-tsconfig": "^4.7.5"
+ "esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
@@ -918,9 +919,9 @@
}
},
"node_modules/typescript": {
- "version": "5.8.2",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
- "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
+ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -932,9 +933,9 @@
}
},
"node_modules/undici-types": {
- "version": "6.20.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
- "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
@@ -943,19 +944,6 @@
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==",
"license": "MIT"
- },
- "node_modules/uuid": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
- "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
- "funding": [
- "https://github.com/sponsors/broofa",
- "https://github.com/sponsors/ctavan"
- ],
- "license": "MIT",
- "bin": {
- "uuid": "dist/esm/bin/uuid"
- }
}
}
}
diff --git a/tools/matrix-bot/package.json b/tools/matrix-bot/package.json
index 900016a6..d9585bea 100644
--- a/tools/matrix-bot/package.json
+++ b/tools/matrix-bot/package.json
@@ -15,15 +15,15 @@
"license": "MPL-2.0",
"description": "Matrix bot that listens for messages containing GP Reader links.",
"devDependencies": {
- "@biomejs/biome": "^1.8.3",
- "@tsconfig/node20": "^20.1.4",
- "@types/node": "^22.13.14",
- "typescript": "^5.6.3"
+ "@biomejs/biome": "^2.5.1",
+ "@tsconfig/node20": "^20.1.9",
+ "@types/node": "^26.0.0",
+ "typescript": "^6.0.2"
},
"dependencies": {
"@fluffylabs/links-metadata": "^1.3.0",
- "dotenv": "^16.4.7",
- "matrix-js-sdk": "^37.3.0",
- "tsx": "^4.19.3"
+ "dotenv": "^17.3.1",
+ "matrix-js-sdk": "^41.7.0",
+ "tsx": "^4.22.4"
}
}
diff --git a/tools/snapshot-tests/.gitignore b/tools/snapshot-tests/.gitignore
new file mode 100644
index 00000000..335bd46d
--- /dev/null
+++ b/tools/snapshot-tests/.gitignore
@@ -0,0 +1,8 @@
+
+# Playwright
+node_modules/
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
+/playwright/.auth/
diff --git a/tools/snapshot-tests/README.md b/tools/snapshot-tests/README.md
new file mode 100644
index 00000000..8e44c202
--- /dev/null
+++ b/tools/snapshot-tests/README.md
@@ -0,0 +1,78 @@
+# Snapshot Tests
+
+This directory contains Playwright snapshot tests for the Gray Paper Reader application.
+
+## Setup
+
+The tests are configured to work both locally and in CI environments. The key feature is that the web server can be started automatically by Playwright when needed.
+
+### Environment Variables
+
+- `PLAYWRIGHT_START_SERVER`: Set to "true" to automatically start the web server before running tests
+- `PLAYWRIGHT_PORT`: Port number for the web server (default: 5173)
+- `PLAYWRIGHT_HOST`: Hostname for the web server (default: localhost)
+
+## Running Tests
+
+### Local Development
+
+1. **With automatic server startup** (recommended for development):
+ ```bash
+ npm run test:local
+ ```
+ This will automatically start the web server and run the tests.
+
+2. **With manual server startup**:
+ ```bash
+ # In one terminal, start the dev server from the root directory
+ cd ../../
+ npm run dev
+
+ # In another terminal, run tests
+ cd tools/snapshot-tests
+ npm run test
+ ```
+
+3. **Interactive mode**:
+ ```bash
+ npm run start
+ ```
+
+### CI Environment
+
+In GitHub Actions, the tests run with:
+- `PLAYWRIGHT_START_SERVER=true`
+- `PLAYWRIGHT_PORT=5173`
+- `PLAYWRIGHT_HOST=localhost`
+
+The web server is automatically started using `npm run serve` from the root directory.
+
+### Updating Snapshots
+
+```bash
+npm run test:update
+```
+
+## Test Structure
+
+The tests are organized into several suites:
+
+- **Homepage Tests**: Basic functionality and screenshot tests
+- **Notes Tab**: Testing note-taking features with localStorage
+- **Search Functionality**: Search interface and results
+- **Top Bar Tests**: Navigation and dropdown interactions
+- **Settings**: Settings modal and configuration
+
+## Configuration
+
+The Playwright configuration (`playwright.config.ts`) includes:
+- Light and dark mode testing
+- Automatic web server startup when `PLAYWRIGHT_START_SERVER` is set
+- Configurable port/hostname via environment variables
+- CI-specific settings (retries, workers, etc.)
+
+## Troubleshooting
+
+1. **Port conflicts**: If port 5173 is in use, set `PLAYWRIGHT_PORT` to a different port
+2. **Server timeout**: The server has a 120-second startup timeout; increase if needed
+3. **Browser installation**: Run `npx playwright install --with-deps` if browsers are missing
\ No newline at end of file
diff --git a/tools/snapshot-tests/package.json b/tools/snapshot-tests/package.json
new file mode 100644
index 00000000..e0228a55
--- /dev/null
+++ b/tools/snapshot-tests/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "snapshot-tests",
+ "version": "1.0.0",
+ "description": "",
+ "license": "ISC",
+ "author": "",
+ "type": "commonjs",
+ "main": "index.js",
+ "scripts": {
+ "start": "playwright test --ui",
+ "test": "playwright test",
+ "test:update": "playwright test --update-snapshots",
+ "test:with-server": "PLAYWRIGHT_START_SERVER=true playwright test",
+ "test:local": "PLAYWRIGHT_START_SERVER=true PLAYWRIGHT_PORT=5173 PLAYWRIGHT_HOST=localhost playwright test"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.58.2",
+ "@types/node": "^26.0.0"
+ }
+}
diff --git a/tools/snapshot-tests/playwright.config.ts b/tools/snapshot-tests/playwright.config.ts
new file mode 100644
index 00000000..5ec281ce
--- /dev/null
+++ b/tools/snapshot-tests/playwright.config.ts
@@ -0,0 +1,48 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./tests",
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: "html",
+ expect: {
+ toHaveScreenshot: {
+ // PDF rendering can vary slightly between runs
+ maxDiffPixelRatio: 0.02,
+ animations: "disabled",
+ },
+ },
+ use: {
+ trace: "on-first-retry",
+ viewport: { width: 1280, height: 720 },
+ },
+
+ webServer: process.env.PLAYWRIGHT_START_SERVER
+ ? {
+ command: `npm run serve -- --port ${process.env.PLAYWRIGHT_PORT || 5173}`,
+ url: `http://localhost:${process.env.PLAYWRIGHT_PORT || 5173}`,
+ reuseExistingServer: !process.env.CI,
+ cwd: "../../", // Go back to the root directory to run the serve command
+ timeout: 120 * 1000,
+ }
+ : undefined,
+
+ projects: [
+ {
+ name: "light-mode",
+ use: {
+ colorScheme: "light",
+ ...devices["Desktop Chrome"],
+ },
+ },
+ {
+ name: "dark-mode",
+ use: {
+ colorScheme: "dark",
+ ...devices["Desktop Chrome"],
+ },
+ },
+ ],
+});
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts b/tools/snapshot-tests/tests/basic-snapshots.spec.ts
new file mode 100644
index 00000000..84f1c1a1
--- /dev/null
+++ b/tools/snapshot-tests/tests/basic-snapshots.spec.ts
@@ -0,0 +1,214 @@
+import { type BrowserContext, expect, type Page, test } from "@playwright/test";
+import { initialLocalStorage } from "./utils/add-notes-to-local-storage";
+import { maskedPdfScreenshot } from "./utils/screenshot-opts";
+import { waitForPdfReady } from "./utils/wait-for-pdf";
+
+const port = process.env.PLAYWRIGHT_PORT || "5173";
+const host = process.env.PLAYWRIGHT_HOST || "localhost";
+const origin = `http://${host}:${port}`;
+const hostname = `${origin}/#/ab2cdbd?v=0.7.2`;
+
+function getCommonContext() {
+ return {
+ storageState: {
+ cookies: [],
+ origins: [
+ {
+ origin,
+ localStorage: initialLocalStorage,
+ },
+ ],
+ },
+ };
+}
+
+test.describe
+ .serial("Homepage Tests", () => {
+ let context: BrowserContext;
+ let page: Page;
+
+ test.beforeAll(async ({ browser }) => {
+ context = await browser.newContext(getCommonContext());
+ page = await context.newPage();
+ });
+
+ test('page title is "Gray Paper Reader"', async () => {
+ await page.goto(hostname);
+ await expect(page).toHaveTitle("Gray Paper Reader");
+ });
+
+ test("homepage screenshot - with outline", async () => {
+ await page.goto(hostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+ await expect(page).toHaveScreenshot("homepage-outline.png", maskedPdfScreenshot(page));
+ });
+
+ test.describe
+ .serial("Notes Tab", () => {
+ const timeout = 2_500;
+
+ test.beforeAll(async () => {
+ await page.goto(hostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+ });
+
+ test("notes tab - initial state", async ({ browser }) => {
+ await page.click('[data-testid="tab-notes"]', { timeout });
+ await page.locator('[data-testid="tab-content-notes"]').waitFor({ state: "visible", timeout });
+ await expect(page).toHaveScreenshot("notes-tab-initial.png", {
+ // biome-ignore lint/style/noNonNullAssertion: boundingBox is expected to exist at this point
+ clip: (await page.locator('[data-testid="tab-content-notes"] .note-manager').boundingBox())!,
+ fullPage: false,
+ animations: "disabled",
+ });
+ });
+
+ test("notes tab - after note activation", async ({ browser }) => {
+ await page.click('[data-testid="notes-manager-card"]', { timeout });
+ await page
+ .locator('[data-testid="selected-text"]')
+ .filter({ hasNotText: "" })
+ .waitFor({ state: "visible", timeout: timeout * 2 });
+ await expect(page).toHaveScreenshot("notes-tab-after-note-activation.png", {
+ // biome-ignore lint/style/noNonNullAssertion: boundingBox is guaranteed to exist at this point
+ clip: (await page.locator('[data-testid="tab-content-notes"] .note-manager').boundingBox())!,
+ fullPage: false,
+ animations: "disabled",
+ });
+ });
+
+ test("notes tab - after opening dropdown", async ({ browser }) => {
+ await page.click('[data-testid="dropdown-button"]', { timeout });
+ await expect(page).toHaveScreenshot("notes-tab-after-opening-dropdown.png", {
+ // biome-ignore lint/style/noNonNullAssertion: boundingBox is guaranteed to exist at this point
+ clip: (await page.locator('[data-testid="tab-content-notes"] .note-manager').boundingBox())!,
+ fullPage: false,
+ animations: "disabled",
+ });
+ });
+
+ test("notes tab - after set to edit mode", async ({ browser }) => {
+ await page.click('[data-testid="edit-note-button"]', { timeout });
+ await expect(page).toHaveScreenshot("notes-tab-after-note-edit.png", {
+ // biome-ignore lint/style/noNonNullAssertion: boundingBox is guaranteed to exist at this point
+ clip: (await page.locator('[data-testid="tab-content-notes"] .note-manager').boundingBox())!,
+ fullPage: false,
+ animations: "disabled",
+ });
+ });
+ });
+
+ test.describe("Search Functionality", () => {
+ test("search - initial state", async () => {
+ await page.goto(hostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+
+ // Click on search tab or search input
+ const searchTab = page.locator('[data-testid="tab-search"]');
+
+ if ((await searchTab.count()) > 0) {
+ await searchTab.click();
+ await page.locator('[data-testid="tab-content-search"]').waitFor({ state: "visible" });
+ }
+
+ await expect(page).toHaveScreenshot("search-initial.png", maskedPdfScreenshot(page));
+ const searchInput = page.locator('input[placeholder*="search"]');
+ await searchInput.first().fill("protocol");
+ await page.locator(".search-results:not(.search-loading)").waitFor({ state: "visible" });
+ await expect(page).toHaveScreenshot("search-with-query.png", maskedPdfScreenshot(page));
+ await searchInput.first().fill("blockchain");
+ await page.locator(".search-results:not(.search-loading)").waitFor({ state: "visible" });
+ await expect(page).toHaveScreenshot("search-with-results.png", maskedPdfScreenshot(page));
+ });
+ });
+ });
+
+test.describe("Top Bar Tests", () => {
+ test.describe("GitHub Dropdown", () => {
+ test("github dropdown - expanded state", async ({ page }) => {
+ await page.goto(hostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+
+ const githubButton = page.locator(
+ '[data-testid="github-dropdown"], button:has-text("GitHub"), [aria-label*="github"], .github',
+ );
+ if ((await githubButton.count()) > 0) {
+ await githubButton.first().click();
+
+ await expect(page).toHaveScreenshot("topbar-github-expanded.png", maskedPdfScreenshot(page));
+
+ // Hover over dropdown options
+ const dropdownOptions = page.locator('[role="menuitem"], .dropdown-item, .github-menu-item');
+ if ((await dropdownOptions.count()) > 0) {
+ await dropdownOptions.first().hover();
+
+ await expect(page).toHaveScreenshot("topbar-github-option-hover.png", maskedPdfScreenshot(page));
+ }
+ }
+ });
+ });
+
+ test.describe("Top Bar Notes Dropdown", () => {
+ test("notes dropdown - initial state", async ({ browser }) => {
+ const context = await browser.newContext(getCommonContext());
+ const page = await context.newPage();
+ await page.goto(hostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+
+ const notesDropdown = page.locator(
+ '[data-testid="notes-dropdown"], button:has-text("Notes"), [aria-label*="notes"], .notes-dropdown',
+ );
+ if ((await notesDropdown.count()) > 0) {
+ await notesDropdown.first().click();
+
+ await expect(page).toHaveScreenshot("topbar-notes-dropdown.png", maskedPdfScreenshot(page));
+ }
+ });
+
+ test("selecting note on and off", async ({ browser }) => {
+ const context = await browser.newContext(getCommonContext());
+ const page = await context.newPage();
+ await page.goto(hostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+
+ const notesDropdown = page.locator(
+ '[data-testid="notes-dropdown"], button:has-text("Notes"), [aria-label*="notes"], .notes-dropdown',
+ );
+ if ((await notesDropdown.count()) > 0) {
+ await notesDropdown.first().click();
+
+ const noteOptions = page.locator('[type="checkbox"], .note-option, [role="menuitemcheckbox"]');
+ if ((await noteOptions.count()) > 0) {
+ await noteOptions.first().click();
+
+ await expect(page).toHaveScreenshot("topbar-notes-selected-on.png", maskedPdfScreenshot(page));
+
+ await noteOptions.first().click();
+
+ await expect(page).toHaveScreenshot("topbar-notes-selected-off.png", maskedPdfScreenshot(page));
+ }
+ }
+ });
+ });
+
+ test.describe("Settings", () => {
+ test("settings modal or panel", async ({ page }) => {
+ await page.goto(hostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+
+ const moreButton = page.locator('button:has-text("...")');
+ await moreButton.first().click();
+ await expect(page).toHaveScreenshot("topbar-more-clicked.png", maskedPdfScreenshot(page));
+ const settingsMenuItem = page.locator("role=menuitem", { hasText: "Settings" });
+ await settingsMenuItem.click();
+ await expect(page).toHaveScreenshot("topbar-more-settings-clicked.png", maskedPdfScreenshot(page));
+ });
+ });
+});
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/homepage-outline-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/homepage-outline-dark-mode-darwin.png
new file mode 100644
index 00000000..62914758
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/homepage-outline-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/homepage-outline-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/homepage-outline-dark-mode-linux.png
new file mode 100644
index 00000000..4406fa4b
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/homepage-outline-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/homepage-outline-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/homepage-outline-light-mode-darwin.png
new file mode 100644
index 00000000..0ef5fb97
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/homepage-outline-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/homepage-outline-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/homepage-outline-light-mode-linux.png
new file mode 100644
index 00000000..6d2ba89f
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/homepage-outline-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-edit-click-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-edit-click-dark-mode-darwin.png
new file mode 100644
index 00000000..c79d488a
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-edit-click-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-edit-click-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-edit-click-light-mode-darwin.png
new file mode 100644
index 00000000..808ab245
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-edit-click-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-activation-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-activation-dark-mode-darwin.png
new file mode 100644
index 00000000..d5ac9a10
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-activation-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-activation-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-activation-dark-mode-linux.png
new file mode 100644
index 00000000..2efe983c
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-activation-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-activation-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-activation-light-mode-darwin.png
new file mode 100644
index 00000000..87841869
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-activation-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-activation-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-activation-light-mode-linux.png
new file mode 100644
index 00000000..226e59bb
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-activation-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-edit-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-edit-dark-mode-darwin.png
new file mode 100644
index 00000000..803aaab9
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-edit-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-edit-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-edit-dark-mode-linux.png
new file mode 100644
index 00000000..92c64eac
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-edit-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-edit-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-edit-light-mode-darwin.png
new file mode 100644
index 00000000..894521f8
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-edit-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-edit-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-edit-light-mode-linux.png
new file mode 100644
index 00000000..141c4cc4
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-note-edit-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-opening-dropdown-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-opening-dropdown-dark-mode-linux.png
new file mode 100644
index 00000000..230e3439
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-opening-dropdown-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-opening-dropdown-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-opening-dropdown-light-mode-linux.png
new file mode 100644
index 00000000..b7cbb8a4
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-after-opening-dropdown-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-edit-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-edit-dark-mode-darwin.png
new file mode 100644
index 00000000..a2669b8b
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-edit-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-edit-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-edit-dark-mode-linux.png
new file mode 100644
index 00000000..38efa73c
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-edit-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-edit-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-edit-light-mode-darwin.png
new file mode 100644
index 00000000..476e8718
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-edit-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-edit-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-edit-light-mode-linux.png
new file mode 100644
index 00000000..12b82c8f
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-edit-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-initial-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-initial-dark-mode-darwin.png
new file mode 100644
index 00000000..0d6f44f2
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-initial-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-initial-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-initial-dark-mode-linux.png
new file mode 100644
index 00000000..8ce75c69
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-initial-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-initial-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-initial-light-mode-darwin.png
new file mode 100644
index 00000000..a3ff7532
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-initial-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-initial-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-initial-light-mode-linux.png
new file mode 100644
index 00000000..4efbd61a
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/notes-tab-initial-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-initial-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-initial-dark-mode-darwin.png
new file mode 100644
index 00000000..26bafebf
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-initial-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-initial-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-initial-dark-mode-linux.png
new file mode 100644
index 00000000..c824d1d7
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-initial-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-initial-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-initial-light-mode-darwin.png
new file mode 100644
index 00000000..ae029a51
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-initial-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-initial-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-initial-light-mode-linux.png
new file mode 100644
index 00000000..5b727c89
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-initial-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-query-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-query-dark-mode-darwin.png
new file mode 100644
index 00000000..68a1ba3b
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-query-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-query-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-query-dark-mode-linux.png
new file mode 100644
index 00000000..7463499a
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-query-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-query-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-query-light-mode-darwin.png
new file mode 100644
index 00000000..a8126d9f
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-query-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-query-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-query-light-mode-linux.png
new file mode 100644
index 00000000..20dc6cc9
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-query-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-results-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-results-dark-mode-darwin.png
new file mode 100644
index 00000000..5708b009
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-results-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-results-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-results-dark-mode-linux.png
new file mode 100644
index 00000000..ee96d500
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-results-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-results-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-results-light-mode-darwin.png
new file mode 100644
index 00000000..2b8d13cc
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-results-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-results-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-results-light-mode-linux.png
new file mode 100644
index 00000000..197da0dd
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/search-with-results-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-expanded-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-expanded-dark-mode-darwin.png
new file mode 100644
index 00000000..21ddc679
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-expanded-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-expanded-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-expanded-dark-mode-linux.png
new file mode 100644
index 00000000..5fa6cd80
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-expanded-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-expanded-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-expanded-light-mode-darwin.png
new file mode 100644
index 00000000..7f627594
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-expanded-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-expanded-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-expanded-light-mode-linux.png
new file mode 100644
index 00000000..3192dd63
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-expanded-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-option-hover-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-option-hover-dark-mode-darwin.png
new file mode 100644
index 00000000..30998b2b
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-option-hover-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-option-hover-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-option-hover-dark-mode-linux.png
new file mode 100644
index 00000000..390fb72a
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-option-hover-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-option-hover-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-option-hover-light-mode-darwin.png
new file mode 100644
index 00000000..d668c848
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-option-hover-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-option-hover-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-option-hover-light-mode-linux.png
new file mode 100644
index 00000000..b6f1e432
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-github-option-hover-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-clicked-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-clicked-dark-mode-darwin.png
new file mode 100644
index 00000000..eb04a3f8
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-clicked-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-clicked-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-clicked-dark-mode-linux.png
new file mode 100644
index 00000000..67f48a2f
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-clicked-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-clicked-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-clicked-light-mode-darwin.png
new file mode 100644
index 00000000..8c6c88fd
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-clicked-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-clicked-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-clicked-light-mode-linux.png
new file mode 100644
index 00000000..33c7a77a
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-clicked-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-settings-clicked-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-settings-clicked-dark-mode-darwin.png
new file mode 100644
index 00000000..3515bfe6
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-settings-clicked-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-settings-clicked-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-settings-clicked-dark-mode-linux.png
new file mode 100644
index 00000000..81eb55c7
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-settings-clicked-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-settings-clicked-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-settings-clicked-light-mode-darwin.png
new file mode 100644
index 00000000..10cab8c3
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-settings-clicked-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-settings-clicked-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-settings-clicked-light-mode-linux.png
new file mode 100644
index 00000000..51463b58
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-more-settings-clicked-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-dropdown-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-dropdown-dark-mode-darwin.png
new file mode 100644
index 00000000..e17309e9
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-dropdown-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-dropdown-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-dropdown-dark-mode-linux.png
new file mode 100644
index 00000000..b976e78a
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-dropdown-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-dropdown-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-dropdown-light-mode-darwin.png
new file mode 100644
index 00000000..f412e9f1
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-dropdown-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-dropdown-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-dropdown-light-mode-linux.png
new file mode 100644
index 00000000..bd7f3959
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-dropdown-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-off-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-off-dark-mode-darwin.png
new file mode 100644
index 00000000..9a2c864f
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-off-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-off-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-off-dark-mode-linux.png
new file mode 100644
index 00000000..8cc00872
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-off-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-off-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-off-light-mode-darwin.png
new file mode 100644
index 00000000..aefd1e6e
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-off-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-off-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-off-light-mode-linux.png
new file mode 100644
index 00000000..d9fd0752
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-off-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-on-dark-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-on-dark-mode-darwin.png
new file mode 100644
index 00000000..cd1c03bc
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-on-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-on-dark-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-on-dark-mode-linux.png
new file mode 100644
index 00000000..a8765e64
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-on-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-on-light-mode-darwin.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-on-light-mode-darwin.png
new file mode 100644
index 00000000..e72e06f5
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-on-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-on-light-mode-linux.png b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-on-light-mode-linux.png
new file mode 100644
index 00000000..fe76e6f9
Binary files /dev/null and b/tools/snapshot-tests/tests/basic-snapshots.spec.ts-snapshots/topbar-notes-selected-on-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts b/tools/snapshot-tests/tests/narrow-screen.spec.ts
new file mode 100644
index 00000000..eb3d235e
--- /dev/null
+++ b/tools/snapshot-tests/tests/narrow-screen.spec.ts
@@ -0,0 +1,145 @@
+import { expect, test } from "@playwright/test";
+import { initialLocalStorage } from "./utils/add-notes-to-local-storage";
+import { maskedPdfScreenshot } from "./utils/screenshot-opts";
+import { waitForPdfReady } from "./utils/wait-for-pdf";
+
+const port = process.env.PLAYWRIGHT_PORT || "5173";
+const host = process.env.PLAYWRIGHT_HOST || "localhost";
+const origin = `http://${host}:${port}`;
+const hostname = `${origin}/#/ab2cdbd?v=0.7.2`;
+
+function getCommonContext() {
+ return {
+ storageState: {
+ cookies: [],
+ origins: [
+ {
+ origin,
+ localStorage: initialLocalStorage,
+ },
+ ],
+ },
+ };
+}
+
+test.describe("Narrow Screen - Bottom Drawer", () => {
+ test("narrow layout shows bottom drawer collapsed", async ({ browser }) => {
+ const context = await browser.newContext({
+ ...getCommonContext(),
+ viewport: { width: 375, height: 667 },
+ });
+ const page = await context.newPage();
+
+ await page.goto(hostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+
+ const drawer = page.locator(".bottom-drawer");
+ await expect(drawer).toBeVisible();
+ await expect(drawer).toHaveClass(/collapsed/);
+
+ await expect(page).toHaveScreenshot("narrow-layout-collapsed.png", maskedPdfScreenshot(page));
+ });
+
+ test("bottom drawer expands on click", async ({ browser }) => {
+ const context = await browser.newContext({
+ ...getCommonContext(),
+ viewport: { width: 375, height: 667 },
+ });
+ const page = await context.newPage();
+
+ await page.goto(hostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+
+ const handle = page.locator(".drawer-handle");
+ await handle.click();
+
+ const drawer = page.locator(".bottom-drawer");
+ await expect(drawer).toHaveClass(/expanded/);
+
+ await expect(page).toHaveScreenshot("narrow-layout-expanded.png", maskedPdfScreenshot(page));
+ });
+
+ test("bottom drawer collapses on second click", async ({ browser }) => {
+ const context = await browser.newContext({
+ ...getCommonContext(),
+ viewport: { width: 375, height: 667 },
+ });
+ const page = await context.newPage();
+
+ await page.goto(hostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+
+ const handle = page.locator(".drawer-handle");
+
+ // Expand
+ await handle.click();
+ await expect(page.locator(".bottom-drawer")).toHaveClass(/expanded/);
+
+ // Collapse
+ await handle.click();
+ await expect(page.locator(".bottom-drawer")).toHaveClass(/collapsed/);
+
+ await expect(page).toHaveScreenshot("narrow-layout-re-collapsed.png", maskedPdfScreenshot(page));
+ });
+
+ test("narrow layout does not show split tab", async ({ browser }) => {
+ const context = await browser.newContext({
+ ...getCommonContext(),
+ viewport: { width: 375, height: 667 },
+ });
+ const page = await context.newPage();
+
+ await page.goto(hostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+
+ const handle = page.locator(".drawer-handle");
+ await handle.click();
+ await expect(page.locator(".bottom-drawer")).toHaveClass(/expanded/);
+
+ const splitTab = page.locator('[data-testid="tab-split"]');
+ await expect(splitTab).toHaveCount(0);
+ });
+
+ test("narrow layout ignores split URL param", async ({ browser }) => {
+ const context = await browser.newContext({
+ ...getCommonContext(),
+ viewport: { width: 375, height: 667 },
+ });
+ const page = await context.newPage();
+
+ await page.goto(`${origin}/#/ab2cdbd?v=0.7.2&split=0.7.2`, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+
+ const drawer = page.locator(".bottom-drawer");
+ await expect(drawer).toBeVisible();
+
+ const splitPane = page.locator(".split-pane-view");
+ await expect(splitPane).toHaveCount(0);
+
+ await expect(page).toHaveScreenshot("narrow-layout-ignores-split.png", maskedPdfScreenshot(page));
+ });
+});
+
+test.describe("Narrow Screen - Tablet", () => {
+ test("768px width shows bottom drawer layout", async ({ browser }) => {
+ const context = await browser.newContext({
+ ...getCommonContext(),
+ viewport: { width: 768, height: 1024 },
+ });
+ const page = await context.newPage();
+
+ await page.goto(hostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+
+ const drawer = page.locator(".bottom-drawer");
+ await expect(drawer).toBeVisible();
+
+ await expect(page).toHaveScreenshot("tablet-layout.png", maskedPdfScreenshot(page));
+ });
+});
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-collapsed-dark-mode-darwin.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-collapsed-dark-mode-darwin.png
new file mode 100644
index 00000000..4d68c5b1
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-collapsed-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-collapsed-dark-mode-linux.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-collapsed-dark-mode-linux.png
new file mode 100644
index 00000000..cf783a1b
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-collapsed-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-collapsed-light-mode-darwin.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-collapsed-light-mode-darwin.png
new file mode 100644
index 00000000..83236dca
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-collapsed-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-collapsed-light-mode-linux.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-collapsed-light-mode-linux.png
new file mode 100644
index 00000000..338e4b27
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-collapsed-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-expanded-dark-mode-darwin.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-expanded-dark-mode-darwin.png
new file mode 100644
index 00000000..7a16f45c
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-expanded-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-expanded-dark-mode-linux.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-expanded-dark-mode-linux.png
new file mode 100644
index 00000000..8c990d71
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-expanded-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-expanded-light-mode-darwin.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-expanded-light-mode-darwin.png
new file mode 100644
index 00000000..e8c52557
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-expanded-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-expanded-light-mode-linux.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-expanded-light-mode-linux.png
new file mode 100644
index 00000000..01acb51d
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-expanded-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-ignores-split-dark-mode-darwin.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-ignores-split-dark-mode-darwin.png
new file mode 100644
index 00000000..a62a47c0
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-ignores-split-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-ignores-split-dark-mode-linux.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-ignores-split-dark-mode-linux.png
new file mode 100644
index 00000000..cf783a1b
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-ignores-split-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-ignores-split-light-mode-darwin.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-ignores-split-light-mode-darwin.png
new file mode 100644
index 00000000..7adf6b53
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-ignores-split-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-ignores-split-light-mode-linux.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-ignores-split-light-mode-linux.png
new file mode 100644
index 00000000..338e4b27
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-ignores-split-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-re-collapsed-dark-mode-darwin.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-re-collapsed-dark-mode-darwin.png
new file mode 100644
index 00000000..4d68c5b1
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-re-collapsed-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-re-collapsed-dark-mode-linux.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-re-collapsed-dark-mode-linux.png
new file mode 100644
index 00000000..cf783a1b
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-re-collapsed-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-re-collapsed-light-mode-darwin.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-re-collapsed-light-mode-darwin.png
new file mode 100644
index 00000000..83236dca
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-re-collapsed-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-re-collapsed-light-mode-linux.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-re-collapsed-light-mode-linux.png
new file mode 100644
index 00000000..338e4b27
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/narrow-layout-re-collapsed-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/tablet-layout-dark-mode-darwin.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/tablet-layout-dark-mode-darwin.png
new file mode 100644
index 00000000..2b45d520
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/tablet-layout-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/tablet-layout-dark-mode-linux.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/tablet-layout-dark-mode-linux.png
new file mode 100644
index 00000000..48987d26
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/tablet-layout-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/tablet-layout-light-mode-darwin.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/tablet-layout-light-mode-darwin.png
new file mode 100644
index 00000000..4345dabb
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/tablet-layout-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/tablet-layout-light-mode-linux.png b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/tablet-layout-light-mode-linux.png
new file mode 100644
index 00000000..548c0b3d
Binary files /dev/null and b/tools/snapshot-tests/tests/narrow-screen.spec.ts-snapshots/tablet-layout-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts b/tools/snapshot-tests/tests/split-screen.spec.ts
new file mode 100644
index 00000000..54b79a68
--- /dev/null
+++ b/tools/snapshot-tests/tests/split-screen.spec.ts
@@ -0,0 +1,200 @@
+import { expect, test } from "@playwright/test";
+import { initialLocalStorage } from "./utils/add-notes-to-local-storage";
+import { maskedPdfScreenshot } from "./utils/screenshot-opts";
+import { waitForPdfReady, waitForSplitPdfReady } from "./utils/wait-for-pdf";
+
+const port = process.env.PLAYWRIGHT_PORT || "5173";
+const host = process.env.PLAYWRIGHT_HOST || "localhost";
+const origin = `http://${host}:${port}`;
+const hostname = `${origin}/#/ab2cdbd?v=0.7.2`;
+const splitHostname = `${origin}/#/ab2cdbd?v=0.7.2&split=0.7.2`;
+
+function getCommonContext() {
+ return {
+ storageState: {
+ cookies: [],
+ origins: [
+ {
+ origin,
+ localStorage: initialLocalStorage,
+ },
+ ],
+ },
+ };
+}
+
+test.describe("Split Screen", () => {
+ test("sidebar shows split tab icon", async ({ browser }) => {
+ const context = await browser.newContext(getCommonContext());
+ const page = await context.newPage();
+
+ await page.goto(hostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+
+ const splitTab = page.locator('[data-testid="tab-split"]');
+ await expect(splitTab).toBeVisible();
+
+ await expect(page).toHaveScreenshot("sidebar-with-split-tab.png", maskedPdfScreenshot(page));
+ });
+
+ test("split view opens when clicking split tab", async ({ browser }) => {
+ const context = await browser.newContext(getCommonContext());
+ const page = await context.newPage();
+
+ await page.goto(hostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+
+ const splitTab = page.locator('[data-testid="tab-split"]');
+ await splitTab.click();
+ await page.locator(".split-pane-view").waitFor({ state: "visible", timeout: 5000 });
+ await waitForSplitPdfReady(page);
+
+ await expect(page).toHaveScreenshot("split-view-opened.png", maskedPdfScreenshot(page));
+ });
+
+ test("split view opens via URL param", async ({ browser }) => {
+ const context = await browser.newContext(getCommonContext());
+ const page = await context.newPage();
+
+ await page.goto(splitHostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+ await page.locator(".split-pane-view").waitFor({ state: "visible", timeout: 5000 });
+ await waitForSplitPdfReady(page);
+
+ await expect(page).toHaveScreenshot("split-view-via-url.png", maskedPdfScreenshot(page));
+ });
+
+ test("split pane header - version dropdown", async ({ browser }) => {
+ const context = await browser.newContext(getCommonContext());
+ const page = await context.newPage();
+
+ await page.goto(splitHostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+ await page.locator(".split-pane-view").waitFor({ state: "visible", timeout: 10000 });
+ await waitForSplitPdfReady(page);
+
+ const versionButton = page.locator(".split-pane-header button").first();
+ await versionButton.click({ force: true });
+ await page.locator('[role="menu"], [role="radiogroup"]').first().waitFor({ state: "visible", timeout: 5000 });
+
+ await expect(page).toHaveScreenshot("split-header-version-dropdown.png", maskedPdfScreenshot(page));
+ });
+
+ test("split pane header - options menu", async ({ browser }) => {
+ const context = await browser.newContext(getCommonContext());
+ const page = await context.newPage();
+
+ await page.goto(splitHostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+ await page.locator(".split-pane-view").waitFor({ state: "visible", timeout: 10000 });
+ await waitForSplitPdfReady(page);
+
+ const optionsButton = page.locator(".split-pane-header button:has(svg.lucide-ellipsis)");
+ await optionsButton.click({ force: true });
+ await page.locator('[role="menu"]').waitFor({ state: "visible", timeout: 5000 });
+
+ await expect(page).toHaveScreenshot("split-header-options-menu.png", maskedPdfScreenshot(page));
+ });
+
+ test("sidebar overlay in split mode", async ({ browser }) => {
+ const context = await browser.newContext(getCommonContext());
+ const page = await context.newPage();
+
+ await page.goto(splitHostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+ await page.locator(".split-pane-view").waitFor({ state: "visible", timeout: 10000 });
+ await waitForSplitPdfReady(page);
+
+ const sidebarButton = page.locator('.split-pane-header button[title="Toggle sidebar"]');
+ await sidebarButton.click({ force: true });
+ await page.locator(".sidebar-overlay-panel").waitFor({ state: "visible", timeout: 5000 });
+
+ await expect(page).toHaveScreenshot("split-sidebar-overlay.png", maskedPdfScreenshot(page));
+ });
+
+ test("sidebar overlay closes on Escape", async ({ browser }) => {
+ const context = await browser.newContext(getCommonContext());
+ const page = await context.newPage();
+
+ await page.goto(splitHostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+ await page.locator(".split-pane-view").waitFor({ state: "visible", timeout: 5000 });
+ await waitForSplitPdfReady(page);
+
+ const sidebarButton = page.locator('.split-pane-header button[title="Toggle sidebar"]');
+ await sidebarButton.click({ force: true });
+ await page.locator(".sidebar-overlay-panel").waitFor({ state: "visible", timeout: 2500 });
+
+ await page.keyboard.press("Escape");
+ await page.locator(".sidebar-overlay-panel").waitFor({ state: "hidden", timeout: 2500 });
+
+ await expect(page).toHaveScreenshot("split-sidebar-overlay-closed.png", maskedPdfScreenshot(page));
+ });
+
+ test("split view closes via options menu", async ({ browser }) => {
+ const context = await browser.newContext(getCommonContext());
+ const page = await context.newPage();
+
+ await page.goto(splitHostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+ await page.locator(".split-pane-view").waitFor({ state: "visible", timeout: 5000 });
+ await waitForSplitPdfReady(page);
+
+ const optionsButton = page.locator(".split-pane-header button:has(svg.lucide-ellipsis)");
+ await optionsButton.click({ force: true });
+ await page.locator('[role="menu"]').waitFor({ state: "visible", timeout: 2500 });
+
+ await page.locator('[role="menuitem"]:has-text("Close split view")').click();
+ await page.locator(".split-pane-view").waitFor({ state: "hidden", timeout: 2500 });
+ await page.locator(".gp-sidebar").waitFor({ state: "visible", timeout: 2500 });
+
+ await expect(page).toHaveScreenshot("split-view-closed.png", maskedPdfScreenshot(page));
+ });
+
+ test("split tab not visible in sidebar overlay", async ({ browser }) => {
+ const context = await browser.newContext(getCommonContext());
+ const page = await context.newPage();
+
+ await page.goto(splitHostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+ await page.locator(".split-pane-view").waitFor({ state: "visible", timeout: 5000 });
+ await waitForSplitPdfReady(page);
+
+ const sidebarButton = page.locator('.split-pane-header button[title="Toggle sidebar"]');
+ await sidebarButton.click({ force: true });
+ await page.locator(".sidebar-overlay-panel").waitFor({ state: "visible", timeout: 2500 });
+
+ const splitTabInOverlay = page.locator('.sidebar-overlay-panel [data-testid="tab-split"]');
+ await expect(splitTabInOverlay).toHaveCount(0);
+ });
+});
+
+test.describe("Version Compare", () => {
+ test("compare with option in version dropdown", async ({ browser }) => {
+ const context = await browser.newContext(getCommonContext());
+ const page = await context.newPage();
+
+ await page.goto(hostname, { waitUntil: "networkidle" });
+ await page.evaluate(() => document.fonts.ready);
+ await waitForPdfReady(page);
+
+ // The version dropdown trigger contains a ChevronDown SVG — find it in the header end slot
+ const versionButton = page.locator("button:has(svg.lucide-chevron-down)").first();
+ await versionButton.click();
+ await page.locator('[role="menu"], [role="radiogroup"]').first().waitFor({ state: "visible", timeout: 5000 });
+
+ const compareSection = page.locator('text="Compare with..."');
+ await expect(compareSection).toBeVisible();
+
+ await expect(page).toHaveScreenshot("version-dropdown-with-compare.png", maskedPdfScreenshot(page));
+ });
+});
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/sidebar-with-split-tab-dark-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/sidebar-with-split-tab-dark-mode-darwin.png
new file mode 100644
index 00000000..6bdc9100
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/sidebar-with-split-tab-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/sidebar-with-split-tab-dark-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/sidebar-with-split-tab-dark-mode-linux.png
new file mode 100644
index 00000000..4406fa4b
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/sidebar-with-split-tab-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/sidebar-with-split-tab-light-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/sidebar-with-split-tab-light-mode-darwin.png
new file mode 100644
index 00000000..260152e3
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/sidebar-with-split-tab-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/sidebar-with-split-tab-light-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/sidebar-with-split-tab-light-mode-linux.png
new file mode 100644
index 00000000..6d2ba89f
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/sidebar-with-split-tab-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-options-menu-dark-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-options-menu-dark-mode-darwin.png
new file mode 100644
index 00000000..3a870a12
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-options-menu-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-options-menu-dark-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-options-menu-dark-mode-linux.png
new file mode 100644
index 00000000..c80c79d2
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-options-menu-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-options-menu-light-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-options-menu-light-mode-darwin.png
new file mode 100644
index 00000000..64b8ecbd
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-options-menu-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-options-menu-light-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-options-menu-light-mode-linux.png
new file mode 100644
index 00000000..6080cdd3
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-options-menu-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-version-dropdown-dark-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-version-dropdown-dark-mode-darwin.png
new file mode 100644
index 00000000..a1122962
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-version-dropdown-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-version-dropdown-dark-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-version-dropdown-dark-mode-linux.png
new file mode 100644
index 00000000..c80c79d2
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-version-dropdown-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-version-dropdown-light-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-version-dropdown-light-mode-darwin.png
new file mode 100644
index 00000000..f2447a51
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-version-dropdown-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-version-dropdown-light-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-version-dropdown-light-mode-linux.png
new file mode 100644
index 00000000..6080cdd3
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-header-version-dropdown-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-closed-dark-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-closed-dark-mode-darwin.png
new file mode 100644
index 00000000..00a9f33b
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-closed-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-closed-dark-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-closed-dark-mode-linux.png
new file mode 100644
index 00000000..c80c79d2
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-closed-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-closed-light-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-closed-light-mode-darwin.png
new file mode 100644
index 00000000..a3e7dca8
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-closed-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-closed-light-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-closed-light-mode-linux.png
new file mode 100644
index 00000000..6080cdd3
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-closed-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-dark-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-dark-mode-darwin.png
new file mode 100644
index 00000000..4fde3ef8
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-dark-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-dark-mode-linux.png
new file mode 100644
index 00000000..ee761129
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-light-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-light-mode-darwin.png
new file mode 100644
index 00000000..cfacccbf
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-light-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-light-mode-linux.png
new file mode 100644
index 00000000..076c81cb
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-sidebar-overlay-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-closed-dark-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-closed-dark-mode-darwin.png
new file mode 100644
index 00000000..6bdc9100
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-closed-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-closed-dark-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-closed-dark-mode-linux.png
new file mode 100644
index 00000000..4406fa4b
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-closed-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-closed-light-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-closed-light-mode-darwin.png
new file mode 100644
index 00000000..557c41e1
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-closed-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-closed-light-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-closed-light-mode-linux.png
new file mode 100644
index 00000000..6d2ba89f
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-closed-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-opened-dark-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-opened-dark-mode-darwin.png
new file mode 100644
index 00000000..fe94da4d
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-opened-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-opened-dark-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-opened-dark-mode-linux.png
new file mode 100644
index 00000000..c80c79d2
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-opened-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-opened-light-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-opened-light-mode-darwin.png
new file mode 100644
index 00000000..2cf75fa9
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-opened-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-opened-light-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-opened-light-mode-linux.png
new file mode 100644
index 00000000..6080cdd3
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-opened-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-via-url-dark-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-via-url-dark-mode-darwin.png
new file mode 100644
index 00000000..48f0d6b8
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-via-url-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-via-url-dark-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-via-url-dark-mode-linux.png
new file mode 100644
index 00000000..c80c79d2
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-via-url-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-via-url-light-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-via-url-light-mode-darwin.png
new file mode 100644
index 00000000..db71e050
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-via-url-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-via-url-light-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-via-url-light-mode-linux.png
new file mode 100644
index 00000000..6080cdd3
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/split-view-via-url-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/version-dropdown-with-compare-dark-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/version-dropdown-with-compare-dark-mode-darwin.png
new file mode 100644
index 00000000..9f2f19ed
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/version-dropdown-with-compare-dark-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/version-dropdown-with-compare-dark-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/version-dropdown-with-compare-dark-mode-linux.png
new file mode 100644
index 00000000..4c03c2ed
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/version-dropdown-with-compare-dark-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/version-dropdown-with-compare-light-mode-darwin.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/version-dropdown-with-compare-light-mode-darwin.png
new file mode 100644
index 00000000..17919c42
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/version-dropdown-with-compare-light-mode-darwin.png differ
diff --git a/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/version-dropdown-with-compare-light-mode-linux.png b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/version-dropdown-with-compare-light-mode-linux.png
new file mode 100644
index 00000000..b9ada818
Binary files /dev/null and b/tools/snapshot-tests/tests/split-screen.spec.ts-snapshots/version-dropdown-with-compare-light-mode-linux.png differ
diff --git a/tools/snapshot-tests/tests/url-params.spec.ts b/tools/snapshot-tests/tests/url-params.spec.ts
new file mode 100644
index 00000000..746eccbe
--- /dev/null
+++ b/tools/snapshot-tests/tests/url-params.spec.ts
@@ -0,0 +1,69 @@
+import { expect, test } from "@playwright/test";
+import { waitForPdfReady } from "./utils/wait-for-pdf";
+
+const port = process.env.PLAYWRIGHT_PORT || "5173";
+const host = process.env.PLAYWRIGHT_HOST || "localhost";
+const origin = `http://${host}:${port}`;
+
+const SEARCH_INPUT = 'input[placeholder*="search"]';
+const PDF_SCROLL_CONTAINER = ".pdfViewer";
+
+async function getScrollTop(page: import("@playwright/test").Page) {
+ return await page.evaluate(() => {
+ const viewer = document.querySelector(".pdfViewer");
+ return viewer?.parentElement?.scrollTop ?? 0;
+ });
+}
+
+test.describe("URL ?search and ?section parsing", () => {
+ test("fills search input when URL has ?search (no version segment)", async ({ page }) => {
+ await page.goto(`${origin}/#/?search=protocol`, { waitUntil: "networkidle" });
+ await waitForPdfReady(page);
+
+ const input = page.locator(SEARCH_INPUT).first();
+ await expect(input).toHaveValue("protocol");
+ });
+
+ test("fills search input when URL has ?search with a version segment", async ({ page }) => {
+ await page.goto(`${origin}/#/0.7.2?search=protocol`, { waitUntil: "networkidle" });
+ await waitForPdfReady(page);
+
+ const input = page.locator(SEARCH_INPUT).first();
+ await expect(input).toHaveValue("protocol");
+ });
+
+ test("scrolls to section when URL has ?section (no version segment)", async ({ page }) => {
+ await page.goto(`${origin}/#/?section=Header`, { waitUntil: "networkidle" });
+ await waitForPdfReady(page);
+ await page.locator(PDF_SCROLL_CONTAINER).waitFor({ state: "visible" });
+
+ await expect.poll(() => getScrollTop(page), { timeout: 8_000, intervals: [200] }).toBeGreaterThan(1_000);
+ });
+
+ test("scrolls to section AND fills search when URL has both", async ({ page }) => {
+ await page.goto(`${origin}/#/?search=hash§ion=Header`, { waitUntil: "networkidle" });
+ await waitForPdfReady(page);
+
+ const input = page.locator(SEARCH_INPUT).first();
+ await expect(input).toHaveValue("hash");
+
+ await expect.poll(() => getScrollTop(page), { timeout: 8_000, intervals: [200] }).toBeGreaterThan(1_000);
+ });
+
+ test("subsequent navigation drops ?search/?section from the URL", async ({ page }) => {
+ // Open with search/section so they end up in URL after the redirect.
+ await page.goto(`${origin}/#/?search=protocol§ion=Header`, { waitUntil: "networkidle" });
+ await waitForPdfReady(page);
+ await expect(page).toHaveURL(/[?&]search=protocol/);
+ await expect(page).toHaveURL(/[?&]section=Header/);
+
+ // Simulate the user navigating to a "plain" URL within the same SPA session
+ // (e.g. version change from the UI). The next URL must not retain search/section.
+ await page.evaluate(() => {
+ window.location.hash = "#/0.7.2?v=0.7.2";
+ });
+
+ await expect(page).not.toHaveURL(/[?&]search=/);
+ await expect(page).not.toHaveURL(/[?&]section=/);
+ });
+});
diff --git a/tools/snapshot-tests/tests/utils/add-notes-to-local-storage.ts b/tools/snapshot-tests/tests/utils/add-notes-to-local-storage.ts
new file mode 100644
index 00000000..29032ed0
--- /dev/null
+++ b/tools/snapshot-tests/tests/utils/add-notes-to-local-storage.ts
@@ -0,0 +1,43 @@
+const notes = {
+ version: 3,
+ notes: [
+ {
+ noteVersion: 3,
+ content: "asdasdasd",
+ date: 1759742271192,
+ author: "",
+ selectionStart: { pageNumber: 1, index: 88 },
+ selectionEnd: { pageNumber: 1, index: 96 },
+ version: "ab2cdbd5b070ba2176e8dd830b06401ce05a954d",
+ labels: ["local"],
+ },
+ {
+ noteVersion: 3,
+ content: "sadasd",
+ date: 1759742258606,
+ author: "",
+ selectionStart: { pageNumber: 1, index: 85 },
+ selectionEnd: { pageNumber: 1, index: 86 },
+ version: "ab2cdbd5b070ba2176e8dd830b06401ce05a954d",
+ labels: ["local"],
+ },
+ ],
+};
+
+const labels = [
+ { label: "local/local", isActive: true },
+ { label: "local", isActive: true },
+ { label: "remote/fluffy-labs", isActive: true },
+ { label: "remote", isActive: true },
+];
+
+export const initialLocalStorage = [
+ {
+ name: "notes-v2",
+ value: JSON.stringify(notes),
+ },
+ {
+ name: "labels-v1",
+ value: JSON.stringify(labels),
+ },
+];
diff --git a/tools/snapshot-tests/tests/utils/screenshot-opts.ts b/tools/snapshot-tests/tests/utils/screenshot-opts.ts
new file mode 100644
index 00000000..394315ec
--- /dev/null
+++ b/tools/snapshot-tests/tests/utils/screenshot-opts.ts
@@ -0,0 +1,20 @@
+import type { Page } from "@playwright/test";
+
+/**
+ * Build `toHaveScreenshot` options that mask out the rendered PDF.
+ *
+ * The PDF.js canvas can render a few pixels off between otherwise identical
+ * runs. That shift is not a regression we care about in these layout/chrome
+ * snapshots, but it trips the pixel-diff threshold because shifted text
+ * changes a band of pixels around every glyph. Masking the PDF viewer(s) lets
+ * us keep a tight threshold on the surrounding UI while ignoring the
+ * inherently non-deterministic PDF region.
+ *
+ * The selector matches both the main viewer and any split-pane viewer.
+ */
+export function maskedPdfScreenshot(page: Page) {
+ return {
+ fullPage: true,
+ mask: [page.locator(".pdf-viewer-root")],
+ };
+}
diff --git a/tools/snapshot-tests/tests/utils/wait-for-pdf.ts b/tools/snapshot-tests/tests/utils/wait-for-pdf.ts
new file mode 100644
index 00000000..8725de37
--- /dev/null
+++ b/tools/snapshot-tests/tests/utils/wait-for-pdf.ts
@@ -0,0 +1,17 @@
+import type { Page } from "@playwright/test";
+
+/**
+ * Wait for the main PDF viewer to fully render (pages loaded + scale applied).
+ * The PdfViewer component sets data-pdf-ready on its root once the
+ * "pagesloaded" event has fired and the initial scale has been applied.
+ */
+export async function waitForPdfReady(page: Page, timeout = 15_000) {
+ await page.locator("[data-pdf-ready]").first().waitFor({ state: "visible", timeout });
+}
+
+/**
+ * Wait for the split-pane PDF viewer to fully render.
+ */
+export async function waitForSplitPdfReady(page: Page, timeout = 15_000) {
+ await page.locator(".split-pane-view [data-pdf-ready]").first().waitFor({ state: "visible", timeout });
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
index ef6be00e..53cfc7fc 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -2,9 +2,9 @@
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
- "target": "ES2020",
+ "target": "ES2022",
"useDefineForClassFields": true,
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
diff --git a/tsconfig.node.json b/tsconfig.node.json
index 3afdd6e3..ff5ae7d6 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -9,5 +9,5 @@
"strict": true,
"noEmit": true
},
- "include": ["vite.config.ts"]
+ "include": ["vite.config.ts", "src/config/webmanifestPlugin.ts"]
}
diff --git a/update-archive.sh b/update-archive.sh
deleted file mode 100755
index 876a63b2..00000000
--- a/update-archive.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/bin/sh
-#
-cd graypaper-archive
-git pull
-cd ..
-git commit -am "Bump graypaper archive"
diff --git a/vite.config.ts b/vite.config.ts
index 10aad95a..ad07e26f 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,8 +1,14 @@
+import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
-import { defineConfig } from "vite";
+import { defineConfig, loadEnv } from "vite";
import prefetchChunk from "vite-plugin-prefetch-chunk";
+import { webmanifestPlugin } from "./src/config/webmanifestPlugin";
// https://vitejs.dev/config/
-export default defineConfig({
- plugins: [react(), prefetchChunk()],
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd());
+ return {
+ base: env.VITE_DOC_BASE_PATH || "/",
+ plugins: [react(), prefetchChunk(), tailwindcss(), webmanifestPlugin(env)],
+ };
});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 00000000..81f800b2
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ exclude: ["**/node_modules/**", "**/dist/**", "tools/**/*"],
+ environment: "jsdom",
+ setupFiles: ["./vitest.setup.ts"],
+ env: {
+ VITE_DOC_ID: "graypaper",
+ VITE_DOC_NAME: "Gray Paper",
+ VITE_DOC_BASE_PATH: "/",
+ VITE_DOC_METADATA_HOST: "https://gp.fluffylabs.dev",
+ VITE_DOC_READER_DOMAIN: "https://graypaper.fluffylabs.dev",
+ VITE_DOC_LEGACY_READER_HOST: "https://old-graypaper.fluffylabs.dev",
+ VITE_DOC_PDF_FILE_PREFIX: "graypaper",
+ VITE_DOC_LOCAL_STORAGE_PREFIX: "gp",
+ VITE_DOC_SEARCH_PLACEHOLDER: "search the Gray Paper",
+ VITE_DOC_DISCLAIMER_SUBJECT: "the Gray Paper",
+ },
+ },
+});
diff --git a/vitest.setup.ts b/vitest.setup.ts
new file mode 100644
index 00000000..d27a95b4
--- /dev/null
+++ b/vitest.setup.ts
@@ -0,0 +1,55 @@
+import { cleanup } from "@testing-library/react";
+import { afterEach } from "vitest";
+
+// Auto-cleanup @testing-library/react after each test
+afterEach(() => {
+ cleanup();
+});
+
+// Polyfill DOMMatrix which pdfjs-dist requires but jsdom doesn't provide
+if (typeof globalThis.DOMMatrix === "undefined") {
+ // biome-ignore lint/suspicious/noExplicitAny: minimal polyfill for test environment
+ globalThis.DOMMatrix = class DOMMatrix {} as any;
+}
+
+// Polyfill ResizeObserver for components that use it
+if (typeof globalThis.ResizeObserver === "undefined") {
+ globalThis.ResizeObserver = class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ // biome-ignore lint/suspicious/noExplicitAny: minimal polyfill for test environment
+ } as any;
+}
+
+// Polyfill matchMedia for hooks that use media queries
+if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
+ window.matchMedia = (query: string) =>
+ ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ }) as MediaQueryList;
+}
+
+// Ensure localStorage is available (jsdom sometimes fails to provide it)
+if (typeof globalThis.localStorage === "undefined" || typeof globalThis.localStorage.getItem !== "function") {
+ const storage = new Map();
+ const localStoragePolyfill = {
+ getItem: (key: string) => storage.get(key) ?? null,
+ setItem: (key: string, value: string) => storage.set(key, String(value)),
+ removeItem: (key: string) => storage.delete(key),
+ clear: () => storage.clear(),
+ get length() {
+ return storage.size;
+ },
+ key: (index: number) => [...storage.keys()][index] ?? null,
+ };
+ Object.defineProperty(globalThis, "localStorage", { value: localStoragePolyfill, writable: true });
+ Object.defineProperty(window, "localStorage", { value: localStoragePolyfill, writable: true });
+}