@tearleads/windowing
v0.2.16
Published
App-agnostic window management for React: window state, window chrome, and the menu and sidebar primitives it renders with.
Readme
@tearleads/windowing
App-agnostic window management for React: window state (open, focus order, minimize/maximize, a per-window route and Back stack), window chrome (title bar, menu bar, toolbar, sidebar, status bar, resize handles), the menu and sidebar primitives that chrome renders with, a taskbar's start menu and theme switch, and a launcher that runs a set of mini-apps in windows or in the routed (iPad / phone) shell.
The package knows nothing about the applications that run inside its windows. It depends only on React, react-dom, and Phosphor icons, which draw the chrome's own icons unless a host passes its own (see Icons).
Composing a desktop
import {
useWindowActions,
useWindowStateData,
Window,
WindowStateProvider,
} from "@tearleads/windowing";
function Desktop() {
const { windows } = useWindowStateData();
return (
<div style={{ position: "relative", height: "100%" }}>
{windows.map((entry) => (
<Window key={entry.id} windowId={entry.id} />
))}
</div>
);
}
function OpenNotes() {
const { create } = useWindowActions();
return (
<button
type="button"
onClick={() => create("Notes", 200, 160, NotesApp, { appId: "notes" })}
>
Notes
</button>
);
}
<WindowStateProvider>
<OpenNotes />
<Desktop />
</WindowStateProvider>;Windows are absolutely positioned and clamp to their parent element, so the desktop surface must be a positioned container.
Geometry and input
Windows move by their title bar and resize by any side or corner with pointer events, so mouse, touch, and pen all work. The resize targets reach a few pixels past the window's border, so an edge can be taken from just outside the window, and a hovered corner lights up over the window's own rounded border.
While a gesture runs the geometry stays local to that window; when it ends, the
window commits its position and size (surface-relative) to its
WindowEntry. The first layout commits too, so position always reflects
where the window sits. Without size a window takes its stylesheet's default
size.
To save a layout, read position and size from useWindowStateData(). To
restore one, pass them back as create options, which take precedence over
create's viewport x/y. setGeometry(id, { position, size }) moves or
resizes a window programmatically.
Content with a natural size, such as a document page, passes it in CSS pixels
to useWindowContentSize({ width, height }) while it is mounted. Its window
then offers Fit to Content in the View menu, which restores the window if
maximized and sizes it so the scroll pane's content area shows that size. The
fit measures the chrome as rendered (bars, sidebar, the pane's padding and
scrollbars) and stays within the surface. Content the surface cannot hold
scrolls. The width always leaves room for a vertical scrollbar, so the
content keeps its width even when a status message briefly takes height from
the body. Last, the window moves as far as it must to stay on the surface.
undefined withdraws the item.
To open a window already fitted, pass fitToContent: true to create. The
window fits once, as Fit to Content would, when its content has loaded and
reported its size. Content reports its load by calling
useCurrentWindow()?.markContentLoaded?.(), for example once a page has
rendered. A later call does nothing. A window that is maximized when its
content loads, as a host might open it on a narrow screen, stays maximized,
and a window on a hidden surface fits once the surface shows.
Slots
ContentBoundary—Windowrenders a window's component inside this optional wrapper, which receives theWindowEntry. Hosts use it to give content app-specific context (a route provider, an error boundary) without the window layer knowing about apps. Tearleads fills it inpackages/app/src/mini-apps/MiniAppWindow.tsx.appId— an opaque string a host attaches when creating a window. The window layer only stores and compares it; the host interprets it.
Windows register their own menu items, title-bar actions, a Back action, a
sidebar, a background, and a natural size through the useWindow* hooks, from
inside the window's content.
Toolbar
The toolbar row sits between the menu bar and the body. Content registers icon
buttons in it with useWindowTitleBarAction; give an action pressed to make
it a toggle, announced as aria-pressed and drawn pressed while true:
const icon = useMemo(() => <HashIcon aria-hidden size={18} />, []);
useWindowTitleBarAction(
useMemo(
() => ({ icon, id: "ascii", label: "ASCII", onClick: toggle, pressed }),
[icon, pressed, toggle],
),
);A window with an appId also offers Back through its own route history, so its
row is reserved from the start. An app that never routes passes
historyBack={false} to Window; its window then has no row until the app
first registers toolbar actions. From then on the row stays, as in any window,
so the body does not shift when actions come and go.
Icons
Icons are components, from any icon set. MenuItem, StartMenu items, and the
chrome take a WindowingIcon: a component that accepts WindowingIconProps
(aria-hidden, className, focusable, and size), as Phosphor and Lucide
icons do. A menu item renders its icon at 16 pixels:
import { NotePencilIcon } from "@phosphor-icons/react";
<MenuItem icon={NotePencilIcon} label="Notes" onClick={openNotes} />;Toolbar actions take a rendered icon element instead, as shown above.
The chrome draws three icons of its own, from Phosphor: the toolbar's Back
caret and the title bar menu's Move Forward and Move Backward arrows.
WindowingIconsProvider replaces them for the chrome inside it. An icon it
leaves out keeps the one from the nearest provider above, or the default:
import { ArrowDown, ArrowUp, ChevronLeft } from "lucide-react";
<WindowingIconsProvider
icons={{ back: ChevronLeft, moveBackward: ArrowDown, moveForward: ArrowUp }}
>
<Desktop />
</WindowingIconsProvider>;Start menu
StartMenu is a taskbar's start button: place it at the bar's leading edge,
give it an icon to show (a logo or an icon element), and the menu opens above
the button's top-left corner. Its items each take an id, label, optional
icon and disabled, and onSelect, which runs after the menu closes and
receives where the menu opened, for placing a new window near it:
<StartMenu
icon={<Logo />}
items={[
{
icon: NotePencilIcon,
id: "notes",
label: "Notes",
onSelect: ({ x }) => create("Notes", x, 80, NotesApp),
},
]}
/>The button is named "Menu" unless label says otherwise, and announces its
menu with aria-haspopup and aria-expanded. By default it is a square,
borderless control the height of the bar's controls, lit while hovered,
focused, or open (StartMenu.css). A className replaces that styling, so a
host's own button styles apply alone. A menu that needs more than a list passes
renderMenu, which renders the open menu itself and receives its position
and a close callback. Tearleads' footer does this in
packages/app/src/shell/pane/footer/PaneFooter.tsx.
Launching mini-apps
A launcher application is a set of mini-apps, defined once and shown either in
windows on a desktop or one at a time in the routed shell. A
LauncherDefinition lists the apps by id, the order the launchers show them in,
and the app the routed shell shows at the root route:
import type { LauncherDefinition } from "@tearleads/windowing";
const LAUNCHER: LauncherDefinition<"notes" | "contacts"> = {
apps: {
contacts: {
createComponent: () => ContactsApp,
icon: AddressBookIcon,
title: "Contacts",
},
notes: {
createComponent: () => NotesApp,
icon: NoteIcon,
initialShowSidebar: false,
title: "Notes",
},
},
defaultWindowAppId: "notes",
homeAppId: "notes",
order: ["notes", "contacts"],
};LauncherNavigationProvider takes the definition and a NavigationMode, and
goes inside a WindowStateProvider. In windowed mode
useLauncherNavigationActions().openMiniApp({ appId }) opens the app's window,
or raises the one already open; in routed mode it pushes the app's route,
/app/<app id>/…, onto browser history, and Back and Forward walk it. Render
each window with MiniAppWindow, whose AppBoundary slot wraps the app (for
example in an error boundary).
Optional defaultWindowAppId opens on entering an empty windowed desktop,
including mount. Minimized windows count as open. Closing the last window
leaves it empty until the next mode entry. The provider's defaultWindowEnabled
defers that entry while false (e.g. hidden workspaces). Hiding and showing an
already initialized desktop preserves it, even if empty. Omit the app id to
manage startup yourself; homeAppId only sets the routed home.
A mini-app reads and sets its own route with useMiniAppRouteSegments(appId),
or useMiniAppRouteState for a parsed route: the browser route in the routed
shell, its window's own route and Back stack in a window. It registers its
sidebar and toolbar actions with the window hooks, and both shells show them.
useNavigationMode picks the mode: the user's choice from
NavigationModeOverrideProvider (which persists it under a storageKey)
first, while that choice suits the screen; then a host's forcedMode; then
preferredMode, which defaults to routed.
NavigationModeSwitch, for a taskbar's corner, records that choice: given the
layout showing it, it offers the other ("Switch to iPad / mobile layout"). Windowed
applies only where windows suit the screen: at least 1024px wide, with a fine
pointer, and not an iPad. Pass the mode to
useNavigationModeDocumentAttribute, which stamps
<html data-navigation-mode>; the routed touch sizes key off it (see
Styles), and useRoutedLayoutActive reads it back.
Routed shell
RoutedPane is the routed shell, the iPad / phone layout: one mini-app at a
time, chosen by the route, under an app bar with Back, Forward, the app's
sidebar toggle, its title, and its toolbar actions, above a taskbar. Below 760px
(ROUTED_TABLET_BREAKPOINT_PX) it is a phone layout whose launcher is a bottom
sheet of app tiles; at or above it, a tablet layout whose launcher is a left
rail or, at the user's choice, the same sheet. Mini-apps put their actions in
the toolbar: the routed shell has no menu bar.
<WindowStateProvider>
<LauncherNavigationProvider definition={LAUNCHER} mode="routed">
<RoutedPane
AppBoundary={AppErrorBoundary}
menuIcon={<Logo />}
taskbarTray={<ModeSwitch />}
/>
</LauncherNavigationProvider>
</WindowStateProvider>The host fills its chrome: menuIcon is the taskbar's centered launcher
button, taskbarTray its end-aligned tray, contentTop rides above the active
app, and renderAboveTaskbar draws a strip above the taskbar, told whether the
phone's software keyboard is open. appIds narrows the apps the launcher
offers, such as to those the session may see, and
subscribeKeyboardVisibility reports a native keyboard a WebView hides.
Overlays that should fill the content pane rather than the screen portal into
useRoutedPaneOverlayHost().host. The shell sizes to the box its host gives
.routed-pane, as a grid item or a block with a height. Tearleads dresses it in
packages/app/src/shell/layout/routed/AppRoutedPane.tsx.
Themes
A host offers its own color themes. ThemeProvider takes them as a list of
{ id, label, scheme }, the scheme light or dark, and stamps the active one
as <html data-theme> and <html data-theme-scheme>. The host styles each
theme with a block of the design tokens (see Styles):
const THEMES: readonly ThemeDefinition<"paper" | "ink">[] = [
{ id: "paper", label: "Paper", scheme: "light" },
{ id: "ink", label: "Ink", scheme: "dark" },
];
<ThemeProvider
defaultTheme={{ dark: "ink", light: "paper" }}
storageKey="notes.theme"
themes={THEMES}
>
<Desktop />
<ThemeSwitch />
</ThemeProvider>;:root[data-theme="ink"] {
--color-dark: #e5e5e5;
--color-light: #161616;
}defaultTheme, the theme until the user picks one, is an id or an id per OS
color scheme, followed live. The choice persists under storageKey; a stored
id the host no longer offers counts as none, and a default it does not offer
falls back to its first theme. Define the list once, outside render.
ThemeSwitch, for a taskbar's corner, moves to the next theme in order,
wrapping, and names it ("Switch to Ink theme"). It renders nothing outside a
provider or for a single theme. It draws a half-filled square unless given an
icon, and is styled as NavigationModeSwitch unless given a className.
useTheme reads the active and next themes and the list, and sets a theme by
id; useOptionalTheme returns null outside a provider.
Mount one provider above everything themed. It stamps the attributes before
the browser paints and removes them on unmount; under a dark scheme,
tokens.css sets color-scheme: dark. Tearleads sets its themes per host
profile in packages/app/src/host/AppHostConfig.ts.
Styles
Each component imports its own stylesheet, so a bundler that handles CSS imports
picks them up. Stylesheets are also exported by path (for example
@tearleads/windowing/window/WindowTitleBar.css) for surfaces that reuse the
chrome's look without rendering a window.
The stylesheets read design tokens (colors, spacing, radii, shadows, and the
--window-bar-* and --tearleads-window-titlebar-* chrome tokens). The package
entry imports tokens.css, which defines every one of them under
:where(:root), so a page that defines none still renders a complete light
window. That rule has zero specificity: any :root rule, theme, or scoped block
the host writes overrides it. Tearleads overrides them with its own themes in
packages/ui/src/styles.css and packages/app/src/shell/layout/AppChrome.css.
The routed shell also reads the screen's safe areas (--safe-area-*) and, under
:root[data-navigation-mode="routed"], a 44px --control-height and 24px
--control-icon-size, which tokens.css defines the same way.
Several defaults derive from --color-dark, the foreground: the window edge
(--border-strong-color), the hover and selection chip (--emphasis-surface
and --emphasis-text), and --color-hairline. A dark theme that sets only the
--color-* primitives gets near-white edges and chips, so set these too, as
Tearleads' dark theme does.
The window, menus, and routed shell draw thin scrollbars in --scrollbar-thumb
over --scrollbar-track. tokens.css leaves both unset, so the thumb falls
back to --color-dark at 32%, mixed on each surface so it follows the theme
there, including a scoped one, and the track to transparent. They use the
standard scrollbar-width and
scrollbar-color properties, which Firefox, Chromium 121, and Safari 26.2
read; older Safari keeps its own scrollbars. Content in a window or the shell
scrolls the same way: scrollbar-color inherits from the surface, and
scrollbar-width is set on every element in it. Both rules have zero
specificity, so any host rule wins.
The window paints its background, behind its body and sidebar, with
--window-background. tokens.css leaves it unset, so it falls back to
--color-light wherever that is set, including a scoped theme. Content sets
its own window's background with useWindowBackground, while it is mounted;
undefined keeps the default. A document viewer can use it to set its page
apart from the window around it:
useWindowBackground(`color-mix(in srgb, ${foreground} 10%, ${page})`);The window rounds its corners but does not clip its content, since its resize handles and menus reach past its edges. While no visible status bar sits below it, the window body rounds its own bottom corners to the window's inner radius and clips its content there, so content painted edge to edge stays inside the border.
Windows and menus lay out border-box, the box model their sizes assume. Since
box-sizing does not inherit, a zero-specificity rule sets it on the window or
menu and everything inside, including window content; any host rule overrides
it.
Publishing
Inside this workspace the package exports its TypeScript source. The npm
package is built separately, into dist/, with its own generated manifest:
bun run --cwd packages/windowing package # build dist/
bun run --cwd packages/windowing package:smoke # pack, install, render, typecheck, and bundle outside the workspaceThe built package is ES modules with declarations and source maps, plus every
stylesheet. React and react-dom are peer dependencies from 19.2, the first
release with useEffectEvent; Phosphor icons is its one dependency. The
stylesheets and the entry module (which imports tokens.css) are declared side
effects, so bundlers that trust sideEffects keep the token defaults.
src/publishedPackage.test.ts checks the built manifest and that no module
reaches outside the package, so a new workspace import fails the package's tests
rather than the publish. The declarations drop the stylesheet imports tsc keeps
in them, which a consumer's typecheck could not resolve. The smoke script
installs the lowest React the peer range admits, typechecks against the
declarations with TypeScript's defaults, and bundles a named import with webpack.
Merging to main publishes. When packages/windowing changes there,
.github/workflows/windowing-publish.yml publishes the version in
package.json if it is newer than npm's latest; a merge that leaves the
version alone, a re-run, or a run that finishes after a newer release succeeds
without publishing (scripts/lib/npmPublishDecision.ts at the repository root
makes that call). The workflow
authenticates with
npm trusted publishing: npm accepts
the job's GitHub OIDC token instead of an npm token, and attaches provenance to
each version. On npmjs.com the package's trusted publisher names the a2f0
owner, the tearleads repository, the windowing-publish.yml workflow, and
the npm environment, which the repository restricts to main. Renaming the
workflow or the environment breaks publishing until that setting matches.
To publish by hand instead, run from the repository root with an account that
owns the @tearleads npm scope:
npm login --registry https://registry.npmjs.org
bun run publish:npm:dry-run windowing
bun run publish:npm windowingWindowing has no workspace dependencies, and its React peers and Phosphor
dependency already exist on npm, so it publishes on its own.
scripts/publishNpmPackage.sh publishes each npm package by its directory name;
bun run --cwd packages/windowing publish:npm runs it for windowing, and the
workflow runs it too.
The command rebuilds into a fresh temporary directory, publishes its generated
consumer manifest with public access to https://registry.npmjs.org, and
removes the directory on success or failure. A temporary .npmrc overrides any
scope-specific registry without changing your npm configuration. A dry run
builds and previews the same package without uploading. It accepts
--dry-run, --tag <tag> (default latest), and --otp <code>; for example:
bun run publish:npm windowing --dry-run --tag next
bun run publish:npm windowing --tag next --otp 123456Run package:smoke separately before a release to verify the packed package in
an external consumer. It needs the network to install the consumer dependencies.
ship-pr bumps changed workspace packages before review and merge; the generated
manifest copies version from package.json. For a release outside that flow,
bump the version yourself before publishing. The manifest names this repository,
which npm requires of a version with provenance, and declares
"license": "UNLICENSED" until a license is chosen.
Dependency compatibility
React and react-dom are host peers with the range ^19.2.0. Workspace tests
use React 19.3; the tarball smoke test installs the minimum React 19.2, renders
a window, typechecks a consumer, and bundles its JavaScript and CSS.
