Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/insert-slot-0282.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@xnetjs/plugins': minor
---

`insertSlot(tree, viewId, region, index)` joins the workspace layout primitives (exploration 0282): positional insertion for within-dock reorders and cross-region moves; `moveSlot` is now insert-at-end. Purely additive.
36 changes: 36 additions & 0 deletions apps/web/src/lib/labs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Labs registry (0282): entries resolve the real flag keys, and toggling
* round-trips localStorage exactly the way the feature readers check it.
*/
import { describe, expect, it } from 'vitest'
import { isLayoutTreeEnabled, LAYOUT_TREE_KEY } from '../workbench/experiments'
import { DESK_RADIAL_KEY, isDeskRadialEnabled, QUIET_DEFAULT_KEY } from './desk'
import { isLabEnabled, LABS_FLAGS, setLabEnabled } from './labs'

describe('labs registry', () => {
it('covers the three shipped experiment flags with real keys', () => {
const keys = LABS_FLAGS.map((flag) => flag.key)
expect(keys).toContain(LAYOUT_TREE_KEY)
expect(keys).toContain(QUIET_DEFAULT_KEY)
expect(keys).toContain(DESK_RADIAL_KEY)
// Every entry has honest copy — no empty descriptions.
for (const flag of LABS_FLAGS) {
expect(flag.label.length).toBeGreaterThan(0)
expect(flag.description.length).toBeGreaterThan(20)
}
})

it('round-trips through the same storage the feature readers check', () => {
setLabEnabled(LAYOUT_TREE_KEY, true)
expect(isLabEnabled(LAYOUT_TREE_KEY)).toBe(true)
expect(isLayoutTreeEnabled()).toBe(true)

setLabEnabled(DESK_RADIAL_KEY, true)
expect(isDeskRadialEnabled()).toBe(true)

setLabEnabled(LAYOUT_TREE_KEY, false)
expect(isLayoutTreeEnabled()).toBe(false)
expect(localStorage.getItem(LAYOUT_TREE_KEY)).toBeNull()
setLabEnabled(DESK_RADIAL_KEY, false)
})
})
68 changes: 68 additions & 0 deletions apps/web/src/lib/labs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Labs — the registry behind Settings › Labs (exploration 0282).
*
* Every `xnet:experiment:*` localStorage flag gets one declarative entry
* here, and the Labs settings section renders them as honest toggles —
* the Obsidian-core-plugins pattern, not the chrome://flags incantation.
* Flag KEY constants stay where their features live (desk.ts,
* workbench/experiments.ts); this file only aggregates them.
*
* Named "Labs" and not "Experiments" — the habit tracker owns that word
* (`/experiments`, ExperimentsView).
*/
import { LAYOUT_TREE_KEY } from '../workbench/experiments'
import { DESK_RADIAL_KEY, QUIET_DEFAULT_KEY } from './desk'

export interface LabsFlag {
/** The localStorage key (an existing `xnet:experiment:*` constant). */
key: string
label: string
description: string
/** Honest staging: experimental = may change/vanish; preview = stabilizing. */
stage: 'experimental' | 'preview'
/** Whether flipping it needs a reload to take effect. */
appliesOn: 'reload' | 'immediate'
}

export const LABS_FLAGS: LabsFlag[] = [
{
key: LAYOUT_TREE_KEY,
label: 'Malleable shell',
description:
'Move panels between docks, arrange the layout, and save it as a named workspace. The presets and switcher work either way; this enables the movable-panel shell itself.',
stage: 'experimental',
appliesOn: 'reload'
},
{
key: QUIET_DEFAULT_KEY,
label: 'Quiet surface by default',
description:
'New identities start on the bare quiet surface (Desk + corner chrome) instead of the calm shell. Existing identities are never moved.',
stage: 'preview',
appliesOn: 'reload'
},
{
key: DESK_RADIAL_KEY,
label: 'Desk radial menu',
description: 'Long-press a Desk card for a radial quick-action menu while the gesture settles.',
stage: 'experimental',
appliesOn: 'immediate'
}
]

export function isLabEnabled(key: string): boolean {
try {
return localStorage.getItem(key) === '1'
} catch {
return false
}
}

export function setLabEnabled(key: string, on: boolean): void {
try {
if (on) localStorage.setItem(key, '1')
else localStorage.removeItem(key)
} catch {
/* storage unavailable (private mode) — the toggle just won't stick */
}
}
11 changes: 10 additions & 1 deletion apps/web/src/plugins/workspace-agent-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
*/
import type { FeatureModule, ModuleCapabilities } from '@xnetjs/plugins'
import { evaluateInstallConsent, getCommandRegistry, scaffoldPlugin } from '@xnetjs/plugins'
import { isLayoutTreeEnabled } from '../workbench/experiments'
import {
PRESET_IDS,
REGION_IDS,
Expand Down Expand Up @@ -254,7 +255,15 @@ export function registerWorkspaceCommands(): () => void {
title: `Workspace: Preset: ${PRESET_COMMAND_TITLES[preset]}`,
run: () => useWorkbench.getState().applyPreset(preset)
})
)
),
// Arrange mode (0282): the shell as an editable schematic. Only
// meaningful when the malleable shell renders the tree.
registry.register({
id: 'workspace.customize',
title: 'Workspace: Customize layout…',
when: () => isLayoutTreeEnabled(),
run: () => useWorkbench.getState().setArranging(true)
})
]
return () => {
for (const disposable of disposables) disposable.dispose()
Expand Down
86 changes: 85 additions & 1 deletion apps/web/src/routes/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { MeetingEngineSettings } from '@xnetjs/views'
import {
Palette,
Database,
FlaskConical,
Mic,
Info,
Layers,
Expand Down Expand Up @@ -50,10 +51,12 @@ import { useDerivedData } from '../lib/data-dignity'
import { getTelemetryCollector } from '../lib/error-reporter'
import { persistedHubUrl, setPersistedHubUrl } from '../lib/hub-url'
import { identityManager, logout } from '../lib/identity'
import { isLabEnabled, LABS_FLAGS, setLabEnabled } from '../lib/labs'
import { createLeavePorts, downloadLeaveBundle, type LeaveDeps } from '../lib/leave'
import { isSentryConfigured } from '../lib/sentry'
import { useConsent } from '../lib/use-consent'
import { WINDDOWN_DURATION_CHOICES, useWinddownPreferences } from '../lib/winddown'
import { LAYOUT_TREE_KEY } from '../workbench/experiments'
import { presetForWorkspaceId } from '../workbench/layout-tree'
import { useWorkbench } from '../workbench/state'

Expand All @@ -70,6 +73,7 @@ export const Route = createFileRoute('/settings')({
type SettingsSection =
| 'profile'
| 'appearance'
| 'labs'
| 'dictation'
| 'safety'
| 'data'
Expand Down Expand Up @@ -104,6 +108,7 @@ const LEAVE_DEPS: LeaveDeps = {
const SECTIONS: SectionConfig[] = [
{ id: 'profile', label: 'Profile', icon: <UserRound {...ICON_PROPS} /> },
{ id: 'appearance', label: 'Appearance', icon: <Palette {...ICON_PROPS} /> },
{ id: 'labs', label: 'Labs', icon: <FlaskConical {...ICON_PROPS} /> },
{ id: 'dictation', label: 'Dictation & Meetings', icon: <Mic {...ICON_PROPS} /> },
{ id: 'safety', label: 'Content & Safety', icon: <ShieldCheck {...ICON_PROPS} /> },
{ id: 'data', label: 'Data', icon: <Database {...ICON_PROPS} /> },
Expand Down Expand Up @@ -165,6 +170,7 @@ function SettingsPage() {
<div className="flex-1 overflow-auto bg-surface-0 p-6">
{activeSection === 'profile' && <ProfileSettings />}
{activeSection === 'appearance' && <AppearanceSettings />}
{activeSection === 'labs' && <LabsSettings />}
{activeSection === 'dictation' && <MeetingEngineSettings />}
{activeSection === 'safety' && (
<div className="space-y-10">
Expand All @@ -185,6 +191,80 @@ function SettingsPage() {
)
}

// ─── Labs Settings ────────────────────────────────────────────────────────────

/**
* Labs (exploration 0282): the front door for `xnet:experiment:*` flags.
* Rows render from the declarative registry in lib/labs.ts; reload-scoped
* flags surface a "Reload to apply" chip after a change instead of
* restarting the app out from under the user.
*/
function LabsSettings() {
// localStorage isn't reactive; mirror it into state keyed by flag.
const [enabled, setEnabled] = useState<Record<string, boolean>>(() =>
Object.fromEntries(LABS_FLAGS.map((flag) => [flag.key, isLabEnabled(flag.key)]))
)
const [needsReload, setNeedsReload] = useState(false)

const toggle = (key: string, appliesOn: 'reload' | 'immediate') => (checked: boolean) => {
setLabEnabled(key, checked)
setEnabled((prev) => ({ ...prev, [key]: checked }))
if (appliesOn === 'reload') setNeedsReload(true)
}

return (
<SettingsPanel
title="Labs"
description="Early features. They may change, move, or go away — nothing here touches your data."
>
<SettingsGroup>
{LABS_FLAGS.map((flag) => (
<div key={flag.key} className="relative">
<SettingToggle
label={flag.label}
description={flag.description}
checked={enabled[flag.key] ?? false}
onChange={toggle(flag.key, flag.appliesOn)}
/>
<span
className={`absolute right-14 top-4 rounded-full border px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider ${
flag.stage === 'experimental'
? 'border-hairline text-ink-3'
: 'border-hairline text-ink-2'
}`}
>
{flag.stage}
</span>
{flag.key === LAYOUT_TREE_KEY && (enabled[flag.key] ?? false) && !needsReload && (
<div className="px-4 pb-3">
<button
type="button"
onClick={() => void getCommandRegistry().runCommand('workspace.customize')}
className="cursor-pointer rounded-md border border-hairline bg-transparent px-2.5 py-1 text-xs text-ink-2 transition-colors hover:text-ink-1"
>
Customize layout…
</button>
</div>
)}
</div>
))}
</SettingsGroup>
{needsReload && (
<div className="mt-4 flex items-center gap-3 rounded-lg border border-hairline bg-surface-2 px-4 py-3 text-sm text-ink-2">
Changes apply after a reload.
<button
type="button"
onClick={() => window.location.reload()}
className="cursor-pointer rounded-md border border-hairline bg-surface-1 px-2.5 py-1 text-xs font-medium text-ink-1"
>
Reload now
</button>
</div>
)}
</SettingsPanel>
)
}

// ─── Appearance Settings ──────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -232,7 +312,11 @@ function AppearanceSettings() {
</SettingRow>
<SettingRow
label="Workspaces"
description="Rearranged panels can be saved as named workspaces — switch, share or reset from the switcher (also on the rail and in ⌘K)"
description={
isLabEnabled(LAYOUT_TREE_KEY)
? 'Rearranged panels can be saved as named workspaces — switch, share or reset from the switcher (also on the rail and in ⌘K)'
: 'Presets and the switcher work now; to move panels between docks, enable the malleable shell in Labs'
}
>
<div className="flex gap-1.5">
<ThemeButton
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/workbench/ArrangeOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Arrange mode (0282 phase 3): the schematic edits the tree through the
* same store actions as every other road, Esc exits before the shell
* ladder, and a changed tree earns the save nudge on exit.
*/
import { act, fireEvent, render, screen } from '@testing-library/react'
import React from 'react'
import { beforeEach, describe, expect, it } from 'vitest'
import { ArrangeOverlay } from './ArrangeOverlay'
import { registerBuiltinSlotViews } from './builtin-slot-views'
import { regionOf } from './layout-tree'
import { useWorkbench } from './state'

registerBuiltinSlotViews()

beforeEach(() => {
useWorkbench.getState().applyPreset('bench')
useWorkbench.getState().setArranging(true)
})

describe('ArrangeOverlay', () => {
it('renders every dock as a labeled slot with its chips', () => {
render(<ArrangeOverlay />)
expect(screen.getByRole('region', { name: 'left dock' })).toBeTruthy()
expect(screen.getByRole('region', { name: 'corner dock' })).toBeTruthy()
// Explorer chip sits in the left dock slot.
const left = screen.getByRole('region', { name: 'left dock' })
expect(left.querySelector('[data-arrange-chip="explorer"]')).toBeTruthy()
})

it('tier toggle and hide go through setSlotTier', () => {
render(<ArrangeOverlay />)
fireEvent.click(screen.getByRole('button', { name: 'Hide Explorer' }))
const tree = useWorkbench.getState().tree
const placement = tree.regions['dock.left'].find((p) => p.viewId === 'explorer')
expect(placement?.tier).toBe('hidden')
// The hidden tray now offers it back.
expect(screen.getByRole('region', { name: 'Hidden views' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Show Explorer' }))
expect(
useWorkbench.getState().tree.regions['dock.left'].find((p) => p.viewId === 'explorer')?.tier
).toBe('summoned')
})

it('Esc with an unchanged tree exits arrange mode directly', () => {
render(<ArrangeOverlay />)
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
)
})
expect(useWorkbench.getState().arranging).toBe(false)
})

it('exiting with a changed tree surfaces the save nudge first', () => {
render(<ArrangeOverlay />)
act(() => {
useWorkbench.getState().moveSlot('console', 'dock.corner')
})
fireEvent.click(screen.getByRole('button', { name: /Done/ }))
// Still arranging — the nudge is up.
expect(useWorkbench.getState().arranging).toBe(true)
expect(screen.getByText('Keep this arrangement?')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(useWorkbench.getState().arranging).toBe(false)
expect(regionOf(useWorkbench.getState().tree, 'console')).toBe('dock.corner')
})
})
Loading
Loading