@devslab/workspace
Workspace
Open source by 데브스랩(DevsLab).
English · 한국어 · Documentation · OSS brand guide
Retained work tabs, scoped shortcuts and an open-screen switcher. A framework-independent core with native Solid, React, Vue and Svelte adapters. Your pages keep their own forms, data, router, authorization and subscriptions.
Opening a registered screen creates its tab. The open-screen switcher is included; you supply the screen components and keep their business logic. Workspace 0.1.1 and later use Apache-2.0; the published 0.1.0 retains its original MIT license. DDS is a separate source-available design system.
Install
npm install @devslab/workspace
# Install peers for the adapter you use, for example Solid:
npm install solid-js @ark-ui/solid@5.39.3
Usage
import { Workspace } from '@devslab/workspace/solid';
import '@devslab/workspace/style.css'; // optional
const screens = [
{ id: 'orders', title: 'Orders', component: OrdersPage },
{ id: 'customers', title: 'Customers', component: CustomersPage },
];
<Workspace screens={screens} defaultScreen="orders" />;
Only visited pages mount. Switching retains them behind hidden and inert; closing disposes the instance. A later open creates a fresh instance. Maximum nine tabs by default; stable slot numbers keep Alt+1–9 consistent. Alt+Q opens the switcher. Tabs use manual keyboard activation: arrows move focus; Enter/Space selects, Delete closes. Mobile supports every command through buttons.
The root and ./core exports contain no framework runtime. Install only the optional peers used by your adapter:
| Import | Required peer packages |
|---|---|
@devslab/workspace |
none |
@devslab/workspace/solid |
solid-js, @ark-ui/solid |
@devslab/workspace/react |
react, react-dom, @ark-ui/react |
@devslab/workspace/vue |
vue, @ark-ui/vue |
@devslab/workspace/svelte |
svelte ≥5.29, @ark-ui/svelte |
Ark UI implements accessible interaction internally. Workspace callbacks return our values and results, not Ark details objects. Ark versions are pinned in the manifest; the Svelte adapter uses its independently versioned Ark package.
Existing menus and guards
import { createWorkspace } from '@devslab/workspace';
const controller = createWorkspace({
screens: [{ id: 'orders', title: 'Orders' }],
beforeActivate: id => routerAuthorizeAndNavigate(id),
beforeClose: id => confirmDiscardIfDirty(id),
});
await controller.open('orders');
// <Workspace controller={controller} screens={presentationScreens} menu={false} />
Use useWorkspace().open(id) inside your existing menu. Pass a controller only when the app needs external access. Controller mode and standalone options are mutually exclusive. With a controller, the controller's list is the registry; the screens prop only supplies components, by id, for controller definitions that have none. Options and the screens prop are read once: screens change at run time through the controller (below). Remount for a different app identity.
Commands: open, select (already-open tabs only), close, showSwitcher, hideSwitcher, cancelPending, reset, and the registry methods below. Transitions return ok, unknown, limit, denied, cancelled or stale. For open and select, a refused or throwing guard keeps the selected page, unless the screen being checked is the one on display (re-opening the active screen, showRetainedFirst, or a screen the router already showed): then no screen is active. Only the latest valid transition commits. Closing does not wait for the next screen: after beforeClose allows it, the tab leaves at once and its neighbour shows straight away, then the neighbour's beforeActivate runs. If that guard refuses, close resolves denied and no screen is active: the empty state shows and the first remaining tab stays in the Tab order. If it throws, close rejects with the same empty state. If a later open or select commits, show() makes another screen active, reset() runs, or the shown neighbour is closed or revoked first, the check no longer applies and close resolves stale. A close still waits one microtask before it commits, as in 0.2.0, even without beforeClose. Focus moves to the shown tab when the closed control disappears, but not out of a page the user has already moved into. reset() immediately discards all work and invalidates pending guards: use it for logout/company changes. It bypasses dirty-close confirmation deliberately.
Registering screens at run time
The controller keeps a live registry. The screens option (or standalone prop) is optional and is only the starting list. An app router adds a screen when its route succeeds and removes screens when access is lost. Change screens through the controller (useWorkspace() inside the workspace, or the controller you pass in), not by changing the screens prop.
import { createWorkspace, type AdapterScreen } from '@devslab/workspace/solid';
const controller = createWorkspace<AdapterScreen, { draft: string }>({
home: 'dashboard',
createTabData: () => ({ draft: '' }),
beforeActivate: id => navigateAndReport(id), // the route registers the screen before this resolves
});
// In the router, once a route is allowed:
controller.register({ id: path, title, component: Page }, { show: true });
// When permissions change:
controller.retainOnly(allowedIds);
register(screen, { show?, renew? })adds or updates a definition and returnsokorlimit. Updating an open screen changes its title and the definition the page reads, and keeps the same tab, page and data. A tab keeps the component it was created with;{ renew: true }replaces an open tab in place (same slot and position, a new instance and fresh data) and disposes the old page. Over the tab cap the definition is still stored and only the tab is refused.show(id)makes a registered screen active and returnsok,unknownorlimit.show()andregister(…, { show: true })run no guard. They are meant for the app's router, which has already allowed the route. Showing the screen whose check is running is part of that check; showing another screen overtakes it (stale).unregister(id)andretainOnly(ids)are revocation, not closing. They remove the definition and its tab withoutbeforeCloseand without showing a neighbour; if the active tab goes, no screen is active until the app shows one. A lateropen(id)asks the guard again. Running checks, opens and closes of removed screens resolvestale.retainOnlyreturns the removed ids.reset()closes every tab and restores the starting list exactly.open(id)of an id that is not registered: withoutbeforeActivateit returnsunknown. With a guard it checks the tab cap, runs the guard, then reads the registry: if the route registered the screen meanwhile it opens, otherwiseunknown.home(an id, or a function returning one): closing the only tab when it is home is refused (cancelled) andcanClose(id)is false, so packaged close buttons are disabled and Delete does nothing. Closing the last tab of another screen opens home through its guard. Ifhomeis not registered and there is nobeforeActivateguard, the last tab is still removed andclose()resolvesunknown(the workspace is empty). Packaged parts re-read the disabled state on a snapshot change, so ahomefunction that reads outside state takes effect at the next change.showRetainedFirst: true:open(id)of an open tab shows it at once and checks it afterwards; a refusal leaves no screen active. While it runs, closing other tabs, anopenof another screen that does not commit, orcancelPending()(which the URL adapter calls on every new URL) does not end the check: its answer still decides, the same way a closed tab's neighbour check does. The URL adapter accepts that screen's URL only after the check allows it, so a refused back/forward restores the last accepted URL.createTabData(screen)runs once per tab instance (a new tab or a renewal). Its value isTab.dataanduseWorkspacePage().data. Without it,dataisundefined. The core types' data parameter (Tab<D>,Snapshot<S, D>,Options<S, D>) defaults toundefined;useWorkspacePage<D>()defaults tounknown, so passDto typedata.snapshot.pendingIdis the id whosebeforeActivateis running, for every kind of check; when a newer check starts while a check of the screen on display keeps running underneath, it names the newer one.snapshot.activePendingistruewhile any check of the active screen is running, at either depth, and absent otherwise. Packaged tabs markpendingIdaria-busy. For an id with no tab yet, the optionalslots.pendingTab(id)renders apendingpart after the default tab strip's close buttons, outside the tab list; without that slot nothing is rendered.getScreen(id),getTab(id)andcanClose(id)read the current state.- Pages:
useWorkspacePage()returns{ id, active, data, screen };screenis the live definition, so its title updates without remounting. Packaged panels still render the component with no props. - Adapter definitions are
AdapterScreen(componentoptional). With a controller, its list wins and thescreensprop fills missing components by id. A screen with no component in either renders an empty panel; a component added later takes effect only throughrenew. - The default menu lists the
screensprop when it is non-empty, otherwise the live registry.menusitems may name screens that are not registered yet; give those alabel. - Listeners and
onChangerun synchronously inside every commit, including while a guard runs (pendingIdchanges). They must not register or show screens in reaction to a snapshot change; registration belongs to the router. - SSR: register (and show) on a request-local controller before rendering, one controller per request, and replay the same registrations in the same order on the client so slots match. In Vue, bind boolean options such as
showRetainedFirst(:show-retained-first="true"): a bare attribute arrives as'', which is nottrue. Solid, Svelte and Vue also accept registration during render, before the tab parts render; Vue then renders that tab witharia-selected="false"until hydration, without a mismatch. React supports registration before render only.
Styling and composition
CSS is optional. Theme through --ws-* variables, classNames, content slots, or unstyled. Mechanical visibility and inactive-page focus exclusion work without CSS. Slots change labels/content while preserving accessible buttons. Headless parts provide control over layout:
For DDS styling, load your licensed DDS tokens and @devslab/workspace/style.css, then @devslab/workspace/dds.css. The optional mapping references DDS variables without bundling DDS assets. Set theme variables on :root (or both the workspace and switcher classes) so body-portalled dialogs inherit them. Supported tokens include --ws-text, --ws-background, --ws-surface, --ws-border, --ws-accent, --ws-on-accent, --ws-focus, --ws-overlay, --ws-font, --ws-dialog-radius and --ws-dialog-shadow.
<WorkspaceProvider screens={screens} defaultScreen="orders" unstyled>
<MyNavigation />
<WorkspaceSwitcherTrigger />
<WorkspaceTabList />
<WorkspaceStatus />
<WorkspacePanels />
<WorkspaceSwitcher />
</WorkspaceProvider>
Compose WorkspaceTab and WorkspaceCloseButton for custom tab strips. Keep close controls outside the element with role="tablist". asChild supports existing buttons with merged events/ref; preventDefault() cancels the Workspace action. Solid receives native attributes in its callback; React/Vue use their native Ark composition convention, Svelte uses a snippet and bindable ref.
Pages can optionally use useWorkspacePage() and useActiveEffect() to run app subscriptions only while active. React uses dependency arrays, Vue uses reactive refs, Solid uses accessors, Svelte uses getter/runes. Inactive page portals must consult page activity. Workspace never refetches, invalidates, or transmits analytics. React, Vue and Solid can opt into retained DOM snapshots with previews; custom static previews remain available through slots.preview.
Shortcuts skip editable elements, IME composition, repeat events and app dialogs. They apply only to events originating in the workspace. Disable with shortcuts={false} or configure shortcuts={{switcher:'Ctrl+q',slots:['Alt+1','Alt+2']}}. Set dir explicitly for SSR RTL; otherwise adapters inherit ambient direction after mount.
Default screens open after mounting, so default SSR output is empty. Preopen a request-local controller before SSR to render visited pages (see the registration rule above). Set dir explicitly for server RTL. No Next/Nuxt/SvelteKit integration is claimed.
createHistoryWorkspace(options, browserHistory(window)) optionally connects initial URLs and browser back/forward. Supply screenFromUrl and urlForScreen; the returned {controller,ready,dispose} uses the same activation guard before showing a destination. Rejected back navigation restores the last accepted URL with replaceState, without adding an entry. App selection pushes only a changed URL. Closing the active tab pushes the neighbour's URL as soon as it shows, before its guard finishes. The last accepted URL is always the last screen whose check actually allowed it: a screen shown while its own check runs (a closed tab's neighbour, showRetainedFirst), even underneath a newer check, becomes accepted only when that check allows it. If a neighbour's guard refuses, the URL stays on the refused screen, but a later refused navigation restores the last allowed screen's URL; loading the refused screen's URL again runs the guard again. sync() reads the current URL again and opens its screen. With a guard, back/forward to an id that is not registered runs the guard, and the registry is read after it. Disposal removes the history listener and resets the controller, dropping run-time registrations. Do not combine the history adapter with show() from another router: one URL owner per workspace. The history adapter is synchronous; TanStack/React Router loaders and their async cancellation remain app-owned. A navigation guard bridge alone does not implement an async router integration.
Verification
npm run typecheck, npm run check:svelte, npm test, npm run build, npm run build:demo, npm run test:e2e, then node scripts/verify-package.mjs. Svelte fixtures have separate browser/SSR runners under tests/svelte/. Package installation verification checks optional peers, installed exports and Solid browser hydration; source imports alone are insufficient. SolidJS guide, Native React/Vue examples, Svelte guide, Korean guide.
Externally owned dynamic pages (Solid)
RetainedPanels supports an app whose guarded router synchronously registers authorized pages, including during SSR. The app keeps its own list, payloads, activity predicate and navigation commands; this component owns native page retention and inactive hidden/inert behavior.
import { RetainedPanels } from '@devslab/workspace/solid';
<RetainedPanels
items={authorizedPages()}
active={page => routeReady() && page === activePage()}
as="section"
panelProps={page => ({ id: page.id, role: 'tabpanel', 'aria-labelledby': page.tabId })}
>
{page => <PageContext.Provider value={page.activity}><Dynamic component={page.component} {...page.payload()} /></PageContext.Provider>}
</RetainedPanels>
Keep each item object stable to preserve its component owner. Removing or replacing that object disposes its page; selecting another item retains it. This is a separate integration surface from the Workspace controller registry and supplies no router, authorization store or data cache. Gate page-owned portals with the app's activity context. Native attributes are forwarded through panelProps; activity always controls hidden and inert.
controller.cancelPending() invalidates outstanding activation/close guards without changing retained tabs, active page, or switcher state. A close that has already removed its tab is not undone; its check of the shown neighbour still applies, and so does any other check whose screen is on display (showRetainedFirst, or a screen the router showed during its check). The URL adapter calls it on every new URL intent, including unmapped, unknown, and tab-limited destinations, so an older guard cannot restore an obsolete URL. Switcher-only changes do not push history entries.
Contributing
Issues and PRs are welcome. See CONTRIBUTING.md for setup, verification and documentation conventions. Release maintainers should follow the OIDC release guide.
Family
- kokey — keyboard-layout conversion for business forms.
- numkey — caret-safe numeric inputs.
- DDS — a separately licensed design system, with an optional Workspace token mapping.
- More open source from DevsLab.
License
Retained screen previews (0.4.0)
React, Vue and Solid expose the same opt-in previews presentation option on
Workspace / WorkspaceProvider: <Workspace previews ... /> (Vue:
<Workspace :previews="true" ... />). Import @devslab/workspace/style.css for
the default thumbnail frame, or style the preview part through classNames.
Existing slots.preview(screen) content takes precedence.
The switcher snapshots already mounted screen DOM when opened. It does not mount
screen components again or run their requests. Input, select, checked and scroll
state are copied; closing the switcher disposes snapshots and resize observers.
Previews are inert, hidden from assistive technology, and never replace the live
page. SSR creates no DOM snapshots. Passwords and subtrees marked
data-workspace-preview-private are masked. Mark other sensitive content explicitly.
Canvas/WebGL, iframes, video and external portals are not captured; use the custom
preview slot when a screen needs a different representation. This is a DOM
snapshot, not a continuously updating screenshot.
The default switcher trigger uses the DDS layout-grid icon with an accessible
label. Custom trigger children/default slots still override it. Core/history
remain independent of DDS; only presentation adapters use @devslab/dds-icons.
Related Packages
Ark UI style wheel-picker components for React, Solid, Vue and Svelte, built on...
Type-safe WebSocket client for chanx AsyncAPI schemas, with topic multiplexing....
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.4.0 | ^3.5.0 | Apache-2.0 | |
| 0.3.1 | ^3.5.0 | Apache-2.0 | |
| 0.3.0 | ^3.5.0 | Apache-2.0 | |
| 0.2.0 | ^3.5.0 | Apache-2.0 | |
| 0.1.2 | ^3.5.0 | Apache-2.0 | |
| 0.1.1 | ^3.5.0 | Apache-2.0 | |
| 0.1.0 | ^3.5.0 | MIT |
Pre-releases (1)
| Version | Released | License |
|---|---|---|
| 0.0.0-stage |
