diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index 8d8de58a86..251c1bb030 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -154,6 +154,13 @@ function isLightPreviewQuery(searchParams: URLSearchParams): boolean { export function SceneLoader(props: SceneLoaderProps) { const webXRInstalled = useWebXRInstalled() + // The editor follows its host's `.dark` class, and this app keeps the editor + // dark: the page root carries it from the first paint, and for the + // menus and dialogs the editor portals there. + useEffect(() => { + document.body.classList.add('dark') + return () => document.body.classList.remove('dark') + }, []) return ( @@ -331,7 +338,7 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) { ) return ( -
+
{conflict && (

Another session saved first — refresh?

diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index f30e4e3e08..901bb044db 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -541,7 +541,7 @@ function DisplayMenu() { - Theme + Scene theme {activeTheme.name} diff --git a/packages/editor/src/components/editor/editor-layout-mobile.tsx b/packages/editor/src/components/editor/editor-layout-mobile.tsx index f803363b62..9e952dd24f 100644 --- a/packages/editor/src/components/editor/editor-layout-mobile.tsx +++ b/packages/editor/src/components/editor/editor-layout-mobile.tsx @@ -214,7 +214,7 @@ export function EditorLayoutMobile({ }, [panelSheetHeight, committedSheetH]) return ( -
+
{navbarSlot}
+
{/* Top navbar */} {navbarSlot} diff --git a/packages/editor/src/components/editor/floorplan-mode-coordinator.tsx b/packages/editor/src/components/editor/floorplan-mode-coordinator.tsx index bb8a213e10..e235e52ce9 100644 --- a/packages/editor/src/components/editor/floorplan-mode-coordinator.tsx +++ b/packages/editor/src/components/editor/floorplan-mode-coordinator.tsx @@ -59,7 +59,7 @@ export function FloorplanModeCoordinator() { {notice.message} {notice.kind === 'switch-to-expert' ? (
@@ -236,11 +236,11 @@ function GuidesControl() { 'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors', showGuides ? isOpen - ? 'bg-white/10' - : 'bg-white/5 hover:bg-white/8' + ? 'bg-black/6 dark:bg-white/10' + : 'bg-black/4 dark:bg-white/5 hover:bg-black/5 dark:hover:bg-white/8' : isOpen - ? 'bg-white/8' - : 'opacity-60 hover:bg-white/5 hover:opacity-100', + ? 'bg-black/5 dark:bg-white/8' + : 'opacity-60 hover:bg-black/4 dark:hover:bg-white/5 hover:opacity-100', )} type="button" > @@ -284,7 +284,7 @@ function GuidesControl() { className={cn( 'group/item space-y-2 rounded-xl border bg-background/75 p-2.5 transition-colors', selectedReferenceId === guide.id - ? 'border-foreground/35 bg-white/10' + ? 'border-foreground/35 bg-black/6 dark:bg-white/10' : 'border-border/45', )} key={guide.id} @@ -386,8 +386,8 @@ function ScansControl() { className={cn( 'rounded-r-none p-0', showScans - ? 'bg-white/15' - : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0', + ? 'bg-black/9 hover:bg-black/9 dark:bg-white/15' + : 'opacity-60 grayscale hover:bg-black/4 hover:opacity-100 hover:grayscale-0', )} label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`} onClick={() => setShowScans(!showScans)} @@ -396,7 +396,7 @@ function ScansControl() { >
Scans - + {scans.length}
@@ -411,11 +411,11 @@ function ScansControl() { 'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors', showScans ? isOpen - ? 'bg-white/10' - : 'bg-white/5 hover:bg-white/8' + ? 'bg-black/6 dark:bg-white/10' + : 'bg-black/4 dark:bg-white/5 hover:bg-black/5 dark:hover:bg-white/8' : isOpen - ? 'bg-white/8' - : 'opacity-60 hover:bg-white/5 hover:opacity-100', + ? 'bg-black/5 dark:bg-white/8' + : 'opacity-60 hover:bg-black/4 dark:hover:bg-white/5 hover:opacity-100', )} type="button" > @@ -459,7 +459,7 @@ function ScansControl() { className={cn( 'group/item space-y-2 rounded-xl border bg-background/75 p-2.5 transition-colors', selectedReferenceId === scan.id - ? 'border-foreground/35 bg-white/10' + ? 'border-foreground/35 bg-black/6 dark:bg-white/10' : 'border-border/45', )} key={scan.id} @@ -578,7 +578,7 @@ function ReferenceListSection({
@@ -710,11 +710,11 @@ function ReferencesControl() { 'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors', anyVisible ? isOpen - ? 'bg-white/10' - : 'bg-white/5 hover:bg-white/8' + ? 'bg-black/6 dark:bg-white/10' + : 'bg-black/4 dark:bg-white/5 hover:bg-black/5 dark:hover:bg-white/8' : isOpen - ? 'bg-white/8' - : 'opacity-60 hover:bg-white/5 hover:opacity-100', + ? 'bg-black/5 dark:bg-white/8' + : 'opacity-60 hover:bg-black/4 dark:hover:bg-white/5 hover:opacity-100', )} type="button" > @@ -784,8 +784,8 @@ function ReferenceFloorControl() { className={cn( 'rounded-r-none p-0', showReferenceFloor && selectedLevel - ? 'bg-white/15' - : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0', + ? 'bg-black/9 hover:bg-black/9 dark:bg-white/15' + : 'opacity-60 grayscale hover:bg-black/4 hover:opacity-100 hover:grayscale-0', )} disabled={!hasLowerLevels} label={ @@ -801,7 +801,7 @@ function ReferenceFloorControl() { >
- + {lowerLevels.length}
@@ -815,11 +815,11 @@ function ReferenceFloorControl() { 'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors', showReferenceFloor && selectedLevel ? isOpen - ? 'bg-white/10' - : 'bg-white/5 hover:bg-white/8' + ? 'bg-black/6 dark:bg-white/10' + : 'bg-black/4 dark:bg-white/5 hover:bg-black/5 dark:hover:bg-white/8' : isOpen - ? 'bg-white/8' - : 'opacity-60 hover:bg-white/5 hover:opacity-100', + ? 'bg-black/5 dark:bg-white/8' + : 'opacity-60 hover:bg-black/4 dark:hover:bg-white/5 hover:opacity-100', )} disabled={!hasLowerLevels} type="button" @@ -848,7 +848,7 @@ function ReferenceFloorControl() {
{onPaste && ( )} {onClose && (
- + Scene Graph
+ [...html.matchAll(/aria-pressed="true"[^>]*>(?:<[^>]+>)*([^<]+)/g)].map((match) => match[1]) + +describe('Interface theme setting', () => { + test("the row shows the host's value pressed", () => { + const html = renderToStaticMarkup( {}} value="light" />) + expect(html).toContain('Interface theme') + expect(pressedLabels(html)).toEqual(['Light']) + }) + + test('Settings shows the row a host passes, and none without it', () => { + const row = {}} value="system" /> + expect(renderToStaticMarkup()).toContain('Interface theme') + expect(renderToStaticMarkup()).not.toContain('Interface theme') + }) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index d9753d722d..bfcf92f41a 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -200,7 +200,7 @@ const PropertyLineSection = memo(function PropertyLineSection() { className={cn( 'flex h-6 w-6 cursor-pointer items-center justify-center rounded transition-colors', isEditing - ? 'bg-orange-500/20 text-orange-400' + ? 'bg-orange-500/20 text-orange-700 dark:text-orange-400' : 'text-muted-foreground hover:bg-accent', )} onClick={handleToggleEdit} @@ -256,7 +256,7 @@ const PropertyLineSection = memo(function PropertyLineSection() { className={cn( 'flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded', points.length > 3 - ? 'text-muted-foreground hover:bg-red-500/20 hover:text-red-400' + ? 'text-muted-foreground hover:bg-red-500/20 hover:text-red-600 dark:hover:text-red-400' : 'cursor-not-allowed text-muted-foreground/30', )} disabled={points.length <= 3} diff --git a/packages/editor/src/components/ui/sidebar/panels/zone-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/zone-panel/index.tsx index 914bb85dac..71f81b0589 100644 --- a/packages/editor/src/components/ui/sidebar/panels/zone-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/zone-panel/index.tsx @@ -195,14 +195,14 @@ export function ZonePanel() { /> } + icon={} label="Delete" onClick={() => deleteSelectedZone(false)} type="button" /> } + icon={} label="Delete with contents" onClick={() => deleteSelectedZone(true)} type="button" diff --git a/packages/editor/src/components/ui/sidebar/tab-bar.tsx b/packages/editor/src/components/ui/sidebar/tab-bar.tsx index e65caa5ddc..89f7c2352d 100644 --- a/packages/editor/src/components/ui/sidebar/tab-bar.tsx +++ b/packages/editor/src/components/ui/sidebar/tab-bar.tsx @@ -47,8 +47,8 @@ export function TabBar({ tabs, activeTab, onTabChange }: TabBarProps) { className={cn( 'relative h-7 rounded-md px-3 font-medium text-sm transition-colors', isActive - ? 'bg-accent text-foreground' - : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground', + ? 'bg-sidebar-accent text-foreground' + : 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground', )} key={tab.id} onClick={() => { @@ -101,8 +101,8 @@ export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailPr className={cn( 'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200', showActive - ? 'bg-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0' - : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0', + ? 'bg-sidebar-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0' + : 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0', )} onClick={() => { triggerSFX('sfx:menu-click') diff --git a/packages/editor/src/components/viewer/viewer-controls-bar.tsx b/packages/editor/src/components/viewer/viewer-controls-bar.tsx index 732aa6d718..9a0f24682c 100644 --- a/packages/editor/src/components/viewer/viewer-controls-bar.tsx +++ b/packages/editor/src/components/viewer/viewer-controls-bar.tsx @@ -104,13 +104,14 @@ function VisibilityMenu({ className="hover:bg-white/5 hover:text-foreground" label="Visibility" size="icon" + tooltipClassName="dark" tooltipSide="top" variant="ghost" > - + {canShowScans && ( keepOpen(e, () => useViewer.getState().setShowScans(!showScans))} @@ -161,13 +162,14 @@ function DisplayMenu() { className="hover:bg-white/5 hover:text-foreground" label="Display settings" size="icon" + tooltipClassName="dark" tooltipSide="top" variant="ghost" > - + keepOpen(e, () => useViewer.getState().setShadows(!shadows))} > @@ -208,7 +210,7 @@ function DisplayMenu() { Render {activeShading.name} - + {SHADING_OPTIONS.map((option) => { const OptionIcon = option.icon return ( @@ -233,12 +235,12 @@ function DisplayMenu() { - Theme + Scene theme {activeTheme.name} - + {SCENE_THEMES.map((t) => { const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map( (role) => t.clayTints?.[role] ?? CLAY_PALETTE[role], @@ -271,7 +273,7 @@ function DisplayMenu() { Edges {activeEdges.name} - + {EDGE_OPTIONS.map((option) => ( @@ -384,6 +387,7 @@ export const ViewerControlsBar = ({ useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway') }} size="icon" + tooltipClassName="dark" tooltipSide="top" variant="ghost" > @@ -407,6 +411,7 @@ export const ViewerControlsBar = ({ label={`Walkthrough: ${walkthroughActive ? 'On' : 'Off'}`} onClick={onWalkthroughToggle} size="icon" + tooltipClassName="dark" tooltipSide="top" variant="ghost" > @@ -416,7 +421,7 @@ export const ViewerControlsBar = ({
{/* Follow Pascal */} - + {/* Camera actions */} emitter.emit('camera-controls:orbit-ccw')} size="icon" + tooltipClassName="dark" tooltipSide="top" variant="ghost" > @@ -439,6 +445,7 @@ export const ViewerControlsBar = ({ label="Orbit right" onClick={() => emitter.emit('camera-controls:orbit-cw')} size="icon" + tooltipClassName="dark" tooltipSide="top" variant="ghost" > @@ -454,6 +461,7 @@ export const ViewerControlsBar = ({ label="Top view" onClick={() => emitter.emit('camera-controls:top-view')} size="icon" + tooltipClassName="dark" tooltipSide="top" variant="ghost" > diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index d8888cdb66..4bf2341229 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -300,6 +300,8 @@ export type { ExtraPanel } from './components/ui/sidebar/icon-rail' export { ItemsPanel } from './components/ui/sidebar/panels/items-panel' export type { FunctionTreeNode } from './components/ui/sidebar/panels/items-panel/function-tree-panel' export { + type InterfaceTheme, + InterfaceThemeRow, type ProjectVisibility, SettingsPanel, type SettingsPanelProps, diff --git a/packages/editor/src/lib/sfx-player.test.ts b/packages/editor/src/lib/sfx-player.test.ts index d909ca4c2e..55da0ed03a 100644 --- a/packages/editor/src/lib/sfx-player.test.ts +++ b/packages/editor/src/lib/sfx-player.test.ts @@ -1,10 +1,10 @@ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test' +import useAudio from '../store/use-audio' type FakeContext = { id: string } let activeContext: FakeContext = { id: 'first' } let initialState: 'loaded' | 'loading' = 'loaded' -let muted = false let throwOnPlay = false const instances: FakeHowl[] = [] @@ -92,12 +92,6 @@ const fakeHowler = { } mock.module('howler', () => ({ Howl: FakeHowl, Howler: fakeHowler })) -mock.module('../store/use-audio', () => ({ - default: { - getState: () => ({ masterVolume: 100, muted, sfxVolume: 100 }), - subscribe: () => () => {}, - }, -})) const { disposeSFX, playSFX, preloadSFX, startLoopSFX, stopLoopSFX, updateSFXVolumes } = await import('./sfx-player') @@ -107,7 +101,7 @@ beforeEach(() => { disposeSFXBus() activeContext = { id: 'first' } initialState = 'loaded' - muted = false + useAudio.setState({ masterVolume: 100, muted: false, sfxVolume: 100 }) throwOnPlay = false instances.length = 0 }) @@ -141,11 +135,11 @@ describe('SFX audio context lifecycle', () => { }) test('muted startup neither starts audio nor consumes a later fresh cue', () => { - muted = true + useAudio.setState({ muted: true }) initSFXBus() triggerSFX('sfx:item-place') expect(instances.every((sound) => sound.playCount === 0)).toBe(true) - muted = false + useAudio.setState({ muted: false }) updateSFXVolumes() triggerSFX('sfx:item-place') expect(instances.reduce((sum, sound) => sum + sound.playCount, 0)).toBe(1) @@ -279,7 +273,7 @@ describe('SFX audio context lifecycle', () => { startLoopSFX('terrainLower') const lower = instances.find((sound) => sound.loop && sound.playCount === 1) - muted = true + useAudio.setState({ muted: true }) updateSFXVolumes() expect(lower?.fadeCalls.at(-1)).toEqual([0.2, 0, 90, 1]) diff --git a/packages/nodes/src/block/panel.tsx b/packages/nodes/src/block/panel.tsx index a079fbe2c0..1f5f593fdc 100644 --- a/packages/nodes/src/block/panel.tsx +++ b/packages/nodes/src/block/panel.tsx @@ -39,7 +39,7 @@ import { const SLOT_TRAILING_ACTION_CLASS = 'm-2 ml-0 flex w-8 shrink-0 items-center justify-center rounded-md' const SLOT_DISABLED_ACTION_CLASS = - 'disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-[#2C2C2E] disabled:active:bg-[#2C2C2E]' + 'disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-black/4 dark:disabled:hover:bg-[#2C2C2E] disabled:active:bg-black/4 dark:disabled:active:bg-[#2C2C2E]' const NEW_BLOCK_SLOT_MATERIAL = { preset: 'custom', properties: { @@ -319,7 +319,7 @@ export default function BlockPanel() {
) : null} -
+
{slotDeclarations.map((slot, index) => { const ref = node.slots?.[slot.slotId] const active = @@ -334,7 +334,7 @@ export default function BlockPanel() {
0 ? 'border-t' : '' - } ${active ? 'bg-primary/15' : 'hover:bg-white/[0.035]'}`} + } ${active ? 'bg-primary/15' : 'hover:bg-black/4 dark:hover:bg-white/[0.035]'}`} key={slot.slotId} >
handleServiceBoundaryChange('toLevelId', event.target.value)} value={toLevelId} > @@ -600,7 +600,7 @@ export default function ElevatorPanel() { Default Floor
handleUpdate({ shaftStyle: event.target.value as ElevatorNode['shaftStyle'] }) } @@ -715,7 +715,7 @@ export default function ElevatorPanel() { Opening Style
handleUpdate({ doorPanelStyle: event.target.value as ElevatorNode['doorPanelStyle'], @@ -782,7 +782,7 @@ export default function ElevatorPanel() { return (
{getLevelDisplayName(level)} @@ -790,8 +790,8 @@ export default function ElevatorPanel() {