moodboard: gate Storyboard page behind experimental toggle

Adds a "Storyboard page" toggle to the Experimental settings pane,
persisted via localStorage (key: artcraft_experimental_storyboard_page).
When off (default), the Storyboard tile is filtered out of the apps quick
menu and apps index, goToApp("MOODBOARD") is a no-op, and the page
render is gated. Toggling off opens a confirm modal; on confirm, the
moodboard store is reset and the user is navigated to Text to Image
before the flag flips. Resetting experimental from the settings header
also clears the storyboard flag.
This commit is contained in:
Tanish Manku committed 2026-04-17 15:00:04 -07:00
1 parent 2f5885b81b
commit 3ff9769b49
10 files changed
+164 -30

No files matched your search

@@ -1,19 +1,21 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
GENERATE_APPS,
EDIT_APPS,
useGenerateApps,
useEditApps,
getBadgeStyles,
goToApp,
} from "~/config/appMenu";
export const AppsQuickMenu = () => {
const generateApps = useGenerateApps();
const editApps = useEditApps();
return (
<div className="grid w-[680px] grid-cols-2 gap-3">
<div>
<h3 className="mb-2 px-2 text-xs font-semibold opacity-50">Generate</h3>
<div className="space-y-0.5">
{GENERATE_APPS.map((app) => (
{generateApps.map((app) => (
<button
key={app.id}
onClick={() => goToApp(app.action)}
@@ -61,7 +63,7 @@ export const AppsQuickMenu = () => {
<div>
<h3 className="mb-2 px-2 text-xs font-semibold opacity-50">Edit</h3>
<div className="space-y-0.5">
{EDIT_APPS.map((app) => (
{editApps.map((app) => (
<button
key={app.id}
onClick={() => goToApp(app.action)}
@@ -58,7 +58,8 @@ import { SettingsModal } from "@storyteller/ui-settings-modal";
import { Tooltip } from "@storyteller/ui-tooltip";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { APP_DESCRIPTORS } from "~/config/appMenu";
import { APP_DESCRIPTORS, goToApp } from "~/config/appMenu";
import { useMoodboardStore } from "~/pages/PageMoodboard";
import { useSceneStore } from "@storyteller/ui-pagedraw";
import {
is3DEditorInitialized,
@@ -765,6 +766,10 @@ export const TopBar = ({ pageName }: Props) => {
setIsSettingsModalOpen(false);
setLogoutStates();
}}
onStoryboardPageDisable={() => {
useMoodboardStore.getState().reset();
goToApp("IMAGE");
}}
initialSection={settingsSection}
/>
@@ -11,6 +11,11 @@ import {
faPenNib,
faCrosshairs,
} from "@fortawesome/pro-solid-svg-icons";
import { useMemo } from "react";
import {
useExperimentalStore,
useStoryboardPageEnabled,
} from "@storyteller/ui-settings-modal";
import { useTabStore, TabId } from "~/pages/Stores/TabState";
import { set3DPageMounted } from "~/pages/PageEnigma/Editor/editor";
@@ -206,6 +211,33 @@ export const GENERATE_APPS = ALL_APPS.filter(
);
export const EDIT_APPS = ALL_APPS.filter((app) => app.category === "edit");
export const useVisibleApps = (): FullAppItem[] => {
const storyboardEnabled = useStoryboardPageEnabled();
return useMemo(
() =>
ALL_APPS.filter((app) =>
app.action === "MOODBOARD" ? storyboardEnabled : true,
),
[storyboardEnabled],
);
};
export const useGenerateApps = (): FullAppItem[] => {
const visible = useVisibleApps();
return useMemo(
() => visible.filter((app) => app.category === "generate"),
[visible],
);
};
export const useEditApps = (): FullAppItem[] => {
const visible = useVisibleApps();
return useMemo(
() => visible.filter((app) => app.category === "edit"),
[visible],
);
};
export const getBadgeStyles = (badge?: string) => {
switch (badge) {
case "NEW":
@@ -237,6 +269,11 @@ export const goToApp = (action?: string) => {
"MOODBOARD",
].includes(action)
) {
if (action === "MOODBOARD") {
const { enabled, storyboardPageEnabled } =
useExperimentalStore.getState();
if (!enabled || !storyboardPageEnabled) return;
}
if (action === "3D") {
set3DPageMounted(true);
} else {
@@ -2,16 +2,18 @@ import { faGrid2 } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
GENERATE_APPS,
EDIT_APPS,
useGenerateApps,
useEditApps,
getBadgeStyles,
goToApp,
} from "~/config/appMenu";
export const AppsIndexPage = () => {
const generateApps = useGenerateApps();
const editApps = useEditApps();
const categories = [
{ title: "Generate", apps: GENERATE_APPS },
{ title: "Edit", apps: EDIT_APPS },
{ title: "Generate", apps: generateApps },
{ title: "Edit", apps: editApps },
];
return (
@@ -19,6 +19,7 @@ import { ImageTo3DWorld } from "../PageImageTo3DWorld";
import { RemoveBackground } from "../PageRemoveBackground";
import { Angles } from "../PageAngles";
import { Moodboard } from "../PageMoodboard";
import { useStoryboardPageEnabled } from "@storyteller/ui-settings-modal";
import {
timelineHeight,
@@ -109,6 +110,7 @@ export const PageEditor = () => {
}, [status.value, triggerRecheck]);
const tabStore = useTabStore();
const storyboardPageEnabled = useStoryboardPageEnabled();
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
};
@@ -644,7 +646,7 @@ export const PageEditor = () => {
<Angles />
</div>
)}
{tabStore.activeTabId == "MOODBOARD" && (
{tabStore.activeTabId == "MOODBOARD" && storyboardPageEnabled && (
<div>
<Moodboard />
</div>
@@ -21,6 +21,7 @@ interface MoodboardState {
selectBoard: (id: string) => void;
updateBoard: (id: string, patch: Partial<Omit<Board, "id" | "shotNumber">>) => void;
reorderBoards: (fromIndex: number, toIndex: number) => void;
reset: () => void;
}
const syncShotNumbers = (boards: Board[]): Board[] =>
@@ -77,4 +78,8 @@ export const useMoodboardStore = create<MoodboardState>((set, get) => ({
return { boards: syncShotNumbers(arr) };
});
},
reset: () => {
set({ boards: [], selectedBoardId: null });
},
}));
@@ -1,2 +1,6 @@
export * from './lib/settings-modal';
export {
useExperimentalStore,
useStoryboardPageEnabled,
} from './lib/experimental-store';
@@ -1,23 +1,24 @@
import { create } from "zustand";
const STORAGE_KEY = "artcraft_experimental_enabled";
const ENABLED_STORAGE_KEY = "artcraft_experimental_enabled";
const STORYBOARD_STORAGE_KEY = "artcraft_experimental_storyboard_page";
const readInitial = (): boolean => {
const readBoolFlag = (key: string): boolean => {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(STORAGE_KEY) === "true";
return window.localStorage.getItem(key) === "true";
} catch {
return false;
}
};
const writeStorage = (enabled: boolean) => {
const writeBoolFlag = (key: string, enabled: boolean) => {
if (typeof window === "undefined") return;
try {
if (enabled) {
window.localStorage.setItem(STORAGE_KEY, "true");
window.localStorage.setItem(key, "true");
} else {
window.localStorage.removeItem(STORAGE_KEY);
window.localStorage.removeItem(key);
}
} catch {
// ignore storage failures
@@ -26,18 +27,30 @@ const writeStorage = (enabled: boolean) => {
interface ExperimentalState {
enabled: boolean;
storyboardPageEnabled: boolean;
enable: () => void;
disable: () => void;
setStoryboardPageEnabled: (enabled: boolean) => void;
}
export const useExperimentalStore = create<ExperimentalState>((set) => ({
enabled: readInitial(),
enabled: readBoolFlag(ENABLED_STORAGE_KEY),
storyboardPageEnabled: readBoolFlag(STORYBOARD_STORAGE_KEY),
enable: () => {
writeStorage(true);
writeBoolFlag(ENABLED_STORAGE_KEY, true);
set({ enabled: true });
},
disable: () => {
writeStorage(false);
set({ enabled: false });
// Resetting experimental clears every gated feature flag too.
writeBoolFlag(ENABLED_STORAGE_KEY, false);
writeBoolFlag(STORYBOARD_STORAGE_KEY, false);
set({ enabled: false, storyboardPageEnabled: false });
},
setStoryboardPageEnabled: (enabled: boolean) => {
writeBoolFlag(STORYBOARD_STORAGE_KEY, enabled);
set({ storyboardPageEnabled: enabled });
},
}));
export const useStoryboardPageEnabled = () =>
useExperimentalStore((s) => s.enabled && s.storyboardPageEnabled);
@@ -1,15 +1,73 @@
import { useState } from "react";
import { Label } from "@storyteller/ui-label";
import { Switch } from "@storyteller/ui-switch";
import { gtagEvent } from "@storyteller/google-analytics";
import { useExperimentalStore } from "../experimental-store";
import { ExperimentalConfirmModal } from "../ExperimentalConfirmModal";
interface Props {
// Called immediately before the storyboard flag flips off, so the host app
// can reset page state and navigate away from the soon-to-be-hidden tab.
onStoryboardPageDisable?: () => void;
}
export const ExperimentalSettingsPane = ({ onStoryboardPageDisable }: Props) => {
const storyboardPageEnabled = useExperimentalStore(
(s) => s.storyboardPageEnabled,
);
const setStoryboardPageEnabled = useExperimentalStore(
(s) => s.setStoryboardPageEnabled,
);
const [isDisableConfirmOpen, setIsDisableConfirmOpen] = useState(false);
const fireToggleEvent = (enabled: boolean) => {
gtagEvent("toggle_experimental_feature", {
feature: "storyboard_page",
enabled: String(enabled),
});
};
const handleStoryboardToggle = (enabled: boolean) => {
if (enabled) {
setStoryboardPageEnabled(true);
fireToggleEvent(true);
} else {
setIsDisableConfirmOpen(true);
}
};
const handleConfirmDisable = () => {
onStoryboardPageDisable?.();
setStoryboardPageEnabled(false);
fireToggleEvent(false);
setIsDisableConfirmOpen(false);
};
export const ExperimentalSettingsPane = () => {
return (
<div className="space-y-4 text-base-fg">
<div className="space-y-1">
<Label>Experimental Features</Label>
<p className="opacity-80">
This section is reserved for in-development features. There are no
experimental options available right now.
</p>
<>
<div className="space-y-4 text-base-fg">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-0.5">
<Label htmlFor="experimental-storyboard-page">Storyboard page</Label>
<p className="text-xs opacity-70">
Show the Storyboard page in the apps menu. In-development; expect
rough edges.
</p>
</div>
<Switch
enabled={storyboardPageEnabled}
setEnabled={handleStoryboardToggle}
/>
</div>
</div>
</div>
<ExperimentalConfirmModal
isOpen={isDisableConfirmOpen}
onClose={() => setIsDisableConfirmOpen(false)}
onConfirm={handleConfirmDisable}
title="Disable Storyboard page?"
text="The Storyboard page will be reset and any unsaved changes will be lost."
confirmText="Disable"
/>
</>
);
};
@@ -28,6 +28,7 @@ interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
globalAccountLogoutCallback: () => void;
onStoryboardPageDisable?: () => void;
initialSection?: SettingsSection;
}
@@ -45,6 +46,7 @@ export const SettingsModal = ({
isOpen,
onClose,
globalAccountLogoutCallback,
onStoryboardPageDisable,
initialSection = "general",
}: SettingsModalProps) => {
const [selectedSection, setSelectedSection] =
@@ -110,7 +112,11 @@ export const SettingsModal = ({
case "billing":
return <BillingSettingsPane />;
case "experimental":
return <ExperimentalSettingsPane />;
return (
<ExperimentalSettingsPane
onStoryboardPageDisable={onStoryboardPageDisable}
/>
);
}
};