refactor: rename tiers to stable/preview/unstable, remove dev-build gate

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-06-05 11:52:06 -10:00
parent 8af3aa17e3
commit a5ea4ce7b3
22 changed files with 107 additions and 187 deletions

View File

@@ -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>

View File

@@ -100,7 +100,7 @@ export const settingsSections: SettingsSectionDescriptor[] = [
},
{
value: "experimental",
label: "Experimental",
label: "Preview & Unstable",
icon: FlaskConical,
},
{

View File

@@ -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;
}
}

View File

@@ -10,7 +10,6 @@ export type {
export {
useFeatureEnabled,
useFeatureToggle,
useDevToggle,
useFeatureSnapshot,
resolveEnabled,
} from "./useFeatureEnabled";

View File

@@ -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);
});
});
});

View File

@@ -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;
}

View File

@@ -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");
}

View File

@@ -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";

View File

@@ -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";

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -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"]
}
]
}