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==}