diff --git a/.env.example b/.env.example
index e17457821..1a8354876 100644
--- a/.env.example
+++ b/.env.example
@@ -12,3 +12,8 @@ BUZZ_DEV_VIEWER=
# Keep the same alias mapped to the same origin to retain existing memberships.
# Values are public configuration included in the frontend bundle, never secrets.
# BUZZ_COMMUNITY_ALIASES='{"primary":"wss://relay.example.com","secondary":"wss://other.example.com"}'
+
+# Optional: pause notifications in this checkout without changing saved choices.
+# Only 0 pauses them; unset keeps normal notification behavior and OS permissions.
+# Restart the dev server after changing this. Production builds ignore it.
+# BUZZ_DEV_NOTIFICATIONS=0
diff --git a/dev/vite-config.test.mjs b/dev/vite-config.test.mjs
index 898611403..60ae5d35f 100644
--- a/dev/vite-config.test.mjs
+++ b/dev/vite-config.test.mjs
@@ -13,12 +13,49 @@ it("loads the broker's Vite config without native-compatibility warnings", () =>
"-e",
`
import assert from 'node:assert/strict';
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+ import { tmpdir } from 'node:os';
+ import { join } from 'node:path';
import { loadConfigFromFile } from 'vite';
- const loaded = await loadConfigFromFile(
- { command: 'serve', mode: 'development' },
- );
- assert(loaded?.config.plugins.some(p => p?.name === 'buzz-relay-broker'));
- assert.equal(loaded.config.define['import.meta.env.VITE_BUZZ_LIVE'], '"1"');
+ const configFile = join(process.cwd(), 'vite.config.ts');
+ // Load the real config with isolated .env files, never personal settings.
+ const directory = mkdtempSync(join(tmpdir(), 'buzz-vite-config-'));
+ const cwd = process.cwd();
+ try {
+ process.chdir(directory);
+ const loaded = await loadConfigFromFile(
+ { command: 'serve', mode: 'development' }, configFile,
+ );
+ assert(loaded?.config.plugins.some(p => p?.name === 'buzz-relay-broker'));
+ assert.equal(loaded.config.define['import.meta.env.VITE_BUZZ_LIVE'], '"1"');
+ for (const command of ['serve', 'build']) {
+ for (const setting of [undefined, '', '0', '1', 'true', 'invalid']) {
+ if (setting === undefined) delete process.env.BUZZ_DEV_NOTIFICATIONS;
+ else process.env.BUZZ_DEV_NOTIFICATIONS = setting;
+ const result = await loadConfigFromFile(
+ { command, mode: command === 'serve' ? 'development' : 'production' }, configFile,
+ );
+ assert.equal(
+ result.config.define['import.meta.env.VITE_BUZZ_NOTIFICATIONS_PAUSED'],
+ JSON.stringify(command === 'serve' && setting === '0' ? '1' : '0'),
+ );
+ }
+ }
+ delete process.env.BUZZ_DEV_NOTIFICATIONS;
+ writeFileSync('.env.local', 'BUZZ_DEV_NOTIFICATIONS=0');
+ for (const command of ['serve', 'build']) {
+ const result = await loadConfigFromFile(
+ { command, mode: 'development' }, configFile,
+ );
+ assert.equal(
+ result.config.define['import.meta.env.VITE_BUZZ_NOTIFICATIONS_PAUSED'],
+ JSON.stringify(command === 'serve' ? '1' : '0'),
+ );
+ }
+ } finally {
+ process.chdir(cwd);
+ rmSync(directory, { recursive: true, force: true });
+ }
`,
],
{
diff --git a/docs/contributing.md b/docs/contributing.md
index 464929d33..e007f8ecf 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -47,6 +47,10 @@ need their own validation.
- `just web`: install locked dependencies and start Vite on port 1430 or the
next available port, allowing parallel browser development across worktrees.
- `just desktop`: install locked dependencies and start Tauri, which starts Vite.
+- To pause notifications in your local dev server, set `BUZZ_DEV_NOTIFICATIONS=0`
+ in `.env.local` and restart the server. Only `0` pauses alerts and permission
+ requests; removing the setting restores normal behavior. Saved preferences are
+ untouched and production builds ignore the variable.
- `just fullstack`: reserved, exits unsuccessfully with an explanation. It will
eventually start local Docker services including the Buzz relay backend.
- `just iterate`: install locked dependencies, format Rust, apply Biome safe
diff --git a/docs/notifications.md b/docs/notifications.md
index 3b1e4971f..2a63fd1a5 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -3,6 +3,12 @@
The host provides one `notifications` service for built-in messages and trusted
plugins. Settings → Notifications stores account-local choices: alerts are on
by default, subject to system permission; master off preserves category choices.
+To pause all alerts and permission requests in a local dev server, set
+`BUZZ_DEV_NOTIFICATIONS=0` in `.env.local` and restart the server. Only `0` pauses
+notifications; unset or any other value keeps normal behavior. This development
+gate never rewrites saved preferences. Settings shows the pause and how to remove
+it; normal behavior still honors account choices and system permission.
+Production builds ignore this variable.
Browser sound uses the Notification API. Desktop sound is managed in OS settings;
there is no separate audio player.
diff --git a/src/app/NotificationSettings.test.tsx b/src/app/NotificationSettings.test.tsx
new file mode 100644
index 000000000..29ca44bc3
--- /dev/null
+++ b/src/app/NotificationSettings.test.tsx
@@ -0,0 +1,90 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { Context } from "@deepseek-ai/cordis";
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, expect, it, vi } from "vitest";
+import { provideNavigation } from "../features/navigation/service";
+import { NotificationsService } from "../features/notifications/service";
+import { NotificationSettings } from "./NotificationSettings";
+import { PluginRuntime } from "../plugins/runtime";
+
+const contexts: Context[] = [];
+afterEach(async () => {
+ cleanup();
+ for (const ctx of contexts.splice(0)) await ctx.fiber.dispose();
+ localStorage.clear();
+ vi.unstubAllEnvs();
+});
+
+it.each([true, false])(
+ "represents development pause=%s without changing saved preferences",
+ async (paused) => {
+ vi.stubEnv("VITE_BUZZ_NOTIFICATIONS_PAUSED", paused ? "1" : "0");
+ const ctx = new Context();
+ contexts.push(ctx);
+ const runtime = new PluginRuntime(ctx, async () => ({ apply() {} }));
+ ctx.effect(() => () => runtime.dispose());
+ let permission: "default" | "granted" = "default";
+ const platform = {
+ label: "Browser",
+ permission: vi.fn(async () => permission),
+ requestPermission: vi.fn(async () => {
+ permission = "granted";
+ return permission;
+ }),
+ show: vi.fn(async () => {}),
+ dispose() {},
+ };
+ const service = new NotificationsService(
+ ctx,
+ provideNavigation(ctx).navigation,
+ platform,
+ );
+ service.selectViewer("a".repeat(64));
+ service.updatePreferences({ enabled: true });
+ const saved = localStorage.getItem(
+ `buzz-notification-preferences.v1:${"a".repeat(64)}`,
+ );
+ render(
- {state.requesting - ? "Waiting for system permission…" - : permission === "granted" - ? "Permission granted. Your alert choices still apply." - : permission === "denied" - ? "Blocked. Allow notifications in your browser or system settings." - : permission === "unsupported" - ? "System notifications are unavailable in this build." - : permission === "unknown" - ? "Permission is controlled by system notification settings." - : "Allow notifications to receive alerts."} + {state.developmentPaused + ? "Notifications are paused by your local development setting. Remove BUZZ_DEV_NOTIFICATIONS=0 from .env.local and restart the dev server to resume normal behavior. Your saved alert choices are unchanged." + : state.requesting + ? "Waiting for system permission…" + : permission === "granted" + ? "Permission granted. Your alert choices still apply." + : permission === "denied" + ? "Blocked. Allow notifications in your browser or system settings." + : permission === "unsupported" + ? "System notifications are unavailable in this build." + : permission === "unknown" + ? "Permission is controlled by system notification settings." + : "Allow notifications to receive alerts."}
- {!state.systemManaged && ( + {!state.systemManaged && !state.developmentPaused && (