2026-09-25 17:07:58 +00:00
2026-09-25 17:07:58 +00:00
2026-09-25 17:07:58 +00:00
2026-09-25 17:07:58 +00:00
2026-09-25 17:07:31 +00:00

@design.estate/dees-catalog

Lit and TypeScript web components for application shells, forms, tables, charts, storage, media, and agent workspaces. Shared theme tokens and controls keep these surfaces consistent.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Find the right component

Start with the task below, then open the component's API and source in the component index. Search by both the UI pattern and the element tag before implementing a custom control. For example, segmented control, view switcher, view-toggle, and single-selection toggle all lead to dees-input-multitoggle.

Task or UI pattern Existing component Selection guidance
Switch between a few views, modes, or time periods; segmented control dees-input-multitoggle One selected value in a shared track. Use keyed options when labels differ from stored values.
Choose one form answer from visible radio options dees-input-radiogroup Radio indicators, required validation, optional payload values.
Turn one setting on or off dees-input-toggle, dees-input-checkbox Boolean setting or checkable form choice. Multitoggle's boolean mode is useful when both alternatives need explicit labels.
Toggle an independent toolbar option dees-button with pressed, dees-input-toggle A toolbar or app-bar command that stays on uses a button with pressed; a labelled setting uses the switch. A set of independent options is not a single-selection multitoggle.
Choose from a longer menu of values dees-input-dropdown Searchable single selection; enableSearch defaults to true.
Group related commands dees-button-group, dees-actionbar Layout for actions. A button group does not own a selected value.
Navigate open content panels, with optional close controls dees-appui-tabs Tab selection, close actions and keyboard focus. Use multitoggle for changing a display mode within a panel.
Build an application shell with views and menus dees-appui Main/secondary navigation, content tabs, bars and view lifecycle.
Show, paste or share a link to the current app screen; address/location bar dees-appui-location Clickable ancestors, URL editing and copy feedback. Enable it in App UI; route state belongs to the shell.
Reach any screen, menu item or host command by name dees-appui-commandpalette The shell opens it with the platform modifier and K and with the app bar's search button. Host commands come from IAppConfig.commands / setCommands().
Build a simpler grouped dashboard dees-simple-appdash Grouped dashboard navigation without configuring the full App UI shell.
Offer sign-in methods and reveal an authenticated app dees-simple-login Passkeys, providers and password form; the application owns authentication.
Show products with quantities or select a kit dees-shopping-productcard Independent quantity and selection events; the application owns the cart and inventory.
See components composed into one working application Integrated App UI demo Shared state, layout presets, navigation, service tabs, charts, files, settings and activity. Start here before assembling a shell.
Collect and submit form values dees-form, dees-form-submit Give each input a stable key; use the form's collection and validation flow.
Edit multiline plain text dees-input-textarea Notes and descriptions as strings, with validation and form reset; no rich-text editor.
Track epics, tasks and subtasks across workflow columns dees-kanban Board, ordering, ticket detail, discussion and stable ticket references.
Compose comments with searchable inline references dees-input-reference Typing #_ opens a searchable picker at the caret; the value contains text and stable reference IDs.
Search, filter, sort, edit and act on records dees-table Use its columns, selection and data actions before building a table toolbar or row-action layer.
Show metrics, health and operational context dees-statsgrid, dees-dataview-statusobject Metric tiles versus structured status details and actions.
Plot a time series, compare categories, show proportions, capacity or profiles Chart components Area, bar, donut, gauge and radar have distinct data contracts.
Browse object storage with folders, files and preview dees-storage-browser Owns columns/list switching, provider operations and responsive preview.
Preview a file or display source/diffs dees-preview, dees-dataview-codebox Preview chooses the viewer; codebox owns source and inline/split diffs.
Edit project files with terminals dees-workspace Monaco, file tree, terminal and diff workspace.
Run an agent conversation with tools and decisions dees-harness-chat Compose the harness family; use its session list for conversation management.
Ask for a focused decision or show contextual commands dees-modal, dees-contextmenu Modal task flow versus contextual action menu. Use existing overlay ownership.
Show completion, progress or transient feedback dees-stepper, dees-progressbar, dees-toast Workflow stages, measurable progress, or a brief notification.

Before adding a control

  1. Find the task above and search the tag index below. Read the linked API and the actual implementation; similarly named controls have different contracts.
  2. Open its catalog demo with pnpm run watch (the running workspace uses port 3002). Check selection, events, form participation, keyboard behavior and sizing.
  3. Compose the existing component. If it lacks a needed reusable capability, extend its owner and verify existing consumers instead of copying its markup and styles into each view.
  4. When adding or extending a component, update its task entry, tag/source index, API example and demo together. Keep internal anchor and source links valid.

Documentation map

Installation

The examples below import DeesElement directly, so install both packages:

pnpm add @design.estate/dees-catalog @design.estate/dees-element@^3.2.0

Quick Start

import { html, DeesElement, customElement } from '@design.estate/dees-element';
import '@design.estate/dees-catalog';

@customElement('my-app')
class MyApp extends DeesElement {
  render() {
    return html`
      <dees-button type="accent" @click=${() => alert('Hello!')}>
        Click me!
      </dees-button>
    `;
  }
}

Languages

The words the components say to a reader — button texts, names for assistive technology, tooltips, empty states — come from one typed @push.rocks/smarti18n catalog, exported as dcMessages (namespace @design.estate/dees-catalog). English is the source; German, with the formal "Sie", ships complete. A page provides its language once, through provideI18n from @design.estate/dees-element, and every component re-renders when it changes:

import { createI18n } from '@push.rocks/smarti18n';
import { provideI18n } from '@design.estate/dees-element';
import { dcMessages } from '@design.estate/dees-catalog';

const i18n = createI18n({ locale: 'de-DE', formats: { number: 'de-DE' } });
provideI18n(document.documentElement, i18n, { reflect: true }); // sets <html lang dir>
await i18n.setLocale('en-GB');

// a word of your own, for one language
i18n.override(dcMessages, 'de', { 'appui.skipLink': 'Direkt zum Inhalt' });
  • Without a provider, the components speak English.
  • Modals, menus and toasts appended to document.body find the provider on <html>. An overlay opened from a subtree that speaks another language passes that language through the overlay helper's i18n option (DeesModal.createAndShow({ …, i18n })).
  • Numbers and dates follow the instance's formats (formats.number, formats.date), which a host sets apart from the language.
  • The app shell's labels (see dees-appui) still override the catalog's words in every language.
  • The words of the app shell are in the catalog; the other components move into it step by step. scripts/check-literal-ratchet.cjs, part of pnpm test, counts the English literals left in component templates and lets the count only go down.
  • The pseudo-locales en-XA (longer, accented words) and ar-XB (right to left) show clipped text and direction faults: createI18n({ locale: 'en-XA', pseudo: true }).

Development Guide

For developers working on this library, please refer to the UI Components Playbook for comprehensive patterns, best practices, and architectural guidelines.

Repository development notes, including readme.plan.md and readme.hints.md, live in docs/development/. These notes are retained in Git and excluded from the published package.

Before publishing, verify the installed package as well as the source tests:

pnpm test
node scripts/check-packed-consumer.mjs

pnpm test rebuilds the package first. The consumer check installs its tarball in an isolated temporary project and checks the table, settings, modal, PDF viewer, PDF thumbnail, harness message-list, datepicker, textarea and composer declarations with TypeScript 5.9.3 and 6.0.3, strict: true and skipLibCheck: false. It also runs Chromium construction, reconnect, selection/copy and real keyboard checks against the installed modules and bundle. The packed tests also exercise strict calendar validation, multiline form values and attachment capabilities. Trusted keyboard entry inside a native dialog is checked in UTC, Los Angeles, Berlin and São Paulo. No source aliases or vendor declarations are used. Pass an existing release tarball as the script's argument to verify those exact bytes.

Component index

Search this table by the actual HTML tag. It includes registered catalog elements and supporting subcomponents; the task guide identifies the usual composition entry points. API links lead to sections below; source links always lead to the owning implementation, including components without a dedicated reference section yet. Demo files live beside their components.

Run node scripts/check-component-docs.cjs to check index coverage and README links. After adding a component or API section, run the same command with --write-index to regenerate the tag table from registrations. Update the task guide and examples deliberately; the generated table does not replace them.

Element Family API Source
dees-actionbar feedback DeesActionbar Source
dees-appui appui DeesAppui Source
dees-appui-activitylog appui DeesAppuiActivitylog Source
dees-appui-appbar appui — Source
dees-appui-bottombar appui DeesAppuiBottombar Source
dees-appui-commandpalette appui DeesAppuiCommandpalette Source
dees-appui-location appui DeesAppuiLocation Source
dees-appui-maincontent appui DeesAppuiMaincontent Source
dees-appui-mainmenu appui DeesAppuiMainmenu Source
dees-appui-profiledropdown appui — Source
dees-appui-secondarymenu appui DeesAppuiSecondarymenu Source
dees-appui-tabs appui DeesAppuiTabs Source
dees-audio-viewer media DeesAudioViewer Source
dees-badge feedback DeesBadge Source
dees-button button DeesButton Source
dees-button-exit button DeesButtonExit Source
dees-button-group button DeesButtonGroup Source
dees-chart-area chart DeesChartArea Source
dees-chart-bar chart DeesChartBar Source
dees-chart-donut chart DeesChartDonut Source
dees-chart-gauge chart DeesChartGauge Source
dees-chart-line chart DeesChartLine Source
dees-chart-log chart DeesChartLog Source
dees-chart-radar chart DeesChartRadar Source
dees-chips layout DeesChips Source
dees-contextmenu overlay DeesContextmenu Source
dees-dashboardgrid layout — Source
dees-dataview-codebox dataview DeesDataviewCodebox Source
dees-dataview-statusobject dataview DeesDataviewStatusobject Source
dees-form form DeesForm Source
dees-form-submit form DeesFormSubmit Source
dees-formatting-menu input — Source
dees-grouped-list dataview — Source
dees-harness-chat harness DeesHarnessChat Source
dees-harness-composer harness DeesHarnessComposer Source
dees-harness-content-blocks harness DeesHarnessContentBlocks Source
dees-harness-conversation-picker harness DeesHarnessConversationPicker Source
dees-harness-message harness DeesHarnessMessage Source
dees-harness-message-list harness DeesHarnessMessageList Source
dees-harness-overflow-text harness DeesHarnessOverflowText Source
dees-harness-permission-card harness DeesHarnessPermissionCard Source
dees-harness-question-card harness DeesHarnessQuestionCard Source
dees-harness-reasoning harness DeesHarnessReasoning Source
dees-harness-session-list harness DeesHarnessSessionList Source
dees-harness-session-sidebar harness DeesHarnessSessionSidebar Source
dees-harness-sidebar harness DeesHarnessSidebar Source
dees-harness-status harness DeesHarnessStatus Source
dees-harness-thread-cut harness DeesHarnessThreadCut Source
dees-harness-todos harness DeesHarnessTodos Source
dees-harness-tool-card harness DeesHarnessToolCard Source
dees-harness-tool-fullscreen harness — Source
dees-harness-usage harness DeesHarnessUsage Source
dees-heading layout DeesHeading Source
dees-hint feedback DeesHint Source
dees-icon utility DeesIcon Source
dees-image-viewer media DeesImageViewer Source
dees-input-checkbox input DeesInputCheckbox Source
dees-input-code input DeesInputCode Source
dees-input-datepicker input DeesInputDatepicker Source
dees-input-datepicker-popup input — Source
dees-input-dropdown input DeesInputDropdown Source
dees-input-dropdown-popup input — Source
dees-input-fileupload input DeesInputFileupload Source
dees-input-iban input DeesInputIban Source
dees-input-list input DeesInputList Source
dees-input-multitoggle input DeesInputMultitoggle Source
dees-input-phone input DeesInputPhone Source
dees-input-profilepicture input DeesInputProfilePicture Source
dees-input-quantityselector input DeesInputQuantitySelector Source
dees-input-radiogroup input DeesInputRadiogroup Source
dees-input-reference input DeesInputReference Source
dees-input-richtext input DeesInputRichtext Source
dees-input-tags input DeesInputTags Source
dees-input-text input DeesInputText Source
dees-input-textarea input DeesInputTextarea Source
dees-input-toggle input DeesInputToggle Source
dees-input-typelist input DeesInputTypelist Source
dees-input-wysiwyg input DeesInputWysiwyg Source
dees-kanban kanban DeesKanban Source
dees-kanban-card kanban DeesKanbanCard Source
dees-kanban-column kanban DeesKanbanColumn Source
dees-kanban-discussion kanban DeesKanbanDiscussion Source
dees-kanban-ticket-detail kanban DeesKanbanTicketDetail Source
dees-label layout DeesLabel Source
dees-modal overlay DeesModal Source
dees-mosaic layout DeesMosaic Source
dees-pagination layout DeesPagination Source
dees-panel layout DeesPanel Source
dees-pdf-viewer media DeesPdfViewer Source
dees-popover overlay DeesPopover Source
dees-preview media DeesPreview Source
dees-profilepicture-modal input — Source
dees-progressbar feedback DeesProgressbar Source
dees-resize-handle layout DeesResizeHandle Source
dees-searchbar utility DeesSearchbar Source
dees-settings layout — Source
dees-shopping-productcard simple DeesShoppingProductcard Source
dees-simple-appdash simple DeesSimpleAppDash Source
dees-simple-login simple DeesSimpleLogin Source
dees-slash-menu input — Source
dees-sparklebox layout — Source
dees-speechbubble overlay DeesSpeechbubble Source
dees-spinner feedback DeesSpinner Source
dees-statsgrid dataview DeesStatsGrid Source
dees-stepper layout DeesStepper Source
dees-storage-browser dataview DeesStorageBrowser Source
dees-storage-columns dataview — Source
dees-storage-keys dataview — Source
dees-storage-preview dataview — Source
dees-table dataview DeesTable Source
dees-terminal-view terminal DeesTerminalView Source
dees-theme utility DeesTheme Source
dees-thumbnail-audio media DeesThumbnailAudio Source
dees-thumbnail-folder media DeesThumbnailFolder Source
dees-thumbnail-image media DeesThumbnailImage Source
dees-thumbnail-note media DeesThumbnailNote Source
dees-thumbnail-pdf media DeesThumbnailPdf Source
dees-thumbnail-video media DeesThumbnailVideo Source
dees-tile layout — Source
dees-toast feedback DeesToast Source
dees-updater utility DeesUpdater Source
dees-video-viewer media DeesVideoViewer Source
dees-windowcontrols utility DeesWindowControls Source
dees-windowlayer overlay DeesWindowLayer Source
dees-workspace workspace DeesWorkspace Source
dees-workspace-bottombar workspace DeesWorkspaceBottombar Source
dees-workspace-diff-editor workspace DeesWorkspaceDiffEditor Source
dees-workspace-filetree workspace DeesWorkspaceFiletree Source
dees-workspace-markdown workspace DeesWorkspaceMarkdown Source
dees-workspace-markdownoutlet workspace DeesWorkspaceMarkdownoutlet Source
dees-workspace-monaco workspace DeesWorkspaceMonaco Source
dees-workspace-terminal workspace DeesWorkspaceTerminal Source
dees-workspace-terminal-preview workspace DeesWorkspaceTerminalPreview Source
dees-wysiwyg-block input — Source

Component API

Failed actions

Components that run an application callback — dees-modal's menuOptions[].action and dees-table's dataActions[].actionFunc — await it. A rejected callback is never dropped: the component dispatches the cancelable dees-action-error event (bubbling and composed) and, unless a listener cancels it, shows the error through dees-toast. Successful callbacks behave exactly as before and dispatch nothing.

element.addEventListener('dees-action-error', (event: CustomEvent<IActionErrorDetail>) => {
  const { error, action, source } = event.detail;
  // source: 'modal-menu' | 'modal-dismiss' | 'table-header' | 'table-footer'
  //       | 'table-row' | 'table-rowmenu' | 'table-contextmenu' | 'table-doubleclick'
  event.preventDefault();      // render your own error surface instead of the toast
  reportToCrashService(error);
});

IActionErrorDetail, actionErrorEventName, actionErrorMessage, reportActionError and runAction are exported from the package root, so a component outside the catalog can announce failures the same way.

Frameless components

A component that draws its own box keeps it by default. Where the host already owns the surface — a modal, a preview stage, a transcript row, a table cell, a fullscreen view — set frameless: the component drops its outer border and corner radius and nothing else. Content, padding, headers, separators, focus rings and behavior stay exactly as they are.

frameless is a reflected boolean, so it works from markup, from a property binding, and from the host's own CSS through :host([frameless]).

// The modal owns the window, so the diff fills it without a second border.
const modal = await DeesModal.createAndShow({
  heading: 'config.ts',
  content: html`<dees-dataview-codebox frameless .unifiedDiff=${patch}></dees-dataview-codebox>`,
});

Supported by dees-chart-area, dees-chart-bar, dees-chart-donut, dees-chart-gauge, dees-chart-log, dees-chart-radar, dees-dataview-codebox, dees-dataview-statusobject, dees-input-code, dees-input-dropdown, dees-input-fileupload, dees-input-list, dees-input-richtext, dees-input-text, dees-pdf-viewer, dees-preview, dees-settings, dees-simple-login, dees-table, dees-tile, dees-video-viewer and dees-workspace-terminal-preview.

dees-tile owns the rule for every component built on it; a new boxed component joins the convention by forwarding .frameless=${this.frameless} to its tile. On dees-input-text and dees-input-dropdown — what dees-table uses for inline cell editing — the same flag also hides the label and validation row, because the cell owns that chrome. dees-statsgrid and dees-stepper draw one card per item instead of a frame of their own, so they deliberately have no frameless.

Core UI Components

DeesButton

A versatile button component supporting multiple styles and states.

// Basic usage
const button = document.createElement('dees-button');
button.text = 'Click me';

// With options
<dees-button
  type="accent"       // Options: default, accent, destructive, outline, secondary, ghost, link (legacy: normal, highlighted, discreet, big)
  size="sm"           // Options: default, sm, lg, icon (square)
  shape="pill"        // Options: squircle (default rounded rect), pill (capsule for standalone actions)
  status="pending"    // Options: normal, pending, success, error
  disabled={false}    // Optional: disables the button
  full-width          // Optional: stretches the button face to the host width
  .pressed=${true}    // Optional: makes this button a toggle — true (on), false (off), unset (not a toggle)
  @click=${handleClick}
>Click me</dees-button>

pressed turns a button into a toggle and is what assistive technology announces: the face carries aria-pressed="true"/"false" while pressed is set, and carries no aria-pressed at all while it is unset, so a plain button is never announced as an unpressed toggle. Use it for toolbar options — a DevTools or sidebar button in an app bar — rather than expressing the state through type, which decides the button's role colour. The on face is an accent wash, ring and label layered over whatever type paints (a filled accent/destructive face recesses behind a scrim instead), it stays on when the pointer leaves, and a status face keeps its own surface while it shows; type="link" has no surface at all, so its on state is only a permanent underline — prefer ghost or outline for a link-styled toggle. At size="icon" the accessible name comes from text, so an icon-only toggle must still set it.

<dees-button
  size="icon" icon="lucide:terminal" text="DevTools"
  .pressed=${this.devtoolsOpen}
  @clicked=${() => { this.devtoolsOpen = !this.devtoolsOpen; }}
></dees-button>

The button is stateless: a click never flips pressed, so the consumer owns the state. Only the on state reflects to the host, which makes dees-button[pressed] and :host([pressed]) select a toggle that is on; the attribute form (<dees-button pressed>) therefore means on — any value, including pressed="false", means on, as for every HTML boolean attribute — and off has to be set through the property.

The button is keyboard operable: its face carries role="button" and tabindex, Enter and Space activate it (dispatching a real click, so both @click and @clicked fire), a disabled button leaves the tab order, and keyboard focus draws a :focus-visible ring. Direct light-DOM text and an optional direct dees-icon remain consumer-owned and update reactively. Adopted values remain observable through text, icon, and iconPosition; assigning different public values independently takes control until the corresponding adopted value is restored.

Width changes the button causes itself animate. The face is sized by its content, so a status face appearing or leaving, a new label, or an icon being added or removed changes its width; the face eases from the old width to the new one over --dees-transition-default with --dees-ease-standard, and since that width is real layout, neighbours in a row or a dees-button-group travel with it. While the face is narrower than new content the label stays on one line and is clipped to the face. The spinner or the success / error glyph takes the leading icon's slot, and its room opens and closes in step with the width, so the label slides over, and the glyph blends in and out with it. A size="icon" face stays square instead: its status glyph takes the icon's place, blending in as the icon blends out, and back when the status returns to normal. A change that arrives mid-flight continues from the width on screen, and the face ends on its natural width with nothing left inline. Nothing animates on the first render — light-DOM text or a forwarded label that lands before the first frame included — on a full-width button, on a restyle (type, size, full-width, isHidden), for a width change the button did not cause (a container resize, a font load), or under prefers-reduced-motion: reduce, where no animation is started at all.

DeesBadge

Display status indicators or counts with customizable styles.

<dees-badge
  type="success"  // Options: default, primary, success, warning, error
  text="New"      // Text to display
  size="compact"  // Optional: default, compact — compact fits dense rows such as a collapsed sidebar rail
  rounded        // Optional: applies rounded corners
></dees-badge>

DeesChips

Interactive chips/tags with selection capabilities.

<dees-chips
  selectionMode="multiple"  // Options: none, single, multiple
  chipsAreRemovable        // Optional: each chip gets a remove button ("Remove <label>")
  .selectableChips=${[
    { key: 'tag1', value: 'Important' },
    { key: 'tag2', value: 'Urgent' }
  ]}
  @selection-change=${handleSelection}
></dees-chips>

DeesIcon

Display Lucide icons. Legacy fa: and iconFA inputs are unsupported and log an error.

Applications that only need the icon component can use the granular entry point without registering or bundling the complete catalog:

import '@design.estate/dees-catalog/icon';
// Lucide icons — use 'lucide:' prefix
<dees-icon
  icon="lucide:check"   // Lucide icon with lucide: prefix
  iconSize="24"         // Size in pixels
  color="#22c55e"       // Optional: custom color
></dees-icon>

// Unsupported legacy inputs log an error and render no icon:
// <dees-icon icon="fa:check"></dees-icon>
// <dees-icon iconFA="check"></dees-icon>

dees-icon follows normal CSS text-color inheritance, including translucent rgba(...) colors, and composites the complete Lucide shape as one layer. Use native CSS color and opacity; consumers do not need icon-specific opacity variables or stroke-color workarounds.

DeesLabel

Text label component with optional required indicator and info tooltip. Used internally by all input components.

<dees-label
  .label=${'Email Address'}      // Label text
  .required=${true}              // Optional: shows red asterisk
  .infoText=${'We will never share your email'}  // Optional: shows hover info icon with tooltip
></dees-label>

DeesSpinner

Loading indicator with customizable appearance.

<dees-spinner
  .size=${20}                    // Optional: diameter in pixels (default 20)
  .status=${'normal'}            // Optional: 'normal' | 'pending' | 'success' | 'error'
  .bnw=${true}                   // Optional: black-and-white treatment
></dees-spinner>

The spinner is drawn on Lucide's 24-unit grid, and its settled success and error marks are Lucide's circle-check and circle-x, so they match the Lucide icons beside them at the same size. The spinning arc draws 1.5px heavier than a mark, in the same outline. The ring's outer edge is Lucide's circle, 22 of the box's 24 units, where the old ring filled the box. A status change turns the drawing rather than swapping it: from spinning, the arc closes into the ring from where it is while its colour moves to the mark's, the ring thins to the mark's weight, and the mark draws in; back to pending or normal, the mark withdraws and the ring thickens and opens into the spinning arc; a spinner that first renders with a mark draws the ring and the mark in; success and error withdraw one mark before drawing the other. A change mid-way carries on from what is on screen. The motion runs on the --dees-transition-* and --dees-ease-* tokens and settles within 0.6s at the default theme's --dees-transition-default and --dees-transition-fast; under prefers-reduced-motion: reduce, or while the spinner is not rendered, every change lands at once.

The arc and the success / error mark follow the --dees-spinner-color custom property. Unset, the arc falls back to --dees-color-text-primary and the mark to its status colour (or to the primary text colour under bnw). Set it on an ancestor when the spinner sits on a coloured surface, so the spinner matches that surface's foreground; currentColor follows the colour the spinner inherits. A dees-button status face draws its spinner and mark in the face's own foreground and ignores the property, so a button needs nothing. On another coloured surface:

.banner {
  color: var(--dees-color-on-accent);
  --dees-spinner-color: currentColor;
}

DeesToast

Notification toast messages with various styles, positions, and auto-dismiss functionality.

// Programmatic usage
DeesToast.show({
  message: 'Operation successful',
  type: 'success',      // Options: info, success, warning, error
  duration: 3000,       // Time in milliseconds before auto-dismiss
  position: 'top-right' // Options: top-right, top-left, bottom-right, bottom-left, top-center, bottom-center
});

// Convenience methods
DeesToast.info('Information message');
DeesToast.success('Success message');
DeesToast.warning('Warning message');
DeesToast.error('Error message');

// Advanced control
const toast = await DeesToast.show({
  message: 'Processing...',
  type: 'info',
  duration: 0  // No auto-dismiss
});

// Later dismiss programmatically
toast.dismiss();

Key Features:

  • Multiple toast types with distinct icons and colors
  • 6 position options for flexible placement
  • Auto-dismiss with visual progress indicator
  • Manual dismiss by clicking
  • Smooth animations and transitions
  • Automatic stacking of multiple toasts
  • Theme-aware styling
  • Programmatic control

DeesButtonExit

Exit/close button component with consistent styling.

<dees-button-exit
  @click=${handleClose}
></dees-button-exit>

DeesButtonGroup

Groups slotted action buttons. It does not manage selection; for a segmented choice use dees-input-multitoggle. Container for grouping related buttons together.

<dees-button-group label="Actions" direction="horizontal">
  <dees-button type="accent" @clicked=${handleSave}>Save</dees-button>
  <dees-button @clicked=${handleCancel}>Cancel</dees-button>
</dees-button-group>

DeesHeading

Consistent heading component with level and styling options.

<dees-heading
  level={1}           // 1-6 for H1-H6
  text="Page Title"
  .subheading=${'Optional subtitle'}
  centered           // Optional: center alignment
></dees-heading>

DeesHint

An inline advisory note next to the thing it explains. Use text for a plain sentence and the default slot for markup; both render when both are set. The type chooses the icon and the accent color. A hint with neither text nor slotted content renders nothing and reserves no space, so a conditional note needs no wrapper element.

<dees-hint
  text="Changing the URL breaks existing links."
  type="info"        // Options: info, success, warning, error (default: info)
></dees-hint>

<dees-hint type="warning">
  Keys older than 90 days <strong>stop working</strong>.
</dees-hint>

DeesPanel

Container component for grouping related content with optional title and actions.

<dees-panel
  .title=${'Panel Title'}
  .subtitle=${'Optional subtitle'}
  collapsible        // Optional: allow collapse/expand
  collapsed={false}  // Initial collapsed state
  .actions=${[
    { icon: 'settings', action: handleSettings }
  ]}
>
  <!-- Panel content -->
</dees-panel>

DeesSearchbar

Search input component with suggestions and search handling.

<dees-searchbar
  placeholder="Search..."
  .suggestions=${['item1', 'item2', 'item3']}
  showClearButton    // Show clear button when has value
  @search=${handleSearch}
  @suggestion-select=${handleSuggestionSelect}
></dees-searchbar>

DeesWindowcontrols

Window control buttons (minimize, maximize, close) for desktop-like applications.

<dees-windowcontrols
  .controls=${['minimize', 'maximize', 'close']}
  @minimize=${handleMinimize}
  @maximize=${handleMaximize}
  @close=${handleClose}
></dees-windowcontrols>

DeesActionbar

An inline bar, placed where it should open (typically the bottom of a panel), that asks one question at a time: a message with actions, optionally a countdown to a default action and a close button. show() resolves with the answer; a bar shown while another is up waits its turn.

const actionbar = this.shadowRoot!.querySelector('dees-actionbar')!;
const result = await actionbar.show({
  message: 'The file changed on disk. Reload?',
  type: 'warning', // 'info' (default), 'warning', 'error' or 'question'
  icon: 'lucide:alertTriangle',
  actions: [
    { id: 'reload', label: 'Reload', primary: true, icon: 'lucide:refreshCw' },
    { id: 'ignore', label: 'Ignore' },
  ],
  timeout: { duration: 5000, defaultActionId: 'reload' }, // answers `reload` when it runs out
  dismissible: true, // a close button, which answers `dismissed`
});
// result: { actionId: 'reload' | 'ignore' | 'dismissed', timedOut: boolean }

dismiss() answers the bar on screen with dismissed, and clearQueue() answers every waiting bar the same way. Each bar starts with its own full countdown, and a bar shown while the last one is still leaving comes up once it has left. The default action counts down in its label, (5s), and its button eases to its new width as a digit goes and when the countdown ends, except under prefers-reduced-motion: reduce; a bar that takes over shows its buttons at their own widths at once.


Form Components

DeesForm

Collect keyed inputs with await form.collectFormData() or receive event.detail.data from formData when a dees-form-submit is activated. changeSubject publishes updated form data as fields change. Required values and fields implementing checkValidity() control the submit button. Invalid fields implementing reportValidity() receive a validation/focus request on attempted submission. Fields notify the form with a bubbling validation-change event when their validity changes; this does not publish a successful value change. Disabled fields do not block submission. reset() calls each field's reset method when available. There is no formValidation event. The form subscribes to its fields per connection, so a form that is moved in the DOM or re-attached — a cached view, a re-opened panel — keeps reporting changes and submitting, with no duplicate events after any number of re-attachments, and reports nothing while it is detached.

html`<dees-form @formData=${(event) => savePreferences(event.detail.data)}>
  <dees-input-text key="name" label="Workspace name" value="Design studio" required></dees-input-text>
  <dees-input-toggle key="notifications" label="Notifications" .value=${true}></dees-input-toggle>
  <dees-form-submit type="accent" text="Save preferences"></dees-form-submit>
</dees-form>`

Use grouped for compact settings rows and horizontal-layout for wrapping filters. Keep grouped controls as direct children. Compound fields (tags, lists, uploads, avatars and editors) retain a full-width editing surface and a label above it.

Input family and composition
  • Simple fields: text/password, searchable dropdown, date/time, phone and IBAN.
  • Choices: checkbox and toggle for booleans; radiogroup or multitoggle for one choice; quantityselector for a non-negative integer.
  • Collections: typelist for strings, tags for suggested tokens, list for inline editing and reordering. Fileupload returns File[]; profilepicture returns an image string.
  • Documents: richtext for HTML, wysiwyg for structured blocks, code for source text.

DeesInputBase owns field geometry, label/helper spacing, focus and disabled styling. Controls use 32px desktop height and at least 44px on coarse pointers; grouped values use 28px on desktop. Override --dees-input-control-height only for an intentional density context. A single outer focus ring marks a field; compound actions retain their own keyboard indicator. Larger editors do not fit the compact label/value grid. Subclasses declare inputLayout as field, choice, or compound; this is reflected as input-layout for the form's layout rules.

See Pages → inputShowcase for all 18 implemented inputs in one preferences form, plus editor/settings and validation states. Focused element demos use the same fixtures. Before adding a control, check this list, the task chooser, and the exact source. The empty search-select source does not register a component; use the searchable dropdown.

DeesInputText

Text input field with validation, info tooltips, description text, and context menu (Cut/Copy/Paste/Select All).

<dees-input-text
  key="email"           // Unique identifier for form data
  label="Email"         // Input label
  type="email"          // Native input type: text (default), number, date, email, url, tel, password
  value="initial@value.com"  // Initial value
  required             // Makes the field required
  disabled            // Disables the input
  placeholder="name@example.com"
  inputmode="email"
  .autocomplete=${'username'}   // Autofill hint; unset renders no autocomplete attribute
  .infoText=${'Hover icon tooltip text'}    // Shows ⓘ icon on label with hover tooltip
  .description=${'Permanent help text below the input'}  // Small text below the input
  .validationFunction=${(value) => {        // Auto-validates on every keystroke
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (emailRegex.test(value)) {
      return { valid: true, message: 'Email is valid' };
    }
    return { valid: false, message: 'Please enter a valid email' };
  }}
></dees-input-text>
  • type maps to the native input type and picks the matching keyboard hint; an explicit inputmode on the element still wins. value is a string for every type except number, where it is a number, or null while the field is empty — so a form collects a number rather than a numeric string. A value bound as the wrong shape is converted to the one the type promises.
  • type="password" masks the value. isPasswordBool masks it as well and adds the reveal button that showPasswordBool toggles.
  • maxLength is the longest value the field takes from the keyboard, the clipboard and its own context menu; -1, the default, leaves it unbounded. It is the native maxlength, so it bounds the text-like types the platform applies it to — text, email, url, tel and password, masked or revealed — while a number field is bounded by min and max rather than by length. A value the application assigns is not truncated; this field leaves every verdict on a value to validationFunction. dees-input-textarea takes the same property and bounds its own field the same way, and additionally reports an over-long value as invalid, because it owns a rule-based validation model.
  • validationFunction always receives the value as text, whatever the type.
  • frameless sheds the field chrome — border, label and validation row — so the input blends into a host that owns them, the way dees-table edits a cell.

💡 All input components share these common properties from DeesInputBase: key, label, required, disabled, infoText, description, layoutMode, labelPosition.

DeesInputTextarea

Plain multiline string input for notes and descriptions. Uses a native textarea; line breaks, whitespace and literal HTML remain plain text. It does not load Tiptap. The field uses the shared input styling, a top label and vertical resizing.

html`<dees-input-textarea key="notes" label="Payment notes"
  .value=${'Invoice 42\nReference: equipment'} .rows=${3} .maxLength=${1000}
  .disabled=${false} .required=${true}
  description="Stored as plain text."
  .validationFunction=${(value: string) => ({ valid: value.length <= 1000, message: 'Use up to 1000 characters.' })}
></dees-input-textarea>`

value defaults to '', rows to 3, and maxLength to -1 (unlimited). Use changeSubject or collect the keyed string with dees-form. Enter inserts a line break inside forms. Cut, Copy, Paste and Select All use plain-text editing. getValue(), setValue(string | null), reset(), focus() and blur() are supported; null and reset clear the field. Required, length and custom rules integrate with checkValidity(), reportValidity(), native custom validity, aria-invalid and a live validation message. Hosts can set validationState (valid, warn, invalid) and validationText for server feedback, which persists until the next value change.

DeesInputCheckbox

Checkbox input component for boolean values.

<dees-input-checkbox
  key="terms"
  label="Accept Terms"
  .value=${true}
  required
  @newValue=${handleChange}
></dees-input-checkbox>

An unlabeled checkbox accepts aria-label (or .ariaLabel) for its inner checkbox control. indeterminate exposes the mixed state, and disabled prevents interaction. controlTabIndex (attribute control-tabindex) defaults to 0; use -1 for programmatic-only focus. The control CSS part exposes the full clickable area for sizing in dense layouts. Empty label/description space is omitted.

DeesInputToggle

Toggle switch component for boolean on/off states.

<dees-input-toggle
  key="darkMode"
  label="Enable Dark Mode"
  .value=${true}
  @newValue=${handleToggle}
></dees-input-toggle>

DeesInputDropdown

Dropdown selection component with search and filtering capabilities.

<dees-input-dropdown
  key="country"
  label="Select Country"
  .options=${[
    { key: 'us', option: 'United States' },
    { key: 'uk', option: 'United Kingdom' }
  ]}
  .enableSearch=${true} // Search is enabled by default
  @selectedOption=${(event) => handleCountry(event.detail)}
></dees-input-dropdown>

frameless sheds the field chrome — border, label — so the dropdown blends into a host that owns them, the way dees-table edits a cell.

DeesInputFileupload

File upload component with drag-and-drop support.

<dees-input-fileupload
  key="documents"
  label="Upload Files"
  multiple            // Allow multiple file selection
  accept=".pdf,.doc"  // Accepted file types
  .maxSize=${5 * 1024 * 1024} // Bytes per file; selection is local
></dees-input-fileupload>

frameless drops the dropzone's outer frame when the host owns it.

DeesInputIban

Specialized input for IBAN (International Bank Account Number) with validation.

<dees-input-iban
  key="bankAccount"
  label="IBAN"
  required
></dees-input-iban>

DeesInputPhone

Digit-based phone input with US-style grouping. Ten digits display as (415) 555-0123; longer values retain a leading country-code portion. The stored value contains digits only. It does not offer a country selector or country-specific validation.

<dees-input-phone
  key="phone"
  label="Phone Number"
  required
></dees-input-phone>

DeesInputQuantitySelector

Non-negative integer input with native increment/decrement buttons. Decrement stops at zero; there are no min, max, or step properties.

<dees-input-quantityselector
  key="quantity"
  label="Quantity"
  value="1"           // Initial value
></dees-input-quantityselector>

User increments/decrements emit newValue with the numeric event.detail, bubbling across shadow boundaries. The existing changeSubject subscription remains available for form collection. Assigning .value or calling setValue() does not emit a user event.

DeesInputMultitoggle

The catalog's segmented control for view switchers, modes, time periods and short setting choices. Despite the name and the default type="multi", it selects one value, not several. Both multi and single select a string key; boolean returns a boolean. Use radio groups for radio-style form answers and button groups for action layout.

html`<dees-input-multitoggle
  label="File view"
  .labelPosition=${'none'}
  .options=${[
    { key: 'columns', label: 'Columns' },
    { key: 'list', label: 'List' },
    { key: 'gallery', label: 'Gallery', disabled: true },
  ]}
  .selectedOption=${this.view}
  @change=${(event: CustomEvent<{ value: string }>) => {
    this.view = event.detail.value;
  }}
></dees-input-multitoggle>`;
Property/API Behavior
options: TMultitoggleOption[] Strings or { key, label, disabled? } objects.
selectedOption Selected string/key; bind with .selectedOption in Lit.
value, getValue(), setValue(value) String key, or boolean in boolean mode. value is a JavaScript property, not a value HTML attribute.
type="boolean" Two alternatives named by booleanTrueName / booleanFalseName; .boolValue supplies a boolean selection.
size default or sm for compact toolbars. Touch targets remain at least 44px tall.
label, labelPosition Visible label by default; none hides it while retaining the group name for assistive technology.
disabled Disables the whole control; an option object can disable one choice.
key Name collected by dees-form; required for form participation.
input, change One composed, bubbling CustomEvent each per user selection, with { value, selectedOption }. Programmatic updates do not emit them.
option-activate Composed event with the same detail on every enabled option activation, including reselecting the active option. Used by codebox to pin an automatic layout explicitly.
changeSubject Existing reactive notification with the component instance per user selection.

required validation is not implemented by multitoggle. String mode chooses the first enabled option when initially empty. Avoid required on boolean controls: false is a valid selection, while the form's generic truthiness check treats it as missing. Use a required radio group when an explicit answer is necessary.

Arrow keys select and focus the next enabled option; Home/End select the first/last. Enter and Space activate the focused option. The selected segment has a stable size, follows wrapping/resizing, and respects reduced motion. Containers that translate the inner input event into their own public event should stop the inner event's propagation.

DeesInputRadiogroup

Radio-style single-choice form input, including required validation and optional payload values. For a segmented view switcher, use dees-input-multitoggle. Object options use { key, option, payload? }; the visible text field is option, unlike multitoggle's label.

<dees-input-radiogroup
  key="plan"
  label="Select Plan"
  .options=${['Free', 'Pro', 'Enterprise']}
  selectedOption="Pro"
  required
  @change=${handlePlanChange}
></dees-input-radiogroup>

// With custom option objects
<dees-input-radiogroup
  key="priority"
  label="Priority Level"
  .options=${[
    { key: 'low', option: 'Low Priority' },
    { key: 'medium', option: 'Medium Priority' },
    { key: 'high', option: 'High Priority' }
  ]}
  selectedOption="medium"
></dees-input-radiogroup>

DeesInputTags

Tag input component for managing lists of tags with auto-complete and validation.

<dees-input-tags
  key="skills"
  label="Skills"
  .value=${['JavaScript', 'TypeScript', 'CSS']}
  placeholder="Add a skill..."
  .suggestions=${[
    'JavaScript', 'TypeScript', 'Python', 'Go', 'Rust',
    'React', 'Vue', 'Angular', 'Node.js', 'Docker'
  ]}
  .maxTags=${10}  // Optional: limit number of tags
  required
  @change=${handleTagsChange}
></dees-input-tags>

Key Features:

  • Add tags by pressing Enter or typing comma/semicolon
  • Text typed and not added becomes a tag when the reader leaves the field (Tab, or the focus moving elsewhere, such as to a Save button), so a form saved after it keeps what was typed. Text naming a tag already there is cleared. The window losing the focus leaves the text in the field.
  • Remove tags with click or backspace
  • Auto-complete suggestions with keyboard navigation
  • Maximum tag limit support
  • Full theme support
  • Form validation integration

DeesInputTypelist

Dynamic list input for managing arrays of typed values.

<dees-input-typelist
  key="features"
  label="Product Features"
  .value=${['Feature 1', 'Feature 2']}
></dees-input-typelist>

DeesInputList

Advanced list input with drag-and-drop reordering, inline editing, and validation.

<dees-input-list
  key="items"
  label="List Items"
  placeholder="Add new item..."
  .value=${['Item 1', 'Item 2', 'Item 3']}
  .maxItems=${10}            // Optional: maximum items
  .minItems=${1}             // Optional: minimum items
  .allowDuplicates=${false}  // Optional: allow duplicate values
  .allowFreeform=${true} // Accept items outside the candidates list
  .sortable=${true}          // Optional: enable drag-and-drop reordering
  .confirmDelete=${true}     // Optional: confirm before deletion
  @change=${handleListChange}
></dees-input-list>

Key Features:

  • Add, edit, and remove items inline
  • Drag-and-drop reordering with visual feedback
  • Optional duplicate prevention
  • Min/max item constraints
  • Delete confirmation dialog
  • Full keyboard support
  • Form validation integration
  • frameless drops the list's outer frame when the host owns it

DeesInputProfilepicture

Profile picture input with cropping, zoom, and image processing.

<dees-input-profilepicture
  key="avatar"
  label="Profile Picture"
  shape="round"           // Options: round, square
  .size=${120}              // Display size in pixels
  .value=${imageBase64}   // Base64 encoded image or URL
  .allowUpload=${true}      // Enable upload button
  .allowDelete=${true}      // Enable delete button
  .maxFileSize=${5242880}   // Max file size in bytes (5MB)
  .acceptedFormats=${['image/jpeg', 'image/png', 'image/webp']}
  .outputSize=${800}        // Output resolution in pixels
  .outputQuality=${0.95}    // JPEG quality (0-1)
></dees-input-profilepicture>

Key Features:

  • Interactive cropping modal with zoom and pan
  • Drag-and-drop file upload
  • Round or square output shapes
  • Configurable output size and quality
  • File size and format validation
  • Delete functionality
  • Always-visible change/remove actions and keyboard-accessible image selection

DeesInputDatepicker

Date and time picker component with calendar interface and manual typing support. Use changeSubject for field updates, or collect .value through dees-form. Typing validates a draft; blur or Enter commits a valid date. Invalid calendar fields (including non-leap February 29 and February 31) never roll into another month. Invalid draft text remains visible with an accessible message, and the last committed value remains unchanged without a successful changeSubject emission. checkValidity() and reportValidity() include draft validity, so dees-form cannot submit a stale date while its displayed draft is invalid.

minDate and maxDate are inclusive local calendar-day bounds. disabledDates uses the same calendar-day comparison for typing, day buttons and Today. ISO date-only values are interpreted locally, without a UTC-midnight shift. Empty input or Clear clears the value; required then makes it invalid. setValue() and reset() discard drafts and synchronize the field and next-opened calendar. Alt+Down opens the calendar; Escape closes it. The popup also works inside native dialogs.

<dees-input-datepicker
  key="eventDate"
  label="Event Date"
  placeholder="YYYY-MM-DD"
  value="2025-01-15T14:30:00Z"  // ISO string format
  dateFormat="YYYY-MM-DD"        // Display format (default: YYYY-MM-DD)
  .enableTime=${true}              // Enable time selection
  timeFormat="24h"               // Options: 24h, 12h
  .minuteIncrement=${15}           // Time step in minutes
  minDate="2025-01-01"          // Minimum selectable date
  maxDate="2025-12-31"          // Maximum selectable date
  .disabledDates=${[            // Array of disabled dates
    '2025-01-10',
    '2025-01-11'
  ]}
  .weekStartsOn=${1}              // 0 = Sunday, 1 = Monday
  required
></dees-input-datepicker>

Key Features:

  • Interactive calendar popup
  • Manual date typing with multiple formats
  • Optional time selection
  • Configurable date format
  • Min/max date constraints
  • Disable specific dates
  • Keyboard navigation
  • Today button
  • Clear functionality
  • 12/24 hour time formats
  • Theme-aware styling
  • Live parsing and validation

Manual Input Formats:

// Date formats supported
"2023-12-20"     // ISO format (YYYY-MM-DD)
"20.12.2023"     // European format (DD.MM.YYYY)
"12/20/2023"     // US format (MM/DD/YYYY)

// Date with time (add space and time after any date format)
"2023-12-20 14:30"
"20.12.2023 9:45"
"12/20/2023 16:00"

DeesInputRichtext

Rich text editor with formatting toolbar powered by TipTap.

<dees-input-richtext
  key="content"
  label="Article Content"
  .value=${htmlContent}
  placeholder="Start writing..."
  .minHeight=${300}      // Minimum editor height
  .showWordCount=${true} // Show word count
  @change=${handleContentChange}
></dees-input-richtext>

Key Features:

  • Full formatting toolbar (bold, italic, underline, strike, etc.)
  • Heading levels H1–H3
  • Lists (bullet, ordered)
  • Links with URL editing
  • Code blocks and inline code
  • Blockquotes
  • Horizontal rules
  • Undo/redo support
  • Word count
  • HTML output
  • frameless drops the editor's outer frame when the host owns it

DeesInputWysiwyg

Advanced block-based editor with slash commands and rich content blocks. Use changeSubject for field updates, or collect .value through dees-form.

<dees-input-wysiwyg
  key="document"
  label="Document Editor"
  .value=${documentContent}
  outputFormat="html"  // Options: html, markdown, json
></dees-input-wysiwyg>

Key Features:

  • Slash commands for quick formatting
  • Block-based editing (paragraphs, headings, lists, etc.)
  • Drag and drop block reordering
  • Multiple output formats
  • Keyboard shortcuts
  • Extensible block types

DeesInputCode

Code input component for editing source code with syntax highlighting.

<dees-input-code
  key="snippet"
  label="Code Snippet"
  .value=${codeString}
  language="typescript"
></dees-input-code>

frameless drops the editor's outer frame when the host owns it.

DeesFormSubmit

Submit button component specifically designed for DeesForm.

<dees-form-submit
  disabled            // Optional: disable submit button
  status="normal"     // Options: normal, pending, success, error
  full-width          // Optional: stretches the button face to the host width
>Submit Form</dees-form-submit>

DeesFormSubmit.focus(options) forwards keyboard focus to the inner button without submitting. Call await submit.submit() to gather and dispatch form data explicitly. Enter in the final single-line form input calls submit(); composed text input, multiline editors, and keys handled by a picker keep their own behavior. Disabled or non-normal submit controls do not submit.

Migration: code that previously used submit.focus() to submit must call submit.submit(). This is an intentional breaking correction to the focus contract.


App Shell (Layout) Components

DeesMosaic

Sway-like tiling workspace: a controlled split-tree layout where tiles resize against each other, drag by a slim tilebar (which also closes them), and dock onto any edge of another tile with a live drop preview. Content projects through named slots, so hosts keep light-DOM ownership — Electron <webview> content survives every layout change because slot reassignment never moves the light-DOM node.

import { createMosaicLeaf, mosaicInsert, type IMosaicLayout } from '@design.estate/dees-catalog';

let layout: IMosaicLayout = mosaicInsert({ root: null }, null, 'right', createMosaicLeaf('editor', 'l-editor'));
layout = mosaicInsert(layout, 'l-editor', 'right', createMosaicLeaf('chat', 'l-chat'));
layout = mosaicInsert(layout, null, 'bottom', createMosaicLeaf('terminal', 'l-term'));
<dees-mosaic
  .layout=${layout}
  .surfaces=${{ editor: { title: 'Editor', icon: 'lucide:Code' }, chat: { title: 'AI Chat' }, terminal: { title: 'Terminal' } }}
  @mosaic-layout-change=${(e) => { layout = e.detail.layout; /* persist */ }}
>
  <my-editor slot="tile-l-editor"></my-editor>
  <my-chat slot="tile-l-chat"></my-chat>
  <my-terminal slot="tile-l-term"></my-terminal>
  <div slot="empty">Workspace is empty.</div>
</dees-mosaic>

The component is controlled: apply e.detail.layout back (and persist it) on every mosaic-layout-change (reason: 'resize' | 'drop' | 'close' | 'equalize'). Other events: cancelable mosaic-tile-close, mosaic-tile-focus, throttled mosaic-resizing (re-measure embedded content), mosaic-drag-state. Methods: getLeafRects() (viewport rect per leaf content area), equalize(splitId?), focusTile(id). Pure helpers (mosaicInsert/mosaicRemove/mosaicMove/mosaicResize/mosaicEqualize/mosaicNormalize/mosaicLeaves) are exported for host-side layout logic. Splitters are keyboard-accessible (role="separator", arrow keys resize, double-click equalizes); Escape cancels drags.

DeesResizeHandle

The separator between two panes, which a reader drags or steps with the keyboard to size the pane on its primary side ('start': before it, left or above; 'end': after it). The handle is controlled: it asks for a size and the owner applies it by setting value, so the owner stays the one place that knows how wide its pane is. resize-input ({ value }) asks on every step of a drag and every key; resize-change follows once the size settles — at the end of a drag that moved, and with every key — which is when to persist or relayout.

<dees-resize-handle
  label="Resize sidebar"
  .value=${this.sidebarWidth}
  .min=${160}
  .max=${360}
  .defaultValue=${220}   // Enter and a double-click give it back; unset, neither does anything
  .largeStep=${64}       // Shift + Arrow; unset, as far as step (16)
  @resize-input=${(e) => { this.sidebarWidth = e.detail.value; }}
  @resize-change=${(e) => savePreference('sidebar', e.detail.value)}
></dees-resize-handle>

A drag follows the pointer from where it started, clamped to min/max and in whole pixels, never animated; it captures the pointer, takes only the primary button, and ends on release, cancel or lost capture. The Arrow keys move the separator the way they point (orientation="horizontal" uses Up and Down), Shift takes largeStep, and Home and End reach the bounds. The host itself is the focusable separator, with aria-orientation, aria-valuenow/min/max, label as its name and, while defaultValue is set, hint ("Double-click or press Enter to reset") as its description; it reflects dragging during a drag, so an owner can style the panes it separates. The owner lays out its box — as thin as the 1px line it draws — and the pointer finds it within --dees-resize-handle-reach (4px) on either side; --dees-resize-handle-color colours the line at rest, which turns to the accent on hover and drag and to the focus ring on keyboard focus. dees-appui, dees-workspace and the storage browser and columns use it.

DeesAppui

A comprehensive application shell component providing a complete UI framework with navigation, menus, activity logging, bottom bar, and view management.

Integrated App UI demo

Open App UI → DeesAppui in the catalog. The Gateway workspace uses the whole App UI family in one example. Browse, Inspect and Focus change the shell layout while preserving open service tabs, selected charts and unsaved drafts. Below a 700px shell width, navigation opens on demand and activity fills the content area.

Component Role in the workspace Try it
dees-appui Owns layout, routing and view lifecycle Switch layout presets, navigate, then return to a cached view.
dees-appui-appbar Gateway, File, View and Window menus, location, search and account Search for edge-eu; use a breadcrumb to return to Overview.
dees-appui-location Exact screen links, editable address and copy feedback Paste #settings/notifications or copy a service link with region filters; refresh to restore it.
dees-appui-profiledropdown Account actions Toggle availability or open Profile settings; Escape returns focus.
dees-appui-mainmenu Stable work areas and pinned Settings Observe the Services attention badge, including collapsed mode.
dees-appui-secondarymenu Contextual regions, attention filter and settings sections Filter Services to Europe and Needs attention.
dees-appui-maincontent Cached views with guarded navigation Edit a file or settings draft, switch layouts, then save or discard.
dees-appui-tabs Closable service tabs and analytics views Open two services; close one or use Close other service tabs.
dees-appui-commandpalette Every screen, menu item, section, open tab and service by name Press the platform modifier and K, or the search button, and type edge or runbook.
dees-appui-activitylog Events from the shared workspace Inspect a service, save a file or preferences, then search Activity.
dees-harness-chat The assistant in the side panel, beside the activity log Press the sparkles button or the platform modifier and J, and ask about edge-eu; switch to Activity and back, and the draft is still there.
dees-appui-bottombar Health, sample number and refresh Refresh to advance the shared sample; right-click health for actions.

The incident path is Overview → Simulate latency → Inspect edge-eu → Restore service. The service table, chart measurements, metrics, badges and activity read the same state. Analytics composes area, bar, donut, gauge and radar charts; Files uses the storage browser and preview/editor; Settings uses the form and input components. File changes are disposable drafts and never deploy anything.

Implementation map: shell configuration and walkthrough, shared state and navigation, composed views and search. Presets own collapse and side panel preferences; views own contextual menus and tabs. Use this composition before building custom chrome or embedding standalone component demo launchers inside an application.

The shared shell honors aside in configure(), preserves widgets while the bottom bar is hidden, and keeps hidden panels inert. The side panel shows the activity log or the host's assistant, one at a time, from two app bar buttons; a tool closes it with a bubbling close-request, and the shell hands the focus back to that tool's button. Area charts preserve their visible time range when panel widths change, including explicit ranges supplied through the chart API.

App UI chrome shares an opaque tone and a 40px desktop / 48px touch row across the app bar, pane headings, tabs and compact navigation. The section menu and the side panel's tools are resizable by their inner edge — a drag, the Arrow keys, Enter to reset — and the shell writes their widths to --dees-appui-secondary-width, --dees-appui-activity-width and --dees-appui-assistant-width on itself. It is compact at or below 700px of its own width and says so as its compact attribute; neither it nor the app bar is a CSS size container. Sidebar selection follows the item's key, including immutable badge updates. Tabs use an inset selection surface attached to the tab, keeping it aligned through scrolling and resizing.

Full API Documentation: See ts_web/elements/00group-appui/dees-appui/readme.md for complete documentation including all programmatic APIs, view lifecycle hooks, and TypeScript interfaces.

Quick Start:

import { html, DeesElement, customElement } from '@design.estate/dees-element';
import { DeesAppui } from '@design.estate/dees-catalog';

@customElement('my-app')
class MyApp extends DeesElement {
  private appui: DeesAppui;

  async firstUpdated() {
    this.appui = this.shadowRoot.querySelector('dees-appui');

    this.appui.configure({
      branding: { logoIcon: 'lucide:box', logoText: 'My App' },
      views: [
        { id: 'dashboard', name: 'Dashboard', iconName: 'lucide:home', content: 'my-dashboard' },
        { id: 'settings', name: 'Settings', iconName: 'lucide:settings', content: 'my-settings' },
      ],
      mainMenu: {
        sections: [{ name: 'Main', views: ['dashboard', 'settings'] }]
      },
      defaultView: 'dashboard',
      bottomBar: {
        visible: true,
        widgets: [
          { id: 'status', iconName: 'lucide:activity', label: 'Online', status: 'success' }
        ]
      }
    });
  }

  render() {
    return html`<dees-appui></dees-appui>`;
  }
}

Architecture Overview:

┌─────────────────────────────────────────────────────────────────────┐
│  AppBar (dees-appui-appbar)                                         │
│  ├── Menus (File, Edit, View...)                                    │
│  ├── Breadcrumbs                                                    │
│  ├── User Profile + Dropdown                                        │
│  └── Side Panel Toggles (assistant, activity log)                   │
├─────────────┬───────────────────────────────────┬───────────────────┤
│ Main Menu   │  Content Area                     │  Side Panel       │
│ (collapsed/ │  ├── Content Tabs                 │  (activity log or │
│  expanded)  │  │   (closable, from tables/lists)│   assistant)      │
│             │  └── View Container               │                   │
│ ┌─────────┐ │      └── Active View              │                   │
│ │ 🏠 Home │ ├─────────────────────────────────┐ │                   │
│ │ 📁 Files│ │ Secondary Menu                  │ │                   │
│ │ ⚙ Set.. │ │  ├── Collapsible Groups         │ │                   │
│ │         │ │  │   ├── Tabs / Actions          │ │                   │
│ └─────────┘ │  │   ├── Filters / Links         │ │                   │
│             │  │   └── Dividers / Headers       │ │                   │
├─────────────┴──┴───────────────────────────────┴───────────────────┤
│  Bottom Bar (dees-appui-bottombar) — 24px status bar                │
│  ├── Status widgets (left/right)                                    │
│  └── Action buttons (left/right)                                    │
└─────────────────────────────────────────────────────────────────────┘

Configuration (IAppConfig):

interface IAppConfig {
  branding?: { logoIcon?: string; logoText?: string };
  appBar?: IAppBarConfig;
  views: IViewDefinition[];
  mainMenu?: IMainMenuConfig;
  defaultView?: string;    // the first screen when the address names none
  notFoundView?: string;   // where an address naming no screen lands, told it in query.path
  secondaryMenu?: ISecondaryMenuConfig; // autoHide: no section menu while the view on screen has no sections
  activityLog?: IActivityLogConfig; // toggle: false drops the app bar's activity button; width is its first width
  assistant?: IAppAssistantConfig;  // the side panel's second tool: content, iconName, width, shortcut, focus
  aside?: TAppuiAside | null;       // what the side panel shows first: 'activity', 'assistant' or nothing
  bottomBar?: IBottomBarConfig;
  commands?: ICommand[];
  documentTitle?: string | ((view: IViewDefinition, route: IAppRoute) => string);
  storage?: IAppShellStorage;       // the host's storage for what a reader leaves the shell at; none by default
  restoreLastView?: boolean;        // the first screen is the last view when the address names none
  labels?: Partial<IAppuiLabels>;   // every string the shell says, over the English appuiDefaultLabels
  onViewChange?: (viewId: string, view: IViewDefinition) => void;
  onSearch?: (query: string) => void;
}

configure() can be called again: a later call's onViewChange, onSearch and bottomBar widgets and actions replace the earlier ones instead of adding to them — callbacks it leaves out are gone — and routing starts over. Navigations run one at a time and the latest request wins. The first screen is the route the address names (or notFoundView for one naming no screen), then the stored last view with restoreLastView, then defaultView, then the first main-menu item whose key is a view id.

With a storage adapter (read(key), write(key, value), each at once or as a promise) the shell keeps both menus' collapse, the pane widths, what the side panel shows and, with restoreLastView, the last view, under keys of its own (dees-appui.mainMenuCollapsed) with JSON values, and reads them back when configured. Without one it keeps nothing. A failed read or write is reported once as storage-error ({ key, error }) and never thrown. labels puts the shell in the reader's language: every name, tooltip, placeholder and sentence of the shell and its parts, with {view} and {name} placeholders in the patterns, and locale for the activity log's dates and times. A part used on its own takes the fields it reads through its labels property (TAppuiTabsLabels, TAppuiMainmenuLabels, …) and speaks English until it is set.

Key Features:

  • 🔧 Configure API — Single configure() method for complete app setup
  • 📄 View Management — Automatic view caching, lazy loading, and lifecycle hooks (onActivate, onDeactivate, canDeactivate)
  • 🧩 View-scoped Chrome — Each activation starts the secondary menu and content tabs from what the view declares (keepChrome: true opts out); the selected tab follows its key
  • ⏳ Loading and Failures — A delayed loading bar for slow views, a Retry message in place of a view that failed to load, and notFoundView for unknown addresses
  • 🧭 Hash-based Routing — Automatic URL synchronization with view navigation and parameterized routes
  • 📊 Side Panel — The activity log (stacked entries, date grouping, search and filtering) or the host's assistant, one at a time, from two app bar buttons
  • 📌 Bottom Status Bar — Configurable widgets and actions with status colors and loading states
  • 🎯 RxJS Observables — viewChanged$ and viewLifecycle$ for reactive programming
  • 💾 Persistence Hook — A host-supplied storage adapter keeps collapse states, pane widths, the side panel and the last view; no storage of its own
  • 🌐 Labels — Every chrome string in the reader's language, and dates and times in their locale
  • 🏷️ TypeScript-first — Typed IViewActivationContext passed to views on activation

Programmatic APIs:

Area Methods
Navigation navigateToView(viewId, params?, query?, options?), navigateToUrl(href, options?), getCurrentView(), getViewRegistry()
App Bar setAppBarMenus(), updateAppBarMenu(), setBreadcrumbs(), setUser(), setProfileMenuItems(), setSearchVisible(), onSearch(), setWindowControlsVisible()
Main Menu setMainMenu(), updateMainMenuGroup(), addMainMenuItem(), removeMainMenuItem(), setMainMenuSelection(), setMainMenuCollapsed(), setMainMenuVisible(), setMainMenuBadge(), clearMainMenuBadge()
Secondary Menu setSecondaryMenu(), updateSecondaryMenuGroup(), addSecondaryMenuItem(), setSecondaryMenuSelection(), setSecondaryMenuCollapsed(), setSecondaryMenuVisible(), setSecondaryMenuAutoHide(), clearSecondaryMenu()
Content Tabs setContentTabs(), addContentTab(), updateContentTab(), removeContentTab(), selectContentTab(), getSelectedContentTab(), getContentTabs(), setContentTabsVisible(), setContentTabsAutoHide()
Activity Log activityLog.add(), activityLog.addMany(), activityLog.clear(), activityLog.getEntries(), activityLog.filter(), activityLog.search()
Side Panel setAside(tool | null), getAside() for 'activity' and 'assistant'; the aside-change event ({ aside }) once per change a reader makes
Layout setPaneWidth(pane, width), getPaneWidth(pane) for 'secondary', 'activity' and 'assistant'; the pane-resize event ({ pane, width }) once per width a reader settles
Bottom Bar bottomBar.addWidget(), bottomBar.updateWidget(), bottomBar.removeWidget(), bottomBar.getWidget(), bottomBar.clearWidgets(), bottomBar.addAction(), bottomBar.removeAction(), bottomBar.clearActions(), setBottomBarVisible(), getBottomBarVisible()
Observables viewChanged$, viewLifecycle$

Content tabs are keyed: addContentTab({ key, ... }) opens a record once — adding a key that is already open replaces that tab's descriptor in its place instead of opening a twin, and selectContentTab(key) brings it to the front. updateContentTab(key, patch) changes an open tab (label, icon, badge, closeable, callbacks) while keeping its position and the current selection, and returns whether the key was open; use it to rename a record tab once its title is known. getContentTabs() returns the open tabs in display order as a snapshot, so a host does not have to keep its own copy of the list. When the user presses a tab's close button the shell reports it — the tab's own onClose() callback and the bubbling content-tab-close event (detail { tab }, also re-emitted under the tab bar's tab-close name), each exactly once — and the host decides, calling removeContentTab(key) to take it away.

App bar menus mark the current choice: an IAppBarMenuItem accepts checked, and radioGroup for a set where exactly one member is checked. Checked items announce themselves as menuitemcheckbox/menuitemradio with aria-checked; in a menu they reserve a check column so every label in that menu stays aligned. iconName accepts Building2 or lucide:Building2 — a qualified name is used as written.

View Lifecycle Hooks:

import { DeesElement, customElement } from '@design.estate/dees-element';
import type { IViewActivationContext, IViewLifecycle } from '@design.estate/dees-catalog';

@customElement('my-settings-view')
class MySettingsView extends DeesElement implements IViewLifecycle {
  // Called when view becomes visible
  async onActivate(context: IViewActivationContext) {
    const { appui, viewId, params } = context;

    // Set view-specific secondary menu
    appui.setSecondaryMenu({
      heading: 'Settings',
      groups: [{ name: 'Options', items: [...] }]
    });

    // Control visibility of other shell parts
    appui.setContentTabsVisible(false);
    appui.setSecondaryMenuVisible(true);
  }

  // Called when navigating away
  onDeactivate() { /* cleanup */ }

  // Return false to block navigation, or a question the shell asks in a dialog
  canDeactivate(): boolean | string {
    if (this.hasUnsavedChanges) return 'You have unsaved changes. Leave anyway?';
    return true;
  }
}

A guard that returns a string has its question asked in a dees-modal and awaited — never in the browser's own confirm() box — with Stay and Leave; dismissing it keeps the current screen. true and false are unchanged. That dialog does not block the browser, so a Back or Forward pressed while it stands wins: the waiting request is dropped rather than written to history from there, and the traversal decides.

navigateToView(viewId, params?, query?, { history: 'replace' }) and navigateToUrl(href, { history: 'replace' }) rewrite the current history entry instead of adding one, so a screen that corrects its own route does not become a step of its own. Unset, navigation pushes as before.

Secondary Menu Item Types:

The secondary menu supports 8 distinct item types for building rich contextual sidebars:

Type Description
Tab (default) Selectable item that stays highlighted
Action Executes on click without staying selected (blue styling)
Filter Checkbox toggle for filtering
MultiFilter Collapsible multi-select filter box
Divider Visual separator line
Header Non-interactive section label
Link Opens an external URL
Danger Action Red-styled action with optional confirmation

DeesAppuiLocation

An editable location bar for app screens. Search terms: address bar, URL input, deep link, share screen, and editable breadcrumbs. Its catalog demo uses the same Gateway workspace as App UI.

Enable the optional control through appui.configure({ appBar: { location: true }, views, ... }). Ordinary string breadcrumbs remain available when it is disabled. The shell accepts same-origin URLs at its hosting path and bare hashes; it rejects foreign, unknown or invalid routes before leaving the current view. The location field uses the same canDeactivate(destination) guard as sidebar and browser navigation.

appui.configure({
  appBar: { location: true, showWindowControls: false },
  views: [{ id: 'settings', name: 'Settings', route: 'settings/:section?', content: 'workspace-settings' }],
  defaultView: 'settings',
});
// Derive these ancestors from the view activation context.
appui.setLocationBreadcrumbs([
  { label: 'Settings', href: appui.createViewUrl('settings') },
  { label: 'Notifications' },
]);
await appui.navigateToUrl('#settings/notifications');

In view activation, derive ancestors from context.params and context.query, using setLocationBreadcrumbs([{ label, href? }]). Omit href on the current item. createViewUrl(viewId, params?, query?) builds an absolute link; optional path parameters are omitted, parameter values are encoded, and query keys are sorted inside the hash. getCurrentRoute() returns an isolated { viewId, params, query } snapshot. Views can provide validateRoute(route) to reject unsupported sections, resources or filters.

appBar.locationBaseUrl optionally specifies the canonical hosting URL without a hash. It must share the current origin and path. The Gateway demo uses this to leave catalog sidebar settings out of copied links. Its links include settings section, service, region/attention filters, analytics tab and file folder/preview; drafts, layout presets, activity visibility and sample counters stay local.

The field is centered within the space between the menus and account controls, with a maximum width of 560px. It shows readable ancestors at rest. Click the empty space or focus the edit control and press Enter/F2 to select the URL; Enter navigates and Escape cancels. Unchanged edits close on blur. Invalid or blocked navigation stays visible with an error. At narrow widths ancestors move into a parent-screens menu; on a very narrow bar only edit/copy controls remain, and editing expands across the app bar. Browser Cmd/Ctrl+L is untouched. Copy displays “Copied” only after success. Where clipboard access is unavailable (including insecure HTTP hosts), the field selects the URL and explains how to copy it manually.

For another router, use the controlled component directly:

html`<dees-appui-location .location=${{
  href: currentCanonicalUrl,
  items: [{ label: 'Workspace', href: homeUrl }, { label: screenName }],
  navigate: async (href: string) => appui.navigateToUrl(href),
}}></dees-appui-location>`;

The .location value is IAppLocation (href, items, async navigate). The callback returns false for a blocked transition or throws a user-facing error. The component owns no global route listeners; its host owns the canonical URL and navigation policy. edit() opens the editor programmatically.

DeesAppuiMainmenu

Main navigation menu component for application-wide navigation. Supports collapsed (icon-only) mode: it folds into a 56px rail the way the secondary menu does, the width moving while labels, the logo and badges fade and every icon stays where it is. Each row shows label when the item carries one and key otherwise; a selection is reported as item-select with detail: { item } (the shell re-emits it as mainmenu-item-select). The menu is one Tab stop: Arrow keys walk the rows, Home and End jump to its ends, and Tab reaches the selected row.

<dees-appui-mainmenu
  .menuGroups=${[
    {
      name: 'Main',
      items: [
        { key: 'dashboard', label: 'Dashboard', iconName: 'lucide:home', action: () => navigate('dashboard') },
        { key: 'settings', label: 'Settings', iconName: 'lucide:settings', action: () => navigate('settings') }
      ]
    }
  ]}
  collapsed           // Optional: show collapsed icon-only version
  @item-select=${(e) => handleNavigation(e.detail.item)}
></dees-appui-mainmenu>

DeesAppuiSecondarymenu

Secondary navigation component for sub-section selection with collapsible groups, badges, and 8 item types. Collapsing folds it into a 56px icon rail that shows every group's icons; a group opens and closes to its real height. Inside dees-appui its expanded width is the resizable section pane. It is a nav landmark named after its heading and holds one Tab stop: Arrow keys walk group headers, rows and multi-filter options, Home and End jump to its ends. Every row shows label when the item carries one and key otherwise, so key stays the identity a selection is addressed by.

Filter state belongs to the host: filter and multiFilter rows report the value the reader asked for (onToggle / onChange, plus the filter-toggle and multifilter-change events) and render whatever the next groups assignment contains — the element writes neither active nor checked. A link row is a real anchor that navigates on its own (external opens in a new tab with rel="noopener noreferrer"); confirmMessage on an action asks through DeesModal.confirm.

<dees-appui-secondarymenu
  .heading=${'Projects'}
  .groups=${[
    {
      name: 'Active',
      iconName: 'lucide:folder',
      items: [
        { key: 'frontend', label: 'Frontend App', iconName: 'lucide:code', action: () => select('frontend'), badge: 3, badgeVariant: 'warning' },
        { key: 'api', label: 'API Server', iconName: 'lucide:server', action: () => select('api') },
        { type: 'filter', key: 'attention', label: 'Needs attention', active: this.attentionOnly, onToggle: (active) => { this.attentionOnly = active; } },
        { type: 'link', key: 'docs', label: 'Documentation', href: 'https://docs.example.com' }
      ]
    }
  ]}
  @item-select=${handleSectionChange}
></dees-appui-secondarymenu>

DeesAppuiMaincontent

Main content area with tab management support.

<dees-appui-maincontent
  .tabs=${[
    { key: 'Overview', iconName: 'lucide:home', action: () => selectTab('overview') },
    { key: 'Details', iconName: 'lucide:info', action: () => selectTab('details') }
  ]}
  @tab-select=${handleTabChange}
>
  <!-- Content goes here -->
</dees-appui-maincontent>

DeesAppuiAppbar

Once a window menu is open, hovering another enabled heading switches its dropdown. The headings form a menubar with one Tab stop: Left/Right moves along it and carries an open menu with it, Home and End jump to its ends; Escape closes and restores focus to the current heading. Touch uses taps. With compact set, multiple window menus combine into a single icon-only menu button so every command remains reachable beside the location and account controls, the breadcrumb trail steps aside and the account shows only its avatar. The bar has no breakpoint of its own: dees-appui sets compact at or below 700px of its width, and a bar used on its own compacts when its host sets it.

Collapsed main and secondary sidebars show compact hints beside icons on pointer hover or keyboard focus, including their expand controls. Professional application bar component with hierarchical menus, breadcrumb navigation, user account management, and the side panel's tool buttons.

<dees-appui-appbar
  .menuItems=${[
    {
      name: 'File',
      action: async () => {},
      submenu: [
        { name: 'New File', shortcut: 'Cmd+N', iconName: 'file-plus', action: async () => handleNewFile() },
        { name: 'Open...', shortcut: 'Cmd+O', iconName: 'folder-open', action: async () => handleOpen() },
        { divider: true },
        { name: 'Save', shortcut: 'Cmd+S', iconName: 'save', action: async () => handleSave(), disabled: true }
      ]
    }
  ]}
  .breadcrumbs=${'Project > src > components'}
  .showWindowControls=${true}
  .showSearch=${true}
  .showActivityLogToggle=${true}
  .activityLogCount=${5}
  .showAssistantToggle=${true}  // assistantIconName defaults to 'lucide:Sparkles'
  .aside=${'activity'}          // the tool the side panel shows, or null
  .compact=${false}   // one menu button for a narrow window; dees-appui sets it for you
  .user=${{
    name: 'John Doe',
    avatar: '/path/to/avatar.jpg',
    status: 'online'  // Options: 'online' | 'offline' | 'busy' | 'away'
  }}
  @menu-select=${(e) => handleMenuSelect(e.detail.item)}
  @breadcrumb-navigate=${(e) => handleBreadcrumbClick(e.detail)}
  @aside-toggle=${(e) => handleAsideToggle(e.detail.tool)} // 'activity' | 'assistant'
></dees-appui-appbar>

Key Features:

  • Hierarchical Menu System — Top-level menus with dropdown submenus, icons, and keyboard shortcuts
  • Keyboard Navigation — Full keyboard support (Tab, Arrow keys, Enter, Escape)
  • Breadcrumb Navigation — Customizable breadcrumb trail with click events
  • User Account Section — Avatar with status indicator and profile dropdown
  • Side Panel Buttons — An icon-only assistant button and the activity log's, with its entry count; each is a disclosure (aria-expanded) of the panel it names through asideControlsElement
  • Accessibility — Full ARIA support with menubar roles

DeesAppuiActivitylog

Activity panel with a visible-entry count, date groups and search. Set .live=${true} only while an event stream is connected to show a static live indicator. The shell exposes the same choice through configure({ activityLog: { live: true } }) or the activityLogLive property; it defaults to false. Its words come from .labels (TAppuiActivitylogLabels, English by default), and it writes dates and times through Intl in labels.locale, else the page's lang, else the browser's language; inside the shell, IAppConfig.labels sets both.

<dees-appui-activitylog></dees-appui-activitylog>

// Programmatic API
activityLog.add({
  type: 'update',        // Options: login, logout, view, create, update, delete, custom
  user: 'John Doe',
  message: 'Updated project settings',
  iconName: 'lucide:settings'  // Optional: custom icon
});

activityLog.addMany(entries);  // Add multiple entries
activityLog.clear();           // Clear all entries
activityLog.getEntries();      // Get all entries
activityLog.filter({ user: 'John' });  // Filter by user/type
activityLog.search('settings');        // Search by message

Key Features:

  • Stacked entry layout with icon, user, timestamp, and message
  • Date grouping (Today, Yesterday, etc.)
  • Search and filter functionality
  • Context menu for entry actions
  • Optional static live indicator, without perpetual animation
  • Animated slide-in/out panel
  • Theme-aware styling

DeesAppuiBottombar

A 24px fixed-height status bar at the bottom of the application shell. Supports status widgets and action buttons positioned left or right.

// Configure via DeesAppui
appui.configure({
  bottomBar: {
    visible: true,
    widgets: [
      {
        id: 'status',
        iconName: 'lucide:activity',
        label: 'System Online',
        status: 'success',       // 'idle' | 'active' | 'success' | 'warning' | 'error'
        tooltip: 'All systems operational',
        onClick: () => console.log('Status clicked'),
      },
      {
        id: 'version',
        iconName: 'lucide:gitBranch',
        label: 'v1.2.3',
        position: 'right',
      }
    ],
    actions: [
      {
        id: 'terminal',
        iconName: 'lucide:terminal',
        tooltip: 'Open Terminal',
        position: 'right',
        onClick: () => console.log('Terminal clicked'),
      }
    ]
  }
});

// Programmatic updates
appui.bottomBar.addWidget({ id: 'build', iconName: 'lucide:hammer', label: 'Building...', loading: true, status: 'active' });
appui.bottomBar.updateWidget('build', { label: 'Build complete', loading: false, status: 'success' });
appui.bottomBar.removeWidget('build');

appui.bottomBar.addAction({ id: 'refresh', iconName: 'lucide:refreshCw', onClick: () => location.reload() });
appui.bottomBar.removeAction('refresh');

appui.setBottomBarVisible(false);

Key Features:

  • Configurable status widgets with icons, labels, and colored status indicators
  • Loading spinner state for widgets
  • Contextual actions with icon buttons
  • Left/right positioning for both widgets and actions
  • Tooltips on hover
  • Context menu support per widget

DeesAppuiCommandpalette

One list of everything the shell can reach by name. dees-appui opens it with the platform modifier and K, and with the app bar's search button unless the host cancels appbar-search-click; the same chord closes it again. It groups the registered views it can open (a view's palette — false, or a function asked on each open — withholds it; the notFoundView and views whose route needs a parameter are never listed), the app bar's leaf items with their shortcuts, the account menu, the current view's sections, the open content tabs and the host's own commands from IAppConfig.commands or setCommands(commands). Matching is a case-insensitive substring over the label, the keywords and the group heading; the Arrow keys move the selection while the focus stays in the query field, Enter runs the selected row, and closing hands the focus back to wherever it came from.

It is a native modal dialog of its own, so it needs no z-index and the top layer, the focus scope and Escape are the platform's. Used standalone, open(groups) takes ICommandPaletteGroup[] and close() dismisses it; command-run reports the row that ran, which dees-appui re-emits as command-palette-run. Its own words — its name, the query field, the result list and the empty state — come from .labels (TAppuiCommandpaletteLabels); the group headings are whatever the groups it is handed say, which in the shell are labels too.

appui.setCommands([
  { id: 'invite', label: 'Invite a teammate', iconName: 'UserPlus', group: 'Team',
    keywords: ['member'], action: () => invite() },
]);

DeesAppuiTabs

Content-panel navigation with native tab buttons, separate close controls and arrow/Home/End focus movement. Enter/Space select; Delete closes a closeable tab. IMenuItem.key remains the identity; optional label supplies display text. The selection follows that key, so a host that binds a literal .tabs=${[...]} keeps its open tab across rerenders: descriptors with the same key are adopted without re-running the tab's action. Only a key that is no longer present falls back to the first tab. Descriptors without a key fall back to their label. Use multitoggle for a view mode within the current panel. controls names the panel these tabs control by id, for a panel in the same root; controlsElement names it by element, which is the form that works when the panel lives in another shadow root — an aria-controls IDREF does not cross that boundary. dees-appui-maincontent passes its own content area that way. Both horizontal and vertical layouts use an inset selection surface. showTabIndicator=false hides that surface while retaining selected text and ARIA state. Selection styling stays attached to the tab through scrolling and resizing.

<dees-appui-tabs
  .tabs=${[
    { key: 'Home', iconName: 'lucide:home', action: () => console.log('Home') },
    { key: 'Settings', iconName: 'lucide:settings', action: () => console.log('Settings') }
  ]}
  tabStyle="horizontal"  // Options: horizontal, vertical
  .showTabIndicator=${true}
  @tab-select=${handleTabSelect}
></dees-appui-tabs>

Data Display Components

DeesTable

Schema-driven table with sorting, search, selection, editing, and pinned row actions.

html`<dees-table
  heading1="Transactions"
  heading2="Review ownership and amounts"
  dataName="transactions"
  .data=${transactions}
  .rowKey=${'id'}
  .columns=${[
    { key: 'name', header: 'Name' },
    { key: 'amount', header: 'Amount', align: 'end', editor: 'number', parse: Number },
  ]}
  .dataActions=${[{
    name: 'View details',
    iconName: 'lucide:PanelRight',
    type: ['inRow', 'contextmenu', 'doubleClick'],
    actionFunc: async ({ item, table }) => openTransaction(item),
  }]}
  .selectionMode=${'multi'}
  .showSelectionCheckbox=${true}
  .fixedHeight=${true}
  style="--table-max-height: 420px"
  @selectionChange=${handleSelectionChange}
  @cellEdit=${handleCellEdit}
></dees-table>`
  • heading1 is the title, heading2 its description, and label a compact alternative. Headings default to empty. The footer reports visible, total, and selected rows; filtered-out selections remain counted when their IDs still exist in data.
  • Columns accept align: 'start' | 'center' | 'end' (default start). End-aligned values use tabular numerals. Alignment is explicit and never inferred from formatted content. A displayFunction can supply columns, or augment a partial schema with augmentFromDisplayFunction.
  • A column sorts and is searched by its value. sortValue(row) gives it another value to sort by — the ISO day behind a formatted date, the cents behind a formatted amount — compared as numbers or as case-insensitive text, with a missing value first; compare(a, b, rowA, rowB) replaces that order with the host's own, receiving both rows' sort values and the rows, and a descending sort reverses it. searchValue(row) gives the text the search, the column filters and the search syntax match, case-insensitively — the text the reader sees, such as "3 Apr 2026" or "1.234,56". searchMode: 'data' still searches the raw row fields.
  • Quiet row separators are the default. Set showGrid, showVerticalLines, or showHorizontalLines for stronger grid lines.
  • Search stays visible when searchable is true. Its menu selects searchMode: table searches visible column values, data searches original row values, and server emits searchRequest with { query, mode: 'server' }. The application owns server results; filterText is not applied again in that mode. Local search supports Lucene-like syntax and emits filterChange with { text, columns }. showColumnFilters enables per-column inputs; a column can opt out with filterable: false.
  • The search field is readable and writable from outside in every mode: searchQuery reports what it shows, setSearchQuery(text, { emit }) writes it, and clearSearch({ emit }) empties it. A written query runs the search exactly as a typed one does once its debounce elapses — searchRequest in server mode, filterChange otherwise — and drops a keystroke still waiting out that debounce. Pass { emit: false } to write the field alone, for a host that answers the query itself — a server mode table restoring a query it has already run. In table and data mode the table filters the rows from filterText, so a withheld query shows in the field while the rows still answer the previous one until something else runs the search, a mode change included. Column filters are a separate control and stay untouched. focusSearch() awaits the table's own render, puts the caret in the field and answers whether there is one.
  • Native header buttons sort and expose aria-sort. Shift-click builds a multi-column sort; the header context menu controls sort priority. sortBy and sortChange expose the descriptors.
  • selectionMode accepts none, single, or multi. Use a stable rowKey to retain selection and editing through fresh data arrays. showSelectionCheckbox includes select-all for visible rows.
  • Action type accepts header, inRow, contextmenu, footer and doubleClick. preview and keyCombination, and the useTableBehaviour field, are accepted by the type and have no effect today. Use actionRelevancyCheckFunc for row-specific availability. Desktop rows show up to three relevant primary actions plus overflow; below 700px of table width, one Actions menu contains the row actions. Coarse pointers receive 44px targets independently of width. The opaque pinned column shows an edge shadow only while it covers scrolling columns.
  • fixedHeight uses internal scrolling with sticky headers and --table-max-height; the default uses a floating header while the page scrolls. fill makes the table take the height its host gives it — a grid or flex track of definite size, as an app-like page gives a section that scrolls on its own: the header and footer keep their height, the rows scroll between them under a sticky column header, nothing outside the table scrolls, and a table without rows centres its empty state in that space. fill takes precedence over fixedHeight (no --table-max-height cap), and while its rows scroll inside it no floating header shows. A host without a definite height leaves nothing to fill: the table grows to its rows, the page scrolls, and the floating header keeps the column headers in view as without fill. virtualized limits rendered rows and retains spacer geometry. Rows may differ in height: each rendered row is measured and keeps its height, rows not rendered yet are estimated at the mean of the measured ones, and the row under the reader stays in place when a measurement corrects an estimate. A row above it that changes height after it rendered (a late image, a cell that wraps) moves it, and the table moves it back on the next frame unless the reader scrolled in between. Rows that share an id — a rowKey naming a missing or repeated field, one object listed twice — are told apart by position, and a row that stays in the window keeps its element. A column widens when the window changes, data is replaced or a cell edit is committed and the rows on screen hold a wider value than the column was sized for; a row changed in place without either widens it when the window next changes. The table turns browser scroll anchoring off for its rows (overflow-anchor: none) and anchors them itself.
  • Set a column's editor to text, number, checkbox, dropdown, date, or tags; editable: true defaults to text. Use editorOptions, format, parse, and validate for value handling. Double-click edits a cell; Enter commits and opens the cell below, Tab and Shift+Tab commit and open the next or previous editable cell, leaving the editor commits it, and Escape closes it without committing. A value validate refuses keeps its editor open, Enter and Tab included. cellEdit reports { row, key, oldValue, newValue }; cellEditError reports validation failures. commitCellEdit(row, column, value) applies a value through parse and validate and returns whether it was accepted; it closes the editor only when that is the cell being edited.
  • frameless drops the table's outer frame when the host — a modal, a panel, a dashboard cell — already owns it. Inline cell editors are frameless in every mode.
  • Every actionFunc is awaited. A rejected action dispatches the cancelable dees-action-error event (see Failed actions) instead of disappearing.
  • While an actionFunc is still pending, the control that started it shows a busy face in place of its icon, is marked aria-busy and aria-disabled — never natively disabled, so a keyboard reader keeps the control under the caret — and refuses a second press, from the row button, the row menu, the context menu and a double click alike. A table runs one action against many rows, so exactly that action on that row is held; other actions, other rows, sorting, selection and editing stay usable, and header and footer actions are held for the table. An action is identified by its name, so a host that rebuilds its dataActions while one runs keeps the busy state, and two actions of one table that share a name share it. isActionPending(action, row?) reports the state and accepts a freshly built descriptor. The control is held for as long as the promise is pending — an action that only settles when a dialog it opened closes holds it for exactly that long.
  • An action's iconName is optional. A control whose action has none changes width with its busy face — a header or footer action gains a spinner beside its label, a row action trades its name for one — and eases between the two widths over --dees-transition-default, except under prefers-reduced-motion: reduce, for a control that is not rendered, and for width changes the action did not cause, such as a resize.
  • File drop onto rows and highlightUpdates: 'flash' remain available. Flashing respects reduced motion and stable row identity.

DeesDataviewCodebox

The Inline/Split selector uses dees-input-multitoggle and follows the effective layout, including automatic resizing. frameless removes the outer frame when embedded in a parent-owned preview. Code display component with syntax highlighting and line numbers.

<dees-dataview-codebox
  progLang="typescript"  // Programming language for syntax highlighting
  .codeToDisplay=${`
    import { html } from '@design.estate/dees-element';

    export const myComponent = () => {
      return html\`<div>Hello World</div>\`;
    };
  `}
></dees-dataview-codebox>

Diff mode — set codeBefore (compared against codeToDisplay as the after state) or a pre-computed unifiedDiff patch; diffView selects 'inline', 'split' (side-by-side), or 'auto' and can force diff mode on its own:

<dees-dataview-codebox
  progLang="typescript"
  filename="config.ts"
  diffView="split"
  .codeBefore=${previousSource}
  .codeToDisplay=${newSource}
></dees-dataview-codebox>

Diff rendering includes word-level intraline change highlighting, unchanged-context folding with expandable "N unchanged lines" rows, +added −removed stats in the footer, and a "Copy Unified Diff" context-menu action. The app bar switches between inline and split layouts and emits the bubbling, composed diff-view-change event with IDiffViewChangeDetail ({ diffView: TDiffView }). The exported TDiffView type is 'inline' | 'split'. Split mode keeps a fixed center divider and gives each side an independent horizontal scroll position while the containing surface owns their shared vertical movement. Set showDiffLineNumbers to false when snippet inputs do not carry trustworthy source positions. The copy button yields the after-state text. The dependency-free engine (computeLineDiff, parseUnifiedDiff, foldContextRows, buildSplitRows, diffStats, toUnifiedDiff) is exported for programmatic use.

Unified patches retain their ordered file boundaries. Each file heading shows its before/after path and typed Git metadata such as new, deleted, renamed, copied, mode-only, and binary changes. Set unifiedDiffTruncated when the producer bounded the patch: the footer then labels it as partial and counts only complete hunks. Binary and unparseable inputs never appear as +0 −0. The after-state copy action is available for full before/after inputs; unified patches retain the exact source through the Copy Unified Diff context action.

Use parseUnifiedDiffDocument(patch, { truncated? }) when a caller needs the same structured view. It returns ordered IUnifiedDiffFileSection entries with typed metadata, text rows, and binary state, plus a TUnifiedDiffDocumentSummary that separately reports completeness, trustworthy text stats, and the binary-file count. parseUnifiedDiff(patch) remains the compatibility view that flattens text rows.

Use summarizeUnifiedDiff(patch, { truncated? }) when counts must state their confidence. It returns TUnifiedDiffSummary: { type: 'exact', stats } after validating every hunk, { type: 'partial', stats? } for an explicitly incomplete input, { type: 'binary' } for a binary patch, or { type: 'unparseable' }. stats is IDiffStats ({ added, removed }). The helper never presents an invalid, non-text, or truncated patch as +0 −0.

diffView="auto" uses inline rows below 640px of codebox width and split rows at 640px or wider. Resizing preserves expanded context and does not emit diff-view-change. Clicking either layout button fixes that choice, including when it already matches the automatic layout; set diffView back to 'auto' to resume adaptation. The exported TDiffViewRequest adds 'auto' to TDiffView, while event details and saved manual choices remain 'inline' | 'split'. The default empty diffView and explicit layouts retain their existing behavior.

DeesDataviewStatusobject

Service health with a summary, named checks, values, and status explanations. Checks use distinct icons for ok, partly_ok, and not_ok; long values wrap to the available width. Missing data is shown separately from a healthy result.

<dees-dataview-statusobject
  .statusObject=${{
    id: '1',
    name: 'System Status',
    combinedStatus: 'partly_ok',
    combinedStatusText: 'Partially OK',
    details: [
      { name: 'Database', value: 'Connected', status: 'ok', statusText: 'OK' },
      { name: 'API Service', value: 'Degraded', status: 'partly_ok', statusText: 'Partially OK' }
    ]
  }}
></dees-dataview-statusobject>

Set lastUpdated to an epoch timestamp in milliseconds to show when checks were received. Copy JSON copies the current status object. Each check has a Copy options button for its value, key, or key and value; right-click and Shift+F10 open the same menu. Clipboard success and failure are announced visibly. Controls support keyboard focus and 44px touch targets. frameless drops the outer frame when the host owns it.

DeesStatsGrid

A responsive grid component for displaying statistical data with various visualization types.

<dees-statsgrid
  .tiles=${[
    {
      id: 'revenue',
      title: 'Total Revenue',
      value: 125420,
      unit: '$',
      type: 'number',
      icon: 'lucide:dollarSign',
      description: '+12.5% from last month',
      color: '#22c55e'
    },
    {
      id: 'cpu',
      title: 'CPU Usage',
      value: 73,
      type: 'gauge',
      icon: 'lucide:cpu',
      gaugeOptions: {
        min: 0, max: 100,
        thresholds: [
          { value: 0, color: '#22c55e' },
          { value: 60, color: '#f59e0b' },
          { value: 80, color: '#ef4444' }
        ]
      }
    },
    {
      id: 'requests',
      title: 'API Requests',
      value: '1.2k',
      unit: '/min',
      type: 'trend',
      icon: 'lucide:server',
      trendData: [45, 52, 38, 65, 72, 68, 75, 82, 79, 85, 88, 92]
    },
    {
      id: 'cores',
      title: 'CPU Cores',
      value: 0,
      type: 'cpuCores',
      icon: 'lucide:cpu',
      columnSpan: 2,
      coresData: [
        { id: 0, usage: 45, label: '0' },
        { id: 1, usage: 72, label: '1' },
        { id: 2, usage: 30, label: '2' },
        { id: 3, usage: 88, label: '3' }
      ]
    }
  ]}
  .minTileWidth=${250}
  .gap=${16}
></dees-statsgrid>

Tile Types: number, gauge, percentage, trend, text, multiPercentage, cpuCores, partition, disk.

minTileWidth and gap determine the number of columns from the component's available width. A tile's columnSpan is clamped to that count, so wide metrics fit narrow containers. Descriptions share the same placement across tile types. Replace tiles or gridActions to render a fresh sample; the component does not poll for data.

Tile actions and gridActions accept IMenuItem[]. A single tile action appears as a named button and also runs when the tile is clicked. Multiple actions appear in an explicit menu button. Right-click or Shift+F10 opens the same tile menu; keyboard focus returns to its opener on dismissal. Touch controls use 44px targets, and metric transitions respect reduced motion.

DeesPagination

Pagination component for navigating through large datasets.

<dees-pagination
  totalItems={500}
  itemsPerPage={20}
  currentPage={1}
  maxVisiblePages={7}
  @page-change=${handlePageChange}
></dees-pagination>

DeesStorageBrowser

An object-storage workspace with a shared Columns/List segmented control, breadcrumb navigation, file/folder action menus and a resizable preview. At narrow widths the preview replaces the file list; Back to files restores focus. Editing, selection and navigation respect unsaved changes and in-progress writes.

html`<dees-storage-browser
  style="height: 600px"
  .dataProvider=${this.storageProvider}
  .bucketName=${'assets'}
  .onChangeEvent=${this.subscribeToStorageChanges}
></dees-storage-browser>`;

Implement IStorageDataProvider from the storage interfaces. It provides listObjects, getObject, putObject, deleteObject, deletePrefix, getObjectUrl, moveObject and movePrefix. File content is base64 at the provider boundary; list results contain objects and prefixes. Move methods return a success flag and optional error; write/delete methods return a boolean.

getObject returns IStorageObjectContent. A provider that can describe an object but deliberately withholds its bytes — a secret, an oversized blob, an unsupported media type — returns an empty content together with an unavailableReason sentence. The preview renders that sentence in place of the body, as a note rather than an error, and leaves out Download and Edit because both need the bytes.

onChangeEvent accepts a callback and returns an unsubscribe function. Its events use { type: 'add' | 'modify' | 'delete', key, bucket, size?, lastModified? }. Subscriptions are scoped to the bucket and released when detached. Provider and selection changes invalidate stale preview requests.

getLocation() returns { prefix, key }; setLocation({ prefix, key? }) applies a host-owned location and returns false when the current draft or write blocks it. storage-location-change reports accepted navigation. Columns and List retain the same folder, and narrow Columns layouts show the current folder with the breadcrumb for navigating back. Column and preview dividers support pointer and keyboard resizing.

Row rename, move, and delete operations update the open preview and any affected folder path. The child views and the preview emit storage-before-mutation (cancelable) before a move/rename/delete and storage-mutation after a successful move/rename/delete, with IStorageMutationEvent detail; the preview's own Delete goes through the same pair. When composing the child views directly, use these events to guard drafts and update selected paths. dees-storage-preview exposes a header-actions slot for a close/back control; the slot remains available during loading and errors. Search uses dees-input-text; view switching and selected rows reuse the catalog's shared control and selection styles.

capabilities declares which mutations the browser may offer: create, upload, rename, move, delete, edit and download. An unset flag stays allowed, so a host that never sets the property keeps every surface. A denied mutation is not offered anywhere — its menu entry, the + control, the preview button and the matching drag-and-drop target are absent rather than disabled — and every flag false leaves a read-only browser that still browses, selects and previews. Gating is a UI contract: the provider must still enforce authorization. Revoking edit while the preview's editor is open is the one case that disables instead of removing: the editor stays so the draft survives, and Save is disabled.

html`<dees-storage-browser
  .dataProvider=${this.storageProvider}
  .capabilities=${{ create: false, upload: false, rename: false, move: false, delete: false, edit: false, download: false }}
  .selectionMode=${'multiple'}
  @storage-selection-change=${(event: CustomEvent<IStorageSelectionChange>) => this.showActionsFor(event.detail.entries)}
></dees-storage-browser>`;

selectionMode defaults to single, where a plain click marks the clicked row and does what it always did: a file opens in the preview, a folder opens in the next column. With multiple, ctrl/cmd-click toggles a row without navigating, shift-click takes the inclusive range inside one list, Space toggles the focused row and Shift+Arrow grows the range; multi-select rows report their state as aria-pressed. Files and folders are marked together, and folder entries keep their trailing /. Closing the preview unmarks the file it showed and a breadcrumb jump clears the selection. In Columns a marked folder stays visible in its parent column; in List opening a folder marks that folder while its contents are listed.

storage-selection-change carries { entries: IStorageSelectionEntry[] } in rendered row order, including a selection that followed a move or lost a deleted row. getSelection() returns a copy; setSelection() applies a host-owned selection without emitting, as setLocation() does, and single mode keeps its first entry; it does not validate entries against the current listing, so a host restoring a route keeps its selection even when the listing arrives later. The host renders its own actions from the selection — the browser adds none.

The browser composes dees-storage-columns, dees-storage-keys and dees-storage-preview. Use the browser for the complete workflow; compose these lower-level components only when the application owns navigation and preview state. capabilities reaches all three; selectionMode and the selection belong to the two list views, which emit storage-selection-change themselves.

For route integration, getLocation() returns { prefix, key } and setLocation({ prefix, key? }) applies a host-owned folder/preview selection. It returns false if a pending write or unsaved preview prevents navigation. canLeave() exposes the same guard to app shells. Accepted user navigation emits the composed storage-location-change event with { prefix, key }; programmatic setLocation() does not emit it, preventing host/child update loops. Wait for the browser's initial updateComplete after assigning its provider before applying a deep link.

Media & Thumbnail Components 🎬

A rich collection of thumbnail components for displaying media files in grids. All thumbnails share a consistent base class (DeesThumbnailBase) with lazy loading via IntersectionObserver, hover interactions, click events, and three size variants (small, default, large). DeesThumbnailPdf also supplies PDF-specific context-menu actions.

Thumbnail metadata sits in a shared caption below the preview. The preview stays mounted while loading, so image load events and media rendering can finish. Clickable tiles have an accessible name, a visible keyboard focus ring, and Enter/Space activation through tile-click; set .clickable=${false} for a non-interactive preview. Hover keeps the content visible while scrubbing PDF pages or notes. Labels remain visible during errors.

Image and video tiles accept .fit=${'contain'} to show the complete frame; the default cover preserves cropped photo/video tiles. Audio and video release pending source work on replacement or disconnect; failures show the shared error state and a new source can recover. Audio waveforms come from the recording, and video frame captures are bounded to the preview size and device pixel ratio. PDF pages retain a 2px radius and the existing shared worker/render pipeline. All six demos share a mixed file shelf, open real previews, and retain their individual samples and size examples below it.

DeesThumbnailPdf

PDF document thumbnail with a locally rendered page preview.

<dees-thumbnail-pdf
  .pdfUrl=${'/documents/report.pdf'}
  label="Annual Report"
  .clickable=${true}
  @tile-click=${handleClick}
></dees-thumbnail-pdf>

Key Features:

  • Renders first page as canvas preview
  • Hover to scrub through pages (mouse X position maps to page number)
  • Shows page count, file size, and hover page indicator
  • Detects A4/Letter vs non-standard aspect ratios
  • Bundles the PDF.js worker locally and releases it when the URL changes or the component disconnects

Public properties include pdfUrl, currentPreviewPage, pageCount, rendered, fileSize, clickable, loading, error, size, and label. Loading starts within the thumbnail observer's 200px viewport preload margin. URL replacement, failed loads, and disconnection all release the current PDF document and worker.

DeesThumbnailImage

Image thumbnail with lazy loading and dimension display.

<dees-thumbnail-image
  src="/photos/landscape.jpg"
  alt="Mountain landscape"
  label="landscape.jpg"
  clickable
  @tile-click=${handleClick}
></dees-thumbnail-image>

Key Features:

  • Lazy loads image on scroll into view
  • Shows image dimensions after loading (e.g. "1920 × 1080")
  • Checkerboard background for transparent images

DeesThumbnailAudio

Audio file tile with waveform visualization.

<dees-thumbnail-audio
  src="/music/track.mp3"
  title="Summer Vibes"
  artist="DJ Example"
  clickable
  @tile-click=${handleClick}
></dees-thumbnail-audio>

Key Features:

  • Generates waveform visualization from audio data
  • Shows duration badge (e.g. "3:42")
  • Displays title and artist metadata
  • Keyboard and pointer activation through tile-click

DeesThumbnailVideo

Video tile with thumbnail capture and hover preview.

<dees-thumbnail-video
  src="/videos/intro.mp4"
  poster="/thumbs/intro.jpg"
  label="Introduction"
  clickable
  @tile-click=${handleClick}
></dees-thumbnail-video>

Key Features:

  • Auto-captures first frame as thumbnail (or uses provided poster)
  • Plays video preview on hover
  • Shows duration badge
  • Play button overlay

DeesThumbnailNote

Plain-text note thumbnail with a monospace preview.

<dees-thumbnail-note
  title="config.ts"
  language="TypeScript"
  .content=${codeString}
  clickable
  @tile-click=${handleClick}
></dees-thumbnail-note>

Key Features:

  • Monospace plain-text preview
  • Optional language metadata in the bottom information bar
  • Scrollable content on hover (mouse X position controls scroll)
  • Gradient fade at bottom

DeesThumbnailFolder

Folder tile with 2×2 content preview grid.

<dees-thumbnail-folder
  name="Project Assets"
  .items=${[
    { type: 'image', name: 'logo.png', thumbnailSrc: '/thumbs/logo.png' },
    { type: 'pdf', name: 'spec.pdf' },
    { type: 'audio', name: 'jingle.mp3' },
    { type: 'video', name: 'demo.mp4' },
  ]}
  clickable
  @tile-click=${handleClick}
></dees-thumbnail-folder>

Key Features:

  • 2×2 preview grid showing first 4 items (thumbnails or type icons)
  • Item count badge (e.g. "12 items")
  • Folder icon header with name
  • Supports: pdf, image, audio, video, note, folder, unknown types

DeesPreview

Use dees-preview to inspect a file without choosing an individual viewer. Supply one source: file, url, raw base64 with mimeType, or textContent. It detects images, PDFs, audio, video, text, and source files; contentType and language override detection. filename supplies a readable heading and download name.

html`<dees-preview style="height: 560px" .file=${selectedFile}></dees-preview>`;

The preview owns the frame and filename heading; embedded viewers do not add a second frame. Set frameless when the enclosing layout already owns that border. showFilename hides the heading; showToolbar controls the image/PDF tools. Failed text requests show an error with a retry action (reload()); source changes abort pending reads and revoke owned file URLs. Unsupported types show an empty state.

Use the individual image/PDF/audio/video viewer when its specialized controls or properties are needed. Use dees-thumbnail-* for file tiles, and dees-preview for the selected file's full inspection area. Reuse dees-appui-tabs for the file list and dees-input-multitoggle for view switching instead of local toggle markup.

DeesPdfViewer

Full PDF viewer with page navigation, zoom, fit modes, selectable text, downloads, printing, an optional thumbnail sidebar, and an all-page gallery. PDF.js and its module worker are bundled locally; the component owns and releases each worker as documents are replaced or the viewer disconnects.

<dees-pdf-viewer
  .pdfUrl=${'/documents/report.pdf'}
  .initialPage=${1}
  .initialZoom=${'page-fit'}
  .showSidebar=${true}
></dees-pdf-viewer>

Public properties include pdfUrl, initialPage, initialZoom, showToolbar, showSidebar, sidebarPosition, viewMode (document or gallery), frameless, showFooter, filename, currentPage, totalPages, currentZoom, loading, loadError, and pdfFileSize. The Document/Gallery segmented control changes modes; clicking or pressing Enter on a page tile returns to that page in the document. Gallery tiles share the document's existing PDF.js worker, load near the visible area, and rasterize at the current width and device pixel ratio. Supply .viewMode=${'gallery'} to open directly in the gallery. This works independently of the document's showSidebar preference. URL replacement, failed loads, and disconnection cancel active work, remove observers and listeners, and release the current PDF document and worker.

PdfManager

Use PdfManager when loading a PDF outside the supplied components. Always release the returned document. An abort signal cancels an in-flight load and triggers bounded worker cleanup.

When migrating from 6.x, pass the PDFDocumentProxy returned by loadDocument() to releaseDocument() instead of passing the source URL.

PdfManager.initialize() is retained as an asynchronous no-op for existing callers; static PDF.js imports require no initialization step.

import { PdfManager } from '@design.estate/dees-catalog';

const abortController = new AbortController();
const pdfDocument = await PdfManager.loadDocument(pdfUrl, abortController.signal);

try {
  const firstPage = await pdfDocument.getPage(1);
  // Render or inspect the page.
  firstPage.cleanup();
} finally {
  await PdfManager.releaseDocument(pdfDocument);
}

DeesImageViewer

Image inspection with zoom, pointer pan, Fit/Actual size, dimensions, and download. Set src, alt, optional filename, fit (contain, cover, or actual), and showToolbar. The zoomIn(), zoomOut(), resetZoom(), fitToScreen(), and actualSize() methods operate on the same view state as the toolbar.

DeesAudioViewer

Audio playback with a waveform decoded from src, keyboard-operable seeking, volume, mute, loop, and optional title/artist. showWaveform hides the waveform; autoplay remains subject to browser permission. Waveform decoding failure leaves playback available and shows a note. Playback does not re-rasterize the waveform each frame. Replacing the source or disconnecting stops the old player and aborts its pending download.

DeesVideoViewer

Video playback with seeking, volume, mute, loop, and fullscreen. Set src, poster, autoplay, loop, and muted; showControls=false uses the browser's native controls. Set frameless to fill an enclosing preview stage. Custom controls reappear for keyboard focus and respect reduced motion. Disconnecting releases the media source; reconnecting restores it. Both audio and video expose play(), pause(), togglePlay(), seek(seconds), and setVolume(0..1).


Visualization Components

Charts share a quiet frame, heading, readable series footer and empty/loading states. ECharts bar/donut/gauge/line/radar components hide their drawing from assistive technology and expose a textual data summary in its place, which summary replaces with the host's own words, and respect reduced motion; legend buttons toggle their actual series. Charts resize with their container and recreate their engines when reattached. Assign a bounded height when embedding in a dashboard. Every chart, dees-chart-log included, accepts frameless when the dashboard cell or panel owns the frame.

A series or a slice says what it stands for with tone — 'neutral', 'accent', 'success', 'warning' or 'danger' — and is drawn in the theme's colour for it, in the bright and the dark theme alike, so a host never passes a hex for "paid" or "overdue". An explicit color wins over tone; without either the series takes the shared palette by its index. Bar and radar series, donut slices, line and area series and the gauge's thresholds all take it, and getToneColor(tone, goBright) returns the same colour for a host's own drawing.

Question Component Data
How does a value change over time? dees-chart-area Named series of { x, y } samples, evenly sampled.
What was a value at the moments it was stated? dees-chart-line Named series of { time, value }, spaced by their real distance in time.
How do categories compare? dees-chart-bar categories and named numeric series.
What makes up the whole? dees-chart-donut { name, value, color? } slices.
How close is a value to capacity? dees-chart-gauge value, min, max, unit, optional thresholds.
How do profiles compare on several dimensions? dees-chart-radar indicators and named value arrays.

DeesChartBar

html`<dees-chart-bar label="Weekly requests"
  .categories=${['Mon', 'Tue', 'Wed']}
  .series=${[{ name: 'Gateway', data: [120, 180, 150] }]}
  .valueFormatter=${(value: number) => `${value}k`}
></dees-chart-bar>`;

horizontal, stacked and showLegend control presentation. Each series accepts an optional tone or color; category and series arrays should have matching lengths.

DeesChartDonut

html`<dees-chart-donut label="Requests by region"
  .data=${[{ name: 'Europe', value: 42 }, { name: 'Americas', value: 34 }, { name: 'Asia Pacific', value: 24 }]}
></dees-chart-donut>`;

Use showLabels, showLegend, innerRadiusPercent (default '55%') and valueFormatter to adjust presentation. Narrow layouts use short percentage labels outside the ring while the wrapping footer retains names and values.

DeesChartGauge

html`<dees-chart-gauge label="Storage capacity" .value=${64} .min=${0} .max=${100} unit="%"></dees-chart-gauge>`;

thresholds accepts { value, color?, tone? } entries: a band's color, else its tone in the theme's colour, else the gauge's own colour. showTicks controls the scale; NaN represents an unavailable value and displays the empty state.

DeesChartLine

html`<dees-chart-line label="Account balances"
  .series=${[{ name: 'Current account', data: [
    { time: '2026-05-31', value: 15234.11 },
    { time: '2026-06-30', value: 9000 },
    { time: '2026-09-30', value: 2246.86 },
  ] }]}
  presentation="points"
  .valueFormatter=${(value: number) => `${value.toFixed(2)} €`}
></dees-chart-line>`;

Values at the moments they were stated, on a time axis proportional to time: a value stated a quarter after the one before sits three months further on, and a single value is one point in a month of axis. Every value is marked where it was stated. presentation decides what is drawn between them:

  • points (default): nothing. The reader sees exactly what is known, for values such as balances from statements, where nothing is known in between.
  • step: each value holds until the next one, for values that are true until they change.
  • line: straight lines, for measurements whose course in between the line does describe.

time accepts epoch milliseconds, an ISO string or a Date; points are put in time order, the last value given for a moment wins, and value: null breaks the drawing. includeZero (default true) keeps zero on the value axis. valueFormatter formats values on the axis, in the tooltip and in the text summary; timeFormatter formats moments in the tooltip and the summary (default: the reader's medium date); axisTimeFormatter labels the time axis, which the chart engine otherwise labels in English. showLegend and each series' color work as in the other charts. dees-chart-area stays the chart for evenly sampled measurements: it spaces samples evenly whatever their distance in time and draws an area under them.

DeesChartRadar

html`<dees-chart-radar label="Service profile"
  .indicators=${[{ name: 'Speed', max: 100 }, { name: 'Reliability', max: 100 }, { name: 'Capacity', max: 100 }]}
  .series=${[{ name: 'Gateway', values: [82, 96, 74] }]}
></dees-chart-radar>`;

fillArea and showLegend control presentation. Each series can supply a color. Its values array follows the order of indicators.

DeesChartArea

yAxisFormatter formats the value axis, the legend statistics, the tooltip and the text summary. It receives fractional values from the chart engine even when samples are integers, so bound precision explicitly (for example, (value) => Math.round(value) + "k"). Unbounded decimals reserve unnecessary axis space on small charts. The default is a plain number rounded to two decimal places, without a unit; the unit is the host's to add.

The plot is hidden from assistive technology, and a visually hidden text says what it shows: the label, then per series its period (from its first to its newest sample) and its latest, lowest and highest reading, formatted through yAxisFormatter. The latest reading is the newest sample, "unavailable" when it is null, as the legend shows —. An unnamed series is named by its position, "Series 1", "Series 2", …, in the legend, the tooltip and the summary alike. summary replaces it with the host's own words.

Area chart component built on Lightweight Charts for time-series data. Enable rangeSelectionEnabled to let the user drag across the plot. The composed, bubbling range-change event carries { from, to } as Unix epoch milliseconds. Apply that range back through selectedRange to keep the selection visible; set it to null to clear the overlay.

Lightweight Charts is bundled from the package dependency. Rendering does not load chart scripts from a CDN, so script-src 'self' policies require no extra script origin.

The time axis is fitted to the data on the plot's first real size, so a chart that receives its data while hidden or before its host lays it out shows all of it once it is shown, and again on every later size while it still shows its fitted range. A range the host set through the chart's API (chart.timeScale().setVisibleLogicalRange()) is kept across a resize. In realtime mode with a rollingWindow, a new size shows that window.

Use y: null for an unavailable reading. Its timestamp remains on the time axis, and the area line visibly stops until the next numeric reading. Supply one null point for each missing sampling interval when the width of an outage should reflect its duration. The chart keeps one legend entry per named input series. Legend statistics omit null readings; latest is unavailable when the newest reading is null, while min, max, and avg describe available readings in the selected window. An unavailable statistic is displayed as —. With realtimeMode=true and rollingWindow=3_600_000, the time axis follows the last hour even while the latest reading is unavailable.

Set compact when the chart is embedded in a popover or another component that already owns its heading and frame. Compact mode dedicates the bounded component height to the plot and omits the nested tile chrome, chart heading, full-page action, and legend. The default framed presentation is unchanged.

<dees-chart-area
  label="System Usage"
  .rangeSelectionEnabled=${true}
  .selectedRange=${selectedRange}
  .series=${[
    {
      name: 'CPU',
      data: [
        { x: '2025-01-15T03:00:00', y: 25 },
        { x: '2025-01-15T07:00:00', y: null }, // unavailable
        { x: '2025-01-15T11:00:00', y: 20 }
      ]
    }
  ]}
  @range-change=${(event: CustomEvent<{ from: number; to: number }>) => {
    selectedRange = event.detail;
  }}
></dees-chart-area>

DeesChartLog

A log stream on a terminal surface: the search field, the filter/highlight toggle, auto-scroll, clear and the metrics footer are this component's, the surface is one DeesTerminalView it composes. Log levels are written as ANSI colours and matches as reverse video, so both are drawn from the catalog's one terminal palette and stay readable in either theme.

<dees-chart-log
  label="Production Server Logs"
  .highlightKeywords=${['error', 'failed', 'timeout']}
  .showMetrics=${true}
></dees-chart-log>

addLog(level, message, source?) adds one entry and updateLog(entries) a batch; logEntries binds a list the host owns, where a list that only grew at the end costs one write for the new tail and any other change repaints once. Binding is an assignment: a host that mutates its array in place and re-assigns the same reference changes nothing the component can see, so hand over a new array. maxEntries (10 000) bounds the entries and sizes the grid's scrollback with them. writeRaw(data) and writelnRaw(line) forward output that is already a terminal stream (container logs): it is not an entry, so it is not filtered, not counted by level and not repainted after a move. clearLogs() empties entries, metrics and screen the way a terminal clear does: a bound list that afterwards grows at the end paints only the entries added after the clear, while a list the host replaces is a new list and paints in full. scrollToBottom() follows the newest output, and search(query) / searchNext() / searchPrevious() drive xterm's search addon. autoScroll, highlightKeywords, showMetrics, label and frameless are properties; mode is gone, because nothing read it.

The entries live in the component rather than in the grid: the view builds a terminal per connection and every connection is painted from the entries, so a log moved between containers reads the same afterwards. An entry that arrives while the log is connected is written on its own, and only a changed filter, a changed query or a fresh surface repaints the buffer — one write for the batch, one scroll after it. In filter mode the log shows the matching entries and counts the rest into a [n log lines hidden by filter ...] placeholder; in highlight mode the search addon marks the query on the grid instead. That addon is CDN-only, so canJumpToMatch reports whether it came up: if it did not, the two match buttons are disabled with a title that says why, while filtering, highlighting and the log itself keep working. The metrics footer counts levels and shows the rate over the last ten seconds, measured when an entry arrives — nothing recomputes it behind a log that is not moving. Only entries that arrive count as arrivals: a list the host binds is adopted, so history reaches the level counters without pretending to be traffic, and the rate follows what is appended to it afterwards.


Dialogs & Overlays Components

DeesModal

An opaque, themed window backed by a native modal dialog. The browser contains keyboard focus, makes the underlying page inert, and restores focus on dismissal. Escape closes the frontmost dialog. destroy() is safe to call repeatedly.

const modal = await DeesModal.createAndShow({
  heading: 'Confirm Action',
  content: html`
    <dees-form>
      <dees-input-text key="reason" label="Enter reason"></dees-input-text>
    </dees-form>
  `,
  menuOptions: [
    { name: 'Cancel', action: (instance) => instance!.destroy() },
    { name: 'Confirm', action: async (instance) => { /* handle */ await instance!.destroy(); } }
  ]
});

Use subheading for supporting text next to the heading, description for body copy above the content — a string or a template, announced as the dialog's description through aria-describedby; a dialog without one carries no description reference — and sidebar for a settings navigation template. Sidebar windows reserve 400px of height on desktop and 520px at viewport widths up to 600px, where navigation stacks above the content. Replacing content, heading, or subheading does not resize the window. Set height to another pixel value to override the height in both layouts. Both dimensions are clamped to the viewport; the content scrolls independently of the heading and actions. Dialogs without a sidebar use their content height unless height is supplied.

width accepts small (380px), medium (560px), large (800px), fullscreen, or a pixel value. minWidth and maxWidth remain bounded by the viewport. mobileFullscreen fills the screen below 600px. The optional header controls are showCloseButton, showHelpButton, and onHelp. contentPadding defaults to 24px. The final menuOptions action receives the accent treatment.

For a single value, use await DeesModal.prompt({ heading, label, description?, value?, confirmLabel?, emptyValueMessage?, validate?, onConfirm? }). It returns the trimmed value on success or null on dismissal. Enter confirms, while composition remains with the input. description states what confirming does — text or a template above the field, describing the dialog — and is what a typed-phrase confirmation ("type the slug to close this organization") needs. validate(value) returns an error string or undefined; an empty field reports emptyValueMessage, which defaults to Enter a name.; async onConfirm(value) runs before closing. Rejected operations leave the dialog open with their error, and repeated submission and dismissal are blocked while confirmation is pending. The modal emits modal-close when removed.

For a yes-or-no question, use await DeesModal.confirm({ heading, question?, confirmLabel?, cancelLabel? }). It resolves true only when the confirming action is chosen: Cancel, Escape, the backdrop and the close button all resolve false, so a dismissal is never taken for consent. question is text or a template, announced as the dialog's description. Labels default to Cancel and Continue. A component library asks this way instead of opening the browser's own confirm() box.

Every menuOptions action is awaited, and one action runs at a time: while its promise is pending its button shows the busy face, the other actions are disabled, and a second press is ignored, so an irreversible operation cannot run twice. A rejected action keeps the dialog open, hands the buttons back and reports the failure through dees-action-error (see Failed actions).

A dialog can refuse its own dismissal. beforeClose — a property and an option of createAndShow() — is asked before Escape, a click on the backdrop and the header close button, synchronously or as a promise; only a literal false keeps the dialog open, so a form with unsaved input can ask first. While the hook runs, further dismissals are ignored rather than stacked, and a dialog that is already closing is not asked again. A hook that throws or rejects also keeps the dialog open, and the reader's dismissal reports that failure through dees-action-error with the source modal-dismiss (see Failed actions). requestClose() runs the same path and resolves whether the dialog closed, for a Cancel action that should ask the same question; called directly, it hands a failing hook's rejection to its own caller. A programmatic destroy() is not a dismissal and never asks, so an action that closes its own dialog after saving is unaffected. A refusal holds however often Escape is pressed: the modal answers Escape itself, so Chrome's rule that closes a dialog after a refused cancel or two never comes into play. An Escape a control inside already answered, such as an open dropdown, is left to it. A close the browser still forces, such as a system back gesture, ends the modal as dismissed: it removes itself and fires modal-close.

The dialog renders the host's content on its own, and again only when the host gives new content: the dialog's own state — a pending action, a refusal — never renders it again, so what a reader changed inside, a dropdown's choice included, stays until the host replaces the content. dees-stepper treats each step's content the same way. Asynchronous directives in the content, such as dees-element's directives.subscribe, end when the dialog or the stepper leaves the page, or when a step's body is gone, and start again when the element returns.

The modal gallery includes a working settings draft: values survive navigation, Save returns the draft, and Cancel discards it. The example lives in ts_web/elements/00group-overlay/dees-modal/dees-modal.demo.ts.

Dropdowns, calendars, context menus, and speech bubbles opened from a dialog join its native top layer. Custom overlays should pass their invoking node as ownerElement to DeesWindowLayer.createAndShow(). Toasts use the current keyboard focus scope by default; pass ownerElement explicitly when showing a notification from background work. Toast duration: 0 keeps it visible until dismissal.

DeesContextmenu

Context menu component for right-click actions with nested submenu support.

Menus take focus after becoming visible. ArrowUp/ArrowDown move through enabled items, Enter activates the focused item, and Escape dismisses the menu chain without reaching enclosing app shortcuts. Hosts can register focus restoration with the returned menu's registerGarbageFunction() hook.

A menu blends in out of the corner nearest its anchor over --dees-transition-fast and leaves the same way over --dees-transition-instant: it scales from 0.96, its frosted panel's material — tint, border, shadow and backdrop blur — ramps in from nothing, and the commands fade in. transform-origin follows the computed placement, so a menu flipped left or up grows from its own corner; submenus and the account dropdown (DeesAppuiProfileDropdown.openFromTrigger()) blend the same way, and all of them sit at the medium elevation, --dees-shadow-md. The panel never fades as a whole, which would drop the frosted backdrop for the length of the blend, and nothing is clipped, so every command takes presses from the first frame. Under prefers-reduced-motion: reduce both blends are immediate.

Closing is immediate for everything but the eye. destroy() hands the focus back, dispatches closed and starts the exit blend in the same task, so a host that tracks the open menu — an app bar heading's pressed state — drops it at once, and switching headings or submenus overlaps the leaving menu with the arriving one. The returned promise resolves once the menu has left the DOM; until then isConnected is still true, so code that needs the element gone awaits it. A second close joins the same promise, and a menu dismissed before it was shown leaves at once — openContextMenuWithOptions() then resolves with that detached menu. closePortal() on the account dropdown behaves the same way and announces portal-closed.

// Programmatic usage
DeesContextmenu.openContextMenuWithOptions(mouseEvent, [
  {
    name: 'Edit',
    iconName: 'lucide:edit',
    action: async () => handleEdit()
  },
  { divider: true },
  {
    name: 'More Options',
    iconName: 'lucide:moreHorizontal',
    submenu: [
      { name: 'Duplicate', iconName: 'lucide:copy', action: async () => handleDuplicate() },
      { name: 'Archive', iconName: 'lucide:archive', action: async () => handleArchive() },
    ]
  },
  {
    name: 'Delete',
    iconName: 'lucide:trash2',
    action: async () => handleDelete()
  }
]);

// Component-based (implement getContextMenuItems on any element)
class MyComponent extends DeesElement {
  getContextMenuItems() {
    return [
      { name: 'View Details', iconName: 'lucide:eye', action: async () => { ... } },
      { name: 'Edit', iconName: 'lucide:edit', action: async () => { ... } },
    ];
  }
}

An item that represents a choice rather than a command carries checked (and radioGroup for a set with exactly one checked member). Such a menu reserves a check column on every item, paints a check on the checked ones, and announces them as menuitemcheckbox/menuitemradio with aria-checked; a menu without choices is unchanged. The items are stateless — the host owns the state and passes the new checked values when it reopens or rebuilds the menu.

For menus belonging to an interactive control group such as a menubar, pass a third options argument: { ownerElement: trigger, interactionRoot: menubar }. The trigger determines dialog ownership and restored focus; the interaction root keeps related controls reachable while outside presses still dismiss. Ordinary context menus retain their pointer-blocking backdrop.

DeesPopover

Rich nonmodal content anchored in the browser's top layer. Set .anchor to the invoking element, .open to control visibility, and .label for the dialog's accessible name; place arbitrary content in the default slot. .placement accepts 'top' or 'bottom', while .showTail, .width, and .maxHeight control the surface. Positioning and viewport clamping are component-owned.

The default .dismissOnTab=${true} closes as normal Tab navigation leaves a compact informational popover. Set it to false for interactive content: focus can move inside, the panel closes after focus leaves both anchor and popover, and it does not trap focus. focusFirst() focuses [autofocus] or the first focusable descendant. close() and Escape restore the current anchor's focus; outside pointer and Tab dismissal preserve the destination focus. The bubbling, composed dees-popover-close event carries { reason }, where reason is 'explicit' | 'escape' | 'outside' | 'tab' | 'anchor-hidden'.

popover.anchor = trigger;
popover.label = 'Turn changes';
popover.dismissOnTab = false;
popover.open = true;
await popover.focusFirst();

DeesSpeechbubble

For compact plain-text hints, place the bubble immediately after its control (or set .reffedElement). .disabled suppresses it when labels are already visible. Hints open to the right after a short hover delay or immediately on keyboard focus, escape scroll clipping through the browser's popover layer, and dismiss on Escape, activation, scroll, resize, or disconnect. They use an opaque surface and do not intercept input.

<button aria-label="Services">…</button>
<dees-speechbubble hint text="Services" .disabled=${!collapsed}></dees-speechbubble>

Tooltip-style speech bubble component for contextual information.

// Programmatic usage
const bubble = await DeesSpeechbubble.createAndShow(
  referenceElement,
  'Helpful information about this feature'
);

DeesWindowlayer

Base overlay component used by modal dialogs and other overlay components.

const layer = await DeesWindowLayer.createAndShow({
  dimmed: false,
  ownerElement: invokingElement,
});

Navigation Components

DeesStepper

Guided form and background-work flows with a compact progress strip, shared action buttons, and a vertical transition between steps. Use it inline or open the same steps as a modal with DeesStepper.createAndShow({ steps, cancelable: true }).

  • IStep accepts title, optional description, content, menuOptions, allowBack, validationFunc, onReturnToStepFunc, and progressStep.
  • The last menu action is primary. A nested dees-form gates that action on its required fields and dispatches collected formData before the action runs. The required-fields hint fades beside the heading without changing its layout.
  • Async actions prevent duplicate activation and display failures in the current step. Progress failures expose Try again; retryStep() reruns validation. Keep external work abortable using the callback's AbortSignal.
  • Progress steps use dees-progressbar and advance after successful validation unless progressStep.autoAdvance is false. Use updateProgressStep(), appendProgressStepLine(), and resetProgressStep() to report work.
  • goNext() and goBack() preserve mounted form contents. Back honors allowBack: false. Inactive steps are inert. Transitions respect reduced motion and adjust to changing content and container sizes without snapping.
  • Overlay mode uses native dialog focus containment and restoration. Cancel, Escape, and backdrop clicks share a confirmation flow; cancelable: false disables those dismissal paths, however often Escape is pressed. A close the browser still forces, such as a system back gesture, ends the flow as a dismissal: the step's work is aborted, the stepper is removed and stepper-close fires. destroy() closes the flow; stepper-close fires when it disconnects. Disconnecting also aborts work and releases observers.
  • Overlays opened from a step (dropdown lists, menus, window layers) mount into the stepper's dialog without moving the step under them.
  • Inline steppers fill a bounded area (440px minimum height by default). Set a height appropriate to the composition; --dees-stepper-width controls the card's maximum width (520px by default).

The catalog demo combines setup fields, preferences, a review, background work, and completion. Retry example fails the first background attempt and preserves the choices for recovery. It also shows optional and required-completion overlays.

<dees-stepper
  style="height: 540px"
  .steps=${[
    {
      title: 'Account Setup',
      description: 'Choose the owner of your workspace.',
      content: html`<dees-form>...</dees-form>`,
      menuOptions: [{ name: 'Continue', action: async (stepper) => stepper?.goNext() }]
    },
    {
      title: 'Provision Workspace',
      content: html`<p>Preparing your environment...</p>`,
      progressStep: {
        label: 'Workspace setup',
        indeterminate: true,
        statusRows: 4,
        terminalLines: ['Allocating workspace']
      },
      validationFunc: async (stepper, _element, signal) => {
        stepper.updateProgressStep({ percentage: 35, statusText: 'Installing dependencies...' });
        stepper.appendProgressStepLine('Installing dependencies');
        if (signal?.aborted) return;
        stepper.updateProgressStep({ percentage: 100, indeterminate: false, statusText: 'Workspace ready.' });
      }
    }
  ]}
></dees-stepper>

DeesProgressbar

Progress indicator component for tracking completion status, with optional fixed-height status text or terminal-style recent activity output.

<dees-progressbar
  .percentage=${75}
  label="Uploading"
  statusText="Uploading thumbnails to edge cache..."
  .statusRows=${2}
></dees-progressbar>

<dees-progressbar
  label="Installing dependencies"
  .indeterminate=${true}
  .statusRows=${4}
  .terminalLines=${[
    'Resolving workspace packages',
    'Downloading tarballs',
    'Linking local binaries'
  ]}
></dees-progressbar>

Theming Components

DeesTheme

Theme provider component that wraps children and provides CSS custom properties for consistent theming.

// Basic usage — wrap your app
<dees-theme>
  <my-app></my-app>
</dees-theme>

// With custom overrides
<dees-theme
  .customColors=${{
    primary: '#007bff',
    success: '#28a745'
  }}
  .customSpacing=${{
    lg: '24px',
    xl: '32px'
  }}
>
  <my-section></my-section>
</dees-theme>

Key Features:

  • Provides CSS custom properties for colors, spacing, radius, shadows, and transitions
  • Can be nested for section-specific theming
  • Works with dark/light mode
  • Overrides cascade to all child components

DeesUpdater

Updater controller that opens a non-cancelable dees-stepper flow with a progress step and a ready step.

const updater = await DeesUpdater.createAndShow({
  currentVersion: '3.79.0',
  updatedVersion: '3.80.0',
  moreInfoUrl: 'https://code.foss.global/design.estate/dees-catalog',
  changelogUrl: 'https://code.foss.global/design.estate/dees-catalog/-/blob/main/changelog.md',
  successAction: 'reload',
  successDelayMs: 10000,
});

updater.updateProgress({
  percentage: 35,
  statusText: 'Downloading signed bundle...',
  terminalLines: ['Checking release manifest', 'Downloading signed bundle']
});

updater.appendProgressLine('Verifying checksum');
updater.updateProgress({ percentage: 72, statusText: 'Verifying checksum...' });

await updater.markUpdateReady();

After markUpdateReady(), the updater switches to a second countdown step with a determinate progress bar and runs the configured success action when the timer reaches zero.


Workspace / IDE Components 💻

A full-featured IDE workspace component suite for building browser-based code editors, terminal interfaces, and documentation viewers.

DeesWorkspace

An editor workspace with shared document tabs, file navigation, Terminal/Problems switching, terminal sessions, and a script/status bar. Supply an IExecutionEnvironment; the shell uses that environment and does not own its destruction. WebContainerEnvironment runs real commands in supported browsers; backend implementations use the same interface.

html`<dees-workspace
  workspaceName="My project"
  .executionEnvironment=${environment}
  .initializationPromise=${projectReady}
  initialFilePath="/src/index.ts"
  .fileTreeWidth=${250}
  .terminalHeight=${200}
></dees-workspace>`

initializationPromise optionally waits for your project to be mounted. initialFilePath opens a document after setup. Initialization errors remain visible in the shell. showFileTree and showTerminal control which panels are available; toolbar controls collapse the available panels. On narrow containers, Files opens over the editor and returns to the document on selection. Resizers support pointer dragging and arrow keys, Home, and End.

Use openFile(path, name?), revealPath(path), saveActiveFile(), saveAllFiles(), setFileTreeWidth(width), setTerminalHeight(height), and resetLayout() on the element. Save shortcuts apply only inside the workspace. Failed saves retain dirty state, and external-edit conflicts wait for an explicit choice. A file that cannot be read does not open and operationError names it with the environment's own reason — Could not open .env: … — so a host's refusal reaches the reader. revealPath(path) shows a path the host points at: a file opens in the editor, a directory is expanded and selected in the file tree, which opens if it was collapsed. It resolves with the IFileEntry shown, or with null once operationError says why nothing could be shown. The file tree's selection-change event (below) crosses the workspace, so a host listens for it on the workspace.

Backend environments. A workspace over a server's filesystem implements the same IExecutionEnvironment; three optional parts of it decide how far the workspace reaches:

  • spawn is optional. An environment without it runs no processes: the workspace runs no npmextra.json setup command, renders no terminal and no bottom bar (its script runner and package check both start processes), and its output panel holds Problems alone.
  • rename(from, to) and copy(from, to) are optional. With them the file tree renames, duplicates and pastes in one step, whatever a file holds; without them it copies text entry by entry and removes the source, which suits environments that hold text files only.
  • intelliSense bounds what TypeScript IntelliSense reads once the first file opens: { excludedDirectories?, maxProjectFiles?, packageTypes? }. The project crawl never enters node_modules or hidden (dot) directories, skips every directory named in excludedDirectories at any depth, and stops after maxProjectFiles sources (default 1000). packageTypes: false reads nothing from node_modules — no scan, no watcher, no loads for imported packages — and intelliSense = false turns IntelliSense off.

Monaco is loaded once per page through DeesServiceLibLoader, from jsdelivr by default. A host whose CSP forbids CDN scripts, or that must work offline, serves a copy of node_modules/monaco-editor/min/vs itself and calls DeesServiceLibLoader.getInstance().provideMonacoBaseUrl('/assets/monaco/min/vs') before the first editor mounts. Monaco's loader installs once per page, so once a load has reached it the URL cannot move, even after that load failed, and a different one throws. A load that fails is shown in the editor, naming the URL, and the next editor to mount tries again, fetching what failed: Monaco's loader keeps a module that failed as failed, so a failed editor.main calls reset() on the page's Monaco AMD loader. When the host installed that loader and shares it, the reset clears the host's module registry and loader configuration as well. A page that already runs another AMD loader, such as RequireJS, cannot host Monaco's, and the load says so.

The Workspace → DeesWorkspace demo composes a real, dependency-free Studio Notes project. Try Edit/Run/Focus, edit and save src/notes.js, then choose Scripts → test or preview. Sessions, output, and the filesystem use the same runtime; there is no automatic package installation or simulated shell. Hosting this demo requires the secure context and cross-origin isolation supported by WebContainers.

Document and terminal sessions reuse dees-appui-tabs; output and layout choices reuse dees-input-multitoggle. Tabs support appearance="sidebar" for flat navigation rows, --dees-tabs-font-size, and --dees-tabs-vertical-bg for embedded surfaces. Their item badges show unsaved documents and process exit status. Prefer these shared controls over locally recreated tabs or toggles.

Terminal sessions can be renamed with double-click, F2, or the Rename context menu. The label changes without restarting the process or clearing scrollback. Reusable tabs expose this through IMenuItem.onRename. Both sidebar lists use the shared --dees-color-sidebar-selection raised neutral fill with primary text.

The file explorer's New file and New folder dialogs accept Enter, reject existing names, and show write errors before closing. A created file opens in the editor. When Files is collapsed, the document strip's New file (+) action uses the same creation flow and keeps Files collapsed. The explorer also exposes createNewFile(parentPath?) and createNewFolder(parentPath?), defaulting to its rootPath.

DeesWorkspaceMonaco

Monaco Editor integration for code editing with full IntelliSense, syntax highlighting, and language support. Monaco comes from DeesServiceLibLoader.loadMonaco(), from the CDN or from the base URL a host provided (see DeesWorkspace); when it cannot load, the editor shows why in its place. editorDeferred resolves once the editor exists, and rejects with the reason when Monaco cannot load or the element is removed while it loads; getEditor() resolves with the editor, or with null when there is none.

<dees-workspace-monaco
  .content=${code}
  .language=${'typescript'}
  .filePath=${'/src/index.ts'}
  @content-change=${(event) => saveDraft(event.detail)}
></dees-workspace-monaco>

DeesWorkspaceDiffEditor

Side-by-side diff editor powered by Monaco for comparing file versions.

<dees-workspace-diff-editor
  .originalContent=${originalCode}
  .modifiedContent=${modifiedCode}
  .language=${'typescript'}
></dees-workspace-diff-editor>

DeesWorkspaceFiletree

File tree navigation component with expand/collapse, file icons, and selection.

<dees-workspace-filetree
  .executionEnvironment=${environment}
  .rootPath=${'/'}
  .selectedPath=${'/src/index.ts'}
  @file-select=${handleFileSelect}
></dees-workspace-filetree>

Selection reveals parent directories. Arrow keys navigate the tree; Enter opens a file, and Shift+F10 opens its actions. file-select carries { path, name } when a file is chosen to open; selection-change carries the chosen IFileEntry ({ type, name, path }) for files and directories alike, so a host can act on what the reader picked. revealPath(path) expands every directory above a path, and a directory itself, then selects and scrolls to it; it resolves with the entry, or with null when the tree holds no such path. refresh() — and the environment's watcher, which calls it — reloads the tree with every folder the reader had open, and keeps the current tree on screen while it reads. Rename, Duplicate and Paste use the environment's rename and copy when it has them; a rename, duplicate, paste or delete that fails is named in the tree (Could not duplicate .git: …). A tree belongs to one environment: handing it another one starts from that one's root.

DeesWorkspaceTerminal

Terminal sessions for a workspace: a tab strip, and per tab one DeesTerminalView wired to a process of the host's IExecutionEnvironment. The panel drives no xterm API of its own — the view is the surface, this is the session bookkeeping and the process plumbing around it. Each view is created with its tab and stays mounted for the tab's whole life, stacked in the same box as the others, so switching tabs only changes which one is visible: a session keeps its scrollback and a background session keeps tracking the panel size instead of reflowing when the reader returns to it. Sessions follow the catalog's one terminal theme because the view does.

<dees-workspace-terminal .executionEnvironment=${environment}></dees-workspace-terminal>

executionEnvironment is where sessions run; without one the panel says so and opens nothing, because there is no shell to guess. A connected panel opens one session on the environment's own getShellCommand(), and setupCommand is written to that first session's stdin as soon as its shell is running — the PTY buffers it until the shell reads it, so no prompt is waited for. createShellTab(label?) opens another one and resolves with its id, or with null when the environment offers no shell or the element is not connected; createProcessTab(options) opens a session for one command and resolves with its id once that command is running or failed to start, or with null when the element is not connected, because a session belongs to a connection. selectTab, closeTab, getTabs, getActiveTab, writeToTab and sendInputToTab address sessions by id, setEnvironmentVariables writes /source.env, and showActionbar puts a decision in the panel's own bar (it resolves with null while the panel has not rendered that bar yet). The events are unchanged: tab-created, tab-switched, tab-closed and process-complete.

A session lives inside one connection. The DOM cannot tell a host that moves this element from a host that throws it away, while a process of an execution environment outlives the DOM, so disconnecting ends every session — processes killed, streams released, tabs dropped — and reconnecting opens a fresh default session instead of leaving an empty panel behind. Nothing closes by itself: a session whose process ended keeps its tab, its exit code as a badge and everything on its surface until the reader closes it or runs the command again from the bar, which carries no timeout. A session that ends because its streams broke rather than because the process exited keeps the same tab, and the process behind it is killed as the tab lets go of it — after that nothing could reach it again. ITerminalTab holds view and the stdin / outputPipe the panel keeps for a running process; terminal, fitAddon, inputWriter, terminalDataDisposable and outputPipeAbortController are gone with the xterm code, as are TerminalTabManager, waitForPrompt, environmentPromise, handleResize and the unread environmentVariables property.

DeesWorkspaceTerminalPreview

The output of one command, read-only: a dees-tile with the command in its header and one DeesTerminalView as the surface. A workspace shows it while its runtime is still starting, before there is a session to attach to. command names it, lines carries the output, addLine(line) appends one line and clear() drops the whole transcript. frameless drops the tile's frame when the host owns it, as dees-workspace does for its output panel.

lines is the transcript in full, not a queue: lines appended to what the surface already shows are written on their own, and any other change — a line that changed, a shorter array, a replaced one — is painted from the start, so resetting the transcript resets the screen. The surface is rebuilt on every connection and painted from lines again, so a preview moved between containers shows the same output. The preview drives no xterm API of its own; its private terminal, fit addon, resize observer and theme subscription went with the view it now composes.

DeesWorkspaceMarkdown

Markdown editor with live preview.

DeesWorkspaceMarkdownoutlet

Read-only markdown renderer for documentation display.

DeesWorkspaceBottombar

IDE-style bottom status bar for the workspace.


Kanban Components

DeesKanban

dees-kanban assembles the Kanban family: configurable workflow columns, task cards, epic filtering, search, expandable subtask progress, and ticket detail in dees-modal. The Kanban catalog demo includes a working in-memory provider.

import '@design.estate/dees-catalog';
import type { IKanbanProvider, IKanbanTicket } from '@design.estate/dees-catalog';

const board = document.createElement('dees-kanban');
board.heading = 'Product workspace';
board.columns = [
  { id: 'todo', title: 'To do' },
  { id: 'doing', title: 'In progress' },
  { id: 'done', title: 'Done', done: true },
];
board.tickets = [
  { id: 'epic-1', key: 'APP-1', kind: 'epic', title: 'Team workspace', columnId: 'doing', order: 0 },
  { id: 'task-1', key: 'APP-2', kind: 'task', title: 'Ticket search', parentId: 'epic-1', columnId: 'todo', order: 0 },
  { id: 'subtask-1', key: 'APP-3', kind: 'subtask', title: 'Keyboard selection', parentId: 'task-1', columnId: 'todo', order: 0 },
] satisfies IKanbanTicket[];
document.body.append(board);

Set .provider to an IKanbanProvider backed by the application's authorized API. Omitted mutation callbacks make those actions read-only. The catalog does not store application data or implement authorization. Ticket permissions can disable individual edit, move, comment and create-child actions; the server must enforce the same permissions.

Provider callback Input Result
getTicket (id, signal) Full IKanbanTicket, including tickets outside the current board
searchTickets (query, signal) Tickets matching key or title; omitted uses the supplied board tickets
loadComments (ticketId, signal) IKanbanComment[] for that ticket
addComment { ticketId, body } Saved IKanbanComment with stable ID, author and ISO timestamp
updateTicket { ticketId, changes } Saved IKanbanTicket; nullable assignee/parent clears that field
moveTicket { ticketId, columnId, beforeTicketId? } All IKanbanTicket records whose order/column changed
createTicket { kind, title, columnId, parentId? } Created IKanbanTicket

Provider reads receive an AbortSignal; obsolete reads cannot overwrite a newer selection. Reject mutations with an Error to show the failure and preserve the draft. Successful callbacks supply authoritative records to the component. Moves are awaited before changing .tickets or emitting kanban-change; the provider must assign ordering atomically. Omitting beforeTicketId appends. Mouse and pen dragging shows a card-sized drop slot, animates neighboring cards, and lands the preview into position. A render-only placement stays visible while saving; slow saves show a status, and failures animate back to the saved position. Escape or dropping outside the board cancels. Edge scrolling works inside scroll containers, and reduced motion removes lift, rearrangement and landing animation. Touch retains normal scrolling. The keyboard-accessible Move menu supports both within-column ordering and column selection.

Hierarchy is independent of workflow: tasks optionally belong to epics, and subtasks belong to tasks. Each ticket has its own columnId; moving a parent does not move its children. Column array order controls board order, and done identifies completed subtasks. Configure columns by supplying a new .columns array. Supply .people for the assignee picker, .loading for loading feedback, and .error for a host-side board loading failure. Supply new .tickets arrays for external updates.

Public methods and events:

  • openTicket(id) opens a ticket. Linked-ticket navigation has Back history and preserves comment and field drafts per ticket for the board instance's lifetime.
  • moveTicket(input) invokes the same permission-aware move path as the UI.
  • refreshComments(ticketId?) reloads discussion; setComments(ticketId, comments) accepts authoritative realtime snapshots without introducing transport coupling.
  • kanban-change: detail.tickets contains saved/created/moved records to merge into the host's state.
  • kanban-ticket-opened: detail.ticketId identifies the selected ticket.
  • kanban-comment-saved: detail.comment contains the saved comment.
  • kanban-error: detail.error and detail.message report failed operations.

DeesKanbanColumn

dees-kanban-column accepts .column, .tickets, .allTickets, .columns, .movable, .canCreate, .pendingTicketId and .draggingTicketId. It composes cards and forwards their open, create and Move menu requests. Standalone columns retain native dragging and emit kanban-ticket-move with the insertion target. Use it through dees-kanban for provider handling and board-local drag ownership.

DeesKanbanCard

dees-kanban-card accepts .ticket, .epic, .subtasks, .columns, .movable and .pending. It displays priority, assignee, labels and completion counts. kanban-ticket-open identifies a requested ticket, and kanban-move-menu carries the ticket ID and anchor element. Secondary cards remain usable independently.

DeesKanbanTicketDetail

dees-kanban-ticket-detail is the reusable detail content, composed into the board's modal. It displays the parent, child tickets, description, fields and discussion. It accepts .ticket, .tickets, .columns, .people, .provider, .draft, .comments and .commentDraft. It emits kanban-ticket-save and kanban-ticket-draft intents; dees-kanban owns persistence, navigation and draft caching. finishEditing() returns to the saved view after a successful update. The ticket key is visible by default. Set .showTicketKey = false only when the containing surface already displays it, as the board's modal heading does.

DeesKanbanDiscussion

dees-kanban-discussion renders chronological comments and a reference composer. Provide .ticketId, .comments, .draft, .searchTickets, .addComment, .loading, .error and .readOnly. submit() awaits the save callback; errors leave the draft intact. Ctrl/⌘ Enter submits; Enter inserts a newline. Standalone consumers handle kanban-comment-draft (ticketId, body), kanban-comment-added (comment, submittedBody), kanban-ticket-open and kanban-comments-refresh. The assembled board handles these events itself.

DeesInputReference

dees-input-reference is a reusable editor for chat and discussion. .value is TReferenceContent, an array of { type: 'text', text } and { type: 'reference', id, label }. Labels are display snapshots; IDs remain the lookup keys when a ticket is renamed. Newlines are preserved as text. Content is rendered safely as text and reference nodes, without accepting arbitrary HTML.

const composer = document.createElement('dees-input-reference');
composer.documentKey = 'ticket-42';
composer.value = [
  { type: 'text', text: 'Related to ' },
  { type: 'reference', id: 'ticket-17', label: '#APP-17 Keyboard navigation' },
];
composer.searchReferences = async (query, signal) => {
  // Replace with your authorized search API; honor signal when making requests.
  return [{ id: 'ticket-17', label: '#APP-17 Keyboard navigation' }]
    .filter(item => item.label.toLowerCase().includes(query.toLowerCase()));
};
document.body.append(composer);
await composer.ready();
composer.focus();

Typing #_ opens a dropdown at the caret and immediately focuses its search. Arrow keys navigate, Enter inserts the selected atomic reference plus a space, and Escape dismisses while preserving typed text. Selection returns focus to the editor. The picker belongs to its containing modal, handles viewport edges, and cancels obsolete searches. References participate in editor undo and clipboard operations. Changing .documentKey resets history so undo cannot cross tickets.

Set .trigger, .label, .placeholder, .searchLabel and .disabled as needed. For an integrated composer, style ::part(editor) and set --dees-reference-min-height (default 84px) or --dees-reference-font-size (default var(--dees-font-body-size)). Keep a visible focus indicator when overriding the editor's border or outline; the Kanban discussion applies it to the surrounding composer.

reference-input emits { value }, reference-open emits { id, label }, and reference-submit requests submission on Ctrl/⌘ Enter. referenceText(value) returns a plain-text representation. No callback means no reference picker.

The shared DeesInputDropdownPopup also accepts .searchOptions(query, signal) for asynchronous searches and .searchLabel for accessible search labeling. Remote results are authoritative; failures and loading states replace stale options. Existing local dropdown filtering is unchanged.

Agentic Chat Components 🤖

The 00group-harness family renders what an agent harness produces — streaming messages, reasoning, tool calls, MCP content, permission prompts — behind one normalized data contract (IHarnessMessage, IHarnessToolCall, IHarnessPermissionRequest, IHarnessSessionMeta, IHarnessStatus). Consumers adapt their wire format to these interfaces and feed the components; all events are harness-* CustomEvents (bubbles + composed).

DeesHarnessChat

The assembled chat: optional toolbar (heading, usage chip, host-supplied actions, and session-details control), streaming message list with inline permission cards, transcript status line, and docked composer.

const chat = document.querySelector('dees-harness-chat');
chat.messages = adaptedMessages;          // IHarnessMessage[]
chat.permissions = pendingPermissions;    // IHarnessPermissionRequest[]
chat.status = { type: 'busy', message: 'Responding…' };
chat.account = 'connection-a';
chat.accountOptions = [
  { label: 'Personal · alex@example.com', value: 'connection-a' },
  { label: 'Team · alex@example.com', value: 'connection-b' },
];
chat.modelOptions = ['gpt-5.5', 'o4-mini'];
chat.effortOptions = ['high', 'medium', 'low'];
chat.markdownWhileStreaming = false; // optional: plain streams, one final parse at message end
chat.toolbarActions = [
  {
    id: 'refresh',
    label: 'Refresh conversation',
    iconName: 'lucide:RefreshCw',
    tooltip: 'Refresh conversation',
    action: () => refreshConversation(),
  },
];
const commandSuggestions = [
  { label: 'Explain code', value: 'Explain the selected code' },
  { label: 'Review diff', value: 'Review the current diff', description: 'Focus on bugs and regressions.' },
];
chat.suggestions = commandSuggestions;

chat.addEventListener('harness-input', (event) => {
  const value = event.detail.value;
  chat.suggestions = value.startsWith('/')
    ? commandSuggestions.filter((suggestion) => suggestion.label.toLowerCase().includes(value.slice(1).toLowerCase()))
    : [];
});

chat.addEventListener('harness-send', (event) => {
  const { text, attachments, account, model, reasoningEffort } = event.detail;
});
chat.addEventListener('harness-permission-response', (event) => {
  const { requestId, response, remember } = event.detail; // 'once' | 'always' | 'reject'
});

// streaming fast path — mutates the same message object, re-renders only that element
chat.applyDelta({ type: 'text', messageId: 'm1', delta: 'chunk' });
chat.applyDelta({ type: 'message-end', messageId: 'm1', usage: { totalTokens: 1200 } });

// one-level child-session streaming uses the parent tool-message id
chat.applyDelta({
  type: 'text',
  parentMessageId: 'task-message-1',
  messageId: 'child-message-1',
  delta: 'child chunk',
});

// authoritative same-object corrections use IDs
chat.messages.find((message) => message.id === 'm1')!.text = 'corrected text';
chat.refreshMessages(['m1']);

// structural mutations omit IDs; an empty ID list is a no-op
chat.messages.push({ id: 'm2', role: 'assistant', text: 'next message', createdAt: Date.now() });
chat.refreshMessages();
chat.refreshMessages([]);

Key props: messages, permissions, questions, status, transcriptKey, hasEarlierMessages/loadingEarlier, usage, todos/todosAuthoritative/showTodosPanel, sessionMetrics, scratchpad/scratchpadBusy/scratchpadError, sessionIntelligenceEnabled/intelligenceExchanges/intelligenceBusy/intelligenceError/intelligenceAvailabilityStatus/intelligenceUnavailableReason/intelligenceHeading, showSessionSidebar, heading, subheading, toolbarActions, showToolbar, busy, queuedCount, steeringEnabled, disabled, inputLocked, abortEnabled, abortDisabledReason, suggestions, account/accountOptions, model/modelOptions, reasoningEffort/effortOptions, mode/modeOptions, markdownWhileStreaming, toolRegistry. toolbarActions accepts IHarnessChatToolbarAction[]; each action has id, human-readable label, iconName, action, and optional tooltip/disabled, and renders immediately before the session-details toggle. disabled: true makes only that toolbar action inert and prevents its callback without changing the chat-level disabled state. markdownWhileStreaming defaults to true; setting it to false keeps active outer and nested subtask markdown as plain text, then performs the final markdown parse when message-end arrives. suggestions is forwarded unchanged to the controlled composer. Account options use human-readable label values and opaque stable value identifiers. The status line renders inside the transcript column (it never pushes between the session panel and the composer), and both the toolbar and the docked composer cast a subtle shadow over the scrolling messages.

Light-DOM content assigned to slot="composer-status" renders between the transcript and composer, outside the transcript scroller. inputLocked keeps the controlled draft readable, focusable, and copyable while blocking every mutation control and canceling queued or in-flight attachment ingestion. It does not disable Stop. While busy, Stop remains visible; abortEnabled=${false} disables it, suppresses harness-abort, and exposes abortDisabledReason as its explanation.

After mutating canonical message objects in place, call refreshMessages(ids?). Pass IDs for targeted content corrections, omit them after structural mutations, and pass an empty array for a no-op. DeesHarnessChat.refreshMessages(ids?) uses the list semantics and, except for [], also recomputes transcript-derived sidebar state. The list and individual message components expose the corresponding refreshMessages(ids?) and refreshMessage() methods.

A session side panel shows Tasks, Tools & Subagents, Tokens, Session Scratchpad, and Session Intelligence next to the transcript. Its sections form a single-open accordion: opening one collapses the others, clicking the open heading collapses all, and each expanded section owns its scrolling while every heading remains visible. Collapsed sections retain their region wrapper but omit the content subtree until expansion; sidebar-owned scratchpad and intelligence drafts survive that lazy remount. Tools & Subagents separates active calls from terminal history and derives both directly from top-level tool messages still present in the host's retained transcript window; evicted calls are not cached. Every activity row opens DeesHarnessToolFullscreen over the canonical call and follows in-place updates through refreshMessages(). The inspector closes when the call is evicted, the session changes, or the sidebar disconnects, then restores the row's focus. Subagent drill-in bridges the existing harness-subtask-open event once. Long activity labels use an ellipsis while descriptions use the 40px right-edge fade and reveal behavior. Session Intelligence keeps its exchange history scrollable above a docked, chat-style question composer. Set .todos explicitly, or leave it empty and the chat derives tasks from the newest todo tool call in messages; showTodosPanel={false} hides only that section. Unknown token metrics remain absent. The toolbar controls the session panel; host-owned navigation controls belong outside the chat component. In narrow containers the session panel becomes an accessible overlay; when the toolbar is disabled, an inline Session details control keeps it reachable. showSessionSidebar={false} disables the complete panel. Methods: applyDelta(), refreshMessages(ids?), focusComposer(), clearComposer(), scrollToBottom(), openSessionSidebar(section?), openModelSelector(), openAttachmentPicker(), and focusPendingPermission(). Section names use the exported THarnessSessionSidebarSection union. Capability launchers return false when their target is unavailable or locked, so command palettes can remain outside the shadow DOM. Events (also from children, all composed): harness-send, harness-steer, harness-abort, harness-input, harness-attachments-change, harness-attachment-error, harness-account-change, harness-model-change, harness-mode-change, harness-permission-response, harness-question-response, harness-subtask-open, harness-scratchpad-save, harness-session-intelligence-ask.

Set .todosAuthoritative=${true} when an empty host-owned task list must suppress transcript-derived fallback. A changed transcriptKey also clears the previous session's unsent Session Intelligence question while preserving the existing scratchpad revision handling.

Session Intelligence availability is host-controlled through intelligenceAvailabilityStatus (THarnessIntelligenceAvailabilityStatus, exported as 'checking' | 'available' | 'unavailable'), intelligenceUnavailableReason, and intelligenceHeading. Their compatibility defaults are available, an empty reason, and Session Intelligence. A blank heading also falls back to Session Intelligence, and unavailable state without a reason renders <heading> is unavailable. sessionIntelligenceEnabled remains the visibility switch. Checking and unavailable states keep exchange history visible and disable only the intelligence composer, while the main chat composer remains usable. Availability copy is rendered as text in a polite live status region; intelligenceError remains the separate operation-error channel.

History paging adds the composed child event harness-load-earlier.

DeesHarnessMessageList

Scrollable streaming-safe log. Up to 40 canonical entries (messages + permissions + questions) keep keyed full-timeline DOM; larger inputs activate a measured variable-height window with 800px overscan and return to full rendering when they shrink. Rows outside that overscanned window are unmounted, except focused rows, both endpoints of an active selection, and fullscreen-owned cards, which remain mounted until the interaction ends; threshold-mode changes are likewise deferred while protected DOM is active. applyDelta() targets a mounted message or mutates its offscreen backing model. Markdown output, disclosures, reasoning expansion, question drafts, permission choices, diff layout, terminal scroll state, and live nested-transcript state restore from retained data after an actual eviction. Hosts may pass a stable mutable IHarnessTranscriptViewState through viewState; it must contain messages, permissions, and questions maps. The list creates entries for current canonical IDs, deletes records for removed IDs, and session-keys and prunes live nested-subtask records. Without that prop, equivalent state remains list-local. Set transcriptKey to the stable identity of the displayed transcript so reused components reset scroll-local state between chats. When hasEarlierMessages is true, an intentional upward gesture reaching the top emits one bubbling, composed harness-load-earlier event. Set loadingEarlier while handling it; the next true to false transition rearms the event. Initial rendering, anchor restoration, and other component-owned scrolling never emit it.

refreshMessages(ids?) reconciles in-place host mutations without replacing canonical objects. With IDs, it refreshes only matching message rows that are currently mounted; matching offscreen rows remain unmounted and use the mutated canonical objects when later mounted. With IDs omitted, it rebuilds structural transcript state and refreshes all mounted rows. Either way a message whose pending changed moves between the queue and the transcript. [] is a no-op. focusPendingPermission() scrolls to the first unanswered permission and focuses its first response action, returning false when no request is pending.

Producers with authoritative source chronology can set IHarnessMessage.order to { messageIndex, partIndex }; explicitly ordered messages retain that relative order while timestamps continue to place legacy messages and ancillary cards. Every entry is one row of a single column at its chronological position — there are no tool groups, grids or columns, and a status change never moves a row. Rows are held to a reading measure — --dees-harness-content-width, default 820px, capped at the available width — and a message that is nothing but a tool call renders as one inline row rather than a card. Only the row a reader opens takes the full pane, on a max-width transition running on --dees-transition-default with --dees-ease-standard, so a diff or command output gets the room while its neighbours keep the measure; the opened row also gains a --dees-spacing-sm margin so it steps out of the run around it, and consecutive collapsed tool rows pull together into one list. A row that mounts open is already at its width and does not animate into it, and prefers-reduced-motion: reduce drops the width and margin transitions. Every row that arrives live at the tail of an already-rendered transcript — a message of any role, a tool row, a permission or question card, one that lands above a request still pinned to the end included — takes its place in two steps on the same timing: the list opens the row's space from nothing, and only then does the row blend into it, so what sits below and the followed bottom travel rather than jump, in the flowed and the virtualized transcript alike; rows arriving in one update open together. The space step needs keyword size interpolation — where a browser lacks it the row is simply there and only blends in. Nothing enters on the first render, a new transcriptKey, earlier history, a re-render, or a row coming back from eviction; reduced motion means the row is simply there. A host that gives a message a new id, when its stream ends say, makes it a new row, which arrives again. While a row opens its space the list sets entering on its dees-harness-message, so a block that appears in the message meanwhile comes with the row rather than arriving on its own.

A role: 'user' message with pending: true has not been taken up by the session yet, and waits in a queue under the working indicator instead of in the transcript: a thin rule either side of a centred "Queued" label in caption size and muted text, then the waiting messages in the order they were sent, each in its ordinary bubble, all on the status line's measure. Without a status line the queue sits directly under the transcript, still under its divider. It is never virtualized: it is the bottom that auto-follow keeps in view. A message joining the queue arrives like a row, and the divider arrives with the first. When the host clears pending on the same id — by passing the message again without it, or by mutating the shared object and calling refreshMessages([id]) — the message glides from its slot to its place in the transcript in one motion on --dees-transition-slow with --dees-ease-standard, while the working indicator, the divider and the rest of the queue travel with it; what waited below it stays where it is, and the divider closes its space and blends out with the last message to leave. Its place is chronological like any message's, by order or createdAt, so a host that delivers queued prompts when the running turn ends stamps them then, and they land at the tail rather than among that turn's output. Taking a message up keeps a pinned transcript pinned and an unpinned reader's anchor still, virtualized or not. Once the message has landed it sweeps its acceptance scan, never over the glide (see DeesHarnessMessage). A message taken up while it still arrives in the queue finishes arriving first. Under reduced motion the message is simply in its place, with neither glide nor sweep; a browser without keyword size interpolation places it at once and sweeps; where the list is not rendered — hidden, or skipped by content-visibility — or cannot tell (no Element.checkVisibility), it is simply in its place. A message the host removes from the queue just goes. The composer's queuedCount stays host-driven. Subagent rows have no body at all: activating one opens its thread in the fullscreen view, exactly like opening it from the sidebar, and the transcript stays where it was. The docked composer keeps its full-bleed bar and insets its content to the same measure, so the prompt lines up with the messages above it. The status line under the transcript sits on that measure too, so the working indicator starts where the messages start rather than at the pane edge. Set the custom property on the chat or the list to widen or disable the column; it is read when rows are measured, so change it at mount rather than while a virtualized transcript is scrolled. Nothing opens or closes on its own: a reader's disclosure is kept in the retained view state and survives status changes, eviction and remounting. Auto-follow responds to streamed growth but does not repeatedly repin while a disclosure shrinks. Permission requests and questions behave as before. Props: messages, permissions/permissionsBusy, questions, status, viewState, transcriptKey, hasEarlierMessages, loadingEarlier, autoFollow, hideScrollbar, fadeScrollEdges, showRoles, showTimestamps, markdownWhileStreaming (default true), emptyTitle/emptyText (+ slot="empty"). Native browser find and selection cover the mounted window; hosts that require complete-history search or copy should operate on their canonical messages, permissions, and questions models.

DeesHarnessMessage

One message: role accent, markdown (assistant default, message.markdown overrides), attachments grid, reasoning parts, streaming cursor, usage/error footer. Markdown renders through domtools.plugins.smartmarkdown (GFM, sanitized). markdownWhileStreaming defaults to true, with throttled ≥150 ms re-parses while streaming; set it to false to retain plain text during the stream and parse once when the message ends. Fenced code is highlighted lazily after the final parse. Streaming methods: appendTextDelta(), appendReasoningDelta(), endReasoning(), endStream(usage?, error?). A message that carries nothing but a tool call renders the bare row instead of the message frame, forwards showTimestamp to it, and reserves one line while virtualized when the transcript marks it with data-tool-row. A role: 'user' message carries pending: true while the session has not taken it up yet — waiting in the host's queue behind a running turn, or sent and not yet confirmed. The transcript list shows such a message in its queue (see DeesHarnessMessageList); the element renders it like any other user message. playAcceptanceSweep() sweeps a scan across a user bubble once to mark that the session took it up: the former tool-card outcome scan in --dees-color-accent-primary, a band moving on transform alone, clipped to the bubble's rounded box, with nothing laid out anew and nothing clipped at rest. The transcript list calls it once a message it moved out of the queue has landed; a host that shows a message on its own calls it when the session takes the prompt up. It does nothing for another role or under prefers-reduced-motion: reduce, a sweep already playing plays on, and a new id ends it. A sweep that starts where nobody can see it — a row content-visibility skips, a hidden list, a background tab — still ends on schedule and never plays late. A block that appears in a message already shown — the thinking block as reasoning starts, the reply's text after it, attachments, a tool call beside text, the error and the usage footer as the stream ends — opens its space and then blends in, exactly like a transcript row, and takes no space on its first frame; later reasoning parts and streamed text grow the block they belong to instead. Nothing arrives on a first render, while entering is set (the list sets it while the message's row opens its space, so a block comes with the row), under reduced motion, or where the message is not rendered, and an engine without Element.checkVisibility shows the blocks without motion. Subtask drill-in emits the composed harness-subtask-open event with { sessionId, call }.

refreshMessage() reconciles direct same-object host mutations across text, reasoning, and tool content.

DeesHarnessReasoning

"Thinking" disclosure for one IHarnessReasoningPart. Nothing opens on its own: a live trace keeps streaming into the collapsed preview line until a reader expands it, and their choice wins afterwards. Shows duration once finished.

DeesHarnessToolCard

One tool call as one ~28px row of the transcript: a 2px status rail on the left edge, a 14px glyph, the label, MCP badges, the inline detail (the request, plus the collapsed outcome), and a meta column carrying the diff stat, the duration, a failure word and — with showTimestamp — the clock time. Kind resolution goes through DeesHarnessToolRegistry (see below); bodies for the seeded kinds — terminal, file-read, file-write (renders a before/after diff via dees-dataview-codebox when the input carries previous content), file-delete, dir-list, browser (inline screenshots), http, json, search, subtask, project-task, todo, mcp, unknown — live in renderers.ts. File-write rows carry their exact +N −M line stat while collapsed, from the descriptor's diffStats hook; the count is taken once per call object and omitted entirely when the input does not allow an exact one. File-write cards retain the user's inline/split choice while the same call updates. Numeric patch hunks preserve their source line numbers; patches and replacement snippets without coordinates hide gutters rather than presenting fabricated positions. A producer-supplied IHarnessToolCall.title leads the detail when present — a harness's own one-line description of the call, such as Read SKILL.md for a shell invocation — and the descriptor-derived line stays reachable as the hover tooltip; a blank title falls back to the derived line. Overflowing details use a 40px right-edge fade and reveal the full line on hover plus once after a two-second initial delay. Nothing opens by itself: the disclosure is expanded ?? viewState.toolExpanded ?? false, so a running, failed or stopped call states itself on its collapsed row and only a reader's click opens the body, whatever the status does afterwards. Every change of that disclosure emits the bubbling, composed harness-tool-disclosure with IHarnessToolDisclosureDetail — { open } — because a card's disclosure is not a property its host can observe; the message list listens for it to widen only the row the reader opened. The body grows on --dees-transition-default with --dees-ease-standard and stops under prefers-reduced-motion: reduce; the transcript row around it takes the pane on the same timing. Initially closed bodies mount lazily on first expansion, then retain the same DOM and become inert plus aria-hidden while closed. Long outputs truncate at 6,000 chars with a "Show all" expander. A producer that already cut the payload to a live transfer budget declares it out of band through IHarnessToolCall.outputTruncated / errorTextTruncated: output / errorText is then a marker-free prefix of the real text, and the open body discloses it in words directly after the payload ("Live preview — the complete output arrives with the transcript.", naming the error or both when those are bounded). That notice sits outside the body's own clamp, so "Show all" lifts the 6,000-char limit without retracting the producer's bound, and a collapsed row never carries it. A truthful non-zero numeric exit renders neutral Completed plus an exit N token on the row, and a failed or stopped call spells failed or stopped out there, so the rail colour is never the only carrier of the outcome; a generic failure without a reported process exit stays Error and the component never invents a code. A collapsed terminal shows a one-line outcome (successful exit 0 when reported, line count or no output, and the last meaningful line) inline after the command; descriptors supply that line through summary. Terminal output sits on the catalog's terminal surface — the same --dees-color-terminal-* tokens an xterm surface resolves, so a shell card is a dark block in a dark app and a light one in a bright app — and follows the live tail after reaching the body cap. Scrolling upward detaches the tail; leaving the row returns to the bottom after five seconds, while re-entering cancels that pending return. Terminal calls may provide exitCode separately when output is one combined process stream; the renderer retains support for { stdout, stderr, exitCode } output objects. Bodies taller than bodyCapPx (default 360) scroll in place and offer the fullscreen view.

After mutating a canonical tool call in place, call refreshCall(). It refreshes the mounted card, any mounted nested subtask transcript, and any open fullscreen card while retaining the same canonical call and nested-message object references; it does not clone or replace them.

Subtask calls may carry subtask: IHarnessSubtaskStream. A subagent row has no body under it: its header announces aria-haspopup="dialog" and activating it opens the child thread in dees-harness-tool-fullscreen, the same view the session sidebar opens, so a subagent never unfolds a transcript inside the transcript. With a ready projection the modal is the child's chat and nothing else: a dees-harness-message-list under the binding DeesHarnessChat gives its own transcript — messages, permissions, questions, status, subtask.sessionId as the transcriptKey — filling the panel height, with no brief or result panel beside it and no descriptor body above it. The brief is a message of that chat: the card prepends call.input.prompt ?? call.input.description as a synthesized user message (id <call.id>:brief, timestamped at startedAt) unless the projection already begins with a user message, in which case the host has sent the brief into the child session itself and nothing is added.

A thread whose child has finished opens cut down to what the child was asked and what it answered: the brief as a message, one DeesHarnessThreadCut standing for everything between, the result — the last assistant message carrying text — as a message, and — only for a child that did not end cleanly — the transcript's own status line after it, so a failed child never reads as a clean answer. A click on the cut blends the whole chat in on --dees-transition-default with --dees-ease-standard, still under prefers-reduced-motion: reduce, and is remembered as IHarnessMessageViewState.subtaskThreadExpanded, so reopening the modal returns to what the reader left; that flag is dropped with the projection and with a new child session, exactly like the nested transcript state. Nothing cuts itself: a thread with no brief to open on, no result to end on, or nothing in between is simply the whole chat, and a thread that was already being read while the child worked stays that chat when the child finishes — the cut form is what a finished thread opens as, never something that closes over a reader in it.

truncated projections disclose the omitted earlier messages in a notice above the thread. Loading and unavailable projections say so in the same place and keep the Open subagent chat action, as does a call with no projection at all, which shows the Agent Brief and Result panels — and a descriptor's own renderBody — instead. subtask.sessionId is authoritative for drill-in when a projection exists; calls without a projection continue to use the existing childSessionId field. Child deltas reach the open thread wherever it is: the row routes them to the modal's nested list, and to the backing projection while the modal is closed, so reopening shows the child as it is now. markdownWhileStreaming and the row's retained viewState travel into the modal, so the child transcript follows the host's parse policy and comes back as the reader left it; the nested state is session-keyed and dropped when the projection goes. Closing with Escape, the backdrop or the minimize button scrolls the row back into view and returns focus to it. The host remains responsible for hydration, transfer limits, authorization, gap recovery, and updating the shared objects.

delegateMessage.toolCall = {
  id: 'delegate-call-1',
  name: 'delegate',
  status: 'running',
  subtask: {
    sessionId: 'child-session-1',
    previewState: 'ready',
    status: { type: 'busy' },
    messages: childMessages,
    permissions: childPermissions,
    questions: childQuestions,
  },
};
import { DeesHarnessToolRegistry } from '@design.estate/dees-catalog';

DeesHarnessToolRegistry.default.register({
  kind: 'deploy',
  names: ['deploy_service'],
  label: 'Deploy',
  icon: 'lucide:Rocket',
  subtitle: (call) => String((call.input as any)?.service ?? ''),
  renderBody: (call, helpers) => helpers.keyValues([['service', String((call.input as any)?.service)]]),
});

Custom tool renderers receive IHarnessToolRenderHelpers. Its diffBlock(before, after, options) helper accepts language, filename, and view, plus unifiedDiff for a positioned patch and showLineNumbers to suppress untrustworthy gutters. The default view is 'auto': inline below 640px of codebox width and split at 640px or wider. An explicit helper view remains fixed. User choices override the helper default and retain the existing transcript view-state persistence.

server__tool names resolve automatically to the MCP kind with server + annotation badges (readOnlyHint → success, destructiveHint → error, openWorldHint → warning). Per-element isolation: assign a forked registry to .toolRegistry.

Every descriptor renders as the same row, so there is no layout to choose; a descriptor's kind decides its body and whether the row opens a thread instead. delegate, subagent, and task calls with child-session metadata resolve to the subtask kind and open the fullscreen thread. A task call without child-session metadata uses project-task presentation only when its input has a list, create, update, delete, or clear action; other task calls remain subtasks for OpenCode compatibility. Project tasks prefer authoritative { tasks }, { task }, or { state: { tasks } } output. pending, in_progress, completed, and cancelled statuses use task-list presentation; other statuses remain visible as raw output instead of being normalized. todowrite, todo_write, and todoread retain todo presentation. A descriptor's optional diffStats(call) returns the exact { added, removed } line counts for its row, or undefined when the input does not allow an exact count; the built-in file-write descriptor supplies it from the same reading of the input that renders the body.

Live rows show activity through an opacity pulse on the status rail while pending or running; the row itself stays still, and reduced-motion preferences disable it. Session rows use the same small activity indicator and preserve their geometry when selected.

DeesHarnessThreadCut

The torn-off middle of a finished thread, used by DeesHarnessToolCard between a subagent's brief and its result: a full-width button showing one ellipsis on a strip whose top and bottom edges are torn. The teeth are the strip's own alpha — two rows of conic-gradient teeth one --dees-spacing-sm wide, masked along the two edges of the --dees-color-bg-tertiary surface — so there is no image and no literal colour in it. hiddenCount is how many messages the cut stands for and is the button's accessible name ("Show 4 hidden messages"); the ellipsis itself is decorative. The element dispatches nothing of its own — the host listens for click — and reports aria-expanded="false" always, because a cut that has been opened does not exist any more: the messages it stood for take its place.

DeesHarnessOverflowText

Reusable single-line harness text for constrained headers and activity rows. Slotted text is measured against the component's actual flex/grid width. Only true overflow receives the 40px right-edge fade; short text remains fully opaque. Overflowing text reveals its complete line on hover and once after the initial two-second delay, with reduced-motion preferences respected. Props: fadeWidthPx (default 40) and introDelayMs (default 2000).

DeesHarnessContentBlocks

Renders MCP CallToolResult content structurally: text, base64 images, audio with controls, embedded text resources, downloadable blob resources, and resource_link cards.

DeesHarnessPermissionCard

Permission prompt: title, type badge (shell/delete → error tone, write/browser → warning), metadata key/values, Allow Once / Always Allow / Reject, and a "remember" checkbox (always implies remember). Emits harness-permission-response; disables itself once answered or while busy.

Inside the chat, permission cards render inline with the messages: pending requests pin to the end of the transcript (action required), and answered ones freeze at their response time in the flow. Set response/respondedAt on the request after handling it and keep it in the array — the card stays in the transcript showing what was granted or rejected.

DeesHarnessQuestionCard

A question the agent asks mid-run: option buttons with descriptions (single-select answers on click; multiSelect collects then confirms) plus an optional free-text answer (allowCustom, default on — Enter or "Send Answer" submits, combining selections with the text). Emits harness-question-response with { requestId, answers, request }. Questions flow through dees-harness-chat's questions property and render inline like permissions: pending ones pin to the end of the transcript, and answered ones persist showing the chosen answers once the host sets response/respondedAt.

DeesHarnessComposer

Place optional reply or task context in slot="context" to integrate it above the textarea inside one input surface. Set contextDescription to the same context as plain text for the textarea's accessible description. An empty slot reserves no space. Clicking informational context focuses the prompt; links and controls retain their own behavior. DeesHarnessChat supplies its reply heading here and keeps the composer within the conversation column, alongside the full-height desktop sidebar.

Prompt input: autogrow textarea, attachment picker/paste/drag-drop with configurable maxAttachmentCount, maxAttachmentBytes (10 MB default), and maxTotalAttachmentBytes (30 MB default), image/text/binary classification, optional account, model, and reasoning-effort dropdowns, Cmd/Ctrl+Enter to send. Attachment count is unlimited unless the host sets it. dees-harness-chat forwards all three limits and suggestions to its composer. Controlled component — the host owns value, attachments, suggestions, account, and the other selections. Set accountOptions to { label, value }[] so the dropdown displays readable labels while events retain opaque stable account values. Account changes emit harness-account-change with { account }; harness-send includes optional account alongside model and reasoningEffort.

Set inputLocked when another owner controls the composer. The draft stays focusable and selectable on a quiet read-only surface, while text changes, selectors, attachment actions, Send, Steer, and queued actions are blocked. Lock transitions cancel pending attachment reads. abortEnabled stays independent and defaults true; while busy, Stop remains visible but becomes disabled when abort is unavailable, with abortDisabledReason exposed as its title and accessible label.

openModelSelector() focuses and opens the rendered model dropdown, while synchronous openAttachmentPicker() opens the native file chooser without losing the caller's user-activation task. Both return false when the control is absent or locked. dees-harness-chat forwards both methods.

For text-only hosts, set .attachmentsEnabled=${false} on either dees-harness-composer or dees-harness-chat (a JavaScript property, default true). This removes the attachment action, file input and chips; rejects file paste/drop and addFiles() ingestion; cancels queued/in-flight additions; and sends an empty attachments array for both send and input steering. Normal text paste still works, and file drops cannot navigate away from the page. The host-owned attachments array is retained, so re-enabling the capability restores its chips without data loss.

Set suggestions to IHarnessComposerSuggestion[], where each item is { label, value, description? }. The composer does not filter or generate suggestions: listen for harness-input, update the controlled value, and provide a fresh filtered array. While the textarea owns focus, the listbox stays anchored above the composer; moving focus to another control closes it. Arrow keys wrap through rows, Enter chooses the active row, and Escape dismisses the current list until the next text input or fresh suggestions array. Choosing a row sets value to the suggestion's value and emits the existing bubbling/composed harness-input event; it does not emit harness-send.

While busy, Stop renders as an additional button and Send stays available so hosts can queue messages. Set queueingEnabled to label busy sends Queue next. Set inputSteeringEnabled to add a separate Steer now button that emits harness-steer-input with the same IHarnessComposerSendDetail as harness-send; it requires a nonempty draft. The host binds that input to its active turn and owns acknowledgment and clearing the draft. Both capabilities default false and are forwarded by dees-harness-chat.

A positive queuedCount remains visible independently of steering support. The existing queue-flush action remains available when inputSteeringEnabled is false: with an empty prompt, queuedCount > 0, and steeringEnabled true, the primary action becomes Steer now (harness-steer with { queuedCount }). Hosts implementing input steering should set steeringEnabled false.

Tool calls accept terminal status stopped for confirmed interruptions. Stopped cards use a warning tone, stop their running animation, and enter activity history without being treated as errors. Terminal tool aliases include command; file-write aliases include fileChange and file_change. File writes can provide { changes: [{ path, diff }] } to render each file's unified diff.

Set modeOptions (e.g. ['Planning', 'Building']) to render a segmented mode switcher; the active mode changes on click — or Shift+Tab inside the prompt cycles it — and emits harness-mode-change. Inside dees-harness-chat the composer renders docked — flush edge-to-edge with a top border and an upward shadow (--dees-shadow-up-sm) so it meets the transcript and the session panel like the toolbar does; standalone composers keep the floating-card chrome.

DeesHarnessTodos

Agent task list for IHarnessTodoItem[] — completed items dim with a check and strikethrough, cancelled items use a muted X and strikethrough, the in-progress item leads with a pulsing accent marker, and an optional heading shows completed/total progress. Used automatically as the todo tool-card body (harnessParseTodos normalizes tool input shapes) and standalone for a "current tasks" sidebar panel fed from the latest todowrite call.

DeesHarnessStatus

Idle/busy/error line with animated dots and aria-live="polite". Renders nothing when idle unless showIdle. When the line appears it opens its space and then blends in, as a transcript row does, so what sits below it and a followed bottom travel rather than jump; its first render, new words or a new tone on a line already shown, reduced motion, and a line that is not rendered or cannot tell (no Element.checkVisibility) get no arrival. It leaves as before: it fades where it is, then gives its space back.

DeesHarnessUsage

Token usage chip (in · out · total, compact shows the total only); cache read/write detail in the tooltip.

DeesHarnessSessionSidebar

Generic session-detail panel that composes Tasks, Tools & Subagents, Tokens, Session Scratchpad, and Session Intelligence sections. Headings stay fixed in a single-open accordion; Session Intelligence splits its expanded body into independently scrolling history and a docked question composer. Set .toolMessages to top-level tool messages from the currently retained transcript window and .toolRegistry for matching labels/icons; active subagents use authoritative child status, and removed messages disappear without a second history cache. The host also supplies IHarnessSessionMetrics, IHarnessScratchpad, and IHarnessIntelligenceExchange[]; the component never loads or persists data itself. Context percentage and its progress bar render only when both currentContextTokens and a positive maxContextTokens are present.

Tools & Subagents rows are buttons with a persistent inspect affordance. Selecting one opens the exported DeesHarnessToolFullscreen against that exact canonical call. refreshCall() updates an already open fullscreen renderer without replacing the call identity. The list moves rather than jumps: a new call makes its space — every row and group title it displaces glides there on --dees-transition-default — and then blends in, and a call that settles travels from its place in Active down into History, where it lands, because it keeps its key across the two groups. The first render, expanding the section, a new sessionKey and any update that moves nothing are all still, as is everything under prefers-reduced-motion: reduce.

Scratchpad edits emit harness-scratchpad-save with { text, expectedRevision }. A newer incoming revision preserves a dirty local draft and offers explicit Discard draft and Keep draft on latest actions before saving can continue. Intelligence questions emit harness-session-intelligence-ask with { question }; exchanges and busy/error states remain host-controlled. Direct sidebar hosts should update sessionKey when changing sessions; DeesHarnessChat forwards transcriptKey automatically.

Direct sidebar hosts can set intelligenceAvailabilityStatus, intelligenceUnavailableReason, and intelligenceHeading; intelligenceEnabled still controls whether the section exists. The properties default to available, an empty reason, and Session Intelligence. A blank heading falls back to Session Intelligence, and unavailable state without a reason renders <heading> is unavailable. Only available permits harness-session-intelligence-ask; checking and unavailable states retain history and expose accessible status text. The availability union is exported as THarnessIntelligenceAvailabilityStatus.

openSection(section) expands and focuses an available section and returns false when that section has no current content. Its argument is the exported THarnessSessionSidebarSection union: 'tasks' | 'activity' | 'metrics' | 'scratchpad' | 'intelligence'.

DeesHarnessSidebar

The sidebar shell behind DeesHarnessSessionSidebar's chrome, with host-declared sections instead of session-specific ones: the same paddings, heading rows, chevron affordance, typography and single-open accordion, for a panel the catalog knows nothing about. It owns the accordion and nothing else — every section body is the host's own content, slotted as slot="section-<key>", so the host keeps its state, its updates and its presentation.

Set .sections to IHarnessSidebarSection[], where each section is { key, label, icon?, badge? }: key is the section's stable identity and names its content slot, icon renders before the label (e.g. lucide:Gauge), and badge is the right-aligned summary the session panel uses for counts. Sections render in array order, and a section whose slot holds no content is not offered at all — no heading row, no expandable region — so the divider follows the last visible section and a panel can declare more sections than the host currently fills.

expandedSection is the key of the open section. It is host-settable and reflects what the user opened; it defaults to the first section that has content. A user toggle emits harness-sidebar-section-change with IHarnessSidebarSectionChangeDetail ({ expandedSection }), where undefined means the user collapsed the panel — it stays collapsed until something opens a section again. Host writes never echo an event, so a host that binds the property should take the event's value back into its own state to keep the two in step. A section that has no content yet stays collapsed without losing the key and opens the moment its body is slotted, so a body the host renders conditionally costs neither the host nor the user their choice; only withdrawing the open section from sections moves the panel on to the first populated section. disabled freezes the accordion with its headings still visible.

Section bodies stay mounted while collapsed and keep their identity across renders, so a live panel behaves: updating sections for a new badge, or mutating the slotted content itself, neither collapses the open section nor resets its scroll position. The host mounts and unmounts the element to show or hide the panel — there is no toggle button or open animation — and an element that is re-mounted reopens the section it had. Give the panel a bounded height from its container, as the session panel's column does; the element fills it and scrolls the expanded body.

import type { IHarnessSidebarSection, IHarnessSidebarSectionChangeDetail } from '@design.estate/dees-catalog';

const sections: IHarnessSidebarSection[] = [
  { key: 'screencast', label: 'Screencast', icon: 'lucide:MonitorPlay', badge: '4.4 ms' },
];

html`
  <dees-harness-sidebar
    .sections=${sections}
    @harness-sidebar-section-change=${(event: CustomEvent<IHarnessSidebarSectionChangeDetail>) => {
      this.openSection = event.detail.expandedSection;
    }}
  >
    <div slot="section-screencast">${this.renderScreencastStats()}</div>
  </dees-harness-sidebar>
`;

DeesHarnessConversationPicker

Keyboard-first picker for one conversation, or one attachable resource, used wherever a menu would otherwise list every possible target. It is presentation only: it never mutates a session, a resource, or an association — it resolves one choice and leaves the change to the host.

Set .sessions, .groups and .resources to the same models the list renders. .excludeTargets removes targets that would be a no-op (a resource's existing attachments), and .eligibleTargets restricts the offer to a host-declared allow-list; absent means unrestricted. Typing filters on title, project label and group name — the same fields the session list searches. Up/Down move the active row, Enter picks it, Escape cancels. harness-picker-choose carries { target: IHarnessSessionListItemRef }; harness-picker-cancel carries nothing.

DeesHarnessConversationPicker.pick(options) is the one-call form: it opens the picker inside DeesModal, resolves with the chosen ref or null on cancel, closes the modal, and returns focus to whatever had it before. Reuse it for disambiguation as well — pass a resource's current attachments as eligibleTargets to ask which attachment a Move or Detach applies to.

const target = await DeesHarnessConversationPicker.pick({
  sessions: list.sessions,
  groups: list.groups,
  excludeTargets: currentTargets,
  eligibleTargets: list.eligibleTargetsByResourceId[resource.id],
  heading: `Attach ${resource.title} to…`,
});
if (target) detail.requestAssociation(target, currentTargets.length ? 'attach-additional' : 'attach');

DeesHarnessSessionList

Searchable, card-based conversation column for sidebars or a DeesModal (harness-session-select on click, harness-session-open on double-click/Enter). Cards stay collapsed until their independent accessible expansion control is used. Selection and working-state changes do not affect expansion.

Each card exposes an Actions button. Actions, right-click, and ContextMenu/Shift+F10 on the primary or Actions control emit harness-session-context with the unchanged IHarnessSessionContextDetail (session, viewport clientX/clientY, and originalEvent: MouseEvent). Button and keyboard requests anchor the menu below the invoking control; pointer requests keep the pointer coordinates. Actions do not select, open, expand, or drag the session. Hosts own the menu choices and focus restoration. Call DeesContextmenu.openContextMenuWithOptions(detail.originalEvent, items) synchronously in the event handler, before any await, so the event's composed path retains the owning dialog. The session demo shows menu cleanup and focus restoration through registerGarbageFunction().

IHarnessSessionMeta.state accepts normal, working, external, finished, attention, or error. Every collapsed row shows its status icon and text. external renders a static orange MonitorUp icon and Running elsewhere label without an attention rail, pulse, count, or attention sorting. Optional statusLabel refines the description (for example, Starting, Waiting for feedback, or Approval needed); blank labels fall back to the standard state name. Optional harness: IHarnessAgentIdentity supplies { id, name, icon? }, independently of the model and state. Optional projectLabel names the project or workspace the session belongs to and renders beside the harness on the same collapsed identity line, elided when it does not fit; it is host-owned and never derived from the title or id. The harness name stays on its own metadata line; omitted identity renders no guessed provider. Search includes harness names/ids, project labels and status labels.

Attention and error rows retain a colored edge even when selected. Group headings count both states, including when collapsed. working controls the small activity pulse on normal/working rows, defaulting to state === 'working'; attention, error and finished indicators remain stationary. Reduced-motion preferences disable the pulse. State changes preserve row height, selection, expansion and host-provided order.

Sessions and resources are peers in one ordered list. Every sortable row — conversation or resource — carries a focusable grip: drag it anywhere the other kind can go, including between sessions and into or out of groups, or reorder it with ArrowUp, ArrowDown, Home, and End. Pointer dragging moves a preview the size of the dragged row, opens a matching drop slot, and animates surrounding rows. Reordering does not require enableGrouping, which now only adds group chrome; an active search query renders one flat, non-sortable, recency-ordered section and cancels a drag in flight.

Every move emits harness-item-move with IHarnessItemMoveDetail: { item, fromGroupId, toGroupId, beforeItem, source }, where item and beforeItem are IHarnessSessionListItemRef values ({ kind: 'session' | 'resource'; id }) and a null beforeItem means append. Groups hold itemIds: IHarnessSessionListItemRef[], and ungroupedItemIds controls the order outside groups; omitted or unknown entries of either kind fall back to updatedAt ?? createdAt recency. Set draggable: false on a session or a resource for pinned entries such as terminals; pinned entries sort first in the ungrouped section and stay outside the sortable order.

The compact New group button sits beside search whenever enableGrouping is true, including while filtering, and emits harness-group-create-request. The host owns creation; the list keeps its current search until the host chooses to clear it. The demo clears search after adding the group. Both search and the group action use 44px targets for coarse pointers.

The Sessions demo pairs the list with a selected-session workspace. Its controls step through starting, working, feedback, approval, completion and failure without timers or backend operations. Question and permission responses update the selected session; declining approval pauses it. The narrow layout switches between the preserved list and detail panes with an All sessions control that restores row focus.

Generic host resources use the exported IHarnessResourceMeta and IHarnessResourceAssociation contracts. Set .resources, .resourceAssociations, .selectedResourceId, and .eligibleTargetsByResourceId. Resource rows render as top-level rows in the ordered list, never as children of a session. Optional projectLabel renders on the resource metadata line after the kind and is searched like the title and preview. A resource can be attached to several items at once: IHarnessResourceAssociation.targets is a set of refs, de-duplicated on projection, and the row shows one marker per attachment — two by name, the rest as a +N count with every name in the row's title. IHarnessResourceEventDetail.targets carries the same set on select, open and context events. Resource metadata is presentation-only: the list never mounts a terminal, browser, file viewer, or other runtime.

Attaching is a deliberate gesture, never an accident of reordering. An association is an access grant: it shows as a decorative marker on the resource row (a link icon plus each target's title, resolved from sessions or resources regardless of search or collapse) and never moves the row. A resource dropped on the centre half of a row attaches to it; the outer quarters of that same row, and every gap between rows, stay purely positional. The row being targeted shows a ring, a tint and a link badge, and no insertion gap opens while it does. A drag attaches an unattached resource and moves a singly attached one. A resource attached to several conversations is only repositioned by drag — it offers no attach affordance on any row, because a drag cannot say which of its attachments was meant; use Move to… for that. Keyboard moves stay positional; attaching by keyboard goes through the host's menu. The target is any other item, so a resource may be attached to a session or to another resource such as a terminal, and to several at once:

list.resourceAssociations = [
  { resourceId: 'file:notes', targets: [{ kind: 'resource', id: 'terminal:build' }] },
  {
    resourceId: 'terminal:build',
    targets: [{ kind: 'session', id: 's-4' }, { kind: 'session', id: 's-0' }],
  },
];

Targets are projected only when the resource exists, the target exists, the target is not the resource itself, and no earlier target in the same record already claimed it. eligibleTargetsByResourceId restricts targets for both the host menu and the drop affordance: a missing entry leaves the resource unrestricted, an empty array means no eligible target, and an ineligible row offers no attach affordance and commits nothing. Which resource kinds may host an attachment is the host's rule, not the component's.

requestResourceAssociation(resourceId, toTarget, source?, mode?, fromTarget?) and the context detail's requestAssociation(toTarget, mode?, fromTarget?) emit harness-resource-associate with THarnessResourceAssociateDetail: mode is attach, attach-additional, move or detach, and fromTarget names the single attachment being replaced or removed. With no mode, an unattached resource attaches and a singly attached one moves; a resource with several attachments is refused, because only the host knows which one the user meant — pass a mode and a fromTarget, or ask with DeesHarnessConversationPicker.

import {
  DeesHarnessConversationPicker,
  type IHarnessResourceContextDetail,
  type THarnessResourceAssociateDetail,
} from '@design.estate/dees-catalog';

const list = document.querySelector('dees-harness-session-list')!;

list.resources = [
  { id: 'shell:build', kind: 'terminal', title: 'Build shell', icon: 'lucide:Terminal' },
  { id: 'file:notes', kind: 'file', title: 'Release notes', preview: '6.7.0 draft' },
];
list.resourceAssociations = [
  { resourceId: 'shell:build', targets: [{ kind: 'session', id: 'session-1' }] },
];
list.eligibleTargetsByResourceId = {
  'shell:build': [{ kind: 'session', id: 'session-1' }, { kind: 'session', id: 'session-2' }],
  'file:notes': [{ kind: 'session', id: 'session-2' }, { kind: 'resource', id: 'shell:build' }],
};

list.addEventListener('harness-resource-associate', async (event) => {
  const resourceEvent = event as CustomEvent<THarnessResourceAssociateDetail>;
  const { resource, mode, fromTarget, toTarget } = resourceEvent.detail;
  await persistAssociation(resource.id, { mode, fromTarget, toTarget });
  list.resourceAssociations = await loadAssociations();
});

list.addEventListener('harness-resource-context', async (event) => {
  const resourceEvent = event as CustomEvent<IHarnessResourceContextDetail>;
  const { resource, targets } = resourceEvent.detail;
  resourceEvent.detail.close();
  const target = await DeesHarnessConversationPicker.pick({
    sessions: list.sessions,
    groups: list.groups,
    excludeTargets: targets,
    eligibleTargets: list.eligibleTargetsByResourceId[resource.id],
    heading: `Attach ${resource.title} to…`,
  });
  if (target) {
    resourceEvent.detail.requestAssociation(target, targets.length ? 'attach-additional' : 'attach');
  }
});

harness-resource-select and harness-resource-open carry { resource, targets }, where targets is the controlled set of association refs and is empty for an unattached resource. harness-resource-context adds viewport coordinates, the pointer/Actions/keyboard source, the original event, requestAssociation(toTarget, mode?, fromTarget?), and close() for focus restoration. The visible Actions button has menu disclosure semantics; native ContextMenu and Shift+F10 invoke the same host-owned path. The component does not require DeesContextmenu and does not own menu choices.

All component-originated association paths converge on the public requestResourceAssociation(resourceId, toTarget, source?, mode?, fromTarget?) method. A valid change emits one bubbling/composed harness-resource-associate discriminated by mode: 'attach' | 'attach-additional' | 'move' | 'detach' and carrying resource, fromTarget, toTarget, and source: 'action' | 'keyboard' | 'pointer'. The method returns false and emits nothing for unknown resources, unknown or ineligible targets, a target that is the resource itself, a target already in the set, a move/detach whose fromTarget is not in the set, an attach on an already attached resource, an attach-additional on an unattached one, or an ambiguous request with no mode. It never mutates resources, associations, selection, groups, or order.

Association projection is deterministic and non-destructive: resource records without a non-empty ID, kind, or title, unknown resource/target references, self-targets, and repeated targets are ignored; several records for one resource merge into one de-duplicated set. Duplicate resource metadata IDs likewise use the first resource. Eligibility does not hide an existing authoritative association, which remains visible and detachable. Requested changes are announced separately from completion; completion is announced only after the controlled association input reflects the requested set.

Search covers resource ID, kind, title, and preview, and matches sessions and resources as peers — a matching session no longer reveals its attached resources, and a matching resource no longer reveals its target. Resources render with zero sessions. Resource dragging edge-scrolls the list exactly like session dragging and emits only harness-item-move. Use the Actions/context path for keyboard and mobile association workflows; coarse-pointer grip and Actions targets are at least 44px.


Terminal Components

DeesTerminalView

Transport-agnostic xterm.js surface. The host pipes remote PTY output in with write(data) and receives keystrokes as terminal-input ({ data }); there is no process, runtime or socket coupling. terminal-resize ({ rows, cols }) reports the grid after every refit so the host can negotiate a PTY size, and currentSize reads it back (undefined while no terminal is live). fontSize sets the grid font and scrollback the number of lines kept above the viewport, both applied live. readOnly composes an output surface: xterm's stdin is off and no caret is drawn in either focus state. convertEol starts a bare line feed at column one, for a host that composes its own text instead of forwarding a PTY — both output views of the catalog turn it on rather than rewriting the line endings of everything they write; a PTY sends CRLF itself, so a shell surface leaves it off. disabled stops terminal-input from being emitted, disables xterm's stdin and reflects aria-disabled. focus() moves the caret into the terminal's input surface, and scrollToBottom() jumps to the newest output for a view that follows a live stream. addAddon(create) registers an xterm addon the element does not own itself (a log view adds the search addon this way): the terminal is rebuilt per connection, so the host hands over a factory and reads the live instance from the returned handle's current, which the handle's dispose() releases. An addon is a capability of the host, not of the surface, so a factory or an activate() that throws is isolated — it is reported through terminal-error, leaves current undefined and never settles ready, while the terminal comes up and keeps working; the registration survives, so the factory is tried again on the next connection. xterm (@xterm/xterm 6) and the addons behind fitting, search, serialize() and links are loaded through DeesServiceLibLoader; CSP-locked or offline hosts call provideXtermModules({ xterm, fitAddon, searchAddon, serializeAddon, webLinksAddon }) first to supply their own bundled copies instead of the CDN. Anything left out is fetched on demand. xterm and the fit addon are the terminal itself, so a failed load of either rejects ready; the search, serialize and web-links addons are capabilities on top of it, so a failed load of one of those costs only that capability — reported as terminal-error while the terminal comes up and keeps working. No stylesheet is fetched: xterm's own CSS is exported once as xtermStyles, and every terminal surface in the catalog puts it into its own shadow root, where xterm renders and, since 6.0, measures as well — nothing of xterm reaches the document. A host that opens a terminal outside a catalog component adds xtermStyles to its own surface in the same way.

write() is safe before the terminal exists and while the element is detached: output is buffered and replayed in order on the next connection, bounded by the exported maxPendingWriteBytes (8 MiB — Uint8Array chunks count their byteLength, strings their UTF-16 memory). Past that bound the oldest chunks are dropped and the replay emits RIS first, so a truncated prefix is never rendered as if it were contiguous. clear() resets screen and scrollback through the write stream (RIS, ESC c) rather than around it, so it is ordered against output already queued in xterm — write, clear(), write leaves only the later output on screen.

serialize() takes that state: it returns { data, size } — the whole grid (screen, scrollback, modes and cursor) and the grid size it was taken at, which is exactly the pair restore() takes, so a host moves a live terminal to another element or persists it across a reload without replaying its history. It reads the live grid, so it returns undefined while no terminal is up, while a restore() is still being parsed, and when the serialize addon could not be loaded for this connection — no snapshot rather than a truncated one.

restore(data, { cols, rows }) shows a serialized terminal state — one serialize() took here, or one the host keeps — in one step, so reattaching to a long-running terminal does not replay its history in front of the user. A snapshot is exact only at the grid it was taken at, so the terminal is fully reset (screen, scrollback, modes, alternate buffer), set to that grid, fed the snapshot while invisible, then fitted to its container and revealed. terminal-resize fires at the end when the fitted grid differs from the snapshot's, which is the host's cue to resize the PTY so the application redraws. Until then currentSize reports the snapshot's grid, so the closing event, or the resolved promise, is what a host negotiates a PTY size on. The terminal is hidden with opacity, so it keeps focus and keeps forwarding input meanwhile. The restore is part of the output stream: write() calls issued after it are applied after the snapshot, at the fitted size, so the host does not wait before streaming live output from the snapshot's offset. The resize observer does not refit until the snapshot is parsed. Before the terminal exists, a restore is buffered like write(): it replaces everything buffered before it and counts towards maxPendingWriteBytes. It is the oldest buffered entry, so it is dropped first, and a snapshot that alone exceeds the bound is dropped by the next buffered write.

The promise resolves once the snapshot is on screen. It also resolves, without revealing anything, when a newer restore() supersedes it, or when clear() replaces it while it is still buffered. It rejects when the element is disconnected while the snapshot is being parsed (the terminal and the snapshot are disposed together, as with any live output), when output buffered after it pushes it out of the buffer, or when xterm refuses the data (terminal-error carries the same error). None of these surfaces as an unhandled rejection. A size that is not an integer cols in 2–65535 and rows in 1–65535 is a programming error: the promise rejects with a RangeError and nothing changes; unlike the reasons above this one is not pre-handled, so a restore() nobody observes reports it as an unhandled rejection. There is a fourth outcome: a restore that is still buffered when the element is disconnected stays pending, because the snapshot survives the move like buffered output and is applied on the next connection — a host that discards the element instead of reattaching it must not await that promise.

// Reattach through the host's own transport: paint the snapshot, then keep writing
// live output from the stream offset the snapshot was taken at.
const snapshot = await transport.attachTerminal(terminalId);
// Not awaited, so live output queues behind the snapshot. A rejected restore leaves only
// the live tail on screen, so take a fresh snapshot rather than ignore the rejection.
view
  .restore(snapshot.data, { cols: snapshot.cols, rows: snapshot.rows })
  .catch(() => transport.reattachTerminal(terminalId));
transport.streamOutput(terminalId, snapshot.offset, (chunk) => view.write(chunk));

focus() is the call a host makes when the user selects a terminal — after a click, or after Enter/Space on a row — so the next keystroke goes to the pty instead of the page. Because the terminal is created asynchronously on first connection, a focus() that arrives before it exists is remembered and applied exactly once when it becomes ready; focusPending reads that state back. The request belongs to the connection it was made in and is dropped on disconnect, so an element moved between containers never grabs the caret back from wherever the user went — issue a fresh focus() after the move. Nothing else moves focus: re-renders, buffered output and background writes never steal it.

The grid follows the catalog theme: every terminal surface in the package takes one palette from terminalTheme(goBright) in 00theme.ts — background, foreground and cursor from the colour ladder, the selection tint and the 16 ANSI colours from themeDefaults.terminal, one set designed for each mode — and re-applies it live when the theme changes. The terminal surface is the content surface (bgPrimary) in both themes, mirrored for DOM consumers as --dees-color-terminal-bg / -fg / -fg-secondary / -fg-muted / -cursor / -selection. Every grid uses the catalog's mono stack (terminalFontFamily, the resolved value of --dees-font-family-mono). Since xterm 6 the grid scrolls with a slider xterm draws itself and colours from that same palette, so a terminal's scrollbar matches every other scrolling surface of the catalog.

The surface is a named group (role="group", aria-label from label, aria-disabled from disabled) around the input xterm renders inside it; screenReaderMode opts into xterm's accessibility tree and live region, which cost work on every render and are therefore off by default. The house focus ring flashes once around the surface and fades when a key brought the focus there, and again when the window becomes active with the focus still on the terminal; a click draws no ring, since xterm's input is a textarea that the platform counts as :focus-visible after a click too, and under reduced motion the ring holds and then goes without a fade. The caret shows where typing goes after that, and a blinking caret follows prefers-reduced-motion live, so reducing motion stops the blink without a remount. The element draws no frame: it fills the box its host gives it, and a host that wants a frame (a dees-tile, a panel, a card) draws it around the element, so there is no frameless property here. Fitting is guarded: a hidden tab, a collapsed panel or a pane mid-drag measures zero and is not fitted, and the same ResizeObserver fits the grid again the moment the box comes back. A URL in the output is a link: clicking it opens a new tab with no opener, the way xterm's web-links addon does it. A surface the host marked disabled is not operable, so a click on it opens nothing; readOnly is about typing and leaves links alone, which is what a log or a transcript wants.

ready resolves once the terminal of the current connection is live; a fresh promise is created per connection, so a reattached element can be awaited again. Every connection builds a terminal and every disconnection disposes it, so an element moved between containers, or returned to, is a live surface rather than a dead one; buffered output survives the move. A failed xterm load is not an unhandled rejection: it dispatches the bubbling, composed terminal-error (ITerminalErrorDetail { error }), rejects ready with the same error, leaves the element writable, and is retried on the next connection. xterm's own flow-control refusal is reported the same way. Sizing comes from the host element — give it a bounded height, as the demo does.


Pre-built Templates

DeesSimpleAppDash

Compact application shell with grouped navigation, global notices, a content scroller and an optional terminal. Use this for a small collection of views; choose dees-appui for screen URLs, window menus, secondary navigation and inspectors.

<dees-simple-appdash
  name="Studio"
  .viewTabs=${[
    { name: 'Overview', iconName: 'lucide:layoutGrid', element: StudioOverview },
    { name: 'Settings', iconName: 'lucide:settings', subViews: [
      { name: 'Profile', iconName: 'lucide:contact', element: StudioProfile }
    ] }
  ]}
  @view-select=${(event) => showViewState(event.detail.view)}
  @logout=${signOut}
></dees-simple-appdash>

Each element is a registered DeesElement constructor. Give the shell a bounded height. A group without its own element opens its first navigable child. loadView(view) opens a view; selecting the current view preserves its instance and unsaved edits. Switching to another view creates that view anew, so keep shared application state outside the view instances, as the Studio demo does. Replacing viewTabs removes a selected view that no longer belongs to the collection.

Navigation supports Tab, Enter/Space and Up/Down/Home/End. collapsed selects an icon rail with speechbubble labels. Containers below 640px use the rail automatically.

Supply globalMessages: IGlobalMessage[], or call addMessage({ id?, type, message, dismissible?, icon?, actions? }). Types are info, success, warning, and error; actions have { name, iconName?, action }. addMessage returns the ID and replaces an existing message with the same ID. Property updates synchronize their messages while preserving API-added notices. removeMessage(id) emits message-dismiss with { id } once; clearMessages() clears the stack. Notices wrap and scroll independently instead of covering the content.

launchTerminal() toggles the embedded terminal. Pass a consumer-owned executionEnvironment: IExecutionEnvironment and optional terminalSetupCommand. The shell forwards the environment and removes its terminal on disconnect; it does not destroy a shared runtime. WebContainerEnvironment requires a secure, cross-origin isolated browser context. The Studio demo supplies it without booting until Terminal opens.

The interactive Studio demo connects project archiving, overview counts, activity, workspace name, notification preferences, notices, and sign-out in one sample app.

DeesSimpleLogin

Login surface for an application shell. Offers up to three authentication methods in one card — passkeys, identity providers ("sign in with …") and a username/password form — and renders the authenticated app through its default slot.

A method is offered only when it is configured, so the zero-configuration default is the password form alone:

<dees-simple-login name="My Application" @login=${handleLogin}>
  <!-- authenticated content -->
</dees-simple-login>

login fires with detail.data.{username,password}; call switchToSlottedContent() once credentials check out.

Turn on more methods and they render in the canonical order passkey → provider → password:

<dees-simple-login
  name="login.idp.global"
  .passkey=${true}
  .providers=${[
    { id: 'idp-global', label: 'idp.global', icon: 'lucide:shieldCheck' },
    { id: 'workspace', label: 'Workspace SSO', icon: 'lucide:building2' },
  ]}
  .passkeyLoginHandler=${async (context) => runWebAuthnCeremony(context)}
  .providerLoginHandler=${async (context) => redirectTo(context.providerId)}
  @login=${handleLogin}
></dees-simple-login>

Configuration — passkey (default false), providers (default []), password (default true), passkeyIntents (['authenticate'], add 'register' to offer enrollment), methodOrder, labels, passkeyAvailable (override capability detection), passkeyAutofill (WebAuthn conditional mediation — inert unless passkeyLoginHandler is set, the password method renders a username field, and the browser reports conditional mediation), frameless (drop the login card's border and corner radius when a modal or page frame already owns them).

Who owns the WebAuthn ceremony — not this component. A ceremony needs server-issued options and server-side verification, so the catalog carries no WebAuthn dependency. Plug one in either way, never both for the same interaction:

  • Event mode (no handler set) — the component dispatches passkey-login, passkey-register or provider-login and stops. Report progress back with setBusy() / reportError().
  • Handler mode (passkeyLoginHandler, passkeyRegisterHandler, providerLoginHandler, passwordLoginHandler) — the component awaits the handler, owns that method's busy state and turns a rejection into that method's error message. The request event is not dispatched, so a ceremony can never start twice.

login is a notification rather than a request and always fires, even in handler mode.

Busy and error state is independent per method, per passkey intent and per provider, so a failed passkey attempt never blanks the password form. reportError(target, message), setBusy(target, busy), isBusy(target) and getError(target) each take either a TDeesLoginMethod or an IDeesLoginTarget ({ method, intent?, providerId? }). clearError(target?) takes an optional target and clears every error when called without one; reset() takes no arguments and clears all busy and error state plus the password form.

Provider icons are always consumer-supplied Lucide names — the catalog ships no third-party brand assets.

The optional brand slot accepts an application mark; without it a neutral sign-in glyph is shown. All methods share one framed surface, with a divider between button methods and credentials. The password-only tile remains available through the existing shadow DOM handles. .loginContainer, .login, .slotContainer, the password form, its keys, and externally managed submit status retain their existing contracts.

The sign-in demo offers method configurations, success/error responses and a sign-out round trip. Real consumers should move focus into the authenticated view after awaiting switchToSlottedContent() and back into the login after signing out.


Shopping Components

DeesShoppingProductcard

Product image, title, description, stock label and price in one surface, with optional quantity and selection controls. Reuse this component before assembling a custom product tile. imageUrl is contained without cropping; iconName supplies the fallback artwork.

<dees-shopping-productcard
  .productData=${{
    name: 'Premium Headphones',
    category: 'Electronics',
    description: 'High-quality wireless headphones with noise cancellation',
    price: 199.99,
    originalPrice: 249.99,
    currency: '$',
    inStock: true,
    imageUrl: '/images/headphones.jpg'
  }}
  .quantity=${1}
  .showQuantitySelector=${true}
  .selectable=${true}
  .selected=${false}
  @quantityChange=${handleQuantityChange}
  @selectionChange=${handleSelectionChange}
></dees-shopping-productcard>

quantityChange carries { quantity, productData }; selectionChange carries { selected, productData }. Both bubble across shadow roots. The checkbox is keyboard accessible and clicking the rest of a selectable card toggles it; using the quantity buttons never changes selection. External property assignments do not emit user events.

inStock and stockText describe availability. Cart totals, inventory limits and purchase policy belong to the consumer; omit the selector with showQuantitySelector=false for unavailable products. The work-kit demo demonstrates this, restocking, shared bag totals, and selecting a kit with dees-input-multitoggle. Its Review bag action places no order.


TypeScript Interfaces

The library exports unified interfaces for consistent API patterns:

// Detail of the cancelable `dees-action-error` event (see Failed actions)
interface IActionErrorDetail<TAction = unknown> {
  error: unknown;        // whatever the action callback rejected with
  action: TAction;       // the action descriptor whose callback rejected
  source: string;        // where it ran, e.g. 'modal-menu' or 'table-row'
}

// Base menu item interface (used by tabs, menus, etc.)
// `key` is the stable identity: tab selection follows it across rerenders.
// Give every tab a unique `key`: when it is absent the `label` is used, so two keyless tabs with the same label are the same tab.
interface IMenuItem {
  key: string;
  label?: string;        // display text; falls back to `key`
  iconName?: string;
  action: () => void;
  badge?: string | number;
  badgeVariant?: 'default' | 'success' | 'warning' | 'error';
  closeable?: boolean;
  onClose?: () => void;
}

// Menu group interface for organized menus
interface IMenuGroup {
  name: string;
  items: IMenuItem[];
}

// View definition for app navigation
interface IViewDefinition {
  id: string;
  name: string;
  iconName?: string;
  content: string | (new () => HTMLElement) | (() => TemplateResult) | (() => Promise<any>);
  secondaryMenu?: ISecondaryMenuGroup[];
  contentTabs?: IMenuItem[];
  keepChrome?: boolean;  // keep the chrome that is showing instead of starting empty
  route?: string;
  validateRoute?: (route: IAppRoute) => boolean;
  badge?: string | number;
  badgeVariant?: 'default' | 'success' | 'warning' | 'error';
  cache?: boolean;
}

// Activity log entry
interface IActivityEntry {
  id?: string;
  timestamp?: Date;
  type: 'login' | 'logout' | 'view' | 'create' | 'update' | 'delete' | 'custom';
  user: string;
  message: string;
  iconName?: string;
  data?: Record<string, unknown>;
}

// Bottom bar widget
interface IBottomBarWidget {
  id: string;
  iconName?: string;
  label?: string;
  status?: 'idle' | 'active' | 'success' | 'warning' | 'error';
  tooltip?: string;
  loading?: boolean;
  onClick?: () => void;
  position?: 'left' | 'right';
  order?: number;
}

// Bottom bar action button
interface IBottomBarAction {
  id: string;
  iconName: string;
  tooltip?: string;
  onClick: () => void | Promise<void>;
  disabled?: boolean;
  position?: 'left' | 'right';
}

// View activation context (passed to onActivate)
interface IViewActivationContext {
  appui: DeesAppui;
  viewId: string;
  params?: Record<string, string>;
}

// Thumbnail folder item (for DeesThumbnailFolder)
interface IThumbnailFolderItem {
  type: 'pdf' | 'image' | 'audio' | 'video' | 'note' | 'folder' | 'unknown';
  thumbnailSrc?: string;
  name: string;
}

Third-Party Notices

DeesChartArea bundles TradingView Lightweight Charts under the Apache License 2.0. The built-in chart logo is disabled. The distributed third-party notices retain the upstream NOTICE and Apache license text.

TradingView Lightweight Charts™
Copyright (с) 2025 TradingView, Inc. https://www.tradingview.com/

DeesPdfViewer and DeesThumbnailPdf bundle Mozilla PDF.js 4.10.38 and its module worker under the Apache License 2.0. The attribution and complete license text are provided in third-party-notices.md and copied into dist_bundle/ for standalone bundle distributions.

third-party-notices.md lists every library the package embeds in dist_bundle/bundle.js and every library it loads into the host page at runtime (ECharts, highlight.js, Monaco, xterm). That list is generated from the built artifact and the installed packages — a dependency added, removed, or re-pinned without regenerating fails pnpm test:

node scripts/check-third-party-notices.mjs --write

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
components for building sane web applications
Readme
41 MiB
Languages
TypeScript 99.5%
JavaScript 0.5%