2026-10-07 03:18:49 +00:00
2026-10-07 03:18:49 +00:00
2026-10-07 03:18:49 +00:00
2026-10-07 03:18:49 +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. Routes by hash, or by path beside the host's server routes.
Give a view its page header: title, summary, actions, tabs dees-appui-page One max-width and padding for every view; the primary action at the end of the title row. Do not hand-code headers.
Report a page's warnings and errors from its header instead of stacking hints above its content dees-statusbutton One button beside the page's actions shows the worst status and a count; its speech-bubble panel lists the statuses, and an error or a severe warning opens it by itself. Keep dees-hint for a note next to the thing it explains.
Say that a tile, panel or list is loading, empty or failed dees-state One pattern for all three: icon or spinner, sentence, the way on, Retry and folded technical detail. A dees-table says the same through loading, error and 'empty' actions.
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.
Choose, photograph or drop files, and show each file's upload dees-input-fileupload, dees-dropzone-overlay The field selects files (capture opens a phone camera) and shows the upload state the host reports with setFileState(); the overlay receives files dropped anywhere in the window.
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.
Offer an action about what a tile shows: open its details, export it, add to it dees-tile actions Footer or header actions, drawn and held while they run exactly like a table's header and footer actions. Never a footer bar of buttons built by hand; actions on a table's rows or on the table itself are the table's own. See Where actions go.
Put a table's search field in a page header, beside the page's own filters and buttons dees-table-search The table's own field and scope menu, linked to the table; the table hides its built-in field. Do not wire a plain input to setSearchQuery().
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@^4.0.1

The catalog runs on @design.estate/dees-element 4 and @design.estate/dees-domtools 5. A host that depends on either package itself uses those majors (^4.0.1 and ^5.0.0); an older major puts a second copy in its bundle.

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>
    `;
  }
}

Entry points

Import What it is
@design.estate/dees-catalog Every component, registered on import, with the types of their properties and events.
@design.estate/dees-catalog/icon dees-icon alone (see DeesIcon).
@design.estate/dees-catalog/cdn The CDN script URLs the catalog may load, as constants. Imports no component, Lit or DOM API, so it runs in plain Node.

Name a component's event and property types from the package root, never from a dist_ts_web/... path:

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

table.addEventListener('cellEdit', (event: CustomEvent<ICellEditDetail<IInvoiceRow>>) => {
  save(event.detail.row, event.detail.key, event.detail.newValue);
});

Content Security Policy

Some components load a library from jsdelivr when they first need it, through DeesServiceLibLoader: ECharts for the ECharts-based charts, xterm.js for terminals, Monaco for the workspace editors. Syntax highlighting needs no CDN: highlight.js ships inside the catalog with the languages its code views name, so code views highlight under script-src 'self'. A server whose CSP lists script URLs takes them from the cdn entry, which follows the catalog's pins, instead of writing them by hand:

import { CDN_SCRIPT_URLS, CDN_MONACO_BASE_URL } from '@design.estate/dees-catalog/cdn';

const scriptSrc = [
  "'self'",
  CDN_SCRIPT_URLS.echarts,   // the exact .../echarts@<pinned>/dist/echarts.esm.min.js
  `${CDN_MONACO_BASE_URL}/`, // a directory: the trailing slash makes it match the files below it
].join(' ');

const connectSrc = [
  "'self'",
  `${CDN_MONACO_BASE_URL}/`, // Monaco's editor.main.css is fetched, so it needs connect-src
].join(' ');

CDN_SCRIPT_URLS has one entry per module (echarts, xterm, xtermAddonFit, xtermAddonSearch, xtermAddonSerialize, xtermAddonWebLinks); each is a single self-contained module. Its highlightJs entry and CDN_VERSIONS.highlightJs are deprecated: nothing loads that URL any more, so a CSP does not need it. Monaco is not one module: its loader.js and the modules it requires load from below CDN_MONACO_BASE_URL, and its editor/editor.main.css is fetched from there. A CSP source matches a directory only when it ends in /, so list ${CDN_MONACO_BASE_URL}/ in script-src, and in connect-src as well, because the stylesheet is loaded with fetch(). Whether Monaco also needs a worker-src entry is not verified: the catalog configures no MonacoEnvironment worker URL. A host that bundles a library instead (provideXtermModules, provideMonacoBaseUrl) never loads its CDN URL. A host that already ships highlight.js with more languages or plugins hands it in with provideHighlightJs, and code views use it instead of the catalog's. The bundled languages are Bash (also shell, console), C, C++, C#, CSS, diff, Dockerfile, Go, GraphQL, HTML/XML, INI/TOML, Java, JavaScript, JSON, Kotlin, Less, Markdown, PHP, Python, Ruby, Rust, SCSS, SQL, Swift, TypeScript and YAML, under highlight.js's names and aliases; an unlabelled fence in a harness message is detected among them, and code in another language is shown plain. The same constants are exported from the package root, beside CDN_BASE and CDN_VERSIONS.

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 in neutral formats: numbers ungrouped (5000), inside messages too, at most three fraction digits, file sizes in Intl units (2 kB), and weeks from Monday. A component whose provider goes away returns to this.
  • 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 }), DeesModal.confirm, DeesModal.prompt, DeesToast.show). The overlay's own words (its default buttons, help and close labels) speak it as well as the content it renders, and follow that instance rather than the page.
  • 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 date picker names months and weekdays in the date format's language and starts the week where that format does (Monday in de-DE, Sunday in en-US; Monday without a provider); its weekStartsOn, when set, wins.
  • A helper outside an element (the block editor's slash menu, the storage browser's formatSize and validateMove, the agent chat's harnessFormat* helpers, the PDF formatFileSize) takes the words or formats to speak in and defaults to English; dcSourceWords are the catalog's English source words.
  • The agent chat's tool descriptors get the showing element's words as the optional second argument of label, subtitle and summary, and custom body renderers find them as helpers.t, so a registered tool can speak the reader's language too.
  • Every component's words are in the catalog. 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 two it still counts are URL examples in the block editor's link and video fields, which no language changes.
  • 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.

Test sets

pnpm test runs every test file and is the check before a release. While working on a change, run the test files that change can affect:

pnpm test:changed                  # what changed since origin/main, committed or not
pnpm test:changed --list           # which test files, and the change that picks each, without running them
pnpm test:set element dees-table   # the test files that cover one element
pnpm test:set group dataview       # ... or an element group; `pnpm test:set groups` lists them
pnpm test:set path ts_web/elements/00theme.ts
pnpm test:set why test/test.chips.chromium.ts ts_web/elements/00group-utility/dees-icon/dees-icon.ts

A set runs the ratchet checks pnpm test runs, builds the package when a picked test file reads the build, and then runs the picked files with the options of pnpm test. Arguments after -- go to tstest. Tests are bundled without a type check; pnpm run build checks the types.

scripts/test-set.mjs picks a test file when the change touches what the file covers: the modules it imports, followed through their own imports, and every element whose tag it or one of those modules uses in a template or creates by name. A barrel such as ts_web/index.ts is followed only for the names imported from it, and a component's demo only for a test file that imports or renders it. A change to package.json, pnpm-lock.yaml, pnpm-workspace.yaml, tsconfig.json or .smartconfig.json runs every test file; documentation runs none. What neither imports nor tags show, a test file names in a comment line, // test-set:covers <path> ... (the notices and entry-point tests do).

The full run starts four test files at a time (concurrency in the @git.zone/tstest section of .smartconfig.json), each in a Chromium or Node.js process of its own. A test file therefore keeps to its own process: a server it starts binds port 0 and reads the port it got, and it waits for an element's own events rather than for the clock. A file has 30 seconds (--timeout 30); one that needs more is split by subject.

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-page appui DeesAppuiPage Source
dees-appui-profiledropdown appui — Source
dees-appui-secondarymenu appui DeesAppuiSecondarymenu Source
dees-appui-tabs appui DeesAppuiTabs Source
dees-appui-tray appui DeesAppuiTray 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 DeesDashboardgrid Source
dees-dataview-codebox dataview DeesDataviewCodebox Source
dees-dataview-statusobject dataview DeesDataviewStatusobject Source
dees-dropzone-overlay overlay DeesDropzoneOverlay Source
dees-form form DeesForm Source
dees-form-section form DeesFormSection Source
dees-form-submit form DeesFormSubmit Source
dees-formatting-menu input — Source
dees-grouped-list dataview DeesGroupedList 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-state feedback DeesState Source
dees-statsgrid dataview DeesStatsGrid Source
dees-statusbutton feedback DeesStatusbutton 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-table-search dataview DeesTableSearch 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 DeesTile 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

Reflected properties

A component's public configuration is one surface in markup, in devtools and in CSS: every public property of a string, number or boolean type reflects to its attribute, whether it was set from markup or through a property binding. A page can style around a configuration, such as dees-panel[variant='outline'] or dees-spinner[bnw], and a component's own :host([...]) rules hold however the value arrived. Attribute names are the property name in lower case unless the property declares another one, such as dees-table's show-column-filters.

Four kinds of property stay unreflected, each listed with its reason in scripts/reflect-allowlist.json: user data and state that changes per keystroke, pointer move, scroll or data tick (a field's value, a search query, progress; a toggle's on/off value reflects, since its own styles select it); objects, arrays and functions, which have no attribute; names that are global HTML or ARIA attributes with native behaviour on the host (title, aria-label); and large text or encoded data (a whole file, a diff, a data URL). pnpm test runs scripts/check-reflect-ratchet.cjs, which fails on a new public primitive property that neither reflects nor is listed there.

Failed actions

Components that run an application callback — dees-modal's menuOptions[].action, dees-table's dataActions[].actionFunc and onRetry, dees-tile's actions[].actionFunc, dees-appui-page's actions[].action and dees-state's actions[].action and onRetry — 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-empty'
  //       | 'table-row' | 'table-rowmenu' | 'table-contextmenu' | 'table-doubleclick' | 'table-rowclick'
  //       | 'table-keycombination'
  //       | 'tile-header' | 'tile-footer' | 'page-action' | 'state-action' | 'state-retry'
  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.

Where actions go

An action sits on the thing it acts on, in that thing's own place for actions, so every implementation offers its actions the same way. Choose the place by what the action is about:

The action is about Its place For example
A row of a table the table's dataActions with type: ['inRow', 'contextmenu'] "Open", "Approve"
A table as a whole, or its selected rows the table's dataActions with type: ['header'] or ['footer']; ['empty'] for the way on from no rows "Import", "Export", "Approve selected", "New service"
What a tile shows: a summary, a status, a chart, a list that is not a table the tile's actions, type: ['footer'] for the way on from the content, ['header'] for one that adds to or refreshes it "Open the audit trail", "Export CSV", "New invoice"
A whole page or view dees-appui-page actions "New service"
A decision a dialog asks for dees-modal menuOptions "Cancel", "Delete"
A place that loads, is empty or failed dees-state actions "Retry", "New service"
  • Never build a bar of buttons into a tile's footer or header slot. Tile actions and a table's header and footer actions are one standard, drawn by one renderer: they share their size, type, spacing, wrapping, glyph, busy face, keyboard behaviour and accessible name.
  • A tile whose content is a table offers the actions about the table's rows as the table's own header or footer actions, never in a second footer under the table's: the table's footer already carries its count and its actions.
  • The slots are for content: a status line, a legend, statistics, a chip. Next to actions, slotted content takes the bar's leading side.
  • An action that applies but cannot be taken yet stays visible and disabled; one that never applies is not offered.

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, a coloured panel — set frameless: the component drops its outer border, its corner radius and its surface, so the host's surface shows through its header, content and footer. Content, padding, separators, focus rings and behavior stay as they are, and so do the backgrounds that belong to what the component shows: controls, a terminal, a code editor, a PDF page, a video stage. A selected table row becomes a tint over the host's surface.

A part that content scrolls beneath — a table's sticky column header or sticky footer, a pinned action column — paints an opaque backdrop while content passes beneath it, in --dees-tile-surface (the catalog's content surface unless set). A host whose surface is not that one sets the property on the frameless component, so the backdrop matches:

<dees-table frameless fixed-height style="--dees-tile-surface: var(--my-panel-surface)"></dees-table>

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-input-wysiwyg, 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 and painting no surface of its own over it in frameless mode. 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-simple-login drops its card's surface and the page backdrop around it. On dees-video-viewer, frameless means filling the enclosing frame: the stage keeps its black letterbox and the controls bar its surface, because both sit over the video. dees-dashboardgrid, dees-statsgrid and dees-stepper draw one card per item instead of a frame of their own, so they deliberately have no frameless.

A frameless component also takes the region inset at its edges.

Region inset

--dees-inset-inline is the inline inset of the region an element sits in, measured to that region's edges. It is how content lines up with the heading of the page or pane around it: a page whose heading sits 32px in declares 32px on its body, and a frameless table in that body puts its first and last content 32px in as well.

  • Who sets it. A container that gives its content its full width — a page frame with a flush body, a pane, a drawer or modal body that runs to its edges — sets it there, to its own content inset. The theme sets it nowhere: unset, no region inset is declared. In the catalog, dees-modal declares its heading's inset on a body with contentPadding: 0.
  • Who reads it. Components drawn frameless. Their edges are their host's edges, so the content at their inline edges — cells, header and footer bands, toolbars, notices, list rows, skeleton rows, labels — sits at var(--dees-inset-inline, <own inset>). Unset, nothing moves. A framed component sits inside its host's padding and keeps its own inset; taking the region inset as well would inset it twice.
  • No leaking. A component that reads the token reads it once, on its host, and resets it for everything it renders and slots, so a component nested in it — a table in a table cell, a component in a settings value — sees a region inset only when a nearer container declares one. Two pass it on instead, because their content runs to their own edges with no padding of its own: a frameless dees-tile to its slots, and a frameless dees-preview to the viewer in its content area. A tile's header or footer bar (tile actions) pads to the inset itself, so what it slots sees none.
// A page frame with a flush body: the heading's inset is the region inset of the body.
@customElement('my-page')
export class MyPage extends DeesElement {
  public static styles = [themeDefaultStyles, css`
    :host { display: flex; flex-direction: column; height: 100%; }
    h1 { margin: 0; padding: var(--dees-spacing-xl) var(--dees-spacing-2xl) var(--dees-spacing-md); }
    .body { --dees-inset-inline: var(--dees-spacing-2xl); flex: 1; min-height: 0; display: flex; flex-direction: column; }
  `];
  public render() {
    return html`<h1>Services</h1>
      <div class="body"><dees-table frameless fill .columns=${columns} .data=${services}></dees-table></div>`;
  }
}

Here the table's header band, its first and last cells, its footer, its skeleton rows and its notice sit 32px from the body's edges: a selection checkbox starts at 32px and the row actions end there. A fill or fixed-height table keeps room for its scrollbar at the end of its rows where the platform draws classic scrollbars, so there its last column ends that much further in. A narrow page declares its narrow inset, such as var(--dees-spacing-lg), which the table takes in place of its own narrow values.

A few places keep part of their own inset, each for a reason:

  • an inline cell editor and a column filter field pad their text themselves, so their cell adds only the rest and the text stays where the cell showed it;
  • dees-input-wysiwyg keeps at least the 28px its block handles sit in;
  • ECharts charts (dees-chart-line, -bar, -donut, -gauge, -radar) inset their plot by 16px themselves, so the plot's frame moves in by what the region inset adds;
  • a split diff in dees-dataview-codebox and the thumbnail strip of dees-pdf-viewer take it on their outer sides only, not at the divider;
  • content centred in its box — an empty, loading or error state, the dees-simple-login card — has no edge to line up and keeps its padding.

A component outside the catalog joins the standard with insetInlineStyles, exported from the package root. It reads the token on the host into the component's own property while the host is [frameless], leaves that property unset otherwise, and resets the token for the shadow tree:

import { insetInlineStyles, themeDefaultStyles } from '@design.estate/dees-catalog';

public static styles = [
  themeDefaultStyles,
  insetInlineStyles('--panel-inset-inline'),
  css`.toolbar { padding-inline: var(--panel-inset-inline, 12px); }`,
];

Scroll edges

Where content scrolls beneath fixed chrome — a header row above it, a search field, pinned rows or a footer below it — the edge content lies past casts a shadow: scrolled down, the top edge casts one; with content below the fold, the bottom edge does; content that fits casts none. Each shadow is a 16px band of --dees-color-scroll-edge-shadow painted from the edge inward (a 35% stop at 6px), drawn over the content without taking its room or its pointer input, and fading in and out with the theme's fast step, at once under reduced motion.

In the catalog: dees-appui-mainmenu (its groups between the logo row and the bottom tabs, in the expanded menu, the icon rail and the compact menu alike), dees-appui-secondarymenu, dees-appui-activitylog, dees-appui-commandpalette (its results beneath the query field), dees-appui-maincontent (views beneath the tab bar), dees-harness-session-list, and dees-table with fill or fixed-height, whose rows scroll between the sticky column header and the footer: its top band lies right under the column header, filter row included, and both lie beside the scrollbar's gutter rather than over it. A table whose rows do not scroll in it casts none.

A component of its own joins with ScrollEdgeShadowController and scrollEdgeShadowStyles, exported from the package root, instead of copying the bands. The controller follows the scroller the host names: it measures on the host's renders, on scroll, when the scroller or any of its children resizes (rows added, a group folded, a pane dragged narrower) and when children come or go; a slot among the children stands for the elements assigned to it. The bands go right after the scroller, in a positioned box that has the scroller's size; later in tree order, they also paint over the sticky parts of the content, such as a date heading stuck at the top:

import { ScrollEdgeShadowController, scrollEdgeShadowStyles, themeDefaultStyles } from '@design.estate/dees-catalog';

public static styles = [themeDefaultStyles, scrollEdgeShadowStyles, css`
  :host { display: flex; flex-direction: column; }
  .listFrame { position: relative; flex: 1; min-height: 0; }
  .list { height: 100%; overflow-y: auto; }
`];

private readonly scrollEdges = new ScrollEdgeShadowController(this, () => this.shadowRoot?.querySelector<HTMLElement>('.list'));

public render() {
  return html`<header>…</header>
    <div class="listFrame">
      <div class="list">…</div>
      ${this.scrollEdges.renderShadows()}
    </div>`;
}

scrollEdges.top and scrollEdges.bottom tell whether content lies past each edge. A host that scrolls the list from a frame loop of its own, as a drag that scrolls at the edge does, calls scrollEdges.sync() to show the edges in the same frame. The theme token comes with themeDefaultStyles.

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.

A button that opens a menu, a listbox or a dialog, or shows a panel, says so on its face — the element that carries the button role and the focus — through three ARIA properties: ariaHasPopup ('menu', 'listbox', 'tree', 'grid', 'dialog', 'true' or 'false'; TDeesButtonHasPopup), ariaExpanded ('true' or 'false') and ariaControlsElements (the menu or panel it controls). Each is null by default, which leaves the face a plain button. Set them as properties, or write aria-haspopup and aria-expanded in markup: the attributes set the properties and move from the host to the face, so no generic node beside the button claims the popup, and a later write updates the face; clear one through its property (or write aria-expanded="false"), since the host no longer holds the attribute to remove. The controlled elements are given as elements, not as an aria-controls IDREF, because an IDREF cannot reach from the face inside the button's shadow root to a menu outside it.

The face also carries the button's name. ariaLabel (or aria-label in markup, which moves from the host to the face in the same way) names a face whose label does not say it: an icon face at a size other than icon, where text would show, or a label that needs more words for assistive technology, such as a disabled Stop that says why. null (the default) names the face by its label, or a size="icon" face by its text.

<dees-button
  text="Account" icon="lucide:user"
  aria-haspopup="menu"
  .ariaExpanded=${this.menuOpen ? 'true' : 'false'}
  .ariaControlsElements=${[this.accountMenu]}
  @clicked=${() => this.toggleMenu()}
></dees-button>

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
  dot            // Optional: a status dot before the text, in the badge colour
  pulse          // Optional: the dot pulses for a state in progress; it holds still under reduced motion
  icon="lucide:circleAlert" // Optional: a glyph before the text; takes the place of the dot
></dees-badge>

The dot and the icon are decorative (aria-hidden): the text says what the badge means, so a status badge reads "● Failed" and is announced as "Failed".

DeesChips

Interactive chips/tags with selection capabilities.

<dees-chips
  selectionMode="multiple"  // Options: none, single, multiple
  chipsAreRemovable        // Optional: each chip gets a remove button, named "Remove <key> <value>" in the page language
  .selectableChips=${[
    { key: 'tag1', value: 'Important' },
    { key: 'tag2', value: 'Urgent' }
  ]}
  @selection-change=${handleSelection}  // detail: { selectedChip, selectedChips }
></dees-chips>

In single and multiple mode each chip's words are a toggle button with aria-pressed: Tab reaches it, Space or Enter selects it, and a pointer selects from anywhere on the chip. A keyboard focus ring surrounds the whole chip. selection-change fires when a chip is selected or deselected, and when a selected chip is removed. In none mode chips are display-only and take no focus. Removing a focused chip hands focus to the next remove button.

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. infoText adds an info button after the label, named "More information" in the reader's language. Its speech bubble (markdown) opens on hover and on keyboard focus, describes the button while it shows, and closes on Escape, on blur and when the pointer leaves.

<dees-label
  .label=${'Email Address'}      // Label text
  .required=${true}              // Optional: shows red asterisk
  .infoText=${'We will never share your email'}  // Optional: info button with a hover and focus bubble
></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;
}

DeesState

What a place says while its content loads, when it has none, and when it failed — a dashboard tile, a panel, a list. One element for all three, so every place of an app says them alike; dees-table says the same through its own loading, error and 'empty' actions. Search terms: empty state, loading state, error state, retry, blank slate.

html`<dees-state variant="loading" heading="Loading deployments…"></dees-state>`

html`<dees-state
  variant="empty"
  iconName="lucide:Boxes"
  heading="No services yet"
  description="Deploy a service from an image, or pick one from the app store."
  .actions=${[{ label: 'Deploy service', iconName: 'lucide:Rocket', type: 'primary', action: () => this.deploy() }]}
></dees-state>`

html`<dees-state
  compact
  variant="error"
  heading="Could not load nodes."
  description="The cluster did not answer in time."
  .detail=${String(error)}
  .onRetry=${() => this.loadNodes()}
></dees-state>`
  • variant (reflected) is 'loading', 'empty' (the default) or 'error'. The glyph is a spinner while loading, else iconName or the variant's icon (an inbox, an alert circle in the error colour).
  • heading is the sentence; unset, the variant's own in the reader's language ("Loading…", "Nothing here yet", "This could not be loaded."). description says what comes next or why it failed. Loading and empty are a status, an error is an alert.
  • loading shows after --dees-state-loading-delay (150ms) and fades in, so a quick load never flashes it.
  • actions (IDeesStateAction[]: label, iconName?, type? 'primary' | 'secondary', disabled?, action) are the way on from an empty or failed place; a loading state shows none. onRetry adds a Retry button to an error, and detail folds its technical cause under "Technical details". Every callback is awaited with its button pending meanwhile, and a rejection is reported as dees-action-error (source: 'state-action' or 'state-retry'). The actions slot takes controls of the host's own, the default slot anything below.
  • compact (reflected) tightens the padding and type for a dashboard tile or a narrow panel. The element centres its content in whatever height its host gives it.

DeesStatusbutton

A page's status, as one button in its header beside the page's actions: its icon, colour and count say how the page's data stands, and a press opens a panel — a speech bubble anchored to the button — that lists every status worst first. It takes the place of the dees-hint banners a page would otherwise stack above its content ("No gaps in the statement chains.", a warning about missing data, a list that is partial), so the content starts right under the header. Search terms: status button, page status, warnings, errors, issues panel, health indicator.

The pattern, the design.estate standard for a view whose statuses would otherwise sit above its table: the page header holds the status button beside its actions (and the table's search field), and the page body is the table alone, frameless and fill.

html`<dees-appui-page heading="Bank statements" description="4 statements · 2 accounts" .actions=${actions}>
  <dees-table-search slot="actions" for="statements" dataName="statements"></dees-table-search>
  <dees-statusbutton slot="actions" .items=${statuses}></dees-statusbutton>
  ${loading
    ? html`<dees-state variant="loading"></dees-state>`
    : html`<dees-table id="statements" frameless fill .searchable=${false} dataName="statements"
        .columns=${columns} .data=${rows}></dees-table>`}
</dees-appui-page>`

const statuses: IStatusbuttonItem[] = [
  { key: 'chains', severity: 'success', text: 'No gaps, breaks or duplicates in the statement chains.' },
  { key: 'undated', severity: 'warning', text: '3 transactions have no booking date.',
    detail: 'They are listed under their value date until a booking date arrives.' },
  { key: 'missing-october', severity: 'warning', severe: true,
    text: 'The October statement of the operating account has not arrived.',
    action: { label: 'Request statement', iconName: 'lucide:Send', action: () => this.requestStatement() } },
];
  • items (IStatusbuttonItem[], assign a new array to change them): key (stable and unique within the button), severity (TStatusbuttonSeverity: 'success', 'info', 'warning' or 'error', dees-hint's types), severe? (a warning that must be seen), text (one short sentence), detail? (a second line) and action? (IStatusbuttonAction: label, iconName?, disabled?, action).
  • The button is an outline dees-button like the page's secondary actions. Its glyph and colour are the worst severity's (the all-good check while there are only successes or nothing), in dees-hint's icons and the theme's text colours, and a warning or error tints the face as a hint tints its box. Its number counts the warnings and errors; notes and successes are listed but not counted. Its accessible name is the heading and that summary ("Status: 1 error, 2 warnings", "Status: No warnings or errors"), and it reports aria-haspopup="dialog", aria-expanded and the panel it controls. severity and attentionCount answer the same in code, and the host carries :state(success | info | warning | error) and, without statuses, :state(empty) for the page's own styles.
  • The panel is a dees-popover with its tail, below the button (placement 'top' for a bar at the bottom), at most 360px wide and inside the viewport on a phone. It is a dialog named by heading (empty: the catalog's "Status"), shows the summary under the heading, and lists the statuses errors first, then severe warnings, warnings, notes and successes, in their given order within each; each is a dees-hint with its text, detail and action, and a screen reader hears its severity before the text.
  • A press of the button (click, Enter or Space) opens the panel and moves the focus to its heading; a press of the button, Escape, a press outside or the focus leaving it closes it, and the button and Escape give the focus back to the button.
  • An error, or a warning marked severe, opens the panel by itself when it appears: a key never listed before, or one that was less than that when it was last listed. It opens without moving the focus, and the new statuses are announced, assertively for an error and politely for a severe warning; Escape from elsewhere leaves the focus where it is. The same statuses rendered again do not reopen it, and once the reader closes it, it stays closed until another error or severe warning appears. A key keeps its last state while it is absent, so a host that empties its statuses while it reloads does not reopen the panel for the same error. While the button is off screen (a hidden tab, a collapsed region) the opening waits until it shows. While the panel is open, a new error or severe warning is announced at once.
  • Each action is awaited with its button pending meanwhile and refuses a second press; a rejection is reported as dees-action-error (source: 'statusbutton-action'). A status the action resolves usually leaves the list: the focus then stays in the panel, on its heading.
  • Without statuses the button shows the all-good state, so the header's actions keep their places; hide-empty (reflected, hideEmpty) removes it from the layout instead.
  • open (reflected) is the panel's state; set from code, it opens without moving the focus, and while the button is off screen it stays closed. Every opening and closing is reported as the bubbling, composed panel-toggle (IStatusbuttonToggleDetail: { open, reason }, reason 'button', 'auto', 'host', 'escape', 'outside', 'tab' or 'anchor-hidden'). An open panel closes when the element leaves the page; that closing ('host') reaches only listeners on the element itself, which is no longer in the page.
  • The panel eases in from the button, and appears at once under prefers-reduced-motion: reduce. All words come from the catalog (statusbutton.*) in the reader's language; the statuses' own words are the host's.

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 the toast or its close button
  • Smooth animations and transitions
  • Automatic stacking of multiple toasts
  • Theme-aware styling
  • Programmatic control

The message is a live region: info and success toasts are a role="status", which waits for the reader's current speech, and warning and error toasts a role="alert", which interrupts it. The type's icon is named ("Information", "Success", "Warning", "Error"), so the announcement says what kind of message it is. The close button is a real <button> named "Dismiss" and described by the message; it stays outside the live region. Escape dismisses the toast the focus is in and goes no further, so a dialog behind the toast stays open. A toast dismissed while it holds the focus gives the focus back to the element it came from, if that element is still on the page. A pointer over the toast or the focus inside it holds the auto-dismiss countdown, and the progress bar with it; the countdown resumes with the time it had left once both are gone. All words come from the catalog in the page's language. Under prefers-reduced-motion: reduce the toast appears and leaves without sliding or fading, and the progress bar stands still; the duration is unchanged.

DeesButtonExit

Exit/close button component with consistent styling. Its accessible name is the catalog's "Close" in the language it speaks (the page's, or an overlay's i18n); label overrides it.

<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; the text is slotted.

<dees-heading level="1">Page title</dees-heading>  // '1'-'6' render <h1>-<h6>
<dees-heading level="hr">Today</dees-heading>      // 'hr' (or '7'): words between two faded rules
<dees-heading level="hr-small">Earlier</dees-heading> // 'hr-small' (or '8'): the compact rule heading

Sizes come from the theme's type scale, one step per level: h1 large title (34px), h2 title 1 (28px), h3 title 2 (22px), h4 title 3 (20px), h5 callout (16px), h6 subhead (15px); the small rule heading uses caption 1 (12px). Retuning --dees-font-*-size retunes every 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. hintTypeIcons maps each type to its glyph. A page's statuses — what a page would stack above its content — belong in a dees-statusbutton in its header.

<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 and returns the submit button to its normal state and its own label. setStatus(state, text?) shows normal, pending, success or error on the submit button and enables the fields for normal or disables them for the others; text labels the status ("Saving…") in place of the button's own label, which it never overwrites: without text, and after reset(), the button shows its own label again. 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.

Enter in a field moves to the next enabled field; in the last one it submits. With a dees-form-submit, that is the button's submit(). Without one, the form dispatches form-submit-request (bubbling, composed, cancelable; the name is exported as formSubmitRequestEvent) and the field keeps the focus: dees-stepper answers it with the step's primary action and dees-modal with its last (accent) action, each exactly as a click on that button, gated the same way. A form nobody answers for submits itself: it gathers and dispatches formData. A held Enter submits once; its repeats are ignored. Multiline editors, composition, buttons and pickers keep Enter for themselves.

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 of the form or of a dees-form-section. Compound fields (tags, lists, uploads, avatars and editors) retain a full-width editing surface and a label above it.

DeesFormSection

Structures a longer form into titled parts. Each dees-form-section inside a dees-form is a group of fields (role="group") named by its heading and described by its optional description; heading-level (default 3) places the heading in the page's outline. The form still collects every field under its own key: sections structure the form, not its data.

html`<dees-form @formData=${(event) => save(event.detail.data)}>
  <dees-form-section heading="General" description="Name the workspace and decide who can find it.">
    <dees-input-text key="name" label="Workspace name" required></dees-input-text>
    <dees-input-dropdown key="visibility" label="Visibility" .options=${visibility}></dees-input-dropdown>
  </dees-form-section>
  <dees-form-section heading="Privacy" description="What your teammates see about you.">
    <dees-input-toggle key="activity" label="Share activity"></dees-input-toggle>
  </dees-form-section>
  <dees-form-submit type="accent" text="Save"></dees-form-submit>
</dees-form>`
  • On a wide form the heading and description stand in a column beside the fields and are sticky: they stay at the top of the view while the section's fields scroll past, until the end of the section pushes them on. Below 560px of section width they sit above the fields and scroll with them.
  • The form adds no gap between sections; a hairline separates each section from what follows it. In a grouped form each section draws its own inset card of settings rows beside its heading, instead of one card behind the whole form.
  • --dees-form-section-heading-width (180px) sizes the heading column, --dees-form-section-sticky-top (the lg spacing) sets how far below the top of the view the heading stays, and --dees-form-section-padding-inline (0) insets a section whose form sits edge to edge in a card, as in the input showcase.
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.

Every input reports a value change the reader makes as two DOM events on its host, both bubbling and composed, so a Lit template or a composite input listens with @input and @change as on a native control:

  • input as the value changes: each keystroke of a text field, each edit of an editor (code, richtext, wysiwyg), each pick of a choice.
  • change when the reader commits it: a text field (text, textarea, phone, IBAN) when the reader leaves it with a changed value or presses Enter in a single-line one, as the native field does; an editor when the reader leaves it after editing; every other input — checkbox, toggle, dropdown, radiogroup, multitoggle, quantityselector, datepicker, tags, typelist, list, fileupload, profilepicture — at once, right after its input.

The event's target is the input: read event.target.value (or getValue()). The events an input dispatches itself also carry the value as detail.value; the input of dees-input-text and dees-input-textarea is the native InputEvent, which a composite such as dees-input-phone reads as typing. A value set by code (value, setValue(), reset()) reports neither, and the events of the controls inside an input — a draft entry, a search, an editor's internals — stay inside it. changeSubject notifies as before. A subclass reports through reportValueCommit() (a choice or a commit), reportValueEdit() (an editor's edit, committed on leaving) or forwardNativeChange (its native field's change); a class whose native field's input is its own sets static valueEventsFromNativeField = true.

html`<dees-input-text label="Workspace name"
  @input=${(event: Event) => preview((event.target as DeesInputText).value)}
  @change=${(event: Event) => save((event.target as DeesInputText).value)}></dees-input-text>`

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 an ⓘ button on the label with a hover and focus bubble
  .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). Listen to input and change (see the value events), 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>

.labelPosition=${'none'} draws no label and keeps it as the switch's accessible name, for a row that draws its words itself.

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.

An option (IDropdownOption) may carry description, a second line under its label, and disabled: true. A disabled option stays in the list, marked aria-disabled, with its description saying why or what to do; it is never hidden. Pointer, Enter, search and the API (updateSelection, setValue, value, selectedOption) never select it — a selection that names a disabled option, by its flag or its key, reads as no selection. The arrow keys still reach it so its reason can be read; the list opens on the selected option, else the first usable one.

.options=${[
  { key: 'team', option: 'Team members' },
  { key: 'public', option: 'Anyone with the link', disabled: true,
    description: 'Your organization turned off public links.' },
]}

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.

  • multiple (default true) takes several files at once; .multiple=${false} keeps one, which a new choice replaces.
  • capture ('environment' or 'user') is passed to the file input, so a phone opens its rear or front camera instead of the file chooser; with accept="image/*" the photo arrives as a chosen file. Desktop browsers open their file chooser as before.
  • The field selects files; the host uploads them and reports each one with setFileState(file, state), where state is an IFileUploadState: { status: 'queued' | 'uploading' | 'done' | 'error', progress?: number, message?: string }. While uploading, the row draws a progress bar (role="progressbar", aria-valuenow in percent) filled to progress, from 0 to 1; without a progress the bar shows work of unknown length. done and error show a mark and the message, or "Uploaded" and "Upload failed", and a live region says once that the file finished or failed, with the reason. null takes the state away, getFileState(file) reads it back, and a file the reader removed loses its state; a report for a file that is no longer chosen is ignored. Under prefers-reduced-motion: reduce the bar fills without easing and work of unknown length holds still.
const field = form.querySelector('dees-input-fileupload')!;
for (const file of field.value) {
  field.setFileState(file, { status: 'uploading', progress: 0 });
  try {
    await upload(file, (share) => field.setFileState(file, { status: 'uploading', progress: share }));
    field.setFileState(file, { status: 'done' });
  } catch (error) {
    field.setFileState(file, { status: 'error', message: (error as Error).message });
  }
}

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, and the family's input and change with detail.value. 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.

The options sit in one radiogroup named by the label (with aria-required, aria-disabled and aria-invalid), and the group is one tab stop: Tab reaches the chosen option, or the first while nothing is chosen. The arrow keys move the choice with the focus and wrap; Space chooses the focused option. Enter belongs to the form, as in a native radio group: it moves to the next field, or submits from the last.

<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

Editable list input with inline editing, reordering by pointer and keyboard, candidate completion, and validation.

<dees-input-list
  key="items"
  label="List Items"
  placeholder="Add an item…"
  .value=${['Item 1', 'Item 2', 'Item 3']}
  .maxItems=${10}            // Optional: maximum items
  .minItems=${1}             // Optional: minimum items
  .allowDuplicates=${false}  // Optional: allow duplicate values
  .sortable=${true}          // Optional: show drag handles for reordering
  .confirmDelete=${true}     // Optional: confirm before deletion
  @change=${handleListChange}
></dees-input-list>

// With candidates: typed text completes to a known entry
<dees-input-list
  key="owners"
  label="Owners"
  .candidates=${[
    { viewKey: 'Design team', payload: { id: 'design' } },
    { viewKey: 'Platform team', payload: { id: 'platform' } },
  ]}
  .allowFreeform=${true}     // Optional: also accept entries that are no candidate
></dees-input-list>
Property Description
value The items, as strings. Every add, edit, delete and move emits one change event (detail.value) and notifies changeSubject.
candidates IListCandidate[] ({ viewKey, payload? }). Typing shows the shortest matching viewKey as inline ghost text: Tab, ArrowRight at the end of the text or a click on the ghost text accepts it, ArrowDown and ArrowUp cycle through the matches, Escape dismisses it, Enter adds the entry that matches exactly. Without a completion showing, Tab and Shift+Tab move the focus as ever. An item that is no candidate carries a Custom mark.
allowFreeform With candidates, also accept an entry that matches none; otherwise such an entry is refused with a message. The rule holds for Enter and the add button alike. Without candidates every entry is free.
maxItems / minItems 0 means no limit. With a maximum, a counter beside the add field shows 2 / 8 (read out as "2 of 8 items"), and a full list shows "Limit reached" in place of its add field; validate() reports too few items.
allowDuplicates Accept an entry that is already in the list. Otherwise the entry is refused with a message.
sortable Give each item a drag handle.
confirmDelete Ask through DeesModal.confirm() before an item is deleted.
frameless Drop the list's outer frame when the host owns it (frameless components).
Method Description
getCandidateForItem(viewKey) The candidate an item was added from, with its payload; undefined for an item typed as free text.
getAddedCandidates() The candidates among the items, in list order.
validate() Checks required and minItems and shows the verdict below the list.

Layout: the label names the list, so the tile draws no heading bar. Every row starts its text in one column, after a leading slot that holds an item's drag handle or the add field's plus. With a mouse or trackpad a row shows its edit and delete actions while the pointer or the focus is on it; they stay in the tab order, and on a touch screen they always show. The add button, drawn as the Enter key, appears once there is something to add. Double-click an item, or use its edit action, to edit it in place: Enter or Save keeps the edit, Escape or Cancel drops it, and moving the focus out of the row keeps it. Closed by a key or its own buttons, the editor hands the focus back to the row's edit button. An edit that duplicates another item is refused with a message and stays open; another edit starts only once it is kept or dropped. While an input method composes, Enter and Escape belong to the composition, in the add field and in the editor alike. An Escape that drops an edit or dismisses a completion is spent on it, so a dialog around the list closes only on the next Escape. A failed validate() rings the list in the error colour until a check passes; a refused entry only says why.

Reordering: drag an item by its handle with a mouse, pen or touch (Escape cancels the drag), or focus the handle and press ArrowUp, ArrowDown, Home or End. Either way the move is announced to assistive technology, the focus stays with the moved item, and the rows glide into place unless the reader asked for reduced motion.

Accessibility: the items form a list, every icon button is a named button (edit, delete, save, cancel, add, reorder), and a refused entry's message sits in a status region that the add field references through aria-describedby while it is aria-invalid, together with the counter and the suggestion shown. A refusal lasts as long as the value it refused, in the add field or an item's editor: it holds the message and aria-invalid, and ends once the value is one the list takes, whether the reader changed the value or the list changed under it, or when the edit ends. A failed validate() verdict speaks again once a refusal over it ends. A deletion is announced, a second one of the same text too, and leaves the focus on the next item, or on the add field once the list is empty. The item that fills the list to maxItems takes the focus from the add field it replaces, and the limit is announced. With confirmDelete, the dialog names the item and draws Delete as a destructive action.

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
  @input=${handleContentInput}    // each edit
  @change=${handleContentChange}  // once the reader leaves the editor after editing
></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

Rich editor for HTML or Markdown. 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
></dees-input-wysiwyg>

The toolbar and the footer stay in view while the document scrolls past them, as in dees-table, flush with the edges the reader sees: in a scroller with padding they cover the padding too, so no text shows above the toolbar or below the footer. The toolbar holds undo and redo, the inline marks, the table and code block inserts and, for Markdown, the source view. The footer counts the words and characters a reader sees and searches the document: its search button opens into a field, and once there is a query every match is highlighted and the match count with previous and next appears. Enter and Shift+Enter (or F3 and Ctrl+G) step through the matches, scrolling smoothly to one out of view (at once under reduced motion), Escape clears the query and then closes the field, and Ctrl+F inside the editor opens it with the selected phrase. Formatting keeps the formatted text selected. showToolbar (show-toolbar) and showFooter (show-footer) turn either bar off, openSearch(query?) opens the search from code, and --dees-wysiwyg-sticky-top and --dees-wysiwyg-sticky-bottom keep the bars clear of a host's own fixed bars, measured from those visible edges. frameless drops the outer frame.

Every block has a context menu, opened by a right click, or by the handle beside a Markdown block: change its type, cut, copy and paste the selected text, add a block below, duplicate it, move it up or down, copy a Markdown block's source, or delete it. A table block adds its row and column actions. In Markdown mode each action is one exact, undoable splice of the block's source.

With outputFormat="markdown", .value is the exact source string, and the document is edited as the editor's blocks: paragraphs, headings, lists with task boxes, quotes, dividers, images, GFM tables, and fenced code blocks with the code block's language picker, line numbers, copy button, and syntax highlighting. Inline bold, italic, strike, code, and links edit in place. Each edit changes only its source span, so untouched syntax, line endings, frontmatter, references, and blank lines remain intact.

Writing works as in a block editor. Enter starts a new block or the next list item, and Enter in an empty item ends the list; Shift+Enter breaks the line inside the block; Backspace at a block's start joins it to the block before; the arrow keys move between blocks. A new, empty paragraph writes no source until its first keystroke. Type / in an empty paragraph for the slash menu, including /table, or a Markdown shortcut: # for a heading, - or 1. for a list, > for a quote, three backticks and Enter for a code block, --- and Enter for a divider. In a table, Tab and Enter move between cells and add a row at the end, and the table's actions appear while one of its cells has focus. A code block edits its body as literal code between the fences; picking a language rewrites only the fence's info word. Images load only from http(s), relative, or inline raster sources. Code that cannot be mapped exactly (indented code, fences inside lists or quotes), HTML, and frontmatter appear as raw source, and Source view shows the whole document.

Read .value or getValue() immediately after an input event, including before blur or navigation. exportState() includes markdownSource for exact restoration; setValue() creates one exact-source Markdown segment. importBlocks() preserves structured blocks, IDs, and metadata; exportBlocks() returns those segments without normalizing them. Markdown source segments remain rich-editable. Imported image, YouTube, and attachment blocks keep their edit controls. Other structured legacy blocks appear in a source textarea so their content can be edited without executing embedded HTML. Programmatic insert, move, remove, and update operations preserve the segment structure and participate in undo and redo.

With outputFormat="html", the editor keeps its block workflow: slash commands, paragraph and media blocks, drag and drop block reordering, and keyboard shortcuts. exportAsHtml() and exportAsMarkdown() remain available in either mode.

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>

text (or the text content) is the button's own label. statusText labels a status in its place while set; dees-form's setStatus() writes it and reset() clears it.

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

DeesDashboardgrid

A dashboard of tiles on a column grid that the reader arranges: a tile moves by its header, resizes from its end and bottom edges and is deleted from its menu, and all three work from the keyboard. Every tile is a dees-tile, so frame, corner radius and surface match the rest of the catalog; content that draws its own box, such as a table or a chart, sets frameless inside it. Tiles collide predictably: a tile of the same size that the moved tile lands on alone swaps places with it; every other tile it lands on keeps its column and moves down below it, pushing the tiles under it down in turn; locked and noMove tiles never move, so a tile may not land on one, and a pushed tile goes past it. The grid never moves tiles back up on its own; compact() does.

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

const widgets: DashboardWidget[] = [
  { id: 'revenue', x: 0, y: 0, w: 3, h: 2, title: 'Revenue', icon: 'lucide:dollarSign', content: html`<my-revenue></my-revenue>` },
  { id: 'deploys', x: 3, y: 0, w: 6, h: 4, minW: 4, title: 'Deployments', content: html`<dees-table frameless .data=${rows}></dees-table>` },
  { id: 'uptime', x: 0, y: 2, w: 3, h: 2, content: html`<my-uptime></my-uptime>` },
];

<dees-dashboardgrid
  .widgets=${widgets}
  .columns=${12}
  .cellHeight=${80}
  .margin=${10}
  @layout-change=${(e) => saveLayout(e.detail.layout)}
></dees-dashboardgrid>

A tile (DashboardWidget) has a place and size in grid cells (id, x, y, w, h) and its content, a template or a string. Optional: title and icon (a lucide:<name>) for its header, minW/maxW/minH/maxH, noMove, noResize, locked (neither moves nor resizes nor can be deleted) and autoPosition (placed in the first free slot by addWidget).

Property Default Description
widgets [] The tiles; reading it returns them as the reader arranged them. Assigning an array replaces them, and assigning the array last assigned again changes nothing — see below.
columns 12 Grid columns.
cellHeight 80 Row height, in cellHeightUnits.
cellHeightUnit 'px' 'px'; 'em', measured in the host's font size; 'rem', in the root element's; or 'auto', which makes a row as tall as a column is wide and ignores cellHeight. The grid recomputes its rows when these properties, margin or columns change, when it resizes, and when the font size an em or rem row is measured in changes.
margin 10 Space around every cell in px: a number, or { top, right, bottom, left }.
editable true Whether tiles move, resize and can be deleted at all.
enableAnimation true Tiles glide to their place on the theme's slow timing; under reduced motion they land at once. Reflected.
showGridLines false Shows the grid's cells at all times, each exactly where a one-cell tile would sit. Without it the cells show only while a tile is held.
rtl false Lays the columns out from the right: column 1 is the rightmost, the resize edges sit on a tile's left and bottom and grow it to the left, and the horizontal arrow keys are mirrored.
layouts / activeBreakpoint undefined / 'base' Named layouts (Record<string, DashboardLayoutItem[]>) that applyBreakpointLayout applies.
Method Description
addWidget(widget, autoPosition?) Adds a tile, in the first free slot when autoPosition (or the tile's own) is set.
removeWidget(id) Removes a tile and sends widget-remove.
updateWidget(id, updates) Merges updates into a tile.
getLayout() / setLayout(layout) Reads or applies the places and sizes (DashboardLayoutItem[]) without the content.
compact(direction?) Moves every movable tile up ('vertical', the default) or left ('horizontal') as far as it goes.
applyBreakpointLayout(name) Makes name the active breakpoint and applies its layout from layouts.
lockGrid() / unlockGrid() Sets editable.
Event Detail Sent
widget-move { widget, displaced, swappedWith } after a drag ends or an arrow key moves a tile
widget-resize { widget, displaced, swappedWith } after a resize drag ends or a Shift+arrow key resizes a tile
widget-remove { widget } after a tile was removed
layout-change { layout } whenever a tile's place or size, or the set of tiles, changes, except in the middle of a drag; persist the layout here

Hosts and the arrangement. The grid owns the arrangement once it has the tiles: moves, resizes, deletions and its methods change the tiles it shows, not the array the host passed in. Lit sets an array binding again on every render of the host, even when the host still holds the same array, so the grid ignores the array it was last given when it is assigned again: a host that binds .widgets=${this.widgets} and re-renders for any reason keeps the reader's arrangement. To replace the tiles, bind a different array (a reset builds a new one); to keep the arrangement, store e.detail.layout from layout-change and apply it with setLayout(layout), or fold it into a new array the host binds. layout-change is sent only when the layout changed, so a host that binds every layout back settles after one event.

All events bubble and are composed. displaced lists the tiles that moved out of the way; swappedWith names the tile that traded places. The types (DashboardWidget, DashboardLayoutItem, DashboardMargin, CellHeightUnit, LayoutDirection …) are exported from the package entry.

Density. A tile's header follows the tile's size in px, so tiles can be as small as one short row. The grid derives the level from the size it draws each tile at and sets it as data-density (regular, compact, super) and data-narrow on the tile's wrapper; the levels switch at once, live while a tile is resized. The grid adds no CSS containment to a tile, so position: fixed content (a table's floating header, a popup) keeps the viewport as its reference:

Level When Header
regular 140px tall or more a 40px bar with the icon and title, a divider above the content
compact under 140px tall a 28px bar with a smaller title and no divider
super compact under 88px tall (one row at the default cellHeight) no bar: the title is a small muted label in the tile's top inline-start corner, and the content takes the rest; no icon
narrow under 160px wide (combines with the others) no icon; the title truncates with an ellipsis

The label and the truncated title are still the tile's handle: they keep the full title as the accessible name, the keys, the menu and the focus ring. A tile without a title keeps its 24px grip at every size, and the resize badge moves closer to the edge in short tiles. Content that should adapt to its own space can use container queries on an element of its own, as the demo's metrics do; the containment that brings applies to that content alone.

Keyboard. Every tile the reader may change has one handle, a button: a titled tile's header, or a grip in the top corner of a tile without a title (it shows on hover and on focus). Tab reaches it, and the focus rings the whole tile. The arrow keys move the tile one cell, Shift and the arrow keys resize it one cell (right and down grow it, left and up shrink it; under rtl left grows it and right shrinks it, so each arrow points where the tile or its free edge goes), and the menu key or Shift+F10 opens the tile's menu with Delete, the same menu a right-click on the handle opens. The handle names the tile (its title, else "Tile 3" after its place), states the keys in aria-keyshortcuts and describes them in aria-description, offering only what the tile allows. A live region says where a moved tile landed, what size a resized one took and which tile was deleted, and says it again when the same words repeat, such as for a second deleted tile of the same name. After a deletion the focus moves to the handle of the next tile that has one, else of the nearest one before it; when no tile has a handle left, it lands on the grid itself (or its empty state), outside the Tab order. The resize edges are pointer-only and hidden from assistive technology, since Shift and the arrow keys resize from the handle. With editable off, or on a locked tile, there is no handle, no resize edge and no Delete.

Pointer. A mouse or pen lifts a tile by its handle at once; a finger lifts it after holding the handle still for 300ms, so a finger that swipes over a handle scrolls the page. A touch that lifted a tile and keeps holding still opens the tile's menu instead. The corner grip shows on hover and while the handle has the keyboard focus; the end and bottom edges light up under the pointer. While a tile is held the grid shows its cells and offers one empty row below the lowest tile; a soft placeholder shows where the tile will land and the tiles it pushes move aside, and a resized tile carries a badge with the size it snaps to (columns × rows). The release lands exactly the layout shown. A place the tile may not take keeps the last one shown. Near the top or bottom edge of a scrolling ancestor, or of the viewport, the held tile scrolls it, faster the closer it gets. Escape, a cancelled pointer or the window losing the focus puts every tile back and sends no event. Under prefers-reduced-motion the placeholder, the cells and the lift appear at once, and tiles land without gliding.

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.

DeesTile

The catalog's tile: an outer frame, a header, the content inset between hairline rules and a footer, which most boxed components are built on. The header shows heading unless a slot="header" element replaces it; the footer shows while there is slot="footer" content, footer text or a footer action. frameless drops the frame and the surface where the host owns them, and a frameless tile takes the region inset.

Actions about what the tile shows are its actions, in a footer or header bar (see Where actions go):

html`<dees-tile
  heading="Month close"
  footer-text="Closed through September"
  .actions=${[{
    name: 'Open the audit trail',
    iconName: 'lucide:History',
    type: ['footer'],
    actionFunc: async ({ tile }) => openAuditTrail(),
  }]}
>
  <month-close-summary></month-close-summary>
</dees-tile>`
  • An ITileAction is written like a dees-table header or footer action: name is the control's text and accessible name, iconName an optional glyph, type where the tile offers it (['footer'], ['header'] or both), actionFunc the awaited callback, which receives { tile }. disabled keeps an action that applies but cannot be taken yet visible: dimmed, out of the tab order and never run.
  • The footer bar has footerText (reflected as footer-text), a short text such as the period the content covers, on its leading side, followed by any slotted footer content, and the footer actions at its end: borderless 28px controls in 12px type, as in a table's footer; footer actions that do not fit wrap onto further lines at the bar's end. The header actions sit at the end of the heading's own 24px line, as borderless controls of the footer's face as tall as the heading, so a tile's header is as tall with actions as without and tiles side by side line up; they stay on that line, and the heading, or the slotted header, gives way by truncating, so keep them to one or two: in a column too narrow for them they would overflow the tile. Under a coarse pointer every action takes a 44px target, and a header with actions grows to it. Both bars sit at the tile's inset (16px, or the region inset while frameless), and the footer keeps its rule. A label never wraps.
  • The slots combine with the bars. Without actions and footer text, a slot is the bare container it always was and its content lays itself out. In a bar, slotted content sits on the leading side inside the bar's padding, so it brings no inline padding of its own.
  • A pressed action is awaited. Meanwhile its control shows a spinner in place of its glyph, or beside its label, is marked aria-busy and aria-disabled — never natively disabled, so the focus stays on it — and refuses a second press; the width change eases, except under reduced motion. The busy state is held under the action's name, so a host that rebuilds actions while one runs keeps it, an action offered in both bars is busy in both, and two actions that share a name share it. isActionPending(action) reports it. A rejected callback dispatches dees-action-error with the source tile-footer or tile-header (see Failed actions) and hands the control back.
  • The controls are native buttons in reading order, header before footer: Tab reaches them, Enter and Space run them, and coarse pointers get 44px targets.
  • dees-settings offers its actions as its tile's footer actions.

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;
  routing?: IAppRoutingConfig; // { mode: 'hash' | 'path', basePath?, hostPaths? }; hash when unset
  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[];
  tray?: boolean;                   // the tabs tray: pages a reader keeps across views, above the bottom bar
  documentTitle?: string | ((view: IViewDefinition, route: IAppRoute, location: IAppLocationItem[]) => string); // '{view}', '{location}'
  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; with tray: true it keeps the tray's tabs too. setStorage(storage) takes another adapter while the shell runs, for a host whose scope changes (a reader switching to another set of books), and reads the layout and the tray's tabs from it again: the tray shows the new scope's tabs, and the last scope's are never written there. 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 or Path Routing — Automatic URL synchronization with view navigation and parameterized routes, by hash (default) or by path through the History API (routing: { mode: 'path', basePath, hostPaths }), beside the server routes the host keeps
  • 📊 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
Persistence setStorage(storage): another storage adapter while the shell runs; the layout and the tray's tabs are read from it again, and every change is written to it only
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 9 distinct item types for building rich contextual sidebars:

Type Description
Tab (default) Selectable item that stays highlighted
Parent Expandable row that opens to the tab rows it holds; choosing it selects its first child
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. A trail set before a navigation does not carry over to the screen it leads to.

The document's name can follow the location: documentTitle: '{location} · Gateway' names the browser tab after the trail the location field shows — the labels of the last setLocationBreadcrumbs(), joined by " › " ("Settings › Notifications · Gateway"), else the view's name ("Settings · Gateway"). Each setLocationBreadcrumbs() names the document again, so a record or section the host selects within a view renames the tab with it. {view} is still the view's name alone, and a documentTitle function receives the trail as its third argument ((view, route, location) => string). The trail belongs to the screen it is set for: a navigation starts the next screen without it, so a view sets its own when it activates (or later, when it selects a record), and a view that sets none shows its name — in the location field and in the title alike. A navigation whose view fails to activate keeps the previous route, location field and trail; the content area reports the failure. 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 9 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.

A parent row ({ type: 'parent', key, label, children: [...tab rows] }) opens to its children the way a dees-simple-appdash view opens to its sub-views, with the same indented rule and motion and under the same rule: it is open while one of its children is the selected item, a deep link included, and closes when the selection moves outside it. It is no view of its own: choosing it selects its first child that can be chosen. Right and Left open, close and walk it as a tree; while none of its children is selected a reader may open it by hand, until the selection moves. item-select reports { item, group, parent } for a child; in the rail an open parent's children stand in the icon column. Each row shows its own badge; a parent sums nothing.

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: 'parent', key: 'integrations', label: 'Integrations', iconName: 'lucide:plug', children: [
          { key: 'connections', label: 'Connections', iconName: 'lucide:link', action: () => select('connections') },
          { key: 'syncruns', label: 'Synchronization runs', iconName: 'lucide:refreshCw', action: () => select('syncruns'), badge: 2, badgeVariant: 'error' }
        ] },
        { 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>

DeesAppuiTray

The tabs tray's bar: pages a reader keeps, to come back to them from any view. dees-appui shows it above its bottom bar when configured with tray: true (see the tabs tray); on its own it renders tabs (IAppTrayTab[]), marks currentKey and reports tray-tab-select, tray-tab-remove (detail { tab }), tray-keep and tray-close, all bubbling and composed. The tabs are one Tab stop: arrows and Home/End move, Delete or Backspace removes the focused tab, Escape closes. A tab's remove button is for the pointer; the keyboard has Delete. canKeep=false disables "Keep this page".

DeesAppuiPage

The page of a view inside dees-appui: its header and its content within one max-width and one padding, so every view of an app lines up and none hand-codes a header. Search terms: page header, page title, header actions, page tabs.

html`<dees-appui-page
  heading="Services"
  description="5 services · 3 healthy · 1 deploying · 1 failed"
  .breadcrumbs=${[{ label: 'Workloads', href: appui.createViewUrl('workloads') }, { label: 'Services' }]}
  .actions=${[
    { label: 'Refresh', iconName: 'lucide:RefreshCw', action: () => this.reload() },
    { label: 'New service', iconName: 'lucide:Plus', type: 'primary', action: () => this.create() },
  ]}
  .tabs=${[{ key: 'all', label: 'All' }, { key: 'failed', label: 'Failed', badge: 1 }]}
  selectedTab="all"
  @tab-select=${(event: CustomEvent<IAppuiPageTabSelectEvent>) => this.filter(event.detail.key)}
>
  <dees-badge slot="status" type="error" text="1 failed" rounded></dees-badge>
  <dees-table .data=${services} .columns=${columns} dataName="services"></dees-table>
</dees-appui-page>`
  • heading is the title (an h1) and should equal the view's navigation label; description is one line below it — a summary such as counts — or markup in the description slot. The status slot sits beside the title, for a dees-badge.
  • breadcrumbs (IAppLocationItem[]: label, href?) is a trail above the title in a nav named "Breadcrumb"; the last item is the current page. A shell with appBar.location already shows the trail in its app bar — leave it out there.
  • actions (IAppuiPageAction[]: label, iconName?, type? 'primary' | 'secondary' | 'destructive', disabled?, action) render as buttons at the end of the title row, the primary one last and in the accent colour, secondary ones as outlines. Each action is awaited with its button pending meanwhile, and a rejection is reported as dees-action-error (source: 'page-action'). The actions slot takes controls of the host's own after them, such as a dees-table-search and the page's dees-statusbutton. Below 640px of page width the actions wrap under the title.
  • tabs (IAppuiPageTab[]: key, label, iconName?, badge?) are sections of the page under the header, in a tablist named by the title, and the content is their tabpanel. selectedTab (reflected) is the selected key, the first tab while it names none. A click, or Enter or Space after moving with the arrow keys (in the writing direction), Home and End, selects a tab, sets selectedTab and emits the bubbling, composed tab-select ({ key, tab }); the tabs are one Tab stop. A host whose tabs are routes navigates on tab-select and sets selectedTab from the route.
  • The page owns its padding (24px, 16px below 640px of page width) and its max-width: contentWidth 'default' (1200px), 'wide' (1600px) or 'full', or --dees-appui-page-max-width for another. The content is a grid with a 16px gap.

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. A table whose header has nothing to show — no label, heading1 or heading2, searchable off and no header actions — renders no header band: its column header starts at the top of the frame, so a frameless fill table sits flush under a page header of the host's own. The footer reports visible, total, and selected rows in the reader's language, counted by its plural rules ("Showing 5 of 20 invoices", German "Rechnungen: 5 von 20 angezeigt"); filtered-out selections remain counted when their IDs still exist in data. dataName names the rows: a plural noun (dataName="invoices", as the host wrote it, for every count), or its forms by number (ITableDataName), .dataName=${{ one: 'invoice', other: 'invoices' }}, with which the footer says "Showing 1 of 1 invoice". Where no count goes with the name — "No invoices yet", "Search invoices", the table's accessible name — the table says other; the attribute takes the plural noun and reflects other. Without a dataName the footer counts rows ("Showing 1 of 1 row").
  • 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: words, field:value and ranges field:[from TO to], combined with AND, OR and NOT; a leading - excludes a word (-northwind), except before a number, where it is the sign: -500 and -12,50 find negative amounts. A range includes both ends, and * leaves an end open. Its ends decide how values compare: numbers as numbers, dates as moments, anything else as words, alphabetically. Dates are accepted only in ISO form: 2026-03-31, 2026-03-31T14:30, 2026-03-31T14:30:15 or 2026-03-31T14:30:15.250, a date-time optionally with Z or an offset such as +02:00, and without one in local time. A day covers the whole local day and a date-time the minute, second or millisecond it states, so booked:[2026-03-01 TO 2026-03-31] holds all of March. Row values that are Date objects, millisecond timestamps or texts in those forms compare as dates; texts such as 01.02.2026 never do. A range never matches a missing value (null, undefined, empty text). It 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. inputSearchQuery(text, { composing }) takes a query as a field delivers it while the reader types: it writes the field and runs the search once 200 ms pass without another keystroke, and waits for an IME composition to end. After the table's first render, the bubbling searchChange event (ITableSearchChangeDetail: { query, mode }) follows every change of the query or of searchMode, from typing, code or the scope menu alike. dees-table-search puts the same field outside the table.
  • 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.
  • Header, footer and empty-state actions are bar actions: one renderer draws them and dees-tile's actions, so a table's actions and a tile's look and behave alike, and actions that do not fit beside the heading or the count wrap onto further lines at the end. Where actions go says which to use.
  • Action type accepts header, inRow, contextmenu, footer, empty, doubleClick, rowClick and keyCombination. preview and the useTableBehaviour field are accepted by the type and have no effect today. Use actionRelevancyCheckFunc for row-specific availability. On desktop widths the actions column has a slot for each of the first three in-row actions, in dataActions order, that some row offers, and a slot at the end for a "…" menu when a row offers further in-row actions or relevant actions offered only in the context menu; below 700px of table width, one Actions menu per row contains the row actions. A row leaves the slot of an action it does not offer empty, so every control keeps its place down the column: a row that offers "Open" and no menu shows "Open" where the other rows show it, not where their "…" sits. An empty slot is as wide as the action's control, takes no pointer and no focus, and is hidden from assistive technology. The slots are worked out over all of data with the relevancy checks — not over the declared actions, so a declared action that no row offers takes no slot, and not over the rows in view or a search's matches, so the width holds while the reader scrolls or searches. Actions that exclude one another, such as "Pause" and "Resume", take a slot each. A table without rows reserves no slot. The slots are worked out again when data or dataActions is replaced, in one pass that stops once they are settled. An actions column with a single slot — an icon action, or the "…" menu — lays out that compact way at every width: the column fits the one control, and the header shows the compact mark, keeping "Actions" as its accessible name, so the label never widens it. The compact mark is the row actions' own ellipsis icon, at their glyph size and centred in a box as wide as a row action, so it lines up with the controls below it. Coarse pointers receive 44px targets independently of width. The opaque pinned column shows an edge shadow only while it covers scrolling columns.
  • doubleClick opens a row the way a list of records opens one in a detail view: a double click on the row's data, or Enter on the focused row, runs the row's first relevant doubleClick action, and a single click only selects the row. In a table with a doubleClick or a rowClick action the rows are focusable, one of them a tab stop — the row last clicked or focused, else the first — with ArrowUp and ArrowDown moving the focus between them and a focus ring around the keyboard's row, the pinned actions cell included; a click on a row's data focuses it, so Enter opens the row just clicked. Space does what a click on the row's data does: it selects the row, with Shift from the selection's anchor and with Ctrl or Cmd in or out of the selection. A row whose focused cell is editable gives Enter and the arrows to that cell: Enter edits it, as a double click on that cell does, and the arrows move it, with the focus following it to its row. A rejected action reports source: 'table-doubleclick', from the pointer and from Enter alike.
  • rowClick opens a row with a single click, as a list opens its entry for list→detail navigation: a plain primary click on the row's data runs the row's first relevant rowClick action, and so do Space on the focused row and Enter on a row without a relevant doubleClick action. Enter runs one action: a row with both kinds runs its doubleClick action. Clicks on a control inside the row (a link, a button, anything focusable), on an editable cell, which the click focuses for editing, and on the selection and actions columns stay theirs, as do clicks with Shift, Ctrl, Cmd or Alt (selection gestures), the second click of a double click and a click that ends a drag (a text selection). Rows with such an action show a pointer. A double click activates with its first click and then runs its doubleClick action, which is unchanged. activateRow(row) runs the action as an activation does and answers whether it ran one (a row whose rowClick action is disabled answers false); a rejected action reports source: 'table-rowclick'.
  • keyCombination runs an action on the keys its keyCombination names, written as the app bar's shortcuts: { name: 'Delete', type: ['inRow', 'keyCombination'], keyCombination: 'Delete', … }, 'Mod+E', 'Shift+Alt+D', where Mod (also Cmd or Ctrl) is the platform's modifier — Command on Apple platforms, Control elsewhere. The keys count while the focus is on a row or on the table itself: a row action runs for the focused row, else for selectedDataRow, when its actionRelevancyCheckFunc accepts that row, and an action that is also a header, footer or empty action acts on the table with selectedDataRow, as its button does. The first declared action whose keys match and that applies runs, once per press, ahead of the table's own keys (Enter, Space, the arrows, Ctrl/Cmd+C); a link, a field or another control in the table, and a cell's editor, keep their keys. A rejected action reports source: 'table-keycombination'.
  • fixedHeight uses internal scrolling with sticky headers and --table-max-height; the default uses a floating header while the page scrolls. The footer — row counts, selection and footer actions — sticks only where the table scrolls its own rows, by default: under fill while the rows overflow it, and fixedHeight keeps it under its own box. A table as tall as its rows lets its footer scroll with the page. stickyFooter (sticky-footer, unset by default) decides it: .stickyFooter=${true} (the attribute) keeps the footer of a table as tall as its rows at the bottom of the page's scroller while the table runs past it, the counterpart of the floating header, flush with the bottom edge the reader sees, over any padding the scroller has; it comes into view with the column header, so a table scrolling in from below shows its footer right below the column header, ahead of the rows. .stickyFooter=${false} (sticky-footer="false") never sticks it. A table that fits is unchanged either way. 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. Under fill and fixedHeight an edge of the rows' scroller casts a scroll-edge shadow while rows lie past it: under the column header once the reader scrolled down, above the footer while rows lie below. 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 and surface when the host — a modal, a panel, a dashboard cell — already owns it: the host's surface shows through the header, rows and footer, and a sticky header or footer paints its backdrop only while rows pass beneath it. Inline cell editors are frameless in every mode. A frameless table also takes the region inset of the body it sits in at its edges: header band, first and last cells, footer, skeleton rows and notice; the selection checkbox starts at it and the row actions end at it.
  • Loading, failure and emptiness are said through dees-state, like every other place of an app. loading without rows says "Loading {dataName}…" in place of the empty state; with rows — a refresh — the rows stay, the body is aria-busy and a thin bar runs along the top edge. error — the reader's sentence for why, such as "The registry did not answer." — hides the rows and says "Could not load {dataName}." with it, errorDetail folded under "Technical details" and, with onRetry, a Retry button that stays pending while the awaited retry runs; clear error to show the rows again. An empty table (no rows, no search) offers the data actions of type 'empty' — type: ['header', 'empty'] puts "New service" in the header and in the empty state — and the controls in its empty slot. A search or filter that matches nothing still offers Clear filters.
  • skeletonRows (reflected as skeleton-rows, default 0) draws the first load as placeholder rows instead: while loading finds no rows, nothing failed and no server search is shown, the table draws that many rows where "Loading {dataName}…" would stand — a bar per visible column, aligned as the column aligns its values, with a box for the selection checkbox and a short bar for the row actions; a table that knows no columns yet draws one bar per row. A status region says "Loading {dataName}…" (else "Loading rows…") to assistive technology, which does not hear the placeholder rows. On any first load the footer says the same in place of "Showing 0 of 0". The placeholders breathe in turn, and hold still under prefers-reduced-motion: reduce.
  • 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, a double click and a row activation 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 name and iconName may each be a function of the row: the table asks it for every row the action is shown for, and uses the answer as the control's glyph, accessible name and tooltip, as its entry in the row's menus and context menu, and as its label where it has no glyph. A slot whose label follows the row is as wide as its widest label, in every row. A header, footer or empty-state action is asked with selectedDataRow, the row its actionFunc receives, which is undefined while no row is selected. A string name is the action's identity, under which its busy state is held across rebuilt dataActions; an action whose name is a function is identified by its object, so its busy state holds while its label changes, and a host that rebuilds such an action while it runs keeps the same object. Actions of which only one ever applies to a row — state alternatives such as "Pause" and "Resume", or a row's next step — are better one action whose name and iconName follow the row than several actions that each hold a slot.
  • disabled keeps an action that applies but cannot be taken yet in view without letting it run, as on dees-tile's actions: its control is dimmed and out of the tab order, its entry in the row menus and the context menu is dimmed, and nothing runs it — not its control, a menu, a double click, a row activation or its keys. It may be a function of the row, as name is; header, footer and empty-state actions are asked with selectedDataRow, so disabled: (row) => !row holds "Approve selected" until a row is selected. A disabled action keeps its slot in the actions column, and a row whose rowClick action is disabled shows no pointer. An action that never applies to a row is held back with actionRelevancyCheckFunc instead.
  • 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.

DeesTableSearch

The search field of a dees-table, standing outside it: in a page header beside the page's own filters and buttons, while the table hides its built-in field.

html`<header class="pageHeader">
  <h2>Services</h2>
  <dees-input-multitoggle .options=${['All', 'Healthy', 'Failed']} .selectedOption=${filter}></dees-input-multitoggle>
  <dees-table-search for="servicesTable"></dees-table-search>
  <dees-button type="accent" text="New service"></dees-button>
</header>
<dees-table id="servicesTable" frameless fill .searchable=${false} dataName="services"
  .columns=${columns} .data=${services}></dees-table>`
  • It renders the field the table renders in its header — the input and the scope menu that chooses searchMode (visible columns, original data, server) — in the table's words and look, in both themes, and drives the table through its public search API. Typing searches as the built-in field does: the same 200 ms debounce, Enter at once, an IME composition awaited, and the same filterChange or searchRequest events from the table. A query or mode the table gets from code — setSearchQuery(), clearSearch(), a searchMode change — shows in the field, which follows the table's searchChange event.
  • Link the table with for (reflected; the htmlFor property), the id of a dees-table in the field's document or shadow root, as a label's for does, or with table, an element reference, which takes precedence and stays linked until it is replaced or cleared. The for target is looked up live, so a table rendered after the field, or replaced by another with that id, is found, and a removed one unlinks. linkedTable answers the table the field searches now. Without one the field is disabled.
  • dataName (reflected) names the rows in the field's own words ("Search statements…") while no table is linked — a host that shows a spinner or its own empty state in place of the table — or the linked table names none; the linked table's dataName takes precedence. Without either, the field says the generic "Search rows…".
  • Set searchable = false on the table to hide its built-in field. A table with no title, no description and no header actions then renders no header at all, so a frameless fill table sits flush under the page header.
  • The field is 240px wide, as in the table's header; size the element itself (dees-table-search { width: 320px; }, or a flex basis). It delegates focus, so focus() puts the caret in the input; the input and the scope button carry the built-in field's accessible names, tooltip and menu keyboard behaviour.

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 in the app bar, a native button named "Copy Code" and then "Code copied" in the reader's language, 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.

density="compact" draws short tiles for a row of key figures above other content (a dashboard's charts, a home page's lists): less padding, no minimum height and a 22px value, where the default keeps 148px tiles with a 28px value. Every tile type and action works in both. A tile's title breaks between its words; a word too long for its line is hyphenated in the page's language.

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.

DeesGroupedList

An inset-grouped settings list: sections with an optional header and footnote, and rows with an icon, a value, a toggle, or a chevron.

<dees-grouped-list
  .sections=${[
    {
      header: 'Preferences',
      rows: [
        { icon: 'lucide:moon', iconColor: '#5856d6', label: 'Dark Mode', toggle: true },
        { icon: 'lucide:globe', label: 'Language', value: 'English', navigates: true },
        { label: 'Sign Out', destructive: true, action: () => signOut() },
      ],
      footer: 'Applies to this device.',
    },
  ]}
  .inset=${true}                           // default; false spans the container
  @rowSelect=${(e) => e.detail.row}        // a navigation or action row was activated
  @rowToggle=${(e) => e.detail.value}      // a row's toggle changed; detail: { row, value }
></dees-grouped-list>

A row with navigates or an action is a native button: Tab reaches it and Enter or Space activates it, with a keyboard focus ring. A row with a toggle is operated by its switch, which the row's label names. A row with a toggle and an action has two sibling controls: a button over the icon and label runs the action, and the switch only toggles. disabled disables every control of the row. A section's header names its card for assistive technology.

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 and surface: the frame and fills go, the header bar keeps its hairline. 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.

The palette and the tones keep to the lightness band of data marks — OKLCH L 0.43–0.77 in the bright theme, 0.48–0.67 in the dark — so no series outshines the others, and neighbouring palette slots stay apart for colour-blind readers. The dark theme's muted text and its success, warning and error accents are made for text and lie above the dark band, so in the dark theme a tone other than 'accent' is drawn in a darker step of its hue, as are the palette's cyan, orange and green. test/test.chart-palette.chromium.ts holds the palette to these checks.

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, optionally in stacks that reach both ways from zero.
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. Series names are unique within a chart, and a described stack's name differs from all of them: the tooltip names a series by its name and the legend switches by these names.

A series that names a stack shares one column per category with the other series of that stack, and stacks stand side by side. Within a column a value above zero builds up from zero and a value below zero down from it (ECharts' samesign stacking, set explicitly), so the two halves never cancel. A series without a stack has a column of its own, unless stacked is set: then every such series shares one column, as before. Each column is rounded at its outer ends, above and below zero.

A stack listed in stacks is shown as one: a single legend entry under its name, which switches all of its series together, and one colour for all of them (its color, else its tone, else the palette). The tooltip still names each series with its own value, and the screen-reader summary names the stack once, then each of its series. A series in a described stack that sets its own color or tone keeps it. A stack that is not described lists and colours each of its series on its own.

// Per month an operating and a fixed-assets column: in above zero, out below it.
const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
html`<dees-chart-bar label="Month by month, as booked"
  .categories=${['Jan', 'Feb', 'Mar']}
  .series=${[
    { name: 'Revenue', stack: 'Operating', data: [4200, 3800, 5100] },
    { name: 'Expenses', stack: 'Operating', data: [-2900, -3100, -2700] },
    { name: 'Sale proceeds', stack: 'Fixed assets', data: [0, 1200, 0] },
    { name: 'Purchases', stack: 'Fixed assets', data: [-1800, 0, -600] },
  ]}
  .stacks=${[{ name: 'Operating', tone: 'accent' }, { name: 'Fixed assets', tone: 'neutral' }]}
  .valueFormatter=${(value: number) => euro.format(value)}
></dees-chart-bar>`;

With values below zero the value axis runs below zero, and the category axis line marks zero while the category labels stay at the plot's edge. valueFormatter labels the value axis, the tooltip and the summary alike.

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; at 0 the body runs to the sheet's edges and declares the heading's inset as the region inset, so a frameless table or code view in it lines up with the heading. The final menuOptions action is the primary one, drawn as an accent button; set primaryActionType: 'destructive' to draw it as a destructive button for an action that deletes or cannot be taken back. An action whose label is wider than the dialog wraps onto more lines inside it rather than being cut off; a label that fits keeps the one-line action.

For a single value, use await DeesModal.prompt({ heading, subheading?, width?, label, description?, value?, confirmLabel?, emptyValueMessage?, validate?, onConfirm? }). It returns the trimmed value on success or null on dismissal, and resolves only once the dialog is gone, so a caller's next dialog or navigation never happens behind a dialog that is still closing. subheading and width work as in createAndShow; width defaults to 'small'. 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?, confirmType? }). 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. confirmType: 'destructive' draws the confirming action as a destructive button, for a deletion. 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 leaves the flow, 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 and the disabled items that say why (description), 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.

A menu opens at the event's point. A menu that belongs to a control — a "More options" button — passes the control as anchor in the options: the menu then opens against it, below it, or above it when only that side has room, with their left edges aligned, or their right edges when the menu would leave the window. Pass the same element as ownerElement so the focus returns to it.

An item can carry a description, a second line under its name. On a disabled item it says why the item cannot be chosen: the name, glyphs and shortcut fade and the reason stays readable, it reaches assistive technology as the item's description, and the arrow keys land on the item, so the reason can be heard; Enter there does nothing. A disabled item without a description is skipped.

DeesContextmenu.openContextMenuWithOptions(new MouseEvent('contextmenu'), [
  { name: 'Restart harness', iconName: 'lucide:RotateCcw', action: async () => restart() },
  { name: 'Compact context', disabled: true, description: 'Nothing to compact yet.', action: async () => undefined },
], { anchor: moreButton, ownerElement: moreButton });
// 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() restores the current anchor's focus, and so does Escape while the focus is in the popover or on its anchor; a popover its host opened without moving the focus leaves an Escape from elsewhere where it is. Outside pointer and Tab dismissal preserve the destination focus. While open, the anchor reports aria-expanded="true", aria-haspopup="dialog" and controls the popover, written through the ARIA reflection properties (ariaExpanded, ariaHasPopup, ariaControlsElements): a native anchor reflects them to its attributes, a dees-button carries them on its focusable face, and the anchor's own values come back on close. 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 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.

  • placement is the side of the control a hint prefers: 'end' (default) or 'start', the inline sides in the control's writing direction ('end' is the right in a left-to-right page, the left in a right-to-left one), or 'top' or 'bottom'. A hint takes the opposite side only when its own lacks room in the viewport and the opposite one has it.
  • The Escape that closes an open hint or rich bubble is spent on it (preventDefault() and stopPropagation()), so a dialog around it stays open; the next Escape goes on as usual.
  • A hint describes its control (aria-describedby) unless its text is the control's aria-label. Set .describe=${false} when the control's name already carries the words, so a screen reader does not read them twice.
<button aria-label="Services">…</button>
<dees-speechbubble hint text="Services" .disabled=${!collapsed}></dees-speechbubble>

Without hint, the bubble is a rich one: its text is markdown, shown in a window layer next to the control before it. It opens on hover and on keyboard focus (:focus-visible), describes the control while it shows, and closes when the pointer leaves (unless the control has keyboard focus), on blur (unless hovered), on Escape (which it spends, as a hint does) and when the bubble element leaves the page. A leave that comes before the bubble has appeared closes it as well. The control has to be focusable, such as a <button type="button"> with a name.

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

DeesDropzoneOverlay

Receives files dropped anywhere in the window. While connected it listens on window: a drag that carries files shows a full-window drop target, and the drop reaches the host as the bubbling, composed files-drop event, detail IDropzoneOverlayDropDetail ({ files: File[] }). Place one per window; where it sits in the tree does not matter.

html`<dees-dropzone-overlay
  description="PDFs and images"
  @files-drop=${(event: CustomEvent<IDropzoneOverlayDropDetail>) => addDocuments(event.detail.files)}
></dees-dropzone-overlay>`
  • A drop target inside the page keeps its drops: a dees-input-fileupload, or any element that stops dragover and drop from bubbling, takes a drop made on it, and the overlay steps back while the pointer is over it. Drags of text or elements within the page are not the overlay's.
  • A modal covers the page: while a dees-modal, a dees-stepper or dees-updater overlay, the command palette, a storage-browser dialog or another layer registered as modal is open, the overlay stays hidden and sends no files-drop. A drop target inside the modal, such as a dees-input-fileupload, still takes its drops; anywhere else the drop is refused, so the browser does not open the file in place of the application. Menus, dropdowns and context menus float over a page that stays in use and change nothing. A modal surface of your own takes part through zIndexRegistry.register(element, zIndex, 'modal') (TZIndexLayerKind, 'transient' by default); zIndexRegistry.changes$ reports { activeCount, modalCount } (IZIndexRegistryChange) whenever a layer registers or leaves, and getModalCount() answers at any moment.
  • heading replaces "Drop files to add them"; description adds a line under it. The element carries the active attribute while it shows.
  • disabled stops all of it, for a moment where dropped files have nowhere to go; the browser then opens a dropped file as it would without the overlay.
  • Dragging files is a pointer gesture: the overlay is hidden from assistive technology and takes no pointer input itself, so keep a keyboard path to the same files, such as a file field or a button. It fades in and out, at once under prefers-reduced-motion: reduce.

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: one step at a time in a card, a progress ribbon beside it, shared action buttons, and a short vertical crossfade 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, countsAsStep, validationFunc, onReturnToStepFunc, and progressStep.
  • The last menu action is primary, unless an action carries primary (IStepMenuItem): then only the first action marked primary: true is, and a step whose actions are all primary: false has none, so a lookup's own Cancel is an ordinary button that stays usable while the lookup runs and after it failed. A nested dees-form gates the primary on its required fields and dispatches collected formData before the action runs; a running or failed progress step holds it too. While required fields are empty, "Complete required fields" shows in the footer right before the primary; it fades without changing the footer's size.
  • A stable footer. Back (when the step allows it) holds the footer's inline start; the hint and the actions sit at its inline end, the primary last. The hint is always laid out, so neither it nor a change of step moves a footer button.
  • The ribbon. One slim vertical ribbon beside the steps holds a marker for each step whose countsAsStep is not false, with a neutral fill up to the current one. The current step is a solid dot in the theme's primary text colour (near-black in the light theme, near-white in the dark one), completed steps are slightly smaller muted dots, upcoming ones hollow rings. The ribbon's ends are concentric with its end dots, so the dots sit as far inside it from its ends as from its sides. Every marker is the same fixed box in every state, so a change of step never moves one; each is a 24px target (44px on touch screens). Each marker shows its step's title in a speech bubble on hover and on keyboard focus, on the ribbon's outer side (away from the step; the other side only when the viewport has no room); screen readers hear "2. Members, completed". The ribbon is one tab stop: Home and End and the arrow keys move between the markers. ArrowUp and ArrowDown go to the step before and after; ArrowLeft and ArrowRight follow the writing direction in both layouts (in a right-to-left page ArrowLeft goes to the later step). The counter under it reads "2 of 4" (stepCounter: 'total', default).
  • Going back from the ribbon. A completed marker is a button back to its step (a click, a tap, Enter or Space), when every step from the one after it up to the current one allows going back (allowBack) and no action is running. The current step's work is aborted, the steps in between are passed over, and only the target's onReturnToStepFunc runs. Current and upcoming markers, and completed ones whose way back is closed, only name their steps.
  • A step that does not count (a lookup between a choice and its results) has no marker and reads as part of the counted step before it, or of the one after it when it leads. stepCounter: 'position' (also a createAndShow option) reads "Step 2" and shows the markers up to the current step only, with the ribbon fading out after them, for a flow whose later steps are not known yet.
  • A flow with more steps than the ribbon has room for scrolls the ribbon (never the page) and keeps the current marker in view. Markers are 44px targets on touch screens.
  • Layout. layout (also a createAndShow option) arranges the ribbon and the steps. 'horizontal' keeps the ribbon beside the steps; 'vertical' makes it a horizontal strip above them, its bubbles open above the strip, and a change of step slides sideways along it: the leaving step moves toward the strip's start when it is the earlier one and toward its end when it is the later one, the step coming in enters from the other side, and the card keeps --dees-stepper-width. 'auto' (default) is horizontal and switches to the vertical layout below 640px of stepper width (a phone, a small modal), where the card also tightens its padding in any layout. The ribbon follows the writing direction: in a right-to-left page it stands on the steps' right, the strip fills from the right and the steps slide the other way.
  • Async actions prevent duplicate activation and display failures in the current step. The running action alone shows its busy face; the others are disabled until it settles. 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.
  • One step on the stage. The stepper renders the current step and, while it fades out, the step before (a quick run of changes holds each leaving step until it has gone); nothing else is mounted in the stage, however long the flow. A step's content is rendered when the step first shows and moves with it, so goNext() and goBack() keep what the reader entered, and custom elements in it disconnect while their step is off the stage and connect again when it returns. Back honors allowBack: false. A leaving step is inert. A step starts at a fixed height and grows downward, so new content never moves its title; nothing in the stepper scrolls to place a step. Transitions use the theme's motion tokens, turn from where they are when interrupted, and are instant under reduced motion.
  • Cancelling. Each step's header carries dees-modal's close control (×) at its inline end. It, Escape (overlay mode) and backdrop clicks (anywhere beside the current step and the ribbon) share a confirmation flow (an Escape that closes an open bubble closes only the bubble); cancelable: false removes the close control and 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. Overlay mode uses native dialog focus containment and restoration.
  • The stepper fires deesElementConnected once it has connected and made its selected step the working one (its form read for the primary, its work started); wait for it, not for the first render, before driving a stepper from a test.
  • 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, Lookup a flow of unknown length ('position'), and Long flow a ribbon that scrolls. Narrow shows the strip. 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>

The bar is a role="progressbar" named by its label, or "Progress" without one, with aria-valuemin="0", aria-valuemax="100", and aria-valuenow and aria-valuetext for the percentage. An indeterminate bar claims no value. The percentage is written in the page's number format (42.5%, 42,5 % in German). Status text is a role="status", terminal lines a role="log", and the prefix and spinner glyphs before them are hidden from assistive technology. Under prefers-reduced-motion: reduce the indeterminate bar rests across the whole track in a lighter tint and the text spinner holds still; a preference changed while the bar is shown takes effect at once.


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
  • --dees-inset-inline is not a theme value: a container declares it where its content runs to its edges (see Region inset)

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.

Custom output panels. Set bottomPanels to stable { id, label, icon?, badge? } descriptors and place host-owned content in a matching workspace-panel:<id> slot. IDs are unique lowercase letters, digits, and hyphens, beginning with a letter; terminal and problems are reserved. icon uses a dees-icon name such as lucide:settings. The host can call selectBottomPanel(id) to expand and select any available panel. Removing the active descriptor selects Terminal when processes are available, or Problems otherwise.

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

const panels: IWorkspaceBottomPanel[] = [
  { id: 'config', label: 'Config', icon: 'lucide:settings', badge: 2 },
];

html`<dees-workspace .executionEnvironment=${environment} .bottomPanels=${panels}>
  <section slot="workspace-panel:config">Configuration actions</section>
</dees-workspace>`;

Switching tabs or collapsing the output area hides custom content without unmounting it. The host owns that light-DOM child and its listeners and async work; its normal disconnect cleanup runs when the host removes it or the workspace. showTerminal=false hides the whole output area, including custom panels. An environment without spawn still shows Problems and custom panels.

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). Its output panel holds Problems and any registered custom panels.
  • 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.
  • versionedFiles is optional for environments shared with other writers. Its read(path) returns { contents, version } from one snapshot; version is an opaque per-file token. write(path, contents, expectedVersion) atomically writes only if that token is still current, returning { status: 'written', version }, or returns { status: 'conflict' } without changing the file. A null expectation creates only when the path is absent. Other I/O failures reject. The workspace keeps the version it accepted when it opened, loaded or saved a file; reading an external change or dismissing its prompt does not accept it. Save Local and diff Use Local compare against the external version displayed in that prompt, so a later change still conflicts. Conflicts leave local edits open. Optional versionedFiles.copyToAbsent and renameToAbsent explicitly promise atomic refusal when the destination exists; the file tree uses them for native binary/directory transfers. Without them, a versioned environment can copy text with an absent-only write, but refuses unsafe native transfers. Legacy environments retain their existing read/write behavior.
  • 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',
    disabled: true, description: 'Sign in again in Accounts to use it.' },
];
chat.accountNote = 'One account needs you to sign in again.';
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(),
  },
];
chat.commands = [
  { name: 'new', description: 'Start a new conversation.' },
  {
    name: 'model',
    description: 'Switch the model.',
    argument: { hint: '<model>', options: [{ label: 'GPT-5.5', value: 'gpt-5.5' }, { label: 'o4-mini', value: 'o4-mini' }] },
  },
  { name: 'compact', available: false, unavailableReason: 'Nothing to compact yet.' },
];
chat.starterPrompts = [
  { label: 'Explain code', prompt: 'Explain the selected code' },
  { label: 'Review the current diff' },
];

chat.addEventListener('harness-send', (event) => {
  const { delivery, text, attachments, account, model, reasoningEffort } = event.detail;
});
chat.addEventListener('harness-command', (event) => {
  const { name, argument } = event.detail; // e.g. 'model', 'gpt-5.5'
  chat.clearComposer();
});
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/intelligenceAvailabilityAction/intelligenceHeading, showSessionSidebar, heading, subheading, toolbarActions, showToolbar, busy, busyDeliveryModes, queuedCount, disabled, inputLocked, abortEnabled, abortDisabledReason, commands, starterPrompts, account/accountOptions/accountNote, model/modelOptions, reasoningEffort/effortOptions, mode/modeOptions, menuActions, 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. commands is forwarded unchanged to the controlled composer. starterPrompts (IHarnessStarterPrompt[], each { label, prompt? }) shows as buttons under the empty transcript's text while the composer accepts input; choosing one puts its prompt (else its label) into the composer, focused and unsent, and emits harness-input as typing would. 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 or the More options menu of menuActions. 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. Ordinary tool activity rows open DeesHarnessToolFullscreen over the canonical call and follow in-place updates through refreshMessages(); subagent rows first emit harness-subtask-open. The inspector closes when the call is evicted, the session changes, or the sidebar disconnects, then restores the row's focus. An unhandled subagent request opens the same fullscreen inspector. 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-command, harness-starter-prompt, harness-abort, harness-held-resend, harness-held-discard, 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, harness-session-intelligence-availability-action.

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. The optional intelligenceAvailabilityAction (IHarnessIntelligenceAvailabilityAction, { label }) adds a button below the unavailable reason, for example Select a model in Settings; it renders only in the unavailable state and emits harness-session-intelligence-availability-action with { action } (IHarnessSessionIntelligenceAvailabilityActionDetail). The host performs the navigation. Without it the unavailable state stays text-only.

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 set has not been taken in 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 "Pending" label in caption size and muted text, then the waiting messages in the order they were sent, each in its ordinary bubble with a line that says where it stands (see DeesHarnessMessage), all on the status line's measure. pending is a THarnessPendingState: 'steering' (handed to the running turn, read at its next step), 'queued' (confirmed to wait for the next turn), 'uncertain' (delivery attempted but unconfirmed), or 'held' (not sent: the run was stopped before the session took it in). A held message offers Resend and Discard, which emit harness-held-resend and harness-held-discard with { messageId } (IHarnessHeldMessageDetail), bubbling and composed out of the list and the chat; the host sends it again (setting 'queued' or 'steering') or removes it. Nothing held is ever sent on its own. 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 (the slot keeps showing the state line the message waited with while it closes, so it gives that line's space back too, and under a followed bottom the transcript settles down by it), 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"), starterPrompts. starterPrompts (IHarnessStarterPrompt[]) renders as buttons under the empty transcript's text; choosing one emits the bubbling, composed harness-starter-prompt with IHarnessStarterPromptDetail { prompt } (its prompt, else its label) and sends nothing. They belong to the default empty content, so content assigned to slot="empty" replaces them. 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

For a queued or held user prompt the host can still withdraw, set pendingActionAvailable: true on that specific IHarnessMessage. Its options button and right-click menu offer Edit and Retract and emit harness-pending-action with IHarnessPendingActionDetail ({ messageId, action: 'edit' | 'retract' }). The event bubbles and crosses shadow roots. The host must confirm withdrawal before removing the pending message or putting its text and attachments back into the controlled composer; a refused withdrawal leaves both unchanged. Leave the capability absent when the native harness has already taken the prompt or cannot atomically withdraw it.

Image attachments with inline dataBase64 open in a DeesModal on click. When an image has metadata only, pass .resolveAttachment to dees-harness-chat, dees-harness-message-list, or the message itself. The THarnessAttachmentResolver receives { messageId, attachmentId } and returns a complete IHarnessAttachment with the image's dataBase64 after the host authorizes and reads it. The modal shows loading and read failure; closing it, unmounting the message, or replacing the message ignores a late result. The component accepts image bytes only, never a URL or filesystem path.

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 (THarnessPendingState) while the session has not taken it in yet. The transcript list shows such a message in its queue (see DeesHarnessMessageList), and the element renders it like any other user message with one caption line under its content: "Steering · read at the next step" ('steering'), "Queued for the next turn" ('queued'), "Delivery unconfirmed" ('uncertain'), or "Held · not sent" with Resend and Discard buttons ('held'), which emit harness-held-resend and harness-held-discard with { messageId }, bubbling and composed. A button pressed after the message stopped being held emits nothing. The line is gone once pending is cleared, and another role never shows it. 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 activation emits the bubbling, composed, cancelable harness-subtask-open event with { sessionId, call } before any inspector opens.

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 emits the same harness-subtask-open request as the session sidebar. An uncanceled request opens the child thread in dees-harness-tool-fullscreen, 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.

Host migration: harness-subtask-open now fires directly from the subagent row or sidebar item before the catalog opens an inspector. Hosts that open their own child-chat modal must validate child access and call event.preventDefault() synchronously in the listener. Leave the event uncanceled when access fails so standalone inspection remains available. Existing listeners that open a host modal without canceling the event will open both views. A host that owns the chat can supply { previewState: 'none', sessionId, status } instead of hydrating preview messages; child status still controls the row and sidebar activity counts. Status is authoritative when statusAuthority is omitted or 'authoritative'. Before a status lookup resolves, pass { previewState: 'loading', statusAuthority: 'checking', sessionId }; after a failed lookup, pass { previewState: 'unavailable', statusAuthority: 'unavailable', sessionId, notice }. These variants omit status: checking shows Checking subagent status…, and unavailable shows Subagent status unavailable with a warning tone. Both remain in the sidebar's Status unknown group and count toward neither active calls nor terminal history, even when the parent tool call completed. previewState: 'none' and 'ready' require authoritative child status. Preview loading or unavailability alone does not weaken a supplied authoritative status.

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. A call with no projection, or with the status-only previewState: 'none', shows the Agent Brief and Result panels — and a descriptor's own renderBody — instead. subtask.sessionId is authoritative for the open request 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.

Codex agent calls named spawnAgent, sendInput, resumeAgent, wait, closeAgent, sendMessage, followupTask, interruptAgent, listAgents, or subAgentActivity resolve as subtasks only when childSessionId or subtask is present. A generic wait or listAgents call therefore keeps generic tool presentation. Producers should attach the child session metadata when they want the subtask thread link.

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

An uncertain pending prompt says “Delivery unconfirmed”: the host has attempted dispatch but cannot confirm whether the harness took it. Keep that prompt with pendingActionAvailable: false until the host reconciles the outcome; it cannot be resent or withdrawn meanwhile. Consecutive assistant messages containing only reasoning share the first row's role, model, and time range while each message keeps its own collapsible reasoning trace and stable ID. User messages, tool calls, model changes, or assistant text break the run.

Pass .history as IHarnessComposerHistoryEntry[] in oldest-to-newest send order. Each entry has { id, text, attachments? }; attachments supplied for resend must include their complete payloads. With no pending prompt, ArrowUp at the start of the textarea cycles backward through known entries and ArrowDown at the end moves forward, returning to the draft that was there before browsing. Set .hasEarlierHistory and .loadEarlierHistory when the host has older pages: ArrowUp at the oldest known entry awaits one callback, which updates the authoritative .history, then moves once to the newly preceding entry. A failed callback emits harness-history-load-error with { error } and preserves the current entry. Multiline movement, command menu navigation, modifier keys, composition, and locked input retain their normal behavior. dees-harness-chat forwards these properties and its transcriptKey to the composer, resetting navigation when the conversation changes. When any pending prompt exists, history waits. ArrowUp on an empty composer emits harness-pending-action for Edit of the latest pending prompt only when it is queued or held and its pendingActionAvailable is true; the host withdraws it before filling the composer.

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 commands to its composer. Controlled component — the host owns value, attachments, commands, 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.

An account that cannot be used right now stays listed: give its option disabled: true and a description with the reason or the fix ("Sign in again in Accounts to use it."). It cannot be chosen, and it is never the current account: an account that names a disabled option shows as no selection, and the host's account property is left as the host set it. When the host's account is a disabled option, harness-send carries no account, whatever its delivery. Treat a missing account as "no usable account": refuse the send or prompt for an account, and never fall back to a default account. accountNote is a note on the account choice, shown under the controls with or without options — for accounts that need attention, or for an account service that is unavailable and lists none. Its polite live region (role="status") is in the DOM whenever the model controls show (showModelControls), visually hidden and taking no space while the note is empty, so both the note's first appearance and every later change are announced. It hides with the model controls, and dees-harness-chat forwards it.

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; Stop and the menuActions menu stay available. 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 with every delivery. 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 commands to the IHarnessComposerCommand[] the host understands, each { name, description?, available?, unavailableReason?, argument? }, where argument is { hint?, options? }: hint shows after the name (<model>, [prompt]) and options are IHarnessComposerOption[] values for it. The composer filters the list itself, and the menu belongs to slash commands only: it opens while the prompt is a slash and part of a name (/mo lists the commands whose name starts with mo, case-insensitively), and once a name with argument options is complete (/model ), it lists the options whose label or value contains the typed argument. Any other prompt opens nothing — ordinary text, a path such as /tmp/build.log, a name nothing matches, or a second line. 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 or Tab completes the highlighted row and never sends, and Escape closes the menu until the next keystroke. Completing a command puts /name into the prompt (/name for a command without an argument) and leads on to its options; completing an option puts /name value. Each completion emits the bubbling/composed harness-input. A new commands array highlights the first row again, so keep its reference stable while the list is unchanged. A command with available: false, and an option with disabled: true, stays listed with its reason (unavailableReason, or the option's description) and cannot be completed.

Sending a prompt whose first word names a listed command exactly emits harness-command instead of harness-send, with IHarnessComposerCommandDetail { name, argument, text, attachments, account?, model?, reasoningEffort? }: argument is the trimmed text after the name, text the prompt as typed. A command runs whenever the composer is unlocked, busy or not — busyDeliveryModes applies to messages only — and while the prompt names one, the main action reads Run; an unavailable command cannot run, and the disabled button shows its reason. Every other prompt is a message and emits harness-send, including one that starts with an unlisted /word. Neither event clears the prompt: the host does, once it took the message or ran the command (clearComposer() on the chat).

While busy (a turn runs), Stop renders as an additional button and the main action delivers the draft the way the host supports. Set busyDeliveryModes to the THarnessBusyDelivery[] the host can do while a turn runs (default ['queue'], forwarded by dees-harness-chat):

Delivery Meaning As the main action
steer Handed to the running turn and read at its next step; if the turn ends first, the next turn's input. Steer, "Read at the next step"
next-turn For a harness that reads messages only between turns: read after this turn. Send, "Read after this turn"
queue Held by the host and sent as its own turn after this turn. Queue, "Sent after this turn"

The main action is steer when listed, else next-turn, else queue; its hint shows as a caption beside the actions and as its tooltip. With queue listed beside steer or next-turn, a secondary Queue button ("Queue for after this turn") stands between Stop and the main action. Every send emits harness-send with IHarnessComposerSendDetail.delivery: 'turn' while nothing runs, otherwise the chosen THarnessBusyDelivery. While busy a delivery needs a nonempty draft, and an empty busyDeliveryModes sends nothing. Keyboard: Cmd/Ctrl+Enter is the main action, and Alt+Enter (also with Cmd/Ctrl) queues for after this turn whenever the host lists queue (a prompt naming a listed command runs it on either key instead); while nothing runs, or without queue, Alt+Enter stays the textarea's own. Stop only halts the running turn: it emits harness-abort, sends nothing and keeps the draft. What the session had not taken in yet is the host's to mark held (see DeesHarnessMessageList); the host owns delivery, acknowledgment and clearing the draft. A positive queuedCount shows as "N queued" beside the actions while busy.

Set menuActions to IHarnessComposerMenuAction[] ({ key, label, iconName?, disabled?, disabledReason? }) for host commands that belong to the conversation but not to the prompt, such as restarting the harness it runs in. While the list is not empty, a ghost sm button with a vertical ellipsis stands directly before Stop and the send actions. It is named "More options", announces a menu and whether it is open, takes Enter and Space, and opens a dees-contextmenu against itself — below it, or above it when the composer sits at the bottom of the window. Choosing a command emits harness-menu-action with { key } (IHarnessComposerMenuActionDetail), bubbling and composed; the host runs the command. A command that cannot run right now stays listed with disabled: true and a disabledReason under its label, which the arrow keys reach, so the reason is heard too. The commands belong to the host, not to the prompt: disabled and inputLocked lock composing and sending only, so the button stays enabled and an open menu stays open while the composer is locked — a restart is often wanted exactly while the host holds the composer read-only. Each command's own disabled decides whether it runs, and a command the host disables or withdraws while the menu is open is refused. Emptying menuActions closes an open menu. dees-harness-chat forwards menuActions.

chat.menuActions = [
  { key: 'restart-harness', label: 'Restart harness', iconName: 'lucide:RotateCcw' },
];
chat.addEventListener('harness-menu-action', (event) => {
  if (event.detail.key === 'restart-harness') restartHarness(conversationId);
});

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.

Token facts are optional in IHarnessSessionMetrics (bind .metrics here or .sessionMetrics on dees-harness-chat). Unknown counts stay undefined; zero is valid. The existing lifetimeUsedTokens, compactionCount, tokensBeforeLatestCompaction, maxContextTokens and currentContextTokens retain their meanings. Additional reporting facts describe partial data without presenting it as complete or current:

Optional field Meaning and presentation
reportedLifetimeUsedTokens: number Total from available reports. When lifetimeUsedTokens is absent, renders Reported tokens, never Lifetime used.
reportedLifetimeUsedTokensExact: boolean true: reported total is exact; false: it is a lower bound, shown with ≥. Undefined does not assert exactness. Exact reporting does not imply complete lifetime coverage.
lifetimeUsageComplete: boolean Explicit complete/incomplete lifetime coverage. Without it, a reported count says completeness is unknown. This flag never supplies a missing lifetime count.
inFlight: boolean true: work is in flight and totals may increase; false: idle. Idle does not imply complete reporting.
lastObservedInputTokens: number Renders Last observed input, explicitly historical, never Current context or a context percentage.
lastObservedInputTokensAt: string ISO observation timestamp, shown as Observed at in the reader's date/time format and time zone.
lastObservedInputTokensFreshness: 'historical' Communicates historical input even without a count or timestamp. Last observed input remains historical when this flag is omitted.

Only a supplied lifetimeUsedTokens produces Lifetime used. It takes precedence over the reported-total row. Completeness, exactness, in-flight status and historical observation metadata can keep the Tokens section and chat sidebar relevant without any numeric total. Neither component derives missing values from messages or copies historical input into current context.

import { html } from '@design.estate/dees-element';
import type { IHarnessSessionMetrics } from '@design.estate/dees-catalog';

const metrics: IHarnessSessionMetrics = {
  reportedLifetimeUsedTokens: 84320,
  reportedLifetimeUsedTokensExact: false,
  lifetimeUsageComplete: false,
  inFlight: true,
  lastObservedInputTokens: 47200,
  lastObservedInputTokensAt: '2026-09-27T10:30:00Z',
  lastObservedInputTokensFreshness: 'historical',
  maxContextTokens: 128000,
};
// Shows ≥ 84320 reported tokens and historical input; no current-context bar.
html`<dees-harness-session-sidebar .showTasks=${false} .metrics=${metrics}></dees-harness-session-sidebar>`;

The Session details catalog demo shows complete legacy metrics beside incomplete, in-flight reports and historical input.

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. Set intelligenceAvailabilityAction to { label } to offer a host-owned way out of the unavailable state; its button emits harness-session-intelligence-availability-action with { action }, bubbling and composed, and is not rendered while checking or available. Like subagent navigation it stays usable while disabled is set, because it changes no session state. 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.

Set closeable: true on a resource for an open view the user dismisses from the list, such as a settings page. Its row gets a compact Close button between the content and Actions, so Actions keeps the same column edge in every row. The button is named "Close {title}", sits in the tab order, and never selects the row; Delete on the row closes it too. With a hovering pointer the × shows on the row that is hovered, focused or selected, and keeps its place while hidden; on touch screens it always shows. Closing emits harness-resource-close with the same { resource, targets } detail as select and open. The list never removes the row itself: the host drops the resource from .resources, and when the close came from inside the row, focus moves to the nearest remaining row. Rows without the flag are unchanged.

list.resources = [
  ...list.resources,
  { id: 'settings', kind: 'settings', title: 'Configuration', icon: 'lucide:Settings', closeable: true },
];
list.addEventListener('harness-resource-close', (event) => {
  const { resource } = (event as CustomEvent<IHarnessResourceEventDetail>).detail;
  list.resources = list.resources.filter((candidate) => candidate.id !== resource.id);
});

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. A group is open while it or one of its sub-views is selected and closes when the selection leaves it; dees-appui-secondarymenu's parent rows follow the same rule and share the indented rule and motion. 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. The detail types of component events and the types of component properties are exported from the package root as well, e.g. ICellEditDetail, ICellEditErrorDetail, Column and ISortDescriptor (dees-table), IChip and IChipsSelectionChangeDetail (dees-chips), TRadiogroupOption (dees-input-radiogroup), IDateEvent (dees-input-datepicker) and IStatusbuttonItem and IStatusbuttonToggleDetail (dees-statusbutton):

// 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, 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

Some dependencies (the @tiptap packages, fancy-canvas, format) declare a license but publish no license file. Their license texts, taken from each project's upstream sources at the matching version, ship in licenses/ (sources in licenses/SOURCES.md) and are declared in the published .smartconfig.json under "@git.zone/tsbundle".thirdPartyNotices.supplements. @git.zone/tsbundle 3 reads them from every bundled package, so a production bundle that contains the catalog lists those texts in its <bundle>.third-party-notices.txt / .json, supplied by @design.estate/dees-catalog, without supplements of its own. The catalog's own dist_bundle/bundle.js ships the same generated pair. The terminal stylesheet, derived from xterm.js' css/xterm.css, is attributed with the xterm.js license in third-party-notices.md.

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
83 MiB
Languages
TypeScript 99.2%
JavaScript 0.8%