Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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.
1 change: 1 addition & 0 deletions .storybook/shims/xnet-plugins-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export type {
// shell modules import these through the @xnetjs/plugins alias.
export {
createPresetTree,
insertSlot,
isPresetWorkspaceId,
moveSlot,
parseWorkspacePayload,
Expand Down
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