diff --git a/desktop/package.json b/desktop/package.json
index a85e97aebe..2b14396462 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -70,7 +70,8 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tiptap-markdown": "^0.9.0",
- "yaml": "^2.8.3"
+ "yaml": "^2.8.3",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@noble/hashes": "^2.0.1",
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index e5a2dd846e..78f6655e8e 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -35,7 +35,6 @@ export default defineConfig({
"**/workflows.spec.ts",
"**/identity-archive.spec.ts",
"**/identity-archive-hide.spec.ts",
- "**/screenshot-feature-flags.ts",
],
use: {
...devices["Desktop Chrome"],
diff --git a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx
index 85d34ed8e3..084a363b05 100644
--- a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx
+++ b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx
@@ -1,6 +1,7 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
+import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ForumPostRouteSearch = {
@@ -29,6 +30,7 @@ const ChannelRouteScreen = React.lazy(async () => {
});
function ForumPostRouteComponent() {
+ usePreviewFeatureWarning("forum");
const { channelId, postId } = Route.useParams();
const search = Route.useSearch();
diff --git a/desktop/src/app/routes/projects.$projectId.tsx b/desktop/src/app/routes/projects.$projectId.tsx
index 720544b8df..b4389c76bb 100644
--- a/desktop/src/app/routes/projects.$projectId.tsx
+++ b/desktop/src/app/routes/projects.$projectId.tsx
@@ -1,6 +1,7 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
+import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
const ProjectDetailScreen = React.lazy(async () => {
@@ -13,6 +14,7 @@ export const Route = createFileRoute("/projects/$projectId")({
});
function ProjectDetailRouteComponent() {
+ usePreviewFeatureWarning("projects");
const { projectId } = Route.useParams();
return (
diff --git a/desktop/src/app/routes/projects.tsx b/desktop/src/app/routes/projects.tsx
index fa1a65ff5c..a0affa93e4 100644
--- a/desktop/src/app/routes/projects.tsx
+++ b/desktop/src/app/routes/projects.tsx
@@ -1,6 +1,7 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
+import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
const ProjectsScreen = React.lazy(async () => {
@@ -13,6 +14,7 @@ export const Route = createFileRoute("/projects")({
});
function ProjectsRouteComponent() {
+ usePreviewFeatureWarning("projects");
return (
}
diff --git a/desktop/src/app/routes/pulse.tsx b/desktop/src/app/routes/pulse.tsx
index 5bab0672ef..bac2698b49 100644
--- a/desktop/src/app/routes/pulse.tsx
+++ b/desktop/src/app/routes/pulse.tsx
@@ -1,7 +1,7 @@
import * as React from "react";
-import { createFileRoute, Navigate } from "@tanstack/react-router";
+import { createFileRoute } from "@tanstack/react-router";
-import { FeatureGate } from "@/shared/features";
+import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
const PulseScreen = React.lazy(async () => {
@@ -14,13 +14,12 @@ export const Route = createFileRoute("/pulse")({
});
function PulseRouteComponent() {
+ usePreviewFeatureWarning("pulse");
return (
- }>
- }
- >
-
-
-
+ }
+ >
+
+
);
}
diff --git a/desktop/src/app/routes/workflows.$workflowId.tsx b/desktop/src/app/routes/workflows.$workflowId.tsx
index 2639f86466..22689d3fde 100644
--- a/desktop/src/app/routes/workflows.$workflowId.tsx
+++ b/desktop/src/app/routes/workflows.$workflowId.tsx
@@ -1,6 +1,7 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
+import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
export const Route = createFileRoute("/workflows/$workflowId")({
@@ -13,6 +14,7 @@ const WorkflowsRouteScreen = React.lazy(async () => {
});
function WorkflowDetailRouteComponent() {
+ usePreviewFeatureWarning("workflows");
const { workflowId } = Route.useParams();
return (
diff --git a/desktop/src/app/routes/workflows.tsx b/desktop/src/app/routes/workflows.tsx
index 00d9a26d79..6d23971d3e 100644
--- a/desktop/src/app/routes/workflows.tsx
+++ b/desktop/src/app/routes/workflows.tsx
@@ -1,6 +1,7 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
+import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
export const Route = createFileRoute("/workflows")({
@@ -13,6 +14,7 @@ const WorkflowsRouteScreen = React.lazy(async () => {
});
function WorkflowsRouteComponent() {
+ usePreviewFeatureWarning("workflows");
return (
}
diff --git a/desktop/src/shared/features/index.ts b/desktop/src/shared/features/index.ts
index 4276e345bb..380ac27084 100644
--- a/desktop/src/shared/features/index.ts
+++ b/desktop/src/shared/features/index.ts
@@ -1,5 +1,5 @@
export { FeatureGate } from "./FeatureGate";
-export { allFeatures, desktopFeatures, getFeature } from "./manifest";
+export { allFeatures, desktopFeatures, getFeature, manifest } from "./manifest";
export { getOverrides, setOverride, clearOverride } from "./store";
export type {
FeatureDefinition,
@@ -11,5 +11,6 @@ export {
useFeatureEnabled,
useFeatureToggle,
useFeatureSnapshot,
+ usePreviewFeatureWarning,
resolveEnabled,
} from "./useFeatureEnabled";
diff --git a/desktop/src/shared/features/manifest.ts b/desktop/src/shared/features/manifest.ts
index 42141c00c6..8797854c2e 100644
--- a/desktop/src/shared/features/manifest.ts
+++ b/desktop/src/shared/features/manifest.ts
@@ -1,7 +1,48 @@
import manifestJson from "@features-manifest";
+import { z } from "zod";
import type { FeatureDefinition, FeaturesManifest } from "./types";
-const manifest = manifestJson as FeaturesManifest;
+// ---------------------------------------------------------------------------
+// Schema — runtime-validates the bundled features.json at startup.
+//
+// On parse failure we fall back to an empty manifest and log a console warning.
+// The app keeps working; gated UI stays hidden; nothing accidentally leaks.
+// ---------------------------------------------------------------------------
+
+const FeatureTierSchema = z.enum(["stable", "preview"]);
+const FeaturePlatformSchema = z.enum(["desktop", "mobile"]);
+
+const FeatureDefinitionSchema = z.object({
+ id: z.string().min(1),
+ name: z.string().min(1),
+ description: z.string(),
+ tier: FeatureTierSchema,
+ platforms: z.array(FeaturePlatformSchema).optional(),
+});
+
+const FeaturesManifestSchema = z.object({
+ version: z.number().int().nonnegative(),
+ features: z.array(FeatureDefinitionSchema),
+});
+
+const EMPTY_MANIFEST: FeaturesManifest = { version: 1, features: [] };
+
+function loadManifest(): FeaturesManifest {
+ const result = FeaturesManifestSchema.safeParse(manifestJson);
+ if (!result.success) {
+ console.warn(
+ "[FeatureFlags] features.json failed schema validation; falling back to empty manifest.",
+ result.error.issues,
+ );
+ return EMPTY_MANIFEST;
+ }
+ return result.data;
+}
+
+const manifest = loadManifest();
+
+/** The validated manifest. Use `manifest.version` for cache/storage keys. */
+export { manifest };
/** All features defined in the manifest */
export const allFeatures: FeatureDefinition[] = manifest.features;
diff --git a/desktop/src/shared/features/resolveEnabled.test.mjs b/desktop/src/shared/features/resolveEnabled.test.mjs
index 9edf80471d..00ed0bbfe8 100644
--- a/desktop/src/shared/features/resolveEnabled.test.mjs
+++ b/desktop/src/shared/features/resolveEnabled.test.mjs
@@ -6,11 +6,8 @@ import { resolveEnabled } from "./resolveEnabled.ts";
describe("resolveEnabled", () => {
describe("stable tier", () => {
it("always returns true regardless of overrides", () => {
- assert.equal(resolveEnabled("stable", "channels", {}), true);
- assert.equal(
- resolveEnabled("stable", "channels", { channels: false }),
- true,
- );
+ assert.equal(resolveEnabled("stable", "doctor", {}), true);
+ assert.equal(resolveEnabled("stable", "doctor", { doctor: false }), true);
});
});
diff --git a/desktop/src/shared/features/store.ts b/desktop/src/shared/features/store.ts
index 0ce183dd75..ee55fc3925 100644
--- a/desktop/src/shared/features/store.ts
+++ b/desktop/src/shared/features/store.ts
@@ -1,11 +1,15 @@
/**
* Persistence layer for feature flag overrides.
*
- * localStorage keys (versioned to match manifest):
- * sprout-feature-overrides-v1 — JSON object of { [featureId]: boolean }
+ * The localStorage key is derived from `manifest.version` so a schema bump
+ * naturally orphans the old key — clean reset, no migration logic.
+ *
+ * sprout-feature-overrides-v${manifest.version}
+ * → JSON object of { [featureId]: boolean }
*/
+import { manifest } from "./manifest";
-const OVERRIDES_KEY = "sprout-feature-overrides-v1";
+export const OVERRIDES_KEY = `sprout-feature-overrides-v${manifest.version}`;
export type FeatureOverrides = Record;
diff --git a/desktop/src/shared/features/useFeatureEnabled.ts b/desktop/src/shared/features/useFeatureEnabled.ts
index 05d1f3430a..cf991185b4 100644
--- a/desktop/src/shared/features/useFeatureEnabled.ts
+++ b/desktop/src/shared/features/useFeatureEnabled.ts
@@ -1,7 +1,7 @@
-import { useSyncExternalStore, useCallback } from "react";
+import { useSyncExternalStore, useCallback, useEffect } from "react";
import { getFeature } from "./manifest";
import { resolveEnabled } from "./resolveEnabled";
-import { getOverrides, setOverride } from "./store";
+import { getOverrides, setOverride, OVERRIDES_KEY } from "./store";
// ---------------------------------------------------------------------------
// Reactive store — components re-render when overrides change
@@ -12,7 +12,22 @@ const listeners = new Set();
function subscribe(listener: Listener): () => void {
listeners.add(listener);
- return () => listeners.delete(listener);
+
+ // Cross-window sync: another window writing the overrides key in
+ // localStorage fires a "storage" event in this window. Mirror the
+ // pattern used by useChannelSections / useChannelStars / useChannelMutes /
+ // useThreadFollows.
+ const handleStorage = (event: StorageEvent) => {
+ if (event.key === OVERRIDES_KEY) {
+ emitChange();
+ }
+ };
+ window.addEventListener("storage", handleStorage);
+
+ return () => {
+ listeners.delete(listener);
+ window.removeEventListener("storage", handleStorage);
+ };
}
/** Notify all subscribers that feature state changed */
@@ -24,7 +39,13 @@ export function emitChange(): void {
}
// ---------------------------------------------------------------------------
-// Cached snapshot — avoids JSON.parse on every render per hook instance
+// Cached snapshot
+//
+// useSyncExternalStore requires getSnapshot to return a referentially stable
+// value when nothing has changed. Returning `JSON.stringify(getOverrides())`
+// fresh on every render would produce a new string each tick → infinite
+// re-render. We cache the serialized form and only mint a new parsed object
+// when the serialized form changes.
// ---------------------------------------------------------------------------
let cachedRaw: string | null = null;
@@ -39,6 +60,16 @@ function getSnapshot(): string {
return raw;
}
+/**
+ * Server-side snapshot for useSyncExternalStore.
+ *
+ * Sprout is a Tauri desktop app and does not currently SSR. Returning an
+ * explicit empty-state snapshot is safer than omitting this argument: under
+ * any future test harness or SSR experiment, the hook returns "no overrides"
+ * instead of throwing.
+ */
+const getServerSnapshot = (): string => "{}";
+
function getParsedSnapshot(): Record {
// Ensure snapshot is fresh
getSnapshot();
@@ -55,7 +86,7 @@ function getParsedSnapshot(): Record {
* Use this in components that need the full state (e.g. SettingsView filtering).
*/
export function useFeatureSnapshot(): Record {
- useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+ useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return getParsedSnapshot();
}
@@ -100,5 +131,37 @@ export function useFeatureToggle(
return [enabled, toggle];
}
+/**
+ * Fires a sonner toast.warning when a preview feature is currently disabled.
+ *
+ * Usage: drop in at the top of a route component to give users hitting a
+ * direct link to a disabled preview feature a hint about how to surface it.
+ *
+ * function PulseRouteComponent() {
+ * usePreviewFeatureWarning("pulse");
+ * return ;
+ * }
+ *
+ * Stays a no-op for stable features and for preview features that ARE enabled.
+ */
+export function usePreviewFeatureWarning(featureId: string): void {
+ const enabled = useFeatureEnabled(featureId);
+ const feature = getFeature(featureId);
+
+ useEffect(() => {
+ if (feature?.tier !== "preview" || enabled) return;
+ let cancelled = false;
+ void import("sonner").then(({ toast }) => {
+ if (cancelled) return;
+ toast.warning(
+ `${feature.name} is a preview feature. Enable it in Settings → Experiments to surface it in your sidebar.`,
+ );
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [feature, enabled]);
+}
+
// Re-export for consumers that imported from here
export { resolveEnabled } from "./resolveEnabled";
diff --git a/desktop/tests/e2e/screenshots/settings-experiments-all-on.png b/desktop/tests/e2e/screenshots/settings-experiments-all-on.png
index 07c8446ae8..34c5f4a1b7 100644
Binary files a/desktop/tests/e2e/screenshots/settings-experiments-all-on.png and b/desktop/tests/e2e/screenshots/settings-experiments-all-on.png differ
diff --git a/desktop/tests/e2e/screenshots/settings-experiments-default.png b/desktop/tests/e2e/screenshots/settings-experiments-default.png
index 220023588a..682d6f4819 100644
Binary files a/desktop/tests/e2e/screenshots/settings-experiments-default.png and b/desktop/tests/e2e/screenshots/settings-experiments-default.png differ
diff --git a/desktop/tests/e2e/screenshots/view-forum-posts.png b/desktop/tests/e2e/screenshots/view-forum-posts.png
index dfd4e6bb94..f91872db9d 100644
Binary files a/desktop/tests/e2e/screenshots/view-forum-posts.png and b/desktop/tests/e2e/screenshots/view-forum-posts.png differ
diff --git a/desktop/tests/e2e/screenshots/view-forum-thread.png b/desktop/tests/e2e/screenshots/view-forum-thread.png
index 6245b7d240..5c3e822278 100644
Binary files a/desktop/tests/e2e/screenshots/view-forum-thread.png and b/desktop/tests/e2e/screenshots/view-forum-thread.png differ
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8cb117e13e..f0cdeff657 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -171,6 +171,9 @@ importers:
yaml:
specifier: ^2.8.3
version: 2.9.0
+ zod:
+ specifier: ^4.4.3
+ version: 4.4.3
devDependencies:
'@noble/hashes':
specifier: ^2.0.1
@@ -1303,7 +1306,6 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.2':
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
@@ -1444,14 +1446,12 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
@@ -1608,7 +1608,6 @@ packages:
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
@@ -3027,7 +3026,7 @@ packages:
engines: {node: '>= 0.4'}
wrappy@1.0.2:
- resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+ resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}