From 3fed10416e42c0da94608f95f79bde9ec4fc1fd5 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 10 Nov 2026 01:35:23 -0500 Subject: [PATCH 01/11] feat(studio): keep experimental 3D views opt-in Signed-off-by: Yordis Prieto --- tsworkspace/webapps/studio/README.md | 14 ++ .../src/components/FeaturePreviewDialog.tsx | 131 +++++++++++++++ .../FeaturePreviewIllustration.test.tsx | 67 ++++++++ .../components/FeaturePreviewIllustration.tsx | 35 ++++ .../studio/src/components/ModelShell.test.tsx | 151 ++++++++++++++++-- .../studio/src/components/ModelShell.tsx | 8 +- .../webapps/studio/src/components/TopBar.tsx | 12 +- .../studio/src/components/TopBarMenu.test.tsx | 91 ++++++++++- .../studio/src/components/TopBarMenu.tsx | 55 ++++++- .../src/components/ui/dropdown-menu.tsx | 17 +- .../studio/src/lib/feature-flags.test.ts | 54 +++++++ .../webapps/studio/src/lib/feature-flags.ts | 39 +++++ .../studio/src/lib/feature-preview-model.ts | 74 +++++++++ 13 files changed, 720 insertions(+), 28 deletions(-) create mode 100644 tsworkspace/webapps/studio/src/components/FeaturePreviewDialog.tsx create mode 100644 tsworkspace/webapps/studio/src/components/FeaturePreviewIllustration.test.tsx create mode 100644 tsworkspace/webapps/studio/src/components/FeaturePreviewIllustration.tsx create mode 100644 tsworkspace/webapps/studio/src/lib/feature-flags.test.ts create mode 100644 tsworkspace/webapps/studio/src/lib/feature-flags.ts create mode 100644 tsworkspace/webapps/studio/src/lib/feature-preview-model.ts diff --git a/tsworkspace/webapps/studio/README.md b/tsworkspace/webapps/studio/README.md index f61b62b7..166ca632 100644 --- a/tsworkspace/webapps/studio/README.md +++ b/tsworkspace/webapps/studio/README.md @@ -19,6 +19,20 @@ swimlane below), rendered live from the gRPC store. was taken and does not update. It holds only the board's read responses and the runtime from `build:snapshot`. +## Feature Preview + +Open a model's **More actions** menu and choose **Feature Preview**. Select +**Lane mesh** or **Stream towers** to see its preview and turn its switch on. +Both start off. Each preference applies immediately and is saved in your +browser for Studio at the same address. + +Each preview uses the same 3D view as its model tab, with fictional example +data. Drag to rotate the preview, right-drag to pan, or scroll to zoom. + +Lane mesh appears when the model has slices. Stream towers appears when a +swimlane holds events. Disabling the current view returns to the Board. +Saved HTML snapshots do not include these views. + ## Architecture The browser cannot speak tonic gRPC, so `server/index.mjs` is a thin Node diff --git a/tsworkspace/webapps/studio/src/components/FeaturePreviewDialog.tsx b/tsworkspace/webapps/studio/src/components/FeaturePreviewDialog.tsx new file mode 100644 index 00000000..c6758c00 --- /dev/null +++ b/tsworkspace/webapps/studio/src/components/FeaturePreviewDialog.tsx @@ -0,0 +1,131 @@ +import { Dialog } from '@base-ui/react/dialog'; +import { Switch } from '@base-ui/react/switch'; +import { Tabs } from '@base-ui/react/tabs'; +import { Eye, X } from 'lucide-react'; +import { type RefObject, useRef, useState } from 'react'; +import { FeaturePreviewIllustration } from '@/components/FeaturePreviewIllustration'; +import { Button } from '@/components/ui/button'; +import type { StudioFeatureFlag, StudioFeatureFlags } from '@/lib/feature-flags'; + +const PREVIEWS = [ + { + id: 'laneMesh', + title: 'Lane mesh', + description: + "Explore the Board's swimlanes and slice columns in a 3D layout, with relationships between cards kept visible.", + requirement: 'Available when the model contains slices.', + }, + { + id: 'streamTowers', + title: 'Stream towers', + description: + 'See each event stream as a tower, with its events stacked together and relationships connecting the streams.', + requirement: 'Available when events in the model belong to a swimlane.', + }, +] as const; + +export function FeaturePreviewDialog({ + open, + onOpenChange, + featureFlags, + onFeatureFlagChange, + finalFocus, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + featureFlags: StudioFeatureFlags; + onFeatureFlagChange: (feature: StudioFeatureFlag, enabled: boolean) => void; + finalFocus: RefObject; +}) { + const [selected, setSelected] = useState('laneMesh'); + const closeButton = useRef(null); + + return ( + + + + +
+
+ Feature Preview + + Try new ways to explore your model. Choose the previews you want to use in this browser. + +
+ } + > + + +
+ { + if (value === 'laneMesh' || value === 'streamTowers') setSelected(value); + }} + orientation="vertical" + className="grid min-h-0 flex-1 grid-cols-1 overflow-y-auto md:grid-cols-[240px_minmax(0,1fr)] md:overflow-hidden" + > + + {PREVIEWS.map((preview) => ( + + + {preview.title} + {featureFlags[preview.id] ? 'On' : 'Off'} + + ))} + + {PREVIEWS.map((preview) => ( + +
+

{preview.title}

+
+ + onFeatureFlagChange(preview.id, checked)} + className="inline-flex h-6 w-11 cursor-pointer items-center rounded-full bg-border p-0.5 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[checked]:bg-primary" + > + + +
+
+ +

{preview.description}

+
+

How to try it

+

+ Turn this preview on, close this window, then choose {preview.title} above your model.{' '} + {preview.requirement} +

+
+
+ ))} +
+
+
+
+ ); +} diff --git a/tsworkspace/webapps/studio/src/components/FeaturePreviewIllustration.test.tsx b/tsworkspace/webapps/studio/src/components/FeaturePreviewIllustration.test.tsx new file mode 100644 index 00000000..45f1ab37 --- /dev/null +++ b/tsworkspace/webapps/studio/src/components/FeaturePreviewIllustration.test.tsx @@ -0,0 +1,67 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SceneSpec } from '@/lib/scene3d'; + +const renderer = vi.hoisted(() => ({ webgl: true, specs: [] as SceneSpec[], disposals: 0 })); + +vi.mock('@/lib/webgl', () => ({ hasWebGL: () => renderer.webgl })); +vi.mock('@/components/canvas/three/scene', () => ({ + GraphScene: class { + setSpec(spec: SceneSpec) { + renderer.specs.push(spec); + } + setFocus() {} + setStep() {} + dispose() { + renderer.disposals += 1; + } + }, +})); + +import { FeaturePreviewIllustration } from './FeaturePreviewIllustration'; + +describe('Feature preview renderer', () => { + beforeEach(() => { + renderer.webgl = true; + renderer.specs = []; + renderer.disposals = 0; + }); + afterEach(cleanup); + + it('renders the real Stream towers scene with event discs and linked side cards', async () => { + render(); + await waitFor(() => expect(renderer.specs.length).toBeGreaterThan(0)); + const spec = renderer.specs.at(-1); + expect(spec?.nodes.some((node) => node.entity.kind === 'event' && node.body.shape.type === 'cylinder')).toBe(true); + for (const kind of ['command', 'readModel', 'processor']) { + expect(spec?.nodes.some((node) => node.entity.kind === kind && node.body.shape.type === 'box')).toBe(true); + } + expect(spec?.plates.filter((plate) => plate.kicker === 'Event stream').length).toBeGreaterThan(1); + expect(spec?.links.some((link) => link.kind === 'edge')).toBe(true); + expect(screen.getByText('Example model')).toBeDefined(); + }); + + it('uses the real Lane mesh scene and frees it when switching previews or closing', async () => { + const { rerender, unmount } = render(); + await waitFor(() => expect(renderer.specs.length).toBeGreaterThan(0)); + const lanes = renderer.specs.at(-1); + expect(lanes?.nodes.some((node) => node.entity.kind === 'event' && node.body.shape.type === 'box')).toBe(true); + expect(lanes?.steps.length).toBeGreaterThan(1); + expect(lanes?.links.some((link) => link.kind === 'edge')).toBe(true); + + rerender(); + await waitFor(() => + expect(renderer.specs.at(-1)?.nodes.some((node) => node.body.shape.type === 'cylinder')).toBe(true), + ); + expect(renderer.disposals).toBe(1); + unmount(); + expect(renderer.disposals).toBe(2); + }); + + it('reports when the real renderer cannot run without WebGL', async () => { + renderer.webgl = false; + render(); + expect(await screen.findByText('This view needs WebGL')).toBeDefined(); + expect(renderer.specs).toHaveLength(0); + }); +}); diff --git a/tsworkspace/webapps/studio/src/components/FeaturePreviewIllustration.tsx b/tsworkspace/webapps/studio/src/components/FeaturePreviewIllustration.tsx new file mode 100644 index 00000000..94639d0b --- /dev/null +++ b/tsworkspace/webapps/studio/src/components/FeaturePreviewIllustration.tsx @@ -0,0 +1,35 @@ +import { Box } from 'lucide-react'; +import { lazy, Suspense, useState } from 'react'; +import type { Selection } from '@/components/canvas/Board'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; +import { BoardEmptyState } from '@/components/ui/empty-state'; +import type { StudioFeatureFlag } from '@/lib/feature-flags'; +import { buildFeaturePreviewModel } from '@/lib/feature-preview-model'; + +const ThreeView = lazy(() => import('@/components/canvas/ThreeView').then((module) => ({ default: module.ThreeView }))); +const EXAMPLE_MODEL = buildFeaturePreviewModel(); + +export function FeaturePreviewIllustration({ feature }: { feature: StudioFeatureFlag }) { + const [selections, setSelections] = useState([]); + return ( +
+
+ + }> + setSelections(key ? [{ type: 'entity', key, occId }] : [])} + onSelectEdge={(edge) => setSelections([{ type: 'edge', id: edge.id, data: edge.data }])} + /> + + +
+
+ Example model + Same 3D view, fictional data +
+
+ ); +} diff --git a/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx b/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx index 8c07a93a..5de159aa 100644 --- a/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx +++ b/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render as rtlRender, screen, waitFor, within } from '@testing-library/react'; import { withNuqsTestingAdapter } from 'nuqs/adapters/testing'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -56,6 +56,54 @@ import { storefrontEntities } from '@/lib/scene3d.fixture'; import { ModelShell } from './ModelShell'; const neverResolving = new Promise(() => {}); +const FEATURE_FLAGS_KEY = 'trogon-atlas.studio.feature-flags'; + +function makeLocalStorageStub() { + const store = new Map(); + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => store.clear(), + get length() { + return store.size; + }, + key: (index: number) => [...store.keys()][index] ?? null, + }; +} + +function enable3DViews() { + localStorage.setItem(FEATURE_FLAGS_KEY, JSON.stringify({ laneMesh: true, streamTowers: true })); +} + +async function openFeaturePreview() { + fireEvent.click(screen.getByRole('button', { name: 'More actions' })); + fireEvent.click(await screen.findByRole('menuitem', { name: 'Feature Preview' })); + return within(await screen.findByRole('dialog', { name: 'Feature Preview' })); +} + +async function closeFeaturePreview() { + fireEvent.click( + within(screen.getByRole('dialog', { name: 'Feature Preview' })).getByRole('button', { + name: 'Close Feature Preview', + }), + ); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Feature Preview' })).toBeNull()); +} + +function modelViews() { + return within(screen.getByRole('tablist', { name: 'Model view' })); +} + +async function settleBootstrap() { + await act(async () => { + await vi.mocked(api.info).mock.results[0]?.value; + }); +} // ModelShell reads/writes query state through nuqs, which needs an // adapter above it in the tree. @@ -72,6 +120,7 @@ function setupApiDefaults() { describe('ModelShell', () => { beforeEach(() => { vi.clearAllMocks(); + vi.stubGlobal('localStorage', makeLocalStorageStub()); setupApiDefaults(); // Default: watchEntities starts as a pending promise so it does not // trigger state updates during most tests. @@ -81,6 +130,7 @@ describe('ModelShell', () => { afterEach(() => { cleanup(); vi.useRealTimers(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -145,24 +195,102 @@ describe('ModelShell', () => { }); it('offers the Lane mesh tab when the model has slices, and opens it', async () => { + enable3DViews(); render(); const tab = await screen.findByRole('tab', { name: 'Lane mesh' }); fireEvent.click(tab); expect(await screen.findByTestId('three-view-lanes')).toBeDefined(); }); + it('hides the experimental views by default even when the model has their content', async () => { + render(); + await settleBootstrap(); + expect(screen.queryByRole('tab', { name: 'Lane mesh' })).toBeNull(); + expect(screen.queryByRole('tab', { name: 'Stream towers' })).toBeNull(); + }); + + it('lets the user enable each experimental view independently and remembers the choices', async () => { + const model = buildModel(storefrontEntities() as never); + const { unmount } = render(); + let preview = await openFeaturePreview(); + expect(preview.getByRole('switch', { name: 'Enable Lane mesh' }).getAttribute('aria-checked')).toBe('false'); + fireEvent.click(preview.getByRole('tab', { name: 'Stream towers' })); + expect(preview.getByRole('switch', { name: 'Enable Stream towers' }).getAttribute('aria-checked')).toBe('false'); + + fireEvent.click(preview.getByRole('tab', { name: 'Lane mesh' })); + fireEvent.click(preview.getByRole('switch', { name: 'Enable Lane mesh' })); + await closeFeaturePreview(); + expect(modelViews().getByRole('tab', { name: 'Lane mesh' })).toBeDefined(); + expect(modelViews().queryByRole('tab', { name: 'Stream towers' })).toBeNull(); + + preview = await openFeaturePreview(); + fireEvent.click(preview.getByRole('tab', { name: 'Stream towers' })); + fireEvent.click(preview.getByRole('switch', { name: 'Enable Stream towers' })); + await closeFeaturePreview(); + expect(modelViews().getByRole('tab', { name: 'Lane mesh' })).toBeDefined(); + expect(modelViews().getByRole('tab', { name: 'Stream towers' })).toBeDefined(); + + preview = await openFeaturePreview(); + fireEvent.click(preview.getByRole('tab', { name: 'Lane mesh' })); + fireEvent.click(preview.getByRole('switch', { name: 'Enable Lane mesh' })); + await closeFeaturePreview(); + expect(modelViews().queryByRole('tab', { name: 'Lane mesh' })).toBeNull(); + expect(modelViews().getByRole('tab', { name: 'Stream towers' })).toBeDefined(); + + unmount(); + render(); + await settleBootstrap(); + expect(modelViews().queryByRole('tab', { name: 'Lane mesh' })).toBeNull(); + expect(modelViews().getByRole('tab', { name: 'Stream towers' })).toBeDefined(); + preview = await openFeaturePreview(); + fireEvent.click(preview.getByRole('tab', { name: 'Lane mesh' })); + expect(preview.getByRole('switch', { name: 'Enable Lane mesh' }).getAttribute('aria-checked')).toBe('false'); + fireEvent.click(preview.getByRole('tab', { name: 'Stream towers' })); + expect(preview.getByRole('switch', { name: 'Enable Stream towers' }).getAttribute('aria-checked')).toBe('true'); + }); + + it.each(['lanes', 'towers'])('draws the board for disabled ?view=%s', async (view) => { + rtlRender(, { + wrapper: withNuqsTestingAdapter({ searchParams: `?view=${view}` }), + }); + await settleBootstrap(); + expect(screen.getByTestId('board')).toBeDefined(); + expect(screen.queryByTestId('three-view-lanes')).toBeNull(); + expect(screen.queryByTestId('three-view-towers')).toBeNull(); + expect(screen.getByRole('tab', { name: 'Board' }).getAttribute('aria-selected')).toBe('true'); + }); + + it.each([ + ['lanes', 'Lane mesh'], + ['towers', 'Stream towers'], + ])('returns to the board immediately when the active %s view is disabled', async (view, label) => { + enable3DViews(); + rtlRender(, { + wrapper: withNuqsTestingAdapter({ searchParams: `?view=${view}` }), + }); + expect(await screen.findByTestId(`three-view-${view}`)).toBeDefined(); + const preview = await openFeaturePreview(); + fireEvent.click(preview.getByRole('tab', { name: label })); + fireEvent.click(preview.getByRole('switch', { name: `Enable ${label}` })); + await closeFeaturePreview(); + expect(screen.getByTestId('board')).toBeDefined(); + expect(modelViews().queryByRole('tab', { name: label })).toBeNull(); + expect(screen.queryByTestId(`three-view-${view}`)).toBeNull(); + }); + it('hides the Lane mesh tab when the model has no slices', async () => { + enable3DViews(); const model = buildModel([ { event: { id: { namespace: 'shop', slug: 'order.placed', version: '1' }, title: 'Order placed' } }, ] as never); render(); - await waitFor(() => { - expect(screen.getByRole('tab', { name: 'Board' })).toBeDefined(); - }); + await settleBootstrap(); + expect(screen.getByRole('tab', { name: 'Board' })).toBeDefined(); expect(screen.queryByRole('tab', { name: 'Lane mesh' })).toBeNull(); }); it('hides the 3D tabs on a saved snapshot, which carries no 3D code', async () => { + enable3DViews(); render( { snapshot={{ capturedAt: '2026-10-13T00:00:00Z', branch: 'baseline', slug: 'storefront' }} />, ); - await waitFor(() => { - expect(screen.getByRole('tab', { name: 'Board' })).toBeDefined(); - }); + await settleBootstrap(); + expect(screen.getByRole('tab', { name: 'Board' })).toBeDefined(); expect(screen.queryByRole('tab', { name: 'Lane mesh' })).toBeNull(); expect(screen.queryByRole('tab', { name: 'Stream towers' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'More actions' })).toBeNull(); + expect(screen.queryByRole('menuitem', { name: 'Feature Preview' })).toBeNull(); + expect(screen.queryByRole('dialog', { name: 'Feature Preview' })).toBeNull(); }); it('offers the Stream towers tab when a swimlane holds events, and opens it', async () => { + enable3DViews(); render(); const tab = await screen.findByRole('tab', { name: 'Stream towers' }); fireEvent.click(tab); @@ -185,15 +316,15 @@ describe('ModelShell', () => { }); it('hides the Stream towers tab when no swimlane holds an event', async () => { + enable3DViews(); const id = (slug: string) => ({ namespace: 'shop', slug, version: '1' }); const model = buildModel([ { swimlane: { id: id('order'), title: 'Order' } }, { event: { id: id('order.placed'), title: 'Order placed' } }, ] as never); render(); - await waitFor(() => { - expect(screen.getByRole('tab', { name: 'Board' })).toBeDefined(); - }); + await settleBootstrap(); + expect(screen.getByRole('tab', { name: 'Board' })).toBeDefined(); expect(screen.queryByRole('tab', { name: 'Stream towers' })).toBeNull(); }); diff --git a/tsworkspace/webapps/studio/src/components/ModelShell.tsx b/tsworkspace/webapps/studio/src/components/ModelShell.tsx index c1d262a5..43f96da5 100644 --- a/tsworkspace/webapps/studio/src/components/ModelShell.tsx +++ b/tsworkspace/webapps/studio/src/components/ModelShell.tsx @@ -30,6 +30,7 @@ import { ValidationInspector } from '@/components/ValidationInspector'; import { ValidationSummary } from '@/components/ValidationSummary'; import { api, type BranchDiffEntry, type ServerInfo } from '@/lib/api'; import { branchStatusLookup, useBranchParam } from '@/lib/branch'; +import { useStudioFeatureFlags } from '@/lib/feature-flags'; import { EMPTY_ISSUE_INDEX, type IssueIndex } from '@/lib/issues'; import type { BoardEdgeData } from '@/lib/layout'; import { buildModel, entityKey, hasStreamEvents, type Model } from '@/lib/model'; @@ -128,6 +129,7 @@ export function ModelShell({ }: ModelShellProps) { const [internalModel, setInternalModel] = useState(modelProp ?? EMPTY_MODEL); const [info, setInfo] = useState(); + const [featureFlags, setFeatureFlag] = useStudioFeatureFlags(); const scoped = Boolean(modelProp); const [branch, setBranch] = useBranchParam(); const [missingBranch, setMissingBranch] = useState(); @@ -554,8 +556,8 @@ export function ModelShell({ if (v === 'domain') return model.entities.some((e) => e.kind === 'domain' || e.kind === 'subdomain' || e.kind === 'boundedContext'); // A saved board is one inlined runtime file without three.js, so it has no 3D views. - if (v === 'lanes') return !snapshot && model.slices.length > 0; - if (v === 'towers') return !snapshot && hasStreamEvents(model); + if (v === 'lanes') return featureFlags.laneMesh && !snapshot && model.slices.length > 0; + if (v === 'towers') return featureFlags.streamTowers && !snapshot && hasStreamEvents(model); return true; }); // Deep links to a hidden view (?view=ui on a UI-less EM) render the @@ -598,6 +600,8 @@ export function ModelShell({ backHref={backHref} snapshot={snapshot} onExportHtml={onExportHtml} + featureFlags={featureFlags} + onFeatureFlagChange={setFeatureFlag} branch={branch} onSelectBranch={(next) => { setMissingBranch(undefined); diff --git a/tsworkspace/webapps/studio/src/components/TopBar.tsx b/tsworkspace/webapps/studio/src/components/TopBar.tsx index caebdb2d..2811af51 100644 --- a/tsworkspace/webapps/studio/src/components/TopBar.tsx +++ b/tsworkspace/webapps/studio/src/components/TopBar.tsx @@ -7,6 +7,7 @@ import { TopBarMenu } from '@/components/TopBarMenu'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import type { ServerInfo } from '@/lib/api'; +import type { StudioFeatureFlag, StudioFeatureFlags } from '@/lib/feature-flags'; import type { RealtimeStatus } from '@/lib/realtime'; import type { SnapshotStamp } from '@/lib/snapshot'; import { cn } from '@/lib/utils'; @@ -20,6 +21,8 @@ export function TopBar({ backHref, snapshot, onExportHtml, + featureFlags, + onFeatureFlagChange, branch, onSelectBranch, onOpenBranchReview, @@ -40,6 +43,8 @@ export function TopBar({ snapshot?: SnapshotStamp; /** Saves the board as one HTML file. Omit to hide the button. */ onExportHtml?: () => Promise; + featureFlags?: StudioFeatureFlags; + onFeatureFlagChange?: (feature: StudioFeatureFlag, enabled: boolean) => void; /** Active branch, if any (Phase 3: Studio). Omit to hide the branch UI entirely. */ branch?: string; onSelectBranch?: (name: string | undefined) => void; @@ -132,7 +137,12 @@ export function TopBar({ {onSelectBranch ? ( ) : null} - + )} diff --git a/tsworkspace/webapps/studio/src/components/TopBarMenu.test.tsx b/tsworkspace/webapps/studio/src/components/TopBarMenu.test.tsx index 435b8d23..14c25bc7 100644 --- a/tsworkspace/webapps/studio/src/components/TopBarMenu.test.tsx +++ b/tsworkspace/webapps/studio/src/components/TopBarMenu.test.tsx @@ -1,7 +1,11 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useState } from 'react'; +import type { StudioFeatureFlags } from '@/lib/feature-flags'; import { TopBarMenu } from './TopBarMenu'; +vi.mock('@/lib/webgl', () => ({ hasWebGL: () => false })); + function pendingExport() { let finish!: () => void; let fail!: (reason: Error) => void; @@ -26,6 +30,91 @@ describe('TopBarMenu', () => { vi.restoreAllMocks(); }); + function PreviewMenu() { + const [flags, setFlags] = useState({ laneMesh: false, streamTowers: false }); + return ( + setFlags((current) => ({ ...current, [feature]: enabled }))} + /> + ); + } + + it('opens Feature Preview and preserves independent choices while browsing features', async () => { + render(); + await openMenu(); + expect(screen.queryByRole('menuitemcheckbox')).toBeNull(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Feature Preview' })); + + const dialog = await screen.findByRole('dialog', { name: 'Feature Preview' }); + expect(screen.queryByRole('menu')).toBeNull(); + const previews = within(dialog); + expect(previews.getAllByRole('tabpanel')).toHaveLength(1); + expect(previews.getAllByRole('switch')).toHaveLength(1); + const lanes = previews.getByRole('switch', { name: 'Enable Lane mesh' }); + expect(lanes.getAttribute('aria-checked')).toBe('false'); + fireEvent.click(lanes); + expect(lanes.getAttribute('aria-checked')).toBe('true'); + + fireEvent.click(previews.getByRole('tab', { name: 'Stream towers' })); + expect(previews.getAllByRole('tabpanel')).toHaveLength(1); + expect(previews.getAllByRole('switch')).toHaveLength(1); + const towers = previews.getByRole('switch', { name: 'Enable Stream towers' }); + expect(towers.getAttribute('aria-checked')).toBe('false'); + fireEvent.click(towers); + expect(towers.getAttribute('aria-checked')).toBe('true'); + fireEvent.click(previews.getByRole('tab', { name: 'Lane mesh' })); + expect(previews.getByRole('switch', { name: 'Enable Lane mesh' }).getAttribute('aria-checked')).toBe('true'); + + fireEvent.click(previews.getByRole('button', { name: 'Close Feature Preview' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await openMenu(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Feature Preview' })); + const reopened = within(await screen.findByRole('dialog', { name: 'Feature Preview' })); + expect(reopened.getByRole('switch', { name: 'Enable Lane mesh' }).getAttribute('aria-checked')).toBe('true'); + fireEvent.click(reopened.getByRole('tab', { name: 'Stream towers' })); + expect(reopened.getByRole('switch', { name: 'Enable Stream towers' }).getAttribute('aria-checked')).toBe('true'); + }); + + it('supports keyboard feature selection, closes with Escape, and restores focus to More actions', async () => { + render(); + const trigger = screen.getByRole('button', { name: 'More actions' }); + await openMenu(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Feature Preview' })); + const dialog = await screen.findByRole('dialog', { name: 'Feature Preview' }); + const previews = within(dialog); + const closeButton = previews.getByRole('button', { name: 'Close Feature Preview' }); + await waitFor(() => expect(document.activeElement).toBe(closeButton)); + + const laneTab = previews.getByRole('tab', { name: 'Lane mesh' }); + laneTab.focus(); + fireEvent.keyDown(laneTab, { key: 'ArrowDown', code: 'ArrowDown' }); + await waitFor(() => + expect(previews.getByRole('tab', { name: 'Stream towers' }).getAttribute('aria-selected')).toBe('true'), + ); + const towers = previews.getByRole('switch', { name: 'Enable Stream towers' }); + towers.focus(); + fireEvent.keyDown(towers, { key: ' ', code: 'Space' }); + fireEvent.keyUp(towers, { key: ' ', code: 'Space' }); + expect(towers.getAttribute('aria-checked')).toBe('true'); + + fireEvent.keyDown(dialog, { key: 'Escape', code: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + }); + + it('returns focus to More actions when closed with its close button', async () => { + render(); + const trigger = screen.getByRole('button', { name: 'More actions' }); + await openMenu(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Feature Preview' })); + const dialog = await screen.findByRole('dialog', { name: 'Feature Preview' }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Close Feature Preview' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + }); + it('keeps Refresh reachable while an export is still running, and only locks Export HTML', async () => { const onRefresh = vi.fn(); const { onExportHtml, finish } = pendingExport(); diff --git a/tsworkspace/webapps/studio/src/components/TopBarMenu.tsx b/tsworkspace/webapps/studio/src/components/TopBarMenu.tsx index 644feda6..e7957da2 100644 --- a/tsworkspace/webapps/studio/src/components/TopBarMenu.tsx +++ b/tsworkspace/webapps/studio/src/components/TopBarMenu.tsx @@ -1,12 +1,24 @@ -import { AlertCircle, Check, Download, EllipsisVertical, LoaderCircle, type LucideIcon, RefreshCw } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { + AlertCircle, + Check, + Download, + EllipsisVertical, + Eye, + LoaderCircle, + type LucideIcon, + RefreshCw, +} from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { FeaturePreviewDialog } from '@/components/FeaturePreviewDialog'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import type { StudioFeatureFlag, StudioFeatureFlags } from '@/lib/feature-flags'; import { cn } from '@/lib/utils'; type ExportState = { kind: 'idle' | 'working' | 'done' } | { kind: 'failed'; message: string }; @@ -22,12 +34,19 @@ const TRIGGER: Record void; /** Saves the board as one HTML file. Omit to hide the entry. */ onExportHtml?: () => Promise; + featureFlags?: StudioFeatureFlags; + onFeatureFlagChange?: (feature: StudioFeatureFlag, enabled: boolean) => void; }) { const [exportState, setExportState] = useState({ kind: 'idle' }); + const [menuOpen, setMenuOpen] = useState(false); + const [featurePreviewOpen, setFeaturePreviewOpen] = useState(false); + const menuTrigger = useRef(null); const { Icon, title, tone, resetAfterMs } = TRIGGER[exportState.kind]; useEffect(() => { if (resetAfterMs === undefined) return; @@ -46,11 +65,14 @@ export function TopBarMenu({ {exportState.message} ) : null} - - }> + + } + > - + Refresh @@ -73,8 +95,31 @@ export function TopBarMenu({ Export HTML ) : null} + {featureFlags && onFeatureFlagChange ? ( + <> + + { + setMenuOpen(false); + setFeaturePreviewOpen(true); + }} + > + + Feature Preview + + + ) : null} + {featureFlags && onFeatureFlagChange ? ( + + ) : null} ); } diff --git a/tsworkspace/webapps/studio/src/components/ui/dropdown-menu.tsx b/tsworkspace/webapps/studio/src/components/ui/dropdown-menu.tsx index 46fe6ce5..85c52653 100644 --- a/tsworkspace/webapps/studio/src/components/ui/dropdown-menu.tsx +++ b/tsworkspace/webapps/studio/src/components/ui/dropdown-menu.tsx @@ -5,6 +5,9 @@ import { cn } from '@/lib/utils'; export const DropdownMenu = BaseMenu.Root; export const DropdownMenuTrigger = BaseMenu.Trigger; +const ITEM_CLASS_NAME = + 'flex select-none items-center gap-2 rounded px-2 py-1.5 outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0'; + export function DropdownMenuContent({ className, align = 'end', @@ -29,13 +32,9 @@ export function DropdownMenuContent({ } export function DropdownMenuItem({ className, ...props }: React.ComponentProps) { - return ( - - ); + return ; +} + +export function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) { + return ; } diff --git a/tsworkspace/webapps/studio/src/lib/feature-flags.test.ts b/tsworkspace/webapps/studio/src/lib/feature-flags.test.ts new file mode 100644 index 00000000..0cddecbf --- /dev/null +++ b/tsworkspace/webapps/studio/src/lib/feature-flags.test.ts @@ -0,0 +1,54 @@ +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useStudioFeatureFlags } from './feature-flags'; + +const STORAGE_KEY = 'trogon-atlas.studio.feature-flags'; + +describe('Studio feature flags', () => { + beforeEach(() => window.localStorage.clear()); + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('starts disabled and preserves independent choices across remounts', () => { + const { result, unmount } = renderHook(useStudioFeatureFlags); + expect(result.current[0]).toEqual({ laneMesh: false, streamTowers: false }); + act(() => result.current[1]('laneMesh', true)); + act(() => result.current[1]('streamTowers', true)); + act(() => result.current[1]('laneMesh', false)); + unmount(); + + const reloaded = renderHook(useStudioFeatureFlags); + expect(reloaded.result.current[0]).toEqual({ laneMesh: false, streamTowers: true }); + }); + + it.each(['broken', 'null', '[]', 'true', '{"laneMesh":"true","streamTowers":1}'])( + 'keeps malformed stored flags disabled: %s', + (stored) => { + window.localStorage.setItem(STORAGE_KEY, stored); + const { result } = renderHook(useStudioFeatureFlags); + expect(result.current[0]).toEqual({ laneMesh: false, streamTowers: false }); + }, + ); + + it('accepts only explicitly enabled known flags', () => { + window.localStorage.setItem(STORAGE_KEY, '{"laneMesh":true,"unknown":true}'); + const { result } = renderHook(useStudioFeatureFlags); + expect(result.current[0]).toEqual({ laneMesh: true, streamTowers: false }); + }); + + it('keeps toggles usable when storage cannot be read or written', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('Blocked', 'SecurityError'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('Full', 'QuotaExceededError'); + }); + + const { result } = renderHook(useStudioFeatureFlags); + expect(result.current[0]).toEqual({ laneMesh: false, streamTowers: false }); + act(() => result.current[1]('laneMesh', true)); + expect(result.current[0]).toEqual({ laneMesh: true, streamTowers: false }); + }); +}); diff --git a/tsworkspace/webapps/studio/src/lib/feature-flags.ts b/tsworkspace/webapps/studio/src/lib/feature-flags.ts new file mode 100644 index 00000000..95f4d647 --- /dev/null +++ b/tsworkspace/webapps/studio/src/lib/feature-flags.ts @@ -0,0 +1,39 @@ +import { useCallback, useEffect, useState } from 'react'; + +export interface StudioFeatureFlags { + readonly laneMesh: boolean; + readonly streamTowers: boolean; +} + +export type StudioFeatureFlag = keyof StudioFeatureFlags; + +const STORAGE_KEY = 'trogon-atlas.studio.feature-flags'; +const DEFAULT_FLAGS: StudioFeatureFlags = { laneMesh: false, streamTowers: false }; + +function loadFeatureFlags(): StudioFeatureFlags { + try { + const stored: unknown = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null'); + if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return DEFAULT_FLAGS; + const flags = stored as Record; + return { laneMesh: flags.laneMesh === true, streamTowers: flags.streamTowers === true }; + } catch { + return DEFAULT_FLAGS; + } +} + +export function useStudioFeatureFlags() { + const [flags, setFlags] = useState(loadFeatureFlags); + const setFeatureFlag = useCallback((feature: StudioFeatureFlag, enabled: boolean) => { + setFlags((current) => ({ ...current, [feature]: enabled })); + }, []); + + useEffect(() => { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(flags)); + } catch { + // Storage can be blocked or full; keep the preference for this page. + } + }, [flags]); + + return [flags, setFeatureFlag] as const; +} diff --git a/tsworkspace/webapps/studio/src/lib/feature-preview-model.ts b/tsworkspace/webapps/studio/src/lib/feature-preview-model.ts new file mode 100644 index 00000000..e1b730a0 --- /dev/null +++ b/tsworkspace/webapps/studio/src/lib/feature-preview-model.ts @@ -0,0 +1,74 @@ +import { buildModel, type EntityId } from './model'; + +const id = (slug: string): EntityId => ({ namespace: 'preview-storefront', slug, version: '1' }); +const ref = (slug: string) => ({ id: id(slug) }); + +export function buildFeaturePreviewModel() { + const entities: Parameters[0] = [ + { swimlane: { id: id('orders'), title: 'Orders', order: 1, streamId: 'order-{order_id}' } }, + { swimlane: { id: id('payments'), title: 'Payments', order: 2, streamId: 'payment-{payment_id}' } }, + { command: { id: id('place-order'), title: 'Place order' } }, + { command: { id: id('capture-payment'), title: 'Capture payment' } }, + { event: { id: id('order.placed'), title: 'Order placed', swimlane: ref('orders') } }, + { event: { id: id('payment.started'), title: 'Payment started', swimlane: ref('payments') } }, + { event: { id: id('payment.captured'), title: 'Payment captured', swimlane: ref('payments') } }, + { readModel: { id: id('pending-payments'), title: 'Pending payments', sourceEvents: [ref('order.placed')] } }, + { readModel: { id: id('order-status'), title: 'Order status', sourceEvents: [ref('payment.captured')] } }, + { processor: { id: id('payment-worker'), title: 'Payment worker' } }, + { + commandSlice: { + id: id('place-order-slice'), + title: 'Place order', + command: { command: ref('place-order') }, + emittedEvents: [{ event: ref('order.placed') }], + }, + }, + { + readModelSlice: { + id: id('pending-payments-slice'), + title: 'Pending payments', + readModel: { readModel: ref('pending-payments') }, + sourceEvents: [{ event: ref('order.placed') }], + }, + }, + { + automationSlice: { + id: id('capture-payment-automation'), + title: 'Capture payment automatically', + processor: { processor: ref('payment-worker') }, + sourceReadModels: [{ readModel: ref('pending-payments') }], + emittedCommand: { command: ref('capture-payment') }, + }, + }, + { + commandSlice: { + id: id('capture-payment-slice'), + title: 'Capture payment', + command: { command: ref('capture-payment') }, + emittedEvents: [{ event: ref('payment.started') }, { event: ref('payment.captured') }], + }, + }, + { + readModelSlice: { + id: id('order-status-slice'), + title: 'Show order status', + readModel: { readModel: ref('order-status') }, + sourceEvents: [{ event: ref('payment.captured') }], + }, + }, + { + storyboard: { + id: id('checkout'), + title: 'Example checkout', + slices: [ + ref('place-order-slice'), + ref('pending-payments-slice'), + ref('capture-payment-automation'), + ref('capture-payment-slice'), + ref('order-status-slice'), + ], + }, + }, + ]; + return buildModel(entities); +} From 54097b19ddb17e70070ec8314ca97d82fd62b354 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 10 Nov 2026 01:39:28 -0500 Subject: [PATCH 02/11] chore(studio): keep schema metadata out of the header Signed-off-by: Yordis Prieto --- tsworkspace/webapps/studio/src/components/TopBar.test.tsx | 7 ++++--- tsworkspace/webapps/studio/src/components/TopBar.tsx | 6 ------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/tsworkspace/webapps/studio/src/components/TopBar.test.tsx b/tsworkspace/webapps/studio/src/components/TopBar.test.tsx index 21836674..8002c0a9 100644 --- a/tsworkspace/webapps/studio/src/components/TopBar.test.tsx +++ b/tsworkspace/webapps/studio/src/components/TopBar.test.tsx @@ -72,9 +72,10 @@ describe('TopBar', () => { expect(screen.queryByRole('link', { name: /namespaces/i })).toBeNull(); }); - it('displays the schema version badge when info includes schemaVersion', () => { - render(); - expect(screen.getByText('v2')).toBeDefined(); + it('keeps schema version metadata out of the model header', () => { + const schemaVersion = 'trogonatlas.eventmodel.v1alpha1'; + render(); + expect(screen.queryByText(schemaVersion)).toBeNull(); }); it('displays the error message when error is provided', () => { diff --git a/tsworkspace/webapps/studio/src/components/TopBar.tsx b/tsworkspace/webapps/studio/src/components/TopBar.tsx index 2811af51..7edfa23e 100644 --- a/tsworkspace/webapps/studio/src/components/TopBar.tsx +++ b/tsworkspace/webapps/studio/src/components/TopBar.tsx @@ -4,7 +4,6 @@ import { AsOfTag } from '@/components/AsOfTag'; import { BranchBar } from '@/components/BranchBar'; import { DownloadYamlButton } from '@/components/DownloadYamlButton'; import { TopBarMenu } from '@/components/TopBarMenu'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import type { ServerInfo } from '@/lib/api'; import type { StudioFeatureFlag, StudioFeatureFlags } from '@/lib/feature-flags'; @@ -86,11 +85,6 @@ export function TopBar({ {scopedTitle} ) : null} - {info?.schemaVersion ? ( - - {info.schemaVersion} - - ) : null} {/* min-w-0, not shrink-0: a long branch name must ellipsize here rather than push the title out of the bar. */}
From e9771405fc9aaa911147895efa78da07c9e130e9 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 10 Nov 2026 01:48:09 -0500 Subject: [PATCH 03/11] fix(studio): keep preview choices synchronized Signed-off-by: Yordis Prieto --- .../studio/src/lib/feature-flags.test.ts | 182 +++++++++++++++++- .../webapps/studio/src/lib/feature-flags.ts | 108 +++++++++-- 2 files changed, 271 insertions(+), 19 deletions(-) diff --git a/tsworkspace/webapps/studio/src/lib/feature-flags.test.ts b/tsworkspace/webapps/studio/src/lib/feature-flags.test.ts index 0cddecbf..442e9c72 100644 --- a/tsworkspace/webapps/studio/src/lib/feature-flags.test.ts +++ b/tsworkspace/webapps/studio/src/lib/feature-flags.test.ts @@ -1,14 +1,24 @@ import { act, cleanup, renderHook } from '@testing-library/react'; +import { createElement, useLayoutEffect } from 'react'; +import { renderToString } from 'react-dom/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useStudioFeatureFlags } from './feature-flags'; +import { resetFeatureFlagsForTests, useStudioFeatureFlags } from './feature-flags'; const STORAGE_KEY = 'trogon-atlas.studio.feature-flags'; +function changedStorage(key: string | null = STORAGE_KEY, storageArea = window.localStorage) { + window.dispatchEvent(new StorageEvent('storage', { key, storageArea })); +} + describe('Studio feature flags', () => { - beforeEach(() => window.localStorage.clear()); + beforeEach(() => { + window.localStorage.clear(); + resetFeatureFlagsForTests(); + }); afterEach(() => { cleanup(); vi.restoreAllMocks(); + resetFeatureFlagsForTests(); }); it('starts disabled and preserves independent choices across remounts', () => { @@ -38,6 +48,174 @@ describe('Studio feature flags', () => { expect(result.current[0]).toEqual({ laneMesh: true, streamTowers: false }); }); + it('keeps separate consumers synchronized and merges changes from a stale consumer', () => { + const first = renderHook(useStudioFeatureFlags); + const second = renderHook(useStudioFeatureFlags); + const secondSetter = second.result.current[1]; + + act(() => first.result.current[1]('laneMesh', true)); + expect(second.result.current[0]).toEqual({ laneMesh: true, streamTowers: false }); + act(() => secondSetter('streamTowers', true)); + expect(first.result.current[0]).toEqual({ laneMesh: true, streamTowers: true }); + expect(second.result.current[0]).toBe(first.result.current[0]); + expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null')).toEqual({ + laneMesh: true, + streamTowers: true, + }); + }); + + it('merges the latest persisted value before writing another flag', () => { + const { result } = renderHook(useStudioFeatureFlags); + window.localStorage.setItem(STORAGE_KEY, '{"laneMesh":true,"streamTowers":false}'); + + act(() => result.current[1]('streamTowers', true)); + + expect(result.current[0]).toEqual({ laneMesh: true, streamTowers: true }); + expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null')).toEqual({ + laneMesh: true, + streamTowers: true, + }); + }); + + it('updates mounted consumers from localStorage events', () => { + const { result } = renderHook(useStudioFeatureFlags); + act(() => { + window.localStorage.setItem(STORAGE_KEY, '{"laneMesh":false,"streamTowers":true}'); + changedStorage(); + }); + expect(result.current[0]).toEqual({ laneMesh: false, streamTowers: true }); + }); + + it.each(['remove', 'clear'] as const)('resets mounted flags after an external %s', (operation) => { + window.localStorage.setItem(STORAGE_KEY, '{"laneMesh":true,"streamTowers":true}'); + const { result } = renderHook(useStudioFeatureFlags); + act(() => { + if (operation === 'remove') window.localStorage.removeItem(STORAGE_KEY); + else window.localStorage.clear(); + changedStorage(operation === 'clear' ? null : STORAGE_KEY); + }); + expect(result.current[0]).toEqual({ laneMesh: false, streamTowers: false }); + }); + + it('ignores unrelated keys and sessionStorage events', () => { + const { result } = renderHook(useStudioFeatureFlags); + const snapshot = result.current[0]; + act(() => { + window.localStorage.setItem('unrelated-setting', 'true'); + changedStorage('unrelated-setting'); + window.sessionStorage.setItem(STORAGE_KEY, '{"laneMesh":true,"streamTowers":true}'); + changedStorage(STORAGE_KEY, window.sessionStorage); + }); + expect(result.current[0]).toBe(snapshot); + expect(result.current[0]).toEqual({ laneMesh: false, streamTowers: false }); + }); + + it('keeps snapshot and setter identities stable when values have not changed', () => { + let renders = 0; + const { result, rerender } = renderHook(() => { + renders += 1; + return useStudioFeatureFlags(); + }); + const [snapshot, setter] = result.current; + rerender(); + expect(result.current[0]).toBe(snapshot); + expect(result.current[1]).toBe(setter); + const beforeEvent = renders; + act(() => { + window.localStorage.setItem(STORAGE_KEY, '{"streamTowers":false,"laneMesh":false}'); + changedStorage(); + }); + expect(result.current[0]).toBe(snapshot); + expect(renders).toBe(beforeEvent); + }); + + it('does not write defaults while mounting a consumer', () => { + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + renderHook(useStudioFeatureFlags); + expect(setItem).not.toHaveBeenCalled(); + }); + + it('observes changes between render and subscription without a storage event', () => { + const { result } = renderHook(() => { + const flags = useStudioFeatureFlags(); + useLayoutEffect(() => { + window.localStorage.setItem(STORAGE_KEY, '{"laneMesh":true,"streamTowers":false}'); + }, []); + return flags; + }); + expect(result.current[0]).toEqual({ laneMesh: true, streamTowers: false }); + }); + + it('removes its shared storage listener after the last consumer unmounts', () => { + const add = vi.spyOn(window, 'addEventListener'); + const remove = vi.spyOn(window, 'removeEventListener'); + const first = renderHook(useStudioFeatureFlags); + const second = renderHook(useStudioFeatureFlags); + const registrations = add.mock.calls.filter(([event]) => event === 'storage'); + expect(registrations).toHaveLength(1); + first.unmount(); + expect(remove.mock.calls.filter(([event]) => event === 'storage')).toHaveLength(0); + second.unmount(); + expect(remove.mock.calls.filter(([event]) => event === 'storage')).toEqual([['storage', registrations[0][1]]]); + }); + + it('uses disabled defaults for the server snapshot even if this browser enabled a preview', () => { + window.localStorage.setItem(STORAGE_KEY, '{"laneMesh":true,"streamTowers":true}'); + function Flags() { + return JSON.stringify(useStudioFeatureFlags()[0]); + } + expect(renderToString(createElement(Flags))).toContain( + '{"laneMesh":false,"streamTowers":false}', + ); + }); + + it('shares in-memory updates and preserves them across remounts when writes fail', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('Full', 'QuotaExceededError'); + }); + const first = renderHook(useStudioFeatureFlags); + const second = renderHook(useStudioFeatureFlags); + act(() => first.result.current[1]('laneMesh', true)); + expect(second.result.current[0]).toEqual({ laneMesh: true, streamTowers: false }); + first.unmount(); + second.unmount(); + + const reloaded = renderHook(useStudioFeatureFlags); + expect(reloaded.result.current[0]).toEqual({ laneMesh: true, streamTowers: false }); + act(() => reloaded.result.current[1]('streamTowers', true)); + expect(reloaded.result.current[0]).toEqual({ laneMesh: true, streamTowers: true }); + }); + + it('shares in-memory updates when the localStorage getter is blocked', () => { + vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => { + throw new DOMException('Blocked', 'SecurityError'); + }); + const first = renderHook(useStudioFeatureFlags); + const second = renderHook(useStudioFeatureFlags); + act(() => first.result.current[1]('laneMesh', true)); + expect(second.result.current[0]).toEqual({ laneMesh: true, streamTowers: false }); + act(() => second.result.current[1]('streamTowers', true)); + expect(first.result.current[0]).toEqual({ laneMesh: true, streamTowers: true }); + }); + + it('merges pending choices with persisted flags once blocked storage becomes readable', () => { + window.localStorage.setItem(STORAGE_KEY, '{"laneMesh":false,"streamTowers":true}'); + const getter = vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => { + throw new DOMException('Blocked', 'SecurityError'); + }); + const { result } = renderHook(useStudioFeatureFlags); + act(() => result.current[1]('laneMesh', true)); + getter.mockRestore(); + + act(() => result.current[1]('laneMesh', true)); + + expect(result.current[0]).toEqual({ laneMesh: true, streamTowers: true }); + expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null')).toEqual({ + laneMesh: true, + streamTowers: true, + }); + }); + it('keeps toggles usable when storage cannot be read or written', () => { vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new DOMException('Blocked', 'SecurityError'); diff --git a/tsworkspace/webapps/studio/src/lib/feature-flags.ts b/tsworkspace/webapps/studio/src/lib/feature-flags.ts index 95f4d647..3a6b2600 100644 --- a/tsworkspace/webapps/studio/src/lib/feature-flags.ts +++ b/tsworkspace/webapps/studio/src/lib/feature-flags.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useSyncExternalStore } from 'react'; export interface StudioFeatureFlags { readonly laneMesh: boolean; @@ -8,11 +8,15 @@ export interface StudioFeatureFlags { export type StudioFeatureFlag = keyof StudioFeatureFlags; const STORAGE_KEY = 'trogon-atlas.studio.feature-flags'; -const DEFAULT_FLAGS: StudioFeatureFlags = { laneMesh: false, streamTowers: false }; +const DEFAULT_FLAGS: StudioFeatureFlags = Object.freeze({ laneMesh: false, streamTowers: false }); +const listeners = new Set<() => void>(); +let snapshot = DEFAULT_FLAGS; +let storedValue: string | null | undefined; +let pendingChanges: Partial = {}; -function loadFeatureFlags(): StudioFeatureFlags { +function parseFlags(value: string | null): StudioFeatureFlags { try { - const stored: unknown = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null'); + const stored: unknown = JSON.parse(value ?? 'null'); if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return DEFAULT_FLAGS; const flags = stored as Record; return { laneMesh: flags.laneMesh === true, streamTowers: flags.streamTowers === true }; @@ -21,19 +25,89 @@ function loadFeatureFlags(): StudioFeatureFlags { } } -export function useStudioFeatureFlags() { - const [flags, setFlags] = useState(loadFeatureFlags); - const setFeatureFlag = useCallback((feature: StudioFeatureFlag, enabled: boolean) => { - setFlags((current) => ({ ...current, [feature]: enabled })); - }, []); - - useEffect(() => { - try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(flags)); - } catch { - // Storage can be blocked or full; keep the preference for this page. - } - }, [flags]); +function updateSnapshot(flags: StudioFeatureFlags) { + if (snapshot.laneMesh !== flags.laneMesh || snapshot.streamTowers !== flags.streamTowers) { + snapshot = Object.freeze(flags); + } +} + +function readStoredValue(value: string | null, externalChange = false) { + if (externalChange && value === null) pendingChanges = {}; + if (value === storedValue && !externalChange) return; + storedValue = value; + updateSnapshot({ ...parseFlags(value), ...pendingChanges }); +} + +function refreshFromStorage() { + try { + if (typeof window !== 'undefined') readStoredValue(window.localStorage.getItem(STORAGE_KEY)); + } catch { + // Keep the shared snapshot when browser storage is unavailable. + } +} + +function notify() { + for (const listener of listeners) listener(); +} +function onStorage(event: StorageEvent) { + if (event.key !== null && event.key !== STORAGE_KEY) return; + const previous = snapshot; + try { + const storage = window.localStorage; + if (event.storageArea !== null && event.storageArea !== storage) return; + readStoredValue(storage.getItem(STORAGE_KEY), true); + } catch { + return; + } + if (snapshot !== previous) notify(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + if (listeners.size === 1 && typeof window !== 'undefined') window.addEventListener('storage', onStorage); + const previous = snapshot; + refreshFromStorage(); + if (snapshot !== previous) notify(); + return () => { + listeners.delete(listener); + if (listeners.size === 0 && typeof window !== 'undefined') window.removeEventListener('storage', onStorage); + }; +} + +function getSnapshot() { + if (listeners.size === 0) refreshFromStorage(); + return snapshot; +} + +function getServerSnapshot() { + return DEFAULT_FLAGS; +} + +function setFeatureFlag(feature: StudioFeatureFlag, enabled: boolean) { + const previous = snapshot; + refreshFromStorage(); + pendingChanges = { ...pendingChanges, [feature]: enabled }; + updateSnapshot({ ...snapshot, [feature]: enabled }); + try { + const serialized = JSON.stringify(snapshot); + window.localStorage.setItem(STORAGE_KEY, serialized); + storedValue = serialized; + pendingChanges = {}; + } catch { + // Failed writes stay shared in memory instead of reverting to old storage. + } + if (snapshot !== previous) notify(); +} + +export function useStudioFeatureFlags() { + const flags = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); return [flags, setFeatureFlag] as const; } + +/** The per-tab store outlives hook mounts, so tests reset it after unmounting their consumers. */ +export function resetFeatureFlagsForTests() { + snapshot = DEFAULT_FLAGS; + storedValue = undefined; + pendingChanges = {}; +} From 992817f983a2aa07f29b22d842d06dd08e8b4817 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 10 Nov 2026 01:50:21 -0500 Subject: [PATCH 04/11] chore(studio): keep the header focused on the model Signed-off-by: Yordis Prieto --- tsworkspace/webapps/studio/src/components/TopBar.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tsworkspace/webapps/studio/src/components/TopBar.tsx b/tsworkspace/webapps/studio/src/components/TopBar.tsx index 7edfa23e..3e5e36f3 100644 --- a/tsworkspace/webapps/studio/src/components/TopBar.tsx +++ b/tsworkspace/webapps/studio/src/components/TopBar.tsx @@ -1,5 +1,5 @@ import type { Ref } from 'react'; -import { Camera, ChevronLeft, History, Workflow } from 'lucide-react'; +import { Camera, ChevronLeft, History } from 'lucide-react'; import { AsOfTag } from '@/components/AsOfTag'; import { BranchBar } from '@/components/BranchBar'; import { DownloadYamlButton } from '@/components/DownloadYamlButton'; @@ -75,10 +75,7 @@ export function TopBar({ Overview ) : null} - -

- Event Model Studio -

+

Atlas

{scopedTitle ? ( <> / From 1affc3b8e4bb36aa08134af94d3176a81617ed9f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 10 Nov 2026 02:08:42 -0500 Subject: [PATCH 05/11] chore(studio): clarify the connection status Signed-off-by: Yordis Prieto --- .../studio/src/components/ModelShell.test.tsx | 6 +++--- .../webapps/studio/src/components/TopBar.test.tsx | 8 ++++---- .../webapps/studio/src/components/TopBar.tsx | 14 +++++++------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx b/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx index 5de159aa..80d05373 100644 --- a/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx +++ b/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx @@ -497,13 +497,13 @@ describe('ModelShell', () => { return { close: vi.fn() }; }); render(); - await waitFor(() => expect(screen.getByText('connecting', { exact: true })).toBeDefined()); + await waitFor(() => expect(screen.getByText('Connecting', { exact: true })).toBeDefined()); act(() => onState?.('offline', 'Permission denied for the live update subscription.')); const status = screen.getByRole('status', { name: 'Live updates' }); - expect(status.textContent).toContain('offline'); + expect(status.textContent).toContain('Disconnected'); expect(status.getAttribute('title')).toContain('Permission denied for the live update subscription.'); act(() => onState?.('live')); - expect(status.textContent).toContain('live'); + expect(status.textContent).toContain('Connected'); expect(status.getAttribute('title')).not.toContain('Permission denied'); }); diff --git a/tsworkspace/webapps/studio/src/components/TopBar.test.tsx b/tsworkspace/webapps/studio/src/components/TopBar.test.tsx index 8002c0a9..d646bb4e 100644 --- a/tsworkspace/webapps/studio/src/components/TopBar.test.tsx +++ b/tsworkspace/webapps/studio/src/components/TopBar.test.tsx @@ -13,14 +13,14 @@ describe('TopBar', () => { expect(screen.getByRole('banner')).toBeDefined(); }); - it('shows "live" indicator when connected', () => { + it('shows "Connected" indicator when connected', () => { render(); - expect(screen.getAllByText('live').length).toBeGreaterThan(0); + expect(screen.getAllByText('Connected').length).toBeGreaterThan(0); }); - it('shows "offline" indicator when disconnected', () => { + it('shows "Disconnected" indicator when disconnected', () => { render(); - expect(screen.getAllByText('offline').length).toBeGreaterThan(0); + expect(screen.getAllByText('Disconnected').length).toBeGreaterThan(0); }); it('keeps Refresh behind the actions menu and calls onRefresh from there', async () => { diff --git a/tsworkspace/webapps/studio/src/components/TopBar.tsx b/tsworkspace/webapps/studio/src/components/TopBar.tsx index 3e5e36f3..5cfd7f1f 100644 --- a/tsworkspace/webapps/studio/src/components/TopBar.tsx +++ b/tsworkspace/webapps/studio/src/components/TopBar.tsx @@ -1,5 +1,5 @@ import type { Ref } from 'react'; -import { Camera, ChevronLeft, History } from 'lucide-react'; +import { Camera, ChevronLeft, History, Network } from 'lucide-react'; import { AsOfTag } from '@/components/AsOfTag'; import { BranchBar } from '@/components/BranchBar'; import { DownloadYamlButton } from '@/components/DownloadYamlButton'; @@ -110,18 +110,18 @@ export function TopBar({ } className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground" > -