refactor: rename tiers to stable/preview/unstable, remove dev-build gate
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@@ -1,4 +1,4 @@
|
||||
import { desktopFeatures, useFeatureToggle, useDevToggle } from "@/shared/features";
|
||||
import { desktopFeatures, useFeatureToggle } from "@/shared/features";
|
||||
import type { FeatureDefinition } from "@/shared/features";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
|
||||
@@ -21,19 +21,18 @@ function FeatureRow({ feature }: { feature: FeatureDefinition }) {
|
||||
}
|
||||
|
||||
export function ExperimentalFeaturesCard() {
|
||||
const [devEnabled, setDevEnabled] = useDevToggle();
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
const experimentalFeatures = desktopFeatures.filter(
|
||||
(f) => f.tier === "experimental",
|
||||
const previewFeatures = desktopFeatures.filter(
|
||||
(f) => f.tier === "preview",
|
||||
);
|
||||
const unstableFeatures = desktopFeatures.filter(
|
||||
(f) => f.tier === "unstable",
|
||||
);
|
||||
const devFeatures = desktopFeatures.filter((f) => f.tier === "dev");
|
||||
|
||||
return (
|
||||
<section className="min-w-0" data-testid="settings-experimental">
|
||||
<div className="mb-3 min-w-0">
|
||||
<h2 className="text-sm font-semibold tracking-tight">
|
||||
Experimental Features
|
||||
Preview
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
These features are functional but still being refined. Enable them to
|
||||
@@ -42,44 +41,28 @@ export function ExperimentalFeaturesCard() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{experimentalFeatures.map((f) => (
|
||||
{previewFeatures.map((f) => (
|
||||
<FeatureRow feature={f} key={f.id} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isDev && (
|
||||
{unstableFeatures.length > 0 && (
|
||||
<>
|
||||
<div className="mb-3 mt-6 min-w-0">
|
||||
<h2 className="text-sm font-semibold tracking-tight">
|
||||
Developer Features
|
||||
Unstable
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Only visible in development builds. Toggle the master switch to
|
||||
hide all dev features.
|
||||
Here be dragons — these features might break or disappear. Enable
|
||||
at your own risk.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="mb-3 flex items-center justify-between gap-3 rounded-lg border border-primary/30 bg-primary/5 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">Show developer features</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When off, all dev-tier features are hidden
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={devEnabled}
|
||||
data-testid="feature-toggle-dev-global"
|
||||
onCheckedChange={setDevEnabled}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{devEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{devFeatures.map((f) => (
|
||||
<FeatureRow feature={f} key={f.id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{unstableFeatures.map((f) => (
|
||||
<FeatureRow feature={f} key={f.id} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -100,7 +100,7 @@ export const settingsSections: SettingsSectionDescriptor[] = [
|
||||
},
|
||||
{
|
||||
value: "experimental",
|
||||
label: "Experimental",
|
||||
label: "Preview & Unstable",
|
||||
icon: FlaskConical,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -127,7 +127,7 @@ export function SettingsView({
|
||||
// Feature gate check
|
||||
if (s.featureGate) {
|
||||
const feature = getFeature(s.featureGate);
|
||||
if (feature && !resolveEnabled(feature.tier, feature.id, featureState.o, featureState.d)) {
|
||||
if (feature && !resolveEnabled(feature.tier, feature.id, featureState)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ export type {
|
||||
export {
|
||||
useFeatureEnabled,
|
||||
useFeatureToggle,
|
||||
useDevToggle,
|
||||
useFeatureSnapshot,
|
||||
resolveEnabled,
|
||||
} from "./useFeatureEnabled";
|
||||
|
||||
@@ -5,64 +5,48 @@ import { resolveEnabled } from "./resolveEnabled.ts";
|
||||
|
||||
describe("resolveEnabled", () => {
|
||||
describe("stable tier", () => {
|
||||
it("always returns true regardless of overrides or env", () => {
|
||||
assert.equal(resolveEnabled("stable", "channels", {}, false, false), true);
|
||||
assert.equal(resolveEnabled("stable", "channels", { channels: false }, false, true), true);
|
||||
assert.equal(resolveEnabled("stable", "channels", {}, true, true), true);
|
||||
it("always returns true regardless of overrides", () => {
|
||||
assert.equal(resolveEnabled("stable", "channels", {}), true);
|
||||
assert.equal(resolveEnabled("stable", "channels", { channels: false }), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("experimental tier", () => {
|
||||
describe("preview tier", () => {
|
||||
it("returns false by default (no override)", () => {
|
||||
assert.equal(resolveEnabled("experimental", "workflows", {}, true, true), false);
|
||||
assert.equal(resolveEnabled("preview", "workflows", {}), false);
|
||||
});
|
||||
|
||||
it("returns true when user opts in", () => {
|
||||
assert.equal(
|
||||
resolveEnabled("experimental", "workflows", { workflows: true }, true, true),
|
||||
resolveEnabled("preview", "workflows", { workflows: true }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when user explicitly opts out", () => {
|
||||
assert.equal(
|
||||
resolveEnabled("experimental", "workflows", { workflows: false }, true, true),
|
||||
resolveEnabled("preview", "workflows", { workflows: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores dev toggle and isDev", () => {
|
||||
assert.equal(
|
||||
resolveEnabled("experimental", "workflows", { workflows: true }, false, false),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dev tier", () => {
|
||||
it("returns false in production builds even with devToggle on", () => {
|
||||
assert.equal(resolveEnabled("dev", "doctor", {}, true, false), false);
|
||||
describe("unstable tier", () => {
|
||||
it("returns false by default (no override)", () => {
|
||||
assert.equal(resolveEnabled("unstable", "mesh-compute", {}), false);
|
||||
});
|
||||
|
||||
it("returns false in dev builds when devToggle is off", () => {
|
||||
assert.equal(resolveEnabled("dev", "doctor", {}, false, true), false);
|
||||
});
|
||||
|
||||
it("returns true in dev builds with devToggle on and no override", () => {
|
||||
assert.equal(resolveEnabled("dev", "doctor", {}, true, true), true);
|
||||
});
|
||||
|
||||
it("returns false when per-feature override is explicitly false", () => {
|
||||
it("returns true when user opts in", () => {
|
||||
assert.equal(
|
||||
resolveEnabled("dev", "doctor", { doctor: false }, true, true),
|
||||
false,
|
||||
resolveEnabled("unstable", "mesh-compute", { "mesh-compute": true }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true when per-feature override is explicitly true", () => {
|
||||
it("returns false when user explicitly opts out", () => {
|
||||
assert.equal(
|
||||
resolveEnabled("dev", "doctor", { doctor: true }, true, true),
|
||||
true,
|
||||
resolveEnabled("unstable", "mesh-compute", { "mesh-compute": false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -70,7 +54,7 @@ describe("resolveEnabled", () => {
|
||||
describe("unknown tier", () => {
|
||||
it("returns false for unrecognized tier values", () => {
|
||||
// @ts-expect-error — testing invalid input
|
||||
assert.equal(resolveEnabled("unknown", "foo", {}, true, true), false);
|
||||
assert.equal(resolveEnabled("unknown", "foo", {}), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,27 +3,19 @@ import type { FeatureTier } from "./types";
|
||||
/**
|
||||
* Pure resolution logic for feature visibility.
|
||||
* No side effects, no imports beyond types — safe to test in isolation.
|
||||
*
|
||||
* @param isDev - Whether the current build is a dev build.
|
||||
* Defaults to `import.meta.env?.DEV ?? false` for runtime use.
|
||||
*/
|
||||
export function resolveEnabled(
|
||||
tier: FeatureTier,
|
||||
featureId: string,
|
||||
overrides: Record<string, boolean>,
|
||||
devToggle: boolean,
|
||||
isDev: boolean = (import.meta.env?.DEV as boolean) ?? false,
|
||||
): boolean {
|
||||
switch (tier) {
|
||||
case "stable":
|
||||
return true;
|
||||
case "experimental":
|
||||
case "preview":
|
||||
return overrides[featureId] === true;
|
||||
case "unstable":
|
||||
return overrides[featureId] === true;
|
||||
case "dev":
|
||||
if (!isDev) return false;
|
||||
if (!devToggle) return false;
|
||||
// Allow per-feature suppression even in dev
|
||||
return overrides[featureId] !== false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
*
|
||||
* localStorage keys (versioned to match manifest):
|
||||
* sprout-feature-overrides-v1 — JSON object of { [featureId]: boolean }
|
||||
* sprout-dev-features-v1 — "true" | "false" (global dev toggle)
|
||||
*/
|
||||
|
||||
const OVERRIDES_KEY = "sprout-feature-overrides-v1";
|
||||
const DEV_TOGGLE_KEY = "sprout-dev-features-v1";
|
||||
|
||||
export type FeatureOverrides = Record<string, boolean>;
|
||||
|
||||
@@ -34,20 +32,3 @@ export function clearOverride(featureId: string): void {
|
||||
delete overrides[featureId];
|
||||
window.localStorage.setItem(OVERRIDES_KEY, JSON.stringify(overrides));
|
||||
}
|
||||
|
||||
/** Whether the global "Show developer features" toggle is on */
|
||||
export function getDevToggle(): boolean {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(DEV_TOGGLE_KEY);
|
||||
// Default to true in dev builds, false in prod
|
||||
if (raw === null) return import.meta.env.DEV;
|
||||
return raw === "true";
|
||||
} catch {
|
||||
return import.meta.env.DEV;
|
||||
}
|
||||
}
|
||||
|
||||
/** Set the global dev toggle */
|
||||
export function setDevToggle(enabled: boolean): void {
|
||||
window.localStorage.setItem(DEV_TOGGLE_KEY, enabled ? "true" : "false");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Feature visibility tiers */
|
||||
export type FeatureTier = "stable" | "experimental" | "dev";
|
||||
export type FeatureTier = "stable" | "preview" | "unstable";
|
||||
|
||||
/** Platforms a feature is available on */
|
||||
export type FeaturePlatform = "desktop" | "mobile";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSyncExternalStore, useCallback } from "react";
|
||||
import { getFeature } from "./manifest";
|
||||
import { resolveEnabled } from "./resolveEnabled";
|
||||
import { getOverrides, getDevToggle, setOverride, setDevToggle } from "./store";
|
||||
import { getOverrides, setOverride } from "./store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reactive store — components re-render when overrides change
|
||||
@@ -27,24 +27,19 @@ export function emitChange(): void {
|
||||
// Cached snapshot — avoids JSON.parse on every render per hook instance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ParsedSnapshot {
|
||||
o: Record<string, boolean>;
|
||||
d: boolean;
|
||||
}
|
||||
|
||||
let cachedRaw: string | null = null;
|
||||
let cachedParsed: ParsedSnapshot | null = null;
|
||||
let cachedParsed: Record<string, boolean> | null = null;
|
||||
|
||||
function getSnapshot(): string {
|
||||
const raw = JSON.stringify({ o: getOverrides(), d: getDevToggle() });
|
||||
const raw = JSON.stringify(getOverrides());
|
||||
if (raw !== cachedRaw) {
|
||||
cachedRaw = raw;
|
||||
cachedParsed = JSON.parse(raw) as ParsedSnapshot;
|
||||
cachedParsed = JSON.parse(raw) as Record<string, boolean>;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function getParsedSnapshot(): ParsedSnapshot {
|
||||
function getParsedSnapshot(): Record<string, boolean> {
|
||||
// Ensure snapshot is fresh
|
||||
getSnapshot();
|
||||
return cachedParsed!;
|
||||
@@ -55,11 +50,11 @@ function getParsedSnapshot(): ParsedSnapshot {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the current parsed feature state (overrides + dev toggle).
|
||||
* Returns the current parsed feature overrides.
|
||||
* Reactive — re-renders when any feature toggle changes.
|
||||
* Use this in components that need the full state (e.g. SettingsView filtering).
|
||||
*/
|
||||
export function useFeatureSnapshot(): ParsedSnapshot {
|
||||
export function useFeatureSnapshot(): Record<string, boolean> {
|
||||
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
return getParsedSnapshot();
|
||||
}
|
||||
@@ -68,11 +63,11 @@ export function useFeatureSnapshot(): ParsedSnapshot {
|
||||
* Returns whether a feature is enabled given its tier and user overrides.
|
||||
*
|
||||
* - stable: always true
|
||||
* - experimental: true only if user opted in
|
||||
* - dev: true only if in dev build AND global dev toggle is on
|
||||
* - preview: true only if user opted in
|
||||
* - unstable: true only if user opted in
|
||||
*/
|
||||
export function useFeatureEnabled(featureId: string): boolean {
|
||||
const snapshot = useFeatureSnapshot();
|
||||
const overrides = useFeatureSnapshot();
|
||||
|
||||
const feature = getFeature(featureId);
|
||||
if (!feature) {
|
||||
@@ -84,7 +79,7 @@ export function useFeatureEnabled(featureId: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveEnabled(feature.tier, featureId, snapshot.o, snapshot.d);
|
||||
return resolveEnabled(feature.tier, featureId, overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,19 +101,5 @@ export function useFeatureToggle(
|
||||
return [enabled, toggle];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the global dev toggle. Returns [enabled, toggle].
|
||||
*/
|
||||
export function useDevToggle(): [boolean, (enabled: boolean) => void] {
|
||||
const snapshot = useFeatureSnapshot();
|
||||
|
||||
const toggle = useCallback((value: boolean) => {
|
||||
setDevToggle(value);
|
||||
emitChange();
|
||||
}, []);
|
||||
|
||||
return [snapshot.d, toggle];
|
||||
}
|
||||
|
||||
// Re-export for consumers that imported from here
|
||||
export { resolveEnabled } from "./resolveEnabled";
|
||||
|
||||
BIN
desktop/tests/e2e/screenshots/feature-flags-experimental.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
desktop/tests/e2e/screenshots/feature-flags-full.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
desktop/tests/e2e/screenshots/feature-flags-toggles-enabled.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 43 KiB |
112
features.json
@@ -15,20 +15,6 @@
|
||||
"tier": "stable",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "workflows",
|
||||
"name": "Workflows",
|
||||
"description": "YAML-defined automations with approval gates",
|
||||
"tier": "experimental",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "projects",
|
||||
"name": "Projects",
|
||||
"description": "Git repository browser and collaboration",
|
||||
"tier": "experimental",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "custom-emoji",
|
||||
"name": "Custom Emoji",
|
||||
@@ -36,54 +22,68 @@
|
||||
"tier": "stable",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "huddles",
|
||||
"name": "Huddles",
|
||||
"description": "Voice sessions with humans and agents via TTS/STT",
|
||||
"tier": "dev",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "mesh-compute",
|
||||
"name": "Mesh Compute",
|
||||
"description": "Peer-to-peer inference sharing across workspace members",
|
||||
"tier": "dev",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "identity-archive",
|
||||
"name": "Identity Archive",
|
||||
"description": "Archive and unarchive relay member identities",
|
||||
"tier": "dev",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "relay-members",
|
||||
"name": "Relay Members",
|
||||
"description": "Relay membership administration panel",
|
||||
"tier": "dev",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "pulse",
|
||||
"name": "Pulse",
|
||||
"description": "Activity feed with notes, social posts, and agent activity",
|
||||
"tier": "experimental",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "forum",
|
||||
"name": "Forum Channels",
|
||||
"description": "Forum-style threaded channels for long-form discussions",
|
||||
"tier": "experimental",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "doctor",
|
||||
"name": "Doctor",
|
||||
"description": "Diagnostic and debug panel for troubleshooting",
|
||||
"tier": "stable",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "workflows",
|
||||
"name": "Workflows",
|
||||
"description": "YAML-defined automations with approval gates",
|
||||
"tier": "preview",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "projects",
|
||||
"name": "Projects",
|
||||
"description": "Git repository browser and collaboration",
|
||||
"tier": "preview",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "pulse",
|
||||
"name": "Pulse",
|
||||
"description": "Activity feed with notes, social posts, and agent activity",
|
||||
"tier": "preview",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "forum",
|
||||
"name": "Forum Channels",
|
||||
"description": "Forum-style threaded channels for long-form discussions",
|
||||
"tier": "preview",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "huddles",
|
||||
"name": "Huddles",
|
||||
"description": "Voice sessions with humans and agents via TTS/STT",
|
||||
"tier": "preview",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "mesh-compute",
|
||||
"name": "Mesh Compute",
|
||||
"description": "Peer-to-peer inference sharing across workspace members",
|
||||
"tier": "unstable",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "identity-archive",
|
||||
"name": "Identity Archive",
|
||||
"description": "Archive and unarchive relay member identities",
|
||||
"tier": "unstable",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "relay-members",
|
||||
"name": "Relay Members",
|
||||
"description": "Relay membership administration panel",
|
||||
"tier": "unstable",
|
||||
"platforms": ["desktop"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||