@mythicalos/preact-ui 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,75 @@
1
+ /** @jsxImportSource preact */
2
+ import type { ComponentChildren } from "preact";
3
+ import { breadcrumbSegments, buildBreadcrumb, countFileRows, countLoadedFiles, deriveFileTreeRows, formatFileSize, formatRelativeTime, previewBodyMode, previewMeta, type DirState, type FilePreviewState, type FileScopeOption, type FileTreeMode, type FileTreeRootSpec, type FileTreeRow, type GitMark, type PreviewBadgeState } from "@mythicalos/ui-core/logic";
4
+ export { breadcrumbSegments, buildBreadcrumb, countFileRows, countLoadedFiles, deriveFileTreeRows, formatFileSize, formatRelativeTime, previewBodyMode, previewMeta, type DirState, type FilePreviewState, type FileScopeOption, type FileTreeMode, type FileTreeRootSpec, type FileTreeRow, type GitMark, type PreviewBadgeState, };
5
+ export interface FileScopePickerProps {
6
+ /** The offered scopes. With fewer than two the picker renders a STATIC label — there is nothing
7
+ * to choose, so no control is offered. */
8
+ options: readonly FileScopeOption[];
9
+ activeKey: string;
10
+ /** The active scope's tree mode — drives the trigger glyph (⌂ all-mounts · ◈ project). */
11
+ mode: FileTreeMode;
12
+ open: boolean;
13
+ onToggle?: (next: boolean) => void;
14
+ onSelect?: (key: string) => void;
15
+ /** Fired on Escape so the caller can close AND restore focus to wherever it wants it. */
16
+ onDismiss?: () => void;
17
+ label?: string;
18
+ }
19
+ /**
20
+ * A plain disclosure of buttons — deliberately NOT an ARIA menu/listbox: those roles promise
21
+ * arrow-key behavior this does not implement, and claiming them would be a lie to a screen reader.
22
+ * Each option is a natively Tab/Enter-operable button.
23
+ */
24
+ export declare function FileScopePicker(props: FileScopePickerProps): import("preact").JSX.Element;
25
+ export interface FileTreeProps {
26
+ mode: FileTreeMode;
27
+ roots: readonly FileTreeRootSpec[];
28
+ /** Every known directory listing, keyed by `nodeId(rootKey, relPath)`. An absent key reads as
29
+ * "not fetched yet" and draws a Loading… row, never an empty one. */
30
+ dirs: Readonly<Record<string, DirState>>;
31
+ expanded: ReadonlySet<string>;
32
+ selectedId?: string | null;
33
+ /** Git marks keyed by node id. An absent entry renders NO mark — never a fabricated clean. */
34
+ marks?: Readonly<Record<string, GitMark>>;
35
+ title?: string;
36
+ /** The header count. Defaults to the honest count of files actually listed so far. */
37
+ count?: number;
38
+ branch?: string;
39
+ /** The scope picker (or anything else) for the rail header. */
40
+ scope?: ComponentChildren;
41
+ onToggleDir?: (node: {
42
+ rootKey: string;
43
+ relPath: string;
44
+ id: string;
45
+ }) => void;
46
+ onSelectFile?: (node: {
47
+ rootKey: string;
48
+ relPath: string;
49
+ id: string;
50
+ name: string;
51
+ }) => void;
52
+ class?: string;
53
+ }
54
+ export declare function FileTree(props: FileTreeProps): import("preact").JSX.Element;
55
+ export interface FilePreviewProps {
56
+ /** The previewed file's name. Render this component only when a file IS selected; the
57
+ * no-selection placeholder belongs to the page, not to the component. */
58
+ name: string;
59
+ /** The breadcrumb head — the card: the breadcrumb ALWAYS includes the root. */
60
+ rootLabel: string;
61
+ relPath: string;
62
+ state: FilePreviewState;
63
+ /** The header pill. `null` (the default) renders NO pill — never a fabricated "unchanged". */
64
+ badge?: PreviewBadgeState;
65
+ /** Listing-derived fallbacks for the header, used where the state carries none. */
66
+ bytes?: number;
67
+ mtime?: number;
68
+ /** Injected for deterministic relative times. */
69
+ now?: number;
70
+ /** Renders markdown to nodes. Absent ⇒ a `.md` file falls back to plain mono text rather than
71
+ * this component inventing a renderer (or, worse, injecting raw HTML). */
72
+ renderMarkdown?: (text: string) => ComponentChildren;
73
+ class?: string;
74
+ }
75
+ export declare function FilePreview(props: FilePreviewProps): import("preact").JSX.Element;
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ import { badgeClass, breadcrumbSegments, buildBreadcrumb, countFileRows, countLoadedFiles, deriveFileTreeRows, formatFileSize, formatRelativeTime, gitMarkClass, gitMarkLabel, previewBadge, previewBadgeClass, previewBodyMode, previewMeta, previewNoteClass, previewNoteText, scopeGlyph, SCOPE_CARET, scopeItemClass, } from "@mythicalos/ui-core/logic";
3
+ export { breadcrumbSegments, buildBreadcrumb, countFileRows, countLoadedFiles, deriveFileTreeRows, formatFileSize, formatRelativeTime, previewBodyMode, previewMeta, };
4
+ /**
5
+ * A plain disclosure of buttons — deliberately NOT an ARIA menu/listbox: those roles promise
6
+ * arrow-key behavior this does not implement, and claiming them would be a lie to a screen reader.
7
+ * Each option is a natively Tab/Enter-operable button.
8
+ */
9
+ export function FileScopePicker(props) {
10
+ const { options, activeKey, mode, open, onToggle, onSelect, onDismiss, label } = props;
11
+ const active = options.find((o) => o.key === activeKey);
12
+ const text = label ?? active?.label ?? "";
13
+ const glyph = scopeGlyph(mode);
14
+ const selectable = options.length > 1;
15
+ return (_jsxs("div", { class: "my-files__scope", onKeyDown: (e) => {
16
+ if (e.key === "Escape" && open)
17
+ onDismiss?.();
18
+ }, children: [selectable ? (_jsxs("button", { type: "button", class: "my-files__scope-btn", "aria-expanded": open, onClick: () => onToggle?.(!open), children: [_jsx("span", { class: "my-files__scope-glyph", "aria-hidden": "true", children: glyph }), _jsx("span", { class: "my-files__scope-label", children: text }), _jsx("span", { class: "my-files__scope-caret", "aria-hidden": "true", children: SCOPE_CARET })] })) : (_jsxs("div", { class: "my-files__scope-static", children: [_jsx("span", { class: "my-files__scope-glyph", "aria-hidden": "true", children: glyph }), _jsx("span", { class: "my-files__scope-label", children: text })] })), open && selectable ? (_jsx("div", { class: "my-files__scope-menu", children: options.map((o) => (_jsxs("div", { children: [_jsxs("button", { type: "button", class: scopeItemClass(o.key === activeKey), "aria-current": o.key === activeKey, onClick: () => onSelect?.(o.key), children: [_jsx("span", { class: "my-files__scope-it-name", children: o.label }), _jsx("span", { class: "my-files__scope-it-count", children: o.count })] }), o.dividerAfter === true ? _jsx("div", { class: "my-files__scope-divider", "aria-hidden": "true" }) : null] }, o.key))) })) : null] }));
19
+ }
20
+ export function FileTree(props) {
21
+ const { mode, roots, dirs, expanded, selectedId = null, marks, title = "Files", count, branch, scope, onToggleDir, onSelectFile } = props;
22
+ const rows = deriveFileTreeRows({ mode, roots, dirs, expanded, selectedId, marks });
23
+ // the rendered-row count — provably what the operator sees (countLoadedFiles is a
24
+ // different, cache-wide number; see its doc)
25
+ const total = count ?? countFileRows(rows);
26
+ return (_jsxs("div", { class: props.class === undefined ? "my-files__rail" : `my-files__rail ${props.class}`, children: [_jsxs("div", { class: "my-files__hd", children: [_jsxs("div", { class: "my-files__ti", children: [_jsx("span", { children: title }), _jsxs("span", { class: "my-files__count", children: ["\u00B7 ", total] }), branch === undefined ? null : (_jsxs("span", { class: "my-files__branch", children: [_jsx("span", { class: "my-files__branch-dot", "aria-hidden": "true" }), branch] }))] }), scope] }), _jsx("div", { class: "my-files__tree", children: rows.map((row) => renderRow(row, onToggleDir, onSelectFile)) })] }));
27
+ }
28
+ function renderRow(row, onToggleDir, onSelectFile) {
29
+ if (row.type === "note") {
30
+ return (_jsx("div", { class: row.className, children: row.text }, row.id));
31
+ }
32
+ if (row.type === "dir") {
33
+ // The WHOLE ROW is the hit target — the chevron is the affordance, not a separate control.
34
+ return (_jsxs("button", { type: "button", class: row.className, "aria-expanded": row.open, onClick: () => onToggleDir?.({ rootKey: row.rootKey, relPath: row.relPath, id: row.id }), children: [_jsx("span", { class: "my-files__chev", "aria-hidden": "true", children: row.chevron }), _jsx("span", { class: "my-files__glyph", "aria-hidden": "true", children: row.glyph }), _jsx("span", { class: "my-files__name", children: row.name }), row.badges.map((b) => (_jsx("span", { class: badgeClass(b.tone), children: b.text }, b.text)))] }, row.id));
35
+ }
36
+ return (_jsxs("button", { type: "button", class: row.className, "aria-current": row.selected, onClick: () => onSelectFile?.({ rootKey: row.rootKey, relPath: row.relPath, id: row.id, name: row.name }), children: [_jsx("span", { class: "my-files__spacer", "aria-hidden": "true" }), _jsx("span", { class: "my-files__fglyph", "aria-hidden": "true", children: row.glyph }), _jsx("span", { class: "my-files__fname", children: row.name }), row.mark === null ? null : (
37
+ // role="img" so the aria-label IS the accessible name — a bare "M" would otherwise be
38
+ // announced as the opaque glyph rather than "modified".
39
+ _jsx("span", { class: gitMarkClass(row.mark), role: "img", title: gitMarkLabel(row.mark), "aria-label": gitMarkLabel(row.mark), children: row.mark }))] }, row.id));
40
+ }
41
+ export function FilePreview(props) {
42
+ const { name, rootLabel, relPath, state, badge = null, now, renderMarkdown } = props;
43
+ const meta = previewMeta(state, { bytes: props.bytes, mtime: props.mtime });
44
+ const pill = previewBadge(badge);
45
+ return (_jsxs("div", { class: props.class === undefined ? "my-files__prev" : `my-files__prev ${props.class}`, children: [_jsxs("div", { class: "my-files__prev-hd", children: [_jsxs("div", { class: "my-files__prev-main", children: [_jsx("div", { class: "my-files__crumb", children: buildBreadcrumb(rootLabel, relPath) }), _jsxs("div", { class: "my-files__name-row", children: [_jsx("b", { class: "my-files__prev-name", children: name }), pill === null ? null : _jsx("span", { class: previewBadgeClass(pill.tone), children: pill.text })] })] }), _jsxs("div", { class: "my-files__meta", children: [meta.bytes === undefined ? null : _jsx("span", { children: formatFileSize(meta.bytes) }), meta.mtime === undefined ? null : _jsxs("span", { children: ["modified ", formatRelativeTime(meta.mtime, now)] })] })] }), _jsx("div", { class: "my-files__prev-bd", children: previewBody(name, state, renderMarkdown) })] }));
46
+ }
47
+ /** The body for the current state. Every non-`text` branch is a note — including all four honest
48
+ * states, each with its own distinct class and none styled as a failure. */
49
+ function previewBody(name, state, renderMarkdown) {
50
+ // ONE decision, made in ui-core — this binding never re-derives it.
51
+ const mode = previewBodyMode(name, state, renderMarkdown !== undefined);
52
+ if (mode === "note")
53
+ return _jsx("div", { class: previewNoteClass(state) ?? "", children: previewNoteText(state) });
54
+ // "note" covers every non-text state, so the text branch is what remains
55
+ const text = state.text;
56
+ if (mode === "markdown")
57
+ return _jsx("div", { class: "my-files__md", children: renderMarkdown(text) });
58
+ return _jsx("pre", { class: "my-files__pre", children: text });
59
+ }
@@ -0,0 +1,18 @@
1
+ /** @jsxImportSource preact */
2
+ import { GIT_BRANCH_GLYPH, GIT_BRANCH_UNKNOWN, GIT_CHIP_PARTS, GIT_CLEAN_LABEL, GIT_DETACHED_LABEL, GIT_LOADING_NOTE, GIT_STALE_LABEL, GIT_STALE_TITLE, GIT_UNAVAILABLE_NOTE, gitBranchLabel, gitChipClass, gitChipNote, gitFlagClass, gitFlags, hasGitStatus, type GitFlag, type GitFlagTone, type GitStatus } from "@mythicalos/ui-core/logic";
3
+ export { gitBranchLabel, gitChipClass, gitChipNote, gitFlagClass, gitFlags, hasGitStatus, GIT_BRANCH_GLYPH, GIT_BRANCH_UNKNOWN, GIT_CHIP_PARTS, GIT_CLEAN_LABEL, GIT_DETACHED_LABEL, GIT_LOADING_NOTE, GIT_STALE_LABEL, GIT_STALE_TITLE, GIT_UNAVAILABLE_NOTE, type GitFlag, type GitFlagTone, type GitStatus, };
4
+ export interface GitChipProps {
5
+ /** The reported status. Absent — `undefined` OR `null`, so a "no data yet" slot can be passed
6
+ * straight through — ⇒ the honest unavailable arm, never a fabricated clean tree. */
7
+ status?: GitStatus | null;
8
+ /** Human copy for why there is no status. The product maps its own reason codes to this
9
+ * sentence; the atom carries no reason vocabulary of its own. */
10
+ unavailableNote?: string;
11
+ /** The first read has not returned yet — only used when there is no `status` and no note. */
12
+ loading?: boolean;
13
+ /** The shown status is RETAINED while the live read is failing: stale, not current. Flagged on
14
+ * both arms. */
15
+ stale?: boolean;
16
+ class?: string;
17
+ }
18
+ export declare function GitChip(props: GitChipProps): import("preact").JSX.Element;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ /** @jsxImportSource preact */
3
+ // @mythicalos/preact-ui — the git status chip (ds/components-git-chip.html): a bordered inline
4
+ // mono chip carrying `⎇ <branch>` and honest counter flags (warn-soft behind/uncommitted,
5
+ // error-soft unpushed, ok-soft clean).
6
+ //
7
+ // Extracted from the reference implementation in the reference product's session detail pane. Its
8
+ // `available:false` reason ENUM and copy table were one daemon's wire vocabulary; here the product
9
+ // maps its own reason to a sentence and passes it as `unavailableNote`. The honesty contract is
10
+ // design, not product, and is preserved by `@mythicalos/ui-core`'s `gitFlags`/`gitBranchLabel`:
11
+ // an absent status renders the unavailable arm, `null` behind/unpushed OMIT their flag (never
12
+ // "0 behind"), a `null` branch reads "detached HEAD", and a status that never reported its counters
13
+ // is NEVER collapsed into a green "clean ✓".
14
+ import { GIT_BRANCH_GLYPH, GIT_BRANCH_UNKNOWN, GIT_CHIP_PARTS, GIT_CLEAN_LABEL, GIT_DETACHED_LABEL, GIT_LOADING_NOTE, GIT_STALE_LABEL, GIT_STALE_TITLE, GIT_UNAVAILABLE_NOTE, gitBranchLabel, gitChipClass, gitChipNote, gitFlagClass, gitFlags, hasGitStatus, } from "@mythicalos/ui-core/logic";
15
+ export { gitBranchLabel, gitChipClass, gitChipNote, gitFlagClass, gitFlags, hasGitStatus, GIT_BRANCH_GLYPH, GIT_BRANCH_UNKNOWN, GIT_CHIP_PARTS, GIT_CLEAN_LABEL, GIT_DETACHED_LABEL, GIT_LOADING_NOTE, GIT_STALE_LABEL, GIT_STALE_TITLE, GIT_UNAVAILABLE_NOTE, };
16
+ export function GitChip(props) {
17
+ const { status, stale = false, class: cls = "" } = props;
18
+ const staleMark = stale ? (_jsx("span", { class: GIT_CHIP_PARTS.stale, title: GIT_STALE_TITLE, children: GIT_STALE_LABEL })) : null;
19
+ if (hasGitStatus(status)) {
20
+ const flags = gitFlags(status);
21
+ return (_jsxs("span", { class: `${gitChipClass({ stale })} ${cls}`, children: [_jsxs("span", { class: GIT_CHIP_PARTS.branch, children: [GIT_BRANCH_GLYPH, " ", gitBranchLabel(status.branch)] }), flags.length > 0 ? (_jsx("span", { class: GIT_CHIP_PARTS.flags, children: flags.map((f) => (_jsx("span", { class: gitFlagClass(f.tone), children: f.label }, f.label))) })) : null, staleMark] }));
22
+ }
23
+ const note = gitChipNote({ unavailableNote: props.unavailableNote, loading: props.loading });
24
+ return (_jsxs("span", { class: `${gitChipClass({ unavailable: true, stale })} ${cls}`, children: [_jsxs("span", { class: GIT_CHIP_PARTS.branch, title: note, children: [GIT_BRANCH_GLYPH, " ", GIT_BRANCH_UNKNOWN] }), _jsx("span", { class: GIT_CHIP_PARTS.note, children: note }), staleMark] }));
25
+ }
@@ -0,0 +1,50 @@
1
+ /** @jsxImportSource preact */
2
+ import type { ComponentChildren, Ref } from "preact";
3
+ import { type PopoverIds, type PopoverItem, type PopoverPosition } from "@mythicalos/ui-core/logic";
4
+ export { POPOVER_CARET, POPOVER_CHECK, POPOVER_CLASS, POPOVER_EMPTY_VALUE, popoverItemClass, popoverPanelClass, popoverTriggerClass, popoverTriggerText, resolvePopoverPosition, type PopoverItem, type PopoverPosition, } from "@mythicalos/ui-core/logic";
5
+ export interface PopoverProps {
6
+ /** The menu rows, in display order. Exactly one may carry `selected`. */
7
+ items: PopoverItem[];
8
+ /** Picking a row: closes the popover, returns focus to the trigger, then calls this. */
9
+ onSelect: (key: string, item: PopoverItem) => void;
10
+ /** Static prefix on the trigger, e.g. `"review lane:"`. */
11
+ label?: string;
12
+ /** Trigger value when nothing is selected (default: the em-dash placeholder). */
13
+ placeholder?: string;
14
+ /** Optional panel heading. */
15
+ title?: string;
16
+ /** Optional second heading line — the honest framing/caveat line. */
17
+ caption?: string;
18
+ /** Optional content below a divider (e.g. a "Manage projects →" action). Rows in here are the
19
+ * caller's own elements, and the arrow/Home/End roving deliberately does NOT reach them — a
20
+ * footer input keeps its own caret keys. Escape still closes from inside the footer. */
21
+ footer?: ComponentChildren;
22
+ disabled?: boolean;
23
+ /** Base for the trigger/panel id pair. Defaults to a generated one. */
24
+ id?: string;
25
+ /** Longer description for the trigger (native tooltip). Not the accessible NAME — the trigger's
26
+ * visible label + value already provide that. */
27
+ triggerTitle?: string;
28
+ onOpenChange?: (open: boolean) => void;
29
+ }
30
+ export interface PopoverPanelProps {
31
+ ids: PopoverIds;
32
+ pos: PopoverPosition;
33
+ items: PopoverItem[];
34
+ onPick: (item: PopoverItem) => void;
35
+ title?: string;
36
+ caption?: string;
37
+ footer?: ComponentChildren;
38
+ panelRef?: Ref<HTMLDivElement>;
39
+ }
40
+ /**
41
+ * The OPEN panel, deliberately hook-free (same testability split as the shell's `SwitcherPanel`):
42
+ * `preact-render-to-string` never runs effects or dispatches events, so the panel markup — and a
43
+ * row's REAL onClick closure, reachable off the returned vnode tree — would otherwise be
44
+ * unreachable from a DOM-free test. Exported from this module for tests; NOT part of the package
45
+ * barrel. `panelRef` rather than `ref` because plain Preact does not forward `ref` to a function
46
+ * component, and the React sibling keeps the same prop name so the two stay symmetric.
47
+ */
48
+ export declare function PopoverPanel(props: PopoverPanelProps): import("preact").JSX.Element;
49
+ /** A single-select dropdown popover anchored to its own chip trigger. */
50
+ export declare function Popover(props: PopoverProps): import("preact").JSX.Element;
@@ -0,0 +1,167 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-runtime";
2
+ /** @jsxImportSource preact */
3
+ // @mythicalos/preact-ui — the dropdown popover (ds/components-popover, registry row `popover` v1).
4
+ // A chip trigger over an anchored single-select menu: "review lane: **codex cross-model** ▾", ✓ on
5
+ // the current row, `--my-shadow-modal`, high z-index.
6
+ //
7
+ // This file is WIRING ONLY. Every decision — class strings, the viewport-aware flip/align
8
+ // geometry, which key does what, roving-focus index arithmetic that skips disabled rows, the
9
+ // trigger's text composition, and the ARIA maps — is imported from `@mythicalos/ui-core`, so this
10
+ // binding and its React sibling cannot drift. The three things that genuinely cannot live in the
11
+ // core are all this component adds: measuring the DOM, moving focus, and rendering.
12
+ //
13
+ // Behavioural requirements were taken from the design card plus the hand-rolled selector the
14
+ // product ships today (rail/ProjectSelector.tsx): outside-pointerdown and Escape close; Escape and
15
+ // selecting BOTH return focus to the trigger (never stranding it on <body>); focus moves INTO the
16
+ // menu on open, landing on the selected row; Arrow/Home/End rove within it; and an optional
17
+ // head (title + caption) and footer action sit around the rows.
18
+ //
19
+ // Documented residual: the flip/align decision is re-measured on open, on window resize, and on
20
+ // any scroll — but not on a mutation that changes the panel's own size while it is open (no
21
+ // ResizeObserver). `<mythical-select>` measures on open only; this is a superset of the
22
+ // established approach, not a regression against it.
23
+ //
24
+ // Consumer contract (design card, verbatim): "Ancestors must never clip it." The panel is
25
+ // absolutely positioned inside `.my-pop-anchor`, so an ancestor with `overflow: hidden`/`auto`
26
+ // will crop it.
27
+ import { useEffect, useId, useLayoutEffect, useRef, useState } from "preact/hooks";
28
+ import { POPOVER_CARET, POPOVER_CHECK, POPOVER_CLASS, POPOVER_DECORATIVE_ARIA, POPOVER_DEFAULT_POSITION, POPOVER_SEPARATOR_ARIA, edgePopoverIndex, initialPopoverIndex, popoverHasSlotContent, popoverIds, popoverItemAria, popoverItemClass, popoverAppliesToFocus, popoverKeyHandled, popoverMenuAria, popoverPanelAria, popoverPanelClass, popoverPanelKeyAction, popoverTriggerAria, popoverTriggerClass, popoverTriggerKeyAction, popoverTriggerText, resolvePopoverIndex, resolvePopoverPosition, samePopoverPosition, } from "@mythicalos/ui-core/logic";
29
+ export { POPOVER_CARET, POPOVER_CHECK, POPOVER_CLASS, POPOVER_EMPTY_VALUE, popoverItemClass, popoverPanelClass, popoverTriggerClass, popoverTriggerText, resolvePopoverPosition, } from "@mythicalos/ui-core/logic";
30
+ /**
31
+ * The OPEN panel, deliberately hook-free (same testability split as the shell's `SwitcherPanel`):
32
+ * `preact-render-to-string` never runs effects or dispatches events, so the panel markup — and a
33
+ * row's REAL onClick closure, reachable off the returned vnode tree — would otherwise be
34
+ * unreachable from a DOM-free test. Exported from this module for tests; NOT part of the package
35
+ * barrel. `panelRef` rather than `ref` because plain Preact does not forward `ref` to a function
36
+ * component, and the React sibling keeps the same prop name so the two stay symmetric.
37
+ */
38
+ export function PopoverPanel(props) {
39
+ return (_jsxs("div", { ref: props.panelRef, class: popoverPanelClass(props.pos), tabIndex: -1, ...popoverPanelAria(props.ids), children: [popoverHasSlotContent(props.title) || popoverHasSlotContent(props.caption) ? (_jsxs("div", { class: POPOVER_CLASS.head, children: [popoverHasSlotContent(props.title) ? (_jsx("span", { id: props.ids.title, class: POPOVER_CLASS.title, children: props.title })) : null, popoverHasSlotContent(props.caption) ? _jsx("span", { class: POPOVER_CLASS.caption, children: props.caption }) : null] })) : null, _jsx("div", { class: POPOVER_CLASS.menu, ...popoverMenuAria(props.ids, popoverHasSlotContent(props.title)), children: props.items.map((item) => (_jsxs("button", { type: "button", "data-pop-item": "", class: popoverItemClass(item), disabled: item.disabled, ...popoverItemAria(item), onClick: () => {
40
+ if (item.disabled)
41
+ return; // belt-and-braces: a native disabled button fires no click
42
+ props.onPick(item);
43
+ }, children: [_jsx("span", { class: POPOVER_CLASS.itemLabel, children: item.label }), _jsx("span", { class: POPOVER_CLASS.itemCheck, ...POPOVER_DECORATIVE_ARIA, children: item.selected ? POPOVER_CHECK : "" })] }, item.key))) }), popoverHasSlotContent(props.footer) ? (_jsxs(_Fragment, { children: [_jsx("div", { class: POPOVER_CLASS.divider, ...POPOVER_SEPARATOR_ARIA }), _jsx("div", { class: POPOVER_CLASS.foot, children: props.footer })] })) : null] }));
44
+ }
45
+ /** A single-select dropdown popover anchored to its own chip trigger. */
46
+ export function Popover(props) {
47
+ const { items, disabled = false } = props;
48
+ const generatedId = useId();
49
+ const ids = popoverIds(props.id ?? generatedId);
50
+ const [open, setOpen] = useState(false);
51
+ const [pos, setPos] = useState(POPOVER_DEFAULT_POSITION);
52
+ const anchorRef = useRef(null);
53
+ const triggerRef = useRef(null);
54
+ const panelRef = useRef(null);
55
+ /** Row to focus on the next open (-1 ⇒ let `initialPopoverIndex` decide). */
56
+ const pendingFocus = useRef(-1);
57
+ const itemNodes = () => Array.from(panelRef.current?.querySelectorAll("[data-pop-item]") ?? []);
58
+ const focusRow = (i) => {
59
+ // preventScroll: a bare focus() asks the browser to reveal the row by scrolling every
60
+ // scrollable ancestor — which yanks the page out from under an open popover.
61
+ itemNodes()[i]?.focus({ preventScroll: true });
62
+ };
63
+ const setOpenState = (next) => {
64
+ setOpen(next);
65
+ props.onOpenChange?.(next);
66
+ };
67
+ const openAt = (index) => {
68
+ if (disabled)
69
+ return;
70
+ pendingFocus.current = index;
71
+ setPos(POPOVER_DEFAULT_POSITION); // re-measured below; never reuse a stale flip
72
+ setOpenState(true);
73
+ };
74
+ const closeToTrigger = () => {
75
+ setOpenState(false);
76
+ triggerRef.current?.focus({ preventScroll: true });
77
+ };
78
+ // Placement: measure once the panel is in the DOM but before paint, so it never flashes at the
79
+ // wrong end of the trigger. Guarded by samePopoverPosition so measure → setState can't loop.
80
+ useLayoutEffect(() => {
81
+ if (!open)
82
+ return;
83
+ const measure = () => {
84
+ const trigger = triggerRef.current;
85
+ const panel = panelRef.current;
86
+ if (!trigger || !panel)
87
+ return;
88
+ const next = resolvePopoverPosition(trigger.getBoundingClientRect(), { width: panel.offsetWidth, height: panel.offsetHeight }, { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight });
89
+ setPos((prev) => (samePopoverPosition(prev, next) ? prev : next));
90
+ };
91
+ measure();
92
+ window.addEventListener("resize", measure);
93
+ // capture: scroll does not bubble, so this is the only way to see a scrollable ANCESTOR move
94
+ // the anchor and invalidate the flip decision.
95
+ document.addEventListener("scroll", measure, { capture: true, passive: true });
96
+ return () => {
97
+ window.removeEventListener("resize", measure);
98
+ document.removeEventListener("scroll", measure, { capture: true });
99
+ };
100
+ }, [open, items.length]);
101
+ // a11y: on open, focus moves INTO the menu the role promises — the selected row, else the first
102
+ // enabled one, else the panel itself (an empty or all-disabled list), never left on the trigger.
103
+ useEffect(() => {
104
+ if (!open)
105
+ return;
106
+ const requested = pendingFocus.current;
107
+ pendingFocus.current = -1;
108
+ const index = requested >= 0 ? requested : initialPopoverIndex(items);
109
+ if (index >= 0)
110
+ focusRow(index);
111
+ else
112
+ panelRef.current?.focus({ preventScroll: true });
113
+ }, [open]);
114
+ // Outside pointerdown closes. Focus is NOT pulled back to the trigger here: the pointer is
115
+ // already establishing focus somewhere else, and stealing it back would fight the user.
116
+ useEffect(() => {
117
+ if (!open)
118
+ return;
119
+ const onDown = (e) => {
120
+ if (anchorRef.current && !anchorRef.current.contains(e.target))
121
+ setOpenState(false);
122
+ };
123
+ document.addEventListener("pointerdown", onDown, true);
124
+ return () => document.removeEventListener("pointerdown", onDown, true);
125
+ }, [open]);
126
+ // One handler on the anchor catches keys from the trigger AND the panel (both are inside it).
127
+ // The anchor is a plain <div>, but it is never itself focusable or a tab stop — every key this
128
+ // sees has bubbled up from a real <button>, so this is delegation, not a keyboard handler bolted
129
+ // onto a non-interactive element. Enter/Space are absent by design: the trigger is a real
130
+ // <button>, so the browser already synthesises a click from them.
131
+ const onKeyDown = (e) => {
132
+ if (disabled)
133
+ return;
134
+ if (!open) {
135
+ const action = popoverTriggerKeyAction(e.key);
136
+ if (action === null)
137
+ return;
138
+ e.preventDefault();
139
+ openAt(edgePopoverIndex(items, action === "open-first" ? 1 : -1));
140
+ return;
141
+ }
142
+ const action = popoverPanelKeyAction(e.key);
143
+ // Roving keys apply only on a row (or the panel fallback); the caller's footer content keeps
144
+ // its own arrows/Home/End. Escape and Tab still work from anywhere inside the popover.
145
+ const focused = itemNodes().indexOf(document.activeElement);
146
+ const onRovingSurface = focused >= 0 || document.activeElement === panelRef.current;
147
+ if (!popoverAppliesToFocus(action, onRovingSurface))
148
+ return;
149
+ // Tab ("dismiss") must keep its native behaviour or the popover is a focus trap; moving focus
150
+ // to the trigger first makes the browser continue the tab sequence from AFTER the trigger,
151
+ // which is where tabbing out of a menu belongs.
152
+ if (popoverKeyHandled(action))
153
+ e.preventDefault();
154
+ if (action === "close" || action === "dismiss") {
155
+ closeToTrigger();
156
+ return;
157
+ }
158
+ const next = resolvePopoverIndex(action, items, focused);
159
+ if (next >= 0)
160
+ focusRow(next);
161
+ };
162
+ const text = popoverTriggerText(items, { label: props.label, placeholder: props.placeholder });
163
+ return (_jsxs("div", { class: POPOVER_CLASS.anchor, ref: anchorRef, onKeyDown: onKeyDown, children: [_jsxs("button", { ref: triggerRef, type: "button", class: popoverTriggerClass({ open, disabled }), disabled: disabled, title: props.triggerTitle, ...popoverTriggerAria(open, ids), onClick: () => (open ? closeToTrigger() : openAt(-1)), children: [text.label !== null ? _jsx("span", { class: POPOVER_CLASS.triggerLabel, children: text.label }) : null, _jsx("span", { class: POPOVER_CLASS.triggerValue, children: text.value }), _jsx("span", { class: POPOVER_CLASS.triggerCaret, ...POPOVER_DECORATIVE_ARIA, children: POPOVER_CARET })] }), open ? (_jsx(PopoverPanel, { panelRef: panelRef, ids: ids, pos: pos, items: items, title: props.title, caption: props.caption, footer: props.footer, onPick: (item) => {
164
+ closeToTrigger(); // a11y — selecting returns focus to the trigger, never to <body>
165
+ props.onSelect(item.key, item);
166
+ } })) : null] }));
167
+ }
@@ -0,0 +1,38 @@
1
+ /** @jsxImportSource preact */
2
+ import { type QueueItem, type QueueSource, type QueueUnavailableReason } from "@mythicalos/ui-core/logic";
3
+ export { QUEUE_EMPTY_COPY, QUEUE_LOADING_COPY, QUEUE_STALE_COPY, QUEUE_UNAVAILABLE_COPY, canCancelRow, cancelReducer, queueBadgeClass, queueBadgeLabel, queueRowClass, queueView, shouldDisarmCancel, unavailableText, type CancelEvent, type CancelState, type QueueItem, type QueueItemStatus, type QueueSource, type QueueUnavailableReason, type QueueView, } from "@mythicalos/ui-core/logic";
4
+ export interface QueueRowProps {
5
+ item: QueueItem;
6
+ /** The row is showing its two-step confirm instead of its normal presentation. */
7
+ armed?: boolean;
8
+ /** Whether the cancel affordance may exist at all — combined with the status by ui-core. */
9
+ canCancel?: boolean;
10
+ onArm?(): void;
11
+ onConfirm?(): void;
12
+ onDisarm?(): void;
13
+ }
14
+ /**
15
+ * One queue record. The registry atom — usable standalone, or through `QueuePanel`.
16
+ *
17
+ * INVARIANT 4 is enforced HERE, not only by the panel: `armed` is honored only when the record is
18
+ * genuinely cancellable (`canCancelRow`). A caller that arms a `leased`/`delivered`/`canceled` row,
19
+ * or arms a row whose cancellability was revoked, gets the ordinary row back — never a live
20
+ * "Cancel it" button for something that cannot be cancelled. That also covers the render between a
21
+ * revocation and the panel's disarming effect.
22
+ */
23
+ export declare function QueueRow(props: QueueRowProps): import("preact").JSX.Element;
24
+ export interface QueuePanelProps {
25
+ source: QueueSource;
26
+ /** Whether cancelling is possible at all right now (e.g. the viewer owns the queue). */
27
+ canCancel?: boolean;
28
+ onCancel?(id: string): void;
29
+ /** Changing this clears any pending confirm (e.g. the selected target changed). */
30
+ resetKey?: string;
31
+ /**
32
+ * Extra detail per unavailable reason, APPENDED to this package's own sentence — never
33
+ * substituted for it. Two reasons therefore always render different copy, whatever the caller
34
+ * supplies (see ui-core's `unavailableText`).
35
+ */
36
+ unavailableDetail?: Partial<Record<QueueUnavailableReason, string>>;
37
+ }
38
+ export declare function QueuePanel(props: QueuePanelProps): import("preact").JSX.Element;
@@ -0,0 +1,76 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ /** @jsxImportSource preact */
3
+ // @mythicalos/preact-ui — the delivery queue (ds/components-terminal, spec v2): `QueueRow` is the
4
+ // registry atom; `QueuePanel` is the list that carries the source→view honesty around it.
5
+ //
6
+ // Thin binding: render + wiring only. The source resolution, every copy string, the row/badge class
7
+ // derivation, and the two-step cancel state machine all come from `@mythicalos/ui-core`.
8
+ //
9
+ // Honesty (binding, inherited from ui-core): the empty copy is CAPABILITY-NEUTRAL and renders ONLY
10
+ // on `ok` + zero items — never an operational-emptiness or store-existence claim, and never the
11
+ // design card's "queue empty — deliveries land here by class (ASAP interrupts · ON-DONE waits)";
12
+ // `loading` never renders empty; the three unavailable reasons render DISTINCTLY; `stale` is its own
13
+ // state, flagged as last-known data with no retry claim. The cancel affordance exists ONLY on
14
+ // `queued` records, only when the caller says the record is cancellable, and only while the
15
+ // source is FRESH — a stale (last-known) list carries no cancel controls at all.
16
+ import { useEffect, useReducer } from "preact/hooks";
17
+ import { QUEUE_CANCEL_ASK, QUEUE_CANCEL_LABEL, QUEUE_CANCEL_NO, QUEUE_CANCEL_YES, QUEUE_CLASSES, canCancelRow, cancelReducer, queueBadgeClass, queueBadgeLabel, queueRowClass, queueStatusLabel, queueView, shouldDisarmCancel, } from "@mythicalos/ui-core/logic";
18
+ export { QUEUE_EMPTY_COPY, QUEUE_LOADING_COPY, QUEUE_STALE_COPY, QUEUE_UNAVAILABLE_COPY, canCancelRow, cancelReducer, queueBadgeClass, queueBadgeLabel, queueRowClass, queueView, shouldDisarmCancel, unavailableText, } from "@mythicalos/ui-core/logic";
19
+ /**
20
+ * One queue record. The registry atom — usable standalone, or through `QueuePanel`.
21
+ *
22
+ * INVARIANT 4 is enforced HERE, not only by the panel: `armed` is honored only when the record is
23
+ * genuinely cancellable (`canCancelRow`). A caller that arms a `leased`/`delivered`/`canceled` row,
24
+ * or arms a row whose cancellability was revoked, gets the ordinary row back — never a live
25
+ * "Cancel it" button for something that cannot be cancelled. That also covers the render between a
26
+ * revocation and the panel's disarming effect.
27
+ */
28
+ export function QueueRow(props) {
29
+ const { item } = props;
30
+ const cancellable = canCancelRow(item.status, !!props.canCancel);
31
+ const badge = _jsx("span", { class: queueBadgeClass(item.cls), children: queueBadgeLabel(item.cls) });
32
+ if (props.armed && cancellable) {
33
+ return (_jsxs("div", { class: queueRowClass(item.status, true), children: [badge, _jsx("span", { class: QUEUE_CLASSES.ask, children: QUEUE_CANCEL_ASK }), _jsxs("span", { class: QUEUE_CLASSES.actions, children: [_jsx("button", { type: "button", class: QUEUE_CLASSES.confirmYes, onClick: props.onConfirm, children: QUEUE_CANCEL_YES }), _jsx("button", { type: "button", class: QUEUE_CLASSES.confirmNo, onClick: props.onDisarm, children: QUEUE_CANCEL_NO })] })] }));
34
+ }
35
+ return (_jsxs("div", { class: queueRowClass(item.status, false), children: [badge, _jsx("span", { class: QUEUE_CLASSES.rowBody, children: item.body }), _jsx("span", { class: QUEUE_CLASSES.rowStatus, children: queueStatusLabel(item.status) }), cancellable ? (_jsx("button", { type: "button", class: QUEUE_CLASSES.cancel, onClick: props.onArm, children: QUEUE_CANCEL_LABEL })) : null] }));
36
+ }
37
+ export function QueuePanel(props) {
38
+ const { source } = props;
39
+ const canCancel = !!props.canCancel;
40
+ const [cancel, dispatch] = useReducer(cancelReducer, { armedId: null });
41
+ // A changed target clears any pending ask.
42
+ useEffect(() => {
43
+ dispatch({ type: "disarm" });
44
+ }, [props.resetKey]);
45
+ // An armed confirm must never outlive its cancelability: ownership loss, or the armed row leaving
46
+ // `queued` in the CURRENT source (leased/delivered/canceled/vanished/unavailable), force-disarms.
47
+ const mustDisarm = shouldDisarmCancel(cancel.armedId, canCancel, source);
48
+ useEffect(() => {
49
+ if (mustDisarm)
50
+ dispatch({ type: "disarm" });
51
+ }, [mustDisarm]);
52
+ useEffect(() => {
53
+ if (cancel.armedId === null)
54
+ return;
55
+ const onKey = (e) => {
56
+ if (e.key === "Escape")
57
+ dispatch({ type: "disarm" });
58
+ };
59
+ window.addEventListener("keydown", onKey);
60
+ return () => window.removeEventListener("keydown", onKey);
61
+ }, [cancel.armedId]);
62
+ const view = queueView(source, props.unavailableDetail);
63
+ return (_jsxs("div", { class: QUEUE_CLASSES.panel, children: [view.kind === "state" ? _jsx("div", { class: QUEUE_CLASSES.state, children: view.copy }) : null, view.kind === "empty" ? _jsx("div", { class: QUEUE_CLASSES.empty, children: view.copy }) : null, view.kind === "list" ? (_jsxs("div", { class: QUEUE_CLASSES.list, children: [view.staleCopy !== null ? _jsx("div", { class: QUEUE_CLASSES.stale, children: view.staleCopy }) : null, view.items.map((item) => {
64
+ // A STALE list is last-known data, so no row in it may carry a cancel affordance: the
65
+ // row's real status is unknown, and offering a cancel would contradict the banner right
66
+ // above it. `view.fresh` is ui-core's freshness verdict, not a local guess.
67
+ const rowCanCancel = canCancel && view.fresh;
68
+ return (_jsx(QueueRow, { item: item, canCancel: rowCanCancel, armed: cancel.armedId === item.id, onArm: () => dispatch({ type: "arm", id: item.id }), onConfirm: () => {
69
+ // Re-checked at the moment of confirm: the intent only fires if the row is STILL
70
+ // fresh AND cancellable — never a stale-arm cancel.
71
+ if (canCancelRow(item.status, rowCanCancel))
72
+ props.onCancel?.(item.id);
73
+ dispatch({ type: "confirm" });
74
+ }, onDisarm: () => dispatch({ type: "disarm" }) }, item.id));
75
+ })] })) : null] }));
76
+ }
@@ -0,0 +1,20 @@
1
+ /** @jsxImportSource preact */
2
+ import type { ComponentChildren } from "preact";
3
+ import { SAVE_BAR_DISCARD_LABEL, SAVE_BAR_SAVE_LABEL, SAVE_BAR_PARTS, SAVE_BAR_SEP, saveBarClass, saveBarDirty, saveBarNote, type SaveBarNote } from "@mythicalos/ui-core/logic";
4
+ export { saveBarNote, saveBarDirty, saveBarClass, SAVE_BAR_PARTS, SAVE_BAR_SEP, SAVE_BAR_DISCARD_LABEL, SAVE_BAR_SAVE_LABEL, type SaveBarNote, };
5
+ export interface SaveBarProps {
6
+ /** Human labels of the dirty fields. Empty ⇒ the bar does not render. */
7
+ changed: readonly string[];
8
+ /** A save is in flight: the primary goes loading + inert. */
9
+ saving?: boolean;
10
+ /** Card copy is "Save & apply"; override per surface (the reference product says "Save locally"
11
+ * because its save writes to the local container). */
12
+ saveLabel?: string;
13
+ discardLabel?: string;
14
+ onDiscard: () => void;
15
+ onSave: () => void;
16
+ /** Extra content rendered between the note and the actions (e.g. a validation hint). */
17
+ children?: ComponentChildren;
18
+ class?: string;
19
+ }
20
+ export declare function SaveBar(props: SaveBarProps): import("preact").JSX.Element | null;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ import { SAVE_BAR_DISCARD_LABEL, SAVE_BAR_SAVE_LABEL, SAVE_BAR_PARTS, SAVE_BAR_SEP, saveBarClass, saveBarDirty, saveBarNote, } from "@mythicalos/ui-core/logic";
3
+ import { Button } from "./Button.js";
4
+ export { saveBarNote, saveBarDirty, saveBarClass, SAVE_BAR_PARTS, SAVE_BAR_SEP, SAVE_BAR_DISCARD_LABEL, SAVE_BAR_SAVE_LABEL, };
5
+ export function SaveBar(props) {
6
+ const { changed, saving = false, class: cls = "" } = props;
7
+ const note = saveBarNote(changed);
8
+ if (note.count === 0)
9
+ return null;
10
+ return (_jsxs("div", { class: `${saveBarClass()} ${cls}`, children: [_jsxs("span", { class: SAVE_BAR_PARTS.note, children: [_jsx("b", { class: SAVE_BAR_PARTS.count, children: note.countLabel }), SAVE_BAR_SEP, note.detail] }), props.children, _jsxs("span", { class: SAVE_BAR_PARTS.actions, children: [_jsx(Button, { variant: "sec", onClick: props.onDiscard, children: props.discardLabel ?? SAVE_BAR_DISCARD_LABEL }), _jsx(Button, { variant: "pri", loading: saving, onClick: props.onSave, children: props.saveLabel ?? SAVE_BAR_SAVE_LABEL })] })] }));
11
+ }
@@ -0,0 +1,26 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from "preact";
3
+ import { type DeliveryClass } from "@mythicalos/ui-core/logic";
4
+ export { DEFAULT_DELIVERY_CLASS, DELIVERY_CLASSES, DELIVERY_HINT, SEND_DISABLED_FALLBACK, SEND_PLACEHOLDER, canSend, clearDraftOnSend, deliveryClassLabel, keyAction, resolveSend, sendPlaceholder, showDeliveryHint, type DeliveryClass, type KeyAction, } from "@mythicalos/ui-core/logic";
5
+ export interface SendBarProps {
6
+ /** Initial selection. A manual pick then wins — a later prop change never clobbers it. */
7
+ defaultClass?: DeliveryClass;
8
+ /** A send is in flight: the send button is inert but the field stays editable. */
9
+ busy?: boolean;
10
+ disabled?: boolean;
11
+ /** The honest reason the bar is unusable — surfaced as the placeholder. */
12
+ disabledReason?: string;
13
+ /** A persistent notice rendered below the bar (e.g. a pending stop). */
14
+ notice?: string;
15
+ /** Carried into the enabled placeholder: `Message {name}… (⏎ send · ⇧⏎ newline)`. */
16
+ targetName?: string;
17
+ /** Resolve `true` on success; the draft clears only then (text survives failures). */
18
+ onSend(cls: DeliveryClass, body: string): boolean | Promise<boolean>;
19
+ /**
20
+ * Called when `onSend` REJECTS. The rejection is treated as a failed send (nothing delivered, the
21
+ * draft kept) rather than escaping as an unhandled rejection; this is how a caller still observes
22
+ * it. Omitting it does not hide a success — only a resolved `true` ever counts as sent.
23
+ */
24
+ onSendError?(error: unknown): void;
25
+ }
26
+ export declare function SendBar(props: SendBarProps): JSX.Element;
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ /** @jsxImportSource preact */
3
+ // @mythicalos/preact-ui — the delivery send bar (ds/components-terminal, spec v2). Holds the LOCAL
4
+ // draft + class selection and fires `onSend(cls, body)`; the caller owns the delivery call.
5
+ //
6
+ // Thin binding: render + wiring only. The placeholder composition, the send predicate, the
7
+ // draft-clear rule, the keyboard semantics and the hint all come from `@mythicalos/ui-core`.
8
+ //
9
+ // Honesty (binding, inherited from ui-core): the per-class hint is
10
+ // `ASAP takes the first turn gap · ON-DONE waits for full idle.` — the design card's older
11
+ // "ASAP interrupts" wording is FALSE (delivery always waits for a turn boundary) and must never be
12
+ // reintroduced. The hint renders ONLY where the controls are usable; a disabled bar surfaces just
13
+ // the caller's truthful reason.
14
+ //
15
+ // Keyboard: Enter=send, Shift/Alt-Enter=newline, IME composition suppressed (`keyAction`).
16
+ import { useRef, useState } from "preact/hooks";
17
+ import { DEFAULT_DELIVERY_CLASS, DELIVERY_CLASSES, DELIVERY_HINT, SENDBAR_CLASSES, SEND_BUTTON_LABEL, canSend, clearDraftOnSend, deliveryClassButtonClass, deliveryClassLabel, keyAction, makeSendGate, resolveSend, sendBarClass, sendPlaceholder, showDeliveryHint, } from "@mythicalos/ui-core/logic";
18
+ export { DEFAULT_DELIVERY_CLASS, DELIVERY_CLASSES, DELIVERY_HINT, SEND_DISABLED_FALLBACK, SEND_PLACEHOLDER, canSend, clearDraftOnSend, deliveryClassLabel, keyAction, resolveSend, sendPlaceholder, showDeliveryHint, } from "@mythicalos/ui-core/logic";
19
+ export function SendBar(props) {
20
+ const disabled = !!props.disabled;
21
+ const busy = !!props.busy;
22
+ const [cls, setCls] = useState(props.defaultClass ?? DEFAULT_DELIVERY_CLASS);
23
+ const [text, setText] = useState("");
24
+ // Single-flight: `busy` covers a caller that tracks the request itself, but `onSend` may be async
25
+ // and a caller that flips `busy` a tick later (or never) would let two clicks — or a click racing
26
+ // an Enter — deliver the same message twice. The gate is synchronous, so the second attempt is
27
+ // refused before any state update lands; `sending` only exists to re-render the disabled button.
28
+ const gate = useRef(makeSendGate());
29
+ const [sending, setSending] = useState(false);
30
+ const locked = busy || sending;
31
+ const doSend = async () => {
32
+ if (!canSend(text, disabled, locked))
33
+ return;
34
+ if (!gate.current.tryAcquire())
35
+ return;
36
+ setSending(true);
37
+ const submitted = text;
38
+ try {
39
+ // a REJECTED onSend resolves to `false` here (a failed send) instead of escaping the handler
40
+ // as an unhandled rejection; the caller still sees it through `onSendError`
41
+ const sent = await resolveSend(() => props.onSend(cls, submitted), props.onSendError);
42
+ // Functional update: compared against the LIVE draft at resolve time, not this closure's
43
+ // snapshot, so text typed while the request was in flight is never erased.
44
+ if (sent)
45
+ setText((current) => (clearDraftOnSend(sent, submitted, current) ? "" : current));
46
+ }
47
+ finally {
48
+ // released on every path, so a failing send can never wedge the bar shut. The draft survives
49
+ // either way (it clears only on `sent === true`).
50
+ gate.current.release();
51
+ setSending(false);
52
+ }
53
+ };
54
+ const onKeyDown = (e) => {
55
+ if (keyAction(e) === "send") {
56
+ e.preventDefault();
57
+ void doSend();
58
+ }
59
+ // "newline" / "none": the textarea handles the keystroke natively.
60
+ };
61
+ return (_jsxs("div", { class: SENDBAR_CLASSES.wrap, children: [_jsxs("div", { class: sendBarClass(disabled), children: [_jsx("div", { class: SENDBAR_CLASSES.segment, role: "group", children: DELIVERY_CLASSES.map((c) => (_jsx("button", { type: "button", class: deliveryClassButtonClass(cls === c), disabled: disabled, "aria-pressed": cls === c, onClick: () => setCls(c), children: deliveryClassLabel(c) }, c))) }), _jsx("textarea", { class: SENDBAR_CLASSES.input, rows: 1, placeholder: sendPlaceholder(disabled, props.disabledReason, props.targetName), value: text, disabled: disabled, onInput: (e) => setText(e.target.value), onKeyDown: onKeyDown }), _jsx("button", { type: "button", class: SENDBAR_CLASSES.send, disabled: !canSend(text, disabled, locked), onClick: doSend, children: SEND_BUTTON_LABEL })] }), showDeliveryHint(disabled) ? _jsx("div", { class: SENDBAR_CLASSES.hint, children: DELIVERY_HINT }) : null, props.notice ? _jsx("div", { class: SENDBAR_CLASSES.notice, children: props.notice }) : null] }));
62
+ }
@@ -0,0 +1,42 @@
1
+ /** @jsxImportSource preact */
2
+ import type { ComponentChildren } from "preact";
3
+ import { ctxBand, ctxReading, ctxBarGeom, normalizeCtxThresholds, ctxMeterClass, ctxNoteText, ctxValueText, sessionAvatarInitial, sessionCardClass, sessionCardIsStale, sessionCardStale, sessionSpineSummary, sessionStatus, sessionStatusClass, sessionStatusText, sessionSubline, spineNodeClass, type CtxThresholds, type SessionSpine, type SessionStatusInput } from "@mythicalos/ui-core/logic";
4
+ export { ctxBand, ctxReading, ctxBarGeom, normalizeCtxThresholds, ctxMeterClass, ctxNoteText, ctxValueText, sessionAvatarInitial, sessionCardClass, sessionCardIsStale, sessionCardStale, sessionSpineSummary, sessionStatus, sessionStatusClass, sessionStatusText, sessionSubline, spineNodeClass, };
5
+ export interface SessionCardProps {
6
+ /** Display name — the card's headline and the source of the avatar initial. */
7
+ name: string;
8
+ /**
9
+ * The meta subline, as a free list joined with ` · `. The design card's own states use it for
10
+ * `role · model · duration`, `role · model · queued: 1 ON-DONE` and `role · last seen 00:12 ago`
11
+ * alike. Absent/blank entries collapse; an all-absent list omits the line entirely.
12
+ */
13
+ meta?: readonly (string | null | undefined)[];
14
+ /**
15
+ * What the product actually knows about the session. Every field is optional and every absent
16
+ * field means "not reported" — an omitted `activity` never becomes `idle`.
17
+ */
18
+ status?: SessionStatusInput;
19
+ /** The product's own wording for the derived status (e.g. a wake-specific phrase). The tone,
20
+ * pulse and stale treatment still come from the derivation — only the words change. A blank
21
+ * override, and ANY override on the `unknown` status, is ignored: nothing was claimed, so there
22
+ * is nothing to reword (see ui-core's `sessionStatusText`). */
23
+ statusLabel?: string;
24
+ /** Context reading, 0–100. `undefined`/`null` ⇒ NOT MEASURED: no fill is drawn and the value
25
+ * reads `—`. It is never rendered as 0. */
26
+ contextPct?: number | null;
27
+ /** Product-tunable warn/critical thresholds (default 75/90 — tokens.css rule #4). */
28
+ thresholds?: CtxThresholds;
29
+ /** Spine strip. An unreported `distills` omits the strip entirely. */
30
+ spine?: SessionSpine;
31
+ /** The design's selected state (petrol border + ring). Independent of `stale`. */
32
+ selected?: boolean;
33
+ /** ADD the design's stale treatment (dashed border, muted values) for a reason the card cannot
34
+ * see. It cannot be used to REMOVE the staleness a down link implies — see `sessionCardStale`. */
35
+ stale?: boolean;
36
+ /** Makes the card a real button. Omit for a non-interactive card. */
37
+ onSelect?: () => void;
38
+ class?: string;
39
+ /** Extra content appended inside the card (e.g. a product's own mode glyph). */
40
+ children?: ComponentChildren;
41
+ }
42
+ export declare function SessionCard(props: SessionCardProps): import("preact").JSX.Element;
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ /** @jsxImportSource preact */
3
+ // @mythicalos/preact-ui — the `session-card` atom (design registry spec v1,
4
+ // mythical-design/ds/components-session-card.html): avatar, name, status dot + words, a meta
5
+ // subline, the context meter with its fixed threshold ticks, and the optional spine strip.
6
+ //
7
+ // Extracted from the only product that implemented it (the Control Room's rail SessionCard + its
8
+ // derive.ts / sessionsVm.ts state-dot derivation) with that product's session view-model lifted
9
+ // out — this component takes plain props, never a `SessionVm`.
10
+ //
11
+ // EVERY branch lives in `@mythicalos/ui-core`'s session-card logic module (band/status/stale/class
12
+ // derivation, subline composition, bar geometry, spine summary); this binding renders what those
13
+ // functions return and nothing else, so it and its React sibling cannot drift. The three honesty
14
+ // invariants (absence is not zero · unknown is not idle · thresholds are product-tunable) are
15
+ // enforced there and documented at the top of that file.
16
+ //
17
+ // The avatar is the packaged <Avatar> atom (composed, not re-rolled). The status dot is NOT
18
+ // <StatusLine>: that atom's tone axis has no `unknown` member and it accepts no `class`, so
19
+ // neither the hollow unknown dot nor the ≤1/s pulse could ride on it without reshaping a sibling
20
+ // atom — same tone vocabulary, own elements (see the stylesheet's note).
21
+ //
22
+ // No inline `style=` anywhere (CSP style-src 'self'): the meter's fill and its 75/90 ticks — which
23
+ // the design prototype positioned with `style="width:62%"` / `style="left:75%"` — are SVG rects
24
+ // carrying presentation attributes, which is also the only form that can express product-tuned
25
+ // thresholds.
26
+ import { Fragment } from "preact";
27
+ import { ctxBand, ctxReading, ctxBarGeom, normalizeCtxThresholds, ctxMeterClass, ctxNoteText, ctxValueText, sessionAvatarInitial, sessionCardClass, sessionCardIsStale, sessionCardStale, sessionSpineSummary, sessionStatus, sessionStatusClass, sessionStatusText, sessionSubline, spineNodeClass, CTX_BAR_VIEWBOX, CTX_THRESHOLDS_DEFAULT, } from "@mythicalos/ui-core/logic";
28
+ import { Avatar } from "./Avatar.js";
29
+ export { ctxBand, ctxReading, ctxBarGeom, normalizeCtxThresholds, ctxMeterClass, ctxNoteText, ctxValueText, sessionAvatarInitial, sessionCardClass, sessionCardIsStale, sessionCardStale, sessionSpineSummary, sessionStatus, sessionStatusClass, sessionStatusText, sessionSubline, spineNodeClass, };
30
+ export function SessionCard(props) {
31
+ const { name, meta = [], status: statusInput, statusLabel, contextPct, thresholds = CTX_THRESHOLDS_DEFAULT, spine, selected = false, onSelect, class: cls = "", children, } = props;
32
+ const band = ctxBand(contextPct, thresholds);
33
+ // the band is part of the status derivation, not a second opinion on it: the design card's
34
+ // warn/error states replace the status line with the context claim
35
+ const status = sessionStatus(statusInput, band);
36
+ const stale = sessionCardStale(status, props.stale);
37
+ const bar = ctxBarGeom(contextPct, thresholds);
38
+ const subline = sessionSubline(meta);
39
+ const spineSummary = sessionSpineSummary(spine);
40
+ const body = (_jsxs(Fragment, { children: [_jsxs("span", { class: "my-session-card__head", children: [_jsx(Avatar, { initials: sessionAvatarInitial(name), class: "my-session-card__avatar" }), _jsxs("span", { class: "my-session-card__ident", children: [_jsxs("span", { class: "my-session-card__line1", children: [_jsx("b", { class: "my-session-card__name", children: name }), _jsxs("span", { class: sessionStatusClass(status), children: [_jsx("span", { class: "my-session-card__dot" }), _jsx("span", { class: "my-session-card__status-text", children: sessionStatusText(status, statusLabel) })] })] }), subline.length > 0 ? _jsx("span", { class: "my-session-card__meta", children: subline }) : null] })] }), _jsxs("span", { class: ctxMeterClass({ band, stale }), children: [_jsx("span", { class: "my-session-card__ctx-bar", children: _jsxs("svg", { class: "my-session-card__ctx-svg", viewBox: CTX_BAR_VIEWBOX, preserveAspectRatio: "none", "aria-hidden": "true", children: [_jsx("rect", { class: "my-session-card__ctx-track", x: "0", y: "0", width: bar.span, height: bar.height }), bar.fill === undefined ? null : (_jsx("rect", { class: "my-session-card__ctx-fill", x: "0", y: "0", width: bar.fill, height: bar.height })), bar.ticks.map((t) => (_jsx("rect", { class: "my-session-card__ctx-tick", x: t.x, y: "0", width: t.width, height: bar.height }, t.pct)))] }) }), _jsxs("span", { class: "my-session-card__ctx-legend", children: [_jsx("span", { class: "my-session-card__ctx-note", children: ctxNoteText(band, { stale }) }), _jsx("b", { class: "my-session-card__ctx-value", children: ctxValueText(contextPct, thresholds) })] })] }), spineSummary === undefined ? null : (_jsxs(Fragment, { children: [_jsx("span", { class: "my-session-card__spine", "aria-hidden": "true", children: spineSummary.nodes.map((node, i) => (_jsxs(Fragment, { children: [i > 0 ? _jsx("span", { class: "my-session-card__spine-seg" }) : null, _jsx("span", { class: spineNodeClass(node) })] }, i))) }), _jsxs("span", { class: "my-session-card__ctx-legend", children: [_jsx("span", { class: "my-session-card__spine-note", children: spineSummary.label }), _jsx("b", { class: "my-session-card__spine-value", children: spineSummary.value })] })] })), children] }));
41
+ const classes = sessionCardClass({ selected, stale, extra: cls });
42
+ return onSelect === undefined ? (_jsx("div", { class: classes, children: body })) : (_jsx("button", { type: "button", class: classes, "aria-pressed": selected, onClick: onSelect, children: body }));
43
+ }
@@ -0,0 +1,10 @@
1
+ /** @jsxImportSource preact */
2
+ import { STAT_TILE_EMPTY, STAT_TILE_MINUS, formatStatCompact, formatStatCount, formatStatPercent, formatStatUsd, STAT_TILE_PARTS, statTileClass, statTilesClass, type StatTile, type StatTileTone } from "@mythicalos/ui-core/logic";
3
+ export { formatStatCompact, formatStatCount, formatStatPercent, formatStatUsd, statTileClass, statTilesClass, STAT_TILE_PARTS, STAT_TILE_EMPTY, STAT_TILE_MINUS, type StatTile, type StatTileTone, };
4
+ export interface StatTilesProps {
5
+ /** The tiles, in display order. A tile with no `sub` renders no sub-caption — the atom never
6
+ * invents one (pass `STAT_TILE_EMPTY` if you want the placeholder line). */
7
+ tiles: readonly StatTile[];
8
+ class?: string;
9
+ }
10
+ export declare function StatTiles(props: StatTilesProps): import("preact").JSX.Element;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ /** @jsxImportSource preact */
3
+ // @mythicalos/preact-ui — the stat-tile row (ds/components-stat-tiles.html): a wrapping row of
4
+ // bordered tiles, each an uppercase micro key, a big mono tabular-nums value and a mono
5
+ // sub-caption. `tone: "accent"` is the card's "brag" number; `warn`/`error` are the band tint.
6
+ //
7
+ // Extracted from the reference implementation in the reference product's session detail pane, which
8
+ // hard-coded ITS five session tiles (tokens in/out, prompt cache, cost, spine savings) off a
9
+ // `SessionVm` inside the component. That composition is the product's; this atom takes an
10
+ // already-composed `StatTile[]` so each surface decides its own tiles and order. The value
11
+ // formatters (and the "—" placeholder that must never become a fabricated `0`) live in
12
+ // `@mythicalos/ui-core` and are re-exported here.
13
+ import { STAT_TILE_EMPTY, STAT_TILE_MINUS, formatStatCompact, formatStatCount, formatStatPercent, formatStatUsd, STAT_TILE_PARTS, statTileClass, statTilesClass, } from "@mythicalos/ui-core/logic";
14
+ export { formatStatCompact, formatStatCount, formatStatPercent, formatStatUsd, statTileClass, statTilesClass, STAT_TILE_PARTS, STAT_TILE_EMPTY, STAT_TILE_MINUS, };
15
+ export function StatTiles(props) {
16
+ const { class: cls = "" } = props;
17
+ // A JS caller can hand a non-array where the type says `StatTile[]` (an unloaded slot); render an
18
+ // empty row rather than crashing on `.map` — the same "absent is not a value" rule the
19
+ // formatters follow.
20
+ const tiles = Array.isArray(props.tiles) ? props.tiles : [];
21
+ return (_jsx("div", { class: `${statTilesClass()} ${cls}`, children: tiles.map((t, i) => (_jsxs("div", { class: statTileClass(t.tone), children: [_jsx("div", { class: STAT_TILE_PARTS.label, children: t.label }), _jsx("div", { class: STAT_TILE_PARTS.value, children: t.value }), t.sub !== undefined ? _jsx("div", { class: STAT_TILE_PARTS.sub, children: t.sub }) : null] }, `${i}:${t.label}`))) }));
22
+ }
@@ -0,0 +1,31 @@
1
+ /** @jsxImportSource preact */
2
+ import { type TermRow, type TermSource, type TranscriptSegment } from "@mythicalos/ui-core/logic";
3
+ export { TERM_CLASS, TERM_CLASSES, TERM_STALE_COPY, TERM_WAKE_UNAVAILABLE_COPY, transcriptView, type TermRow, type TermRowKind, type TermSource, type TermView, type TranscriptSegment, } from "@mythicalos/ui-core/logic";
4
+ export interface TerminalProps {
5
+ /** The sole transcript input. All six arms are rendered by ui-core's `transcriptView`. */
6
+ source: TermSource;
7
+ /** Title-line text left of ` · tail · noise {on|off}` (e.g. `jacob.jsonl`). */
8
+ name?: string;
9
+ /** The noise FILTER: on ⇒ noise rows are HIDDEN and the title reads `noise on`. */
10
+ noiseFilterEnabled?: boolean;
11
+ /** Omit for a read-only pane — the title then renders as a caption, not a dead button. */
12
+ onToggleNoise?(): void;
13
+ /** Client-synthetic rows appended after the transcript (e.g. an interrupt marker). */
14
+ localRows?: readonly TermRow[];
15
+ /** Render only from the last `boundary` row on (the current wake). */
16
+ currentWakeOnly?: boolean;
17
+ history?: readonly TranscriptSegment[];
18
+ /** Shows the `wake unavailable` banner. Independent of the transcript branches. */
19
+ wakeUnavailable?: boolean;
20
+ /** The blinking stream caret. */
21
+ caret?: boolean;
22
+ /** Supplied truth only — `undefined` renders NO caption rather than guessing. */
23
+ turnInFlight?: boolean;
24
+ /** Omit to render no stop control at all. */
25
+ onStopTurn?(): void;
26
+ /** A stop is already in flight: the control is disabled and Ctrl-C is ignored. */
27
+ stopBusy?: boolean;
28
+ /** VISUAL weight for the stop control only — never a state claim. */
29
+ stopProminent?: boolean;
30
+ }
31
+ export declare function Terminal(props: TerminalProps): import("preact").JSX.Element;
@@ -0,0 +1,90 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-runtime";
2
+ /** @jsxImportSource preact */
3
+ // @mythicalos/preact-ui — the terminal pane (ds/components-terminal, spec v2). ALWAYS heritage-dark
4
+ // in both themes (token rule 3): the surface is pinned by ui-core's `.my-term` block, never by a
5
+ // theme-conditional class this binding picks.
6
+ //
7
+ // Thin binding: render + wiring only. Every class string (`TERM_CLASSES`), every user-visible
8
+ // string, the six-branch source resolution, the segmentation, the row keying and the Ctrl-C
9
+ // predicate come from `@mythicalos/ui-core` — this file types no class literal and no copy of its
10
+ // own, so it and its React sibling cannot drift.
11
+ //
12
+ // Honesty (binding, inherited from ui-core): the wake-unavailable banner says exactly
13
+ // `wake unavailable` — never "reconnecting", never a retry spinner; the turn caption renders ONLY
14
+ // from a supplied boolean (`undefined` renders nothing, never a guessed "idle"); the six source
15
+ // branches keep loading honesty (`loading` never renders `(no events)`; missing/unaddressable/
16
+ // failed are distinct); foreign transcript segments appear ONLY behind the disclosure, each
17
+ // captioned and separated — never blended into the selected session's log.
18
+ import { useEffect, useRef, useState } from "preact/hooks";
19
+ import { TERM_CLASS, TERM_CLASSES, TERM_NO_EVENTS_COPY, TERM_STALE_COPY, TERM_STOP_KEY_HINT, TERM_STOP_LABEL, TERM_WAKE_UNAVAILABLE_COPY, currentWakeRows, expandLabel, historySegmentCaption, historyToggleLabel, isExpandable, noiseShow, ROW_SCOPE, scopedRowKeys, segmentKeys, shouldStopOnKey, sourceRows, stopButtonClass, termRowClass, termTitleText, transcriptView, turnCaption, visibleRows, } from "@mythicalos/ui-core/logic";
20
+ export { TERM_CLASS, TERM_CLASSES, TERM_STALE_COPY, TERM_WAKE_UNAVAILABLE_COPY, transcriptView, } from "@mythicalos/ui-core/logic";
21
+ function RowView(props) {
22
+ const { row } = props;
23
+ const label = row.label ? _jsx("span", { class: TERM_CLASSES.label, children: row.label }) : null;
24
+ if (!isExpandable(row)) {
25
+ return (_jsxs("div", { class: termRowClass(row.kind), children: [label, label ? " " : null, row.text] }));
26
+ }
27
+ return (_jsxs("div", { class: termRowClass(row.kind), children: [_jsxs("button", { type: "button", class: TERM_CLASSES.head, "aria-expanded": props.expanded, onClick: props.onToggle, children: [label, label ? " " : null, row.text, " ", _jsx("span", { class: TERM_CLASSES.expand, children: expandLabel(props.expanded) })] }), props.expanded ? _jsx("pre", { class: TERM_CLASSES.detail, children: row.detail }) : null] }));
28
+ }
29
+ /**
30
+ * A keyed run of rows. `scopedRowKeys` namespaces every key under its scope, so expand state can
31
+ * never leak between the transcript, the local rows and a history segment — not even when a caller
32
+ * supplies an id that looks like another scope's prefix.
33
+ */
34
+ function RowList(props) {
35
+ const keys = scopedRowKeys(props.scope, props.rows);
36
+ return (_jsx(_Fragment, { children: props.rows.map((row, i) => {
37
+ const key = keys[i];
38
+ return _jsx(RowView, { row: row, expanded: props.expanded.has(key), onToggle: () => props.onToggle(key) }, key);
39
+ }) }));
40
+ }
41
+ export function Terminal(props) {
42
+ const { source, noiseFilterEnabled = false } = props;
43
+ const history = props.history ?? [];
44
+ const [expanded, setExpanded] = useState(new Set());
45
+ const [showHistory, setShowHistory] = useState(false);
46
+ const paneRef = useRef(null);
47
+ const toggle = (key) => setExpanded((prev) => {
48
+ const next = new Set(prev);
49
+ if (next.has(key))
50
+ next.delete(key);
51
+ else
52
+ next.add(key);
53
+ return next;
54
+ });
55
+ const stopAvailable = props.onStopTurn !== undefined;
56
+ // Focused Ctrl-C → stop-turn. The predicate (key-repeat drop, selection guard, focus guard) is
57
+ // ui-core's; the availability + in-flight guards live here. Effects do not run under SSR.
58
+ useEffect(() => {
59
+ if (!stopAvailable)
60
+ return;
61
+ const onKey = (e) => {
62
+ if (props.stopBusy)
63
+ return;
64
+ const sel = window.getSelection?.()?.toString() ?? "";
65
+ const pane = paneRef.current;
66
+ const focused = !!pane && pane.contains(document.activeElement);
67
+ if (shouldStopOnKey(e, sel, focused)) {
68
+ e.preventDefault();
69
+ props.onStopTurn?.();
70
+ }
71
+ };
72
+ window.addEventListener("keydown", onKey);
73
+ return () => window.removeEventListener("keydown", onKey);
74
+ }, [stopAvailable, props.onStopTurn, props.stopBusy]);
75
+ const view = transcriptView(source);
76
+ const showNoise = noiseShow(noiseFilterEnabled);
77
+ const allRows = props.currentWakeOnly ? currentWakeRows(sourceRows(source)) : sourceRows(source);
78
+ const rows = view.kind === "log" ? visibleRows(allRows, showNoise) : [];
79
+ const localRows = view.kind === "log" ? visibleRows(props.localRows ?? [], showNoise) : [];
80
+ const emptyLog = view.kind === "log" && rows.length === 0 && localRows.length === 0;
81
+ const segKeys = segmentKeys(history);
82
+ const titleText = termTitleText(props.name, noiseFilterEnabled);
83
+ const caption = turnCaption(props.turnInFlight);
84
+ return (_jsxs("div", { class: TERM_CLASS, children: [_jsxs("div", { class: TERM_CLASSES.titlebar, children: [_jsxs("span", { class: TERM_CLASSES.lights, "aria-hidden": "true", children: [_jsx("span", { class: TERM_CLASSES.lightRed }), _jsx("span", { class: TERM_CLASSES.lightAmber }), _jsx("span", { class: TERM_CLASSES.lightGreen })] }), props.onToggleNoise ? (_jsx("button", { type: "button", class: TERM_CLASSES.noiseToggle, "aria-pressed": noiseFilterEnabled, onClick: props.onToggleNoise, children: titleText })) : (_jsx("span", { class: TERM_CLASSES.staticTitle, children: titleText })), _jsxs("span", { class: TERM_CLASSES.titleRight, children: [caption !== null ? (props.turnInFlight ? (_jsxs("span", { class: TERM_CLASSES.turn, children: [_jsx("span", { class: TERM_CLASSES.turnDot, "aria-hidden": "true" }), caption] })) : (_jsx("span", { class: TERM_CLASSES.idle, children: caption }))) : null, stopAvailable ? (_jsxs("button", { type: "button", class: stopButtonClass(!!props.stopProminent), disabled: props.stopBusy, onClick: props.onStopTurn, children: [TERM_STOP_LABEL, " ", _jsx("span", { class: TERM_CLASSES.stopKey, children: TERM_STOP_KEY_HINT })] })) : null] })] }), _jsxs("div", { ref: paneRef, class: TERM_CLASSES.body, tabIndex: 0, children: [props.wakeUnavailable ? (_jsxs("div", { class: TERM_CLASSES.banner, children: [_jsx("span", { class: TERM_CLASSES.bannerDot, "aria-hidden": "true" }), TERM_WAKE_UNAVAILABLE_COPY] })) : null, view.kind === "log" && view.stale ? (_jsxs("div", { class: TERM_CLASSES.banner, children: [_jsx("span", { class: TERM_CLASSES.bannerDot, "aria-hidden": "true" }), TERM_STALE_COPY] })) : null, history.length > 0 ? (_jsx("button", { type: "button", class: TERM_CLASSES.more, onClick: () => setShowHistory((s) => !s), children: historyToggleLabel(showHistory, history.length) })) : null, showHistory
85
+ ? history.map((seg, si) => (
86
+ // keyed by the segment's own identity, not its position, so reordering the list moves
87
+ // neither the rendered element nor its rows' expand state
88
+ _jsxs("div", { class: TERM_CLASSES.segment, children: [_jsx("div", { class: TERM_CLASSES.segmentCaption, children: seg.caption ?? historySegmentCaption(seg.id) }), _jsx(RowList, { rows: visibleRows(seg.rows, showNoise), scope: [ROW_SCOPE.history, segKeys[si]], expanded: expanded, onToggle: toggle })] }, segKeys[si])))
89
+ : null, view.kind === "state" ? _jsx("div", { class: TERM_CLASSES.state, children: view.copy }) : null, emptyLog ? _jsx("div", { class: TERM_CLASSES.state, children: TERM_NO_EVENTS_COPY }) : null, _jsx(RowList, { rows: rows, scope: [ROW_SCOPE.transcript], expanded: expanded, onToggle: toggle }), _jsx(RowList, { rows: localRows, scope: [ROW_SCOPE.local], expanded: expanded, onToggle: toggle }), props.caret ? _jsx("span", { class: TERM_CLASSES.caret, "aria-hidden": "true" }) : null] })] }));
90
+ }
package/dist/index.d.ts CHANGED
@@ -13,3 +13,13 @@ export { StatusLine, statusLineClass, type StatusLineProps, type StatusTone } fr
13
13
  export { SearchInput, type SearchInputProps } from "./SearchInput.js";
14
14
  export { Banner, bannerClass, BANNER_ICON, type BannerProps, type BannerTone } from "./Banner.js";
15
15
  export { Gauge, gaugeTone, gaugeGeom, type GaugeProps, type GaugeGeom, type Tone } from "./Gauge.js";
16
+ export { SaveBar, saveBarNote, saveBarDirty, saveBarClass, SAVE_BAR_PARTS, SAVE_BAR_SEP, SAVE_BAR_DISCARD_LABEL, SAVE_BAR_SAVE_LABEL, type SaveBarProps, type SaveBarNote, } from "./SaveBar.js";
17
+ export { StatTiles, statTilesClass, statTileClass, STAT_TILE_PARTS, formatStatCompact, formatStatCount, formatStatUsd, formatStatPercent, STAT_TILE_EMPTY, STAT_TILE_MINUS, type StatTilesProps, type StatTile, type StatTileTone, } from "./StatTiles.js";
18
+ export { GitChip, gitFlags, hasGitStatus, gitBranchLabel, gitChipClass, gitFlagClass, gitChipNote, GIT_CHIP_PARTS, GIT_BRANCH_GLYPH, GIT_BRANCH_UNKNOWN, GIT_DETACHED_LABEL, GIT_CLEAN_LABEL, GIT_LOADING_NOTE, GIT_UNAVAILABLE_NOTE, GIT_STALE_LABEL, GIT_STALE_TITLE, type GitChipProps, type GitStatus, type GitFlag, type GitFlagTone, } from "./GitChip.js";
19
+ export { Popover, POPOVER_CARET, POPOVER_CHECK, POPOVER_EMPTY_VALUE, popoverTriggerClass, popoverPanelClass, popoverItemClass, popoverTriggerText, resolvePopoverPosition, type PopoverProps, type PopoverItem, type PopoverPosition, } from "./Popover.js";
20
+ export { FileTree, FilePreview, FileScopePicker, deriveFileTreeRows, countFileRows, countLoadedFiles, previewMeta, previewBodyMode, buildBreadcrumb, breadcrumbSegments, formatFileSize, formatRelativeTime, type FileTreeProps, type FilePreviewProps, type FileScopePickerProps, type FileTreeMode, type FileTreeRootSpec, type FileTreeRow, type FileScopeOption, type DirState, type FilePreviewState, type PreviewBadgeState, type GitMark, } from "./FileExplorer.js";
21
+ export { SessionCard, ctxBand, ctxReading, ctxBarGeom, normalizeCtxThresholds, ctxMeterClass, ctxNoteText, ctxValueText, sessionAvatarInitial, sessionCardClass, sessionCardIsStale, sessionCardStale, sessionStatusText, sessionSpineSummary, sessionStatus, sessionStatusClass, sessionSubline, spineNodeClass, type SessionCardProps, } from "./SessionCard.js";
22
+ export type { CtxThresholds, CtxBand, CtxBarTick, CtxBarGeom, SessionLifecycle, SessionActivity, SessionStatusTone, SessionStatusKey, SessionStatus, SessionStatusInput, SessionSpine, SpineNode, SpineSummary, } from "@mythicalos/ui-core/logic";
23
+ export { Terminal, TERM_CLASS, TERM_STALE_COPY, TERM_WAKE_UNAVAILABLE_COPY, transcriptView, type TerminalProps, type TranscriptSegment, type TermRow, type TermRowKind, type TermSource, type TermView, } from "./Terminal.js";
24
+ export { QueuePanel, QueueRow, QUEUE_EMPTY_COPY, QUEUE_LOADING_COPY, QUEUE_STALE_COPY, QUEUE_UNAVAILABLE_COPY, canCancelRow, cancelReducer, queueBadgeClass, queueBadgeLabel, queueRowClass, queueView, shouldDisarmCancel, type QueuePanelProps, type QueueRowProps, type CancelEvent, type CancelState, type QueueItem, type QueueItemStatus, type QueueSource, type QueueUnavailableReason, type QueueView, } from "./QueuePanel.js";
25
+ export { SendBar, DELIVERY_CLASSES, DELIVERY_HINT, SEND_DISABLED_FALLBACK, SEND_PLACEHOLDER, canSend, clearDraftOnSend, deliveryClassLabel, keyAction, sendPlaceholder, showDeliveryHint, type SendBarProps, type DeliveryClass, type KeyAction, } from "./SendBar.js";
package/dist/index.js CHANGED
@@ -19,3 +19,17 @@ export { StatusLine, statusLineClass } from "./StatusLine.js";
19
19
  export { SearchInput } from "./SearchInput.js";
20
20
  export { Banner, bannerClass, BANNER_ICON } from "./Banner.js";
21
21
  export { Gauge, gaugeTone, gaugeGeom } from "./Gauge.js";
22
+ // ── small atoms graduated out of a single product (save-bar / stat-tiles / git-chip cards) ──
23
+ export { SaveBar, saveBarNote, saveBarDirty, saveBarClass, SAVE_BAR_PARTS, SAVE_BAR_SEP, SAVE_BAR_DISCARD_LABEL, SAVE_BAR_SAVE_LABEL, } from "./SaveBar.js";
24
+ export { StatTiles, statTilesClass, statTileClass, STAT_TILE_PARTS, formatStatCompact, formatStatCount, formatStatUsd, formatStatPercent, STAT_TILE_EMPTY, STAT_TILE_MINUS, } from "./StatTiles.js";
25
+ export { GitChip, gitFlags, hasGitStatus, gitBranchLabel, gitChipClass, gitFlagClass, gitChipNote, GIT_CHIP_PARTS, GIT_BRANCH_GLYPH, GIT_BRANCH_UNKNOWN, GIT_DETACHED_LABEL, GIT_CLEAN_LABEL, GIT_LOADING_NOTE, GIT_UNAVAILABLE_NOTE, GIT_STALE_LABEL, GIT_STALE_TITLE, } from "./GitChip.js";
26
+ // ── dropdown popover (ds/components-popover — registry row `popover` v1) ──
27
+ export { Popover, POPOVER_CARET, POPOVER_CHECK, POPOVER_EMPTY_VALUE, popoverTriggerClass, popoverPanelClass, popoverItemClass, popoverTriggerText, resolvePopoverPosition, } from "./Popover.js";
28
+ // ── file explorer & markdown preview (ds/components-file-explorer) ──
29
+ export { FileTree, FilePreview, FileScopePicker, deriveFileTreeRows, countFileRows, countLoadedFiles, previewMeta, previewBodyMode, buildBreadcrumb, breadcrumbSegments, formatFileSize, formatRelativeTime, } from "./FileExplorer.js";
30
+ // ── session-card (design registry `session-card` v1) ──
31
+ export { SessionCard, ctxBand, ctxReading, ctxBarGeom, normalizeCtxThresholds, ctxMeterClass, ctxNoteText, ctxValueText, sessionAvatarInitial, sessionCardClass, sessionCardIsStale, sessionCardStale, sessionStatusText, sessionSpineSummary, sessionStatus, sessionStatusClass, sessionSubline, spineNodeClass, } from "./SessionCard.js";
32
+ // ── terminal set (ds/components-terminal v2): terminal · queue-row · send-bar ──
33
+ export { Terminal, TERM_CLASS, TERM_STALE_COPY, TERM_WAKE_UNAVAILABLE_COPY, transcriptView, } from "./Terminal.js";
34
+ export { QueuePanel, QueueRow, QUEUE_EMPTY_COPY, QUEUE_LOADING_COPY, QUEUE_STALE_COPY, QUEUE_UNAVAILABLE_COPY, canCancelRow, cancelReducer, queueBadgeClass, queueBadgeLabel, queueRowClass, queueView, shouldDisarmCancel, } from "./QueuePanel.js";
35
+ export { SendBar, DELIVERY_CLASSES, DELIVERY_HINT, SEND_DISABLED_FALLBACK, SEND_PLACEHOLDER, canSend, clearDraftOnSend, deliveryClassLabel, keyAction, sendPlaceholder, showDeliveryHint, } from "./SendBar.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mythicalos/preact-ui",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "mythicalOS thin Preact bindings over @mythicalos/ui-core — Button, Input/Toggle/Checkbox, MaskedSecretInput, EmptyState, ConfirmDialog/Scrim, Toast/ToastProvider, Chip, Card, Avatar, StatusLine, SearchInput, Banner, Gauge, and the usePoll/useInterval hooks. Render + framework wiring only — every class string, poll-scheduling math, glyph map, and text composition is derived by @mythicalos/ui-core so this binding and its React sibling can never drift. Apache-2.0.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  }
36
36
  },
37
37
  "dependencies": {
38
- "@mythicalos/ui-core": "^0.2.0"
38
+ "@mythicalos/ui-core": "^0.3.0"
39
39
  },
40
40
  "scripts": {
41
41
  "build": "tsc -p tsconfig.build.json",