// — A structured information display tile. // // Renders a card with title, name-subtitle, accent color header, and a key-value // property list. Designed to be embedded in dashboards, data visualizations, // and other composite UI layouts. // // Attributes: // title — main heading (entity kind, e.g. "Task", "Concept") // tile-id — identifier badge shown in the header // accent-color — top border and header gradient tint // selected — boolean attribute for selection highlight // dragging — boolean attribute for drag-in-progress state // collapsed — boolean attribute reflecting collapse-toggle state // editing — boolean attribute; when true the tile renders inline // inputs for name and properties so the user can edit // in-place instead of opening a separate dialog. // not-collapsible — boolean attribute that HIDES the collapse button // (presence = hidden; absence = visible). The // attribute name carries the negation because most // tiles SHOULD show the button — defaulting on // button presence avoids host apps having to opt in // on every node. Use cases: leaves of a hierarchy // (nothing to collapse), single-node graphs, etc. // // Properties: // data — { id, title, subtitle, accentColor, properties, types, // displayValues, hiddenKeys } object // subtitle is an optional secondary heading shown below the title. // When no properties are present the content area collapses to zero height. // displayValues maps property keys to human-friendly formatted strings // (e.g. ISO dates → "Jun 2, 2026, 5:00 PM"). In view mode the tile // prefers displayValues over the raw property value; edit mode always // uses the raw value so the host receives unchanged stored data. // selected — boolean, when true applies the selection highlight ring // collapsed — boolean, when true renders the toggle button in its // "collapsed" state (caret pointing right). Hosts read // the `ars-info-tile:toggle-collapse` event to react. // editing — boolean, when true renders inline inputs and save/cancel // // Events: // ars-info-tile:activate — fired on double-click (composed, bubbles) // ars-info-tile:toggle-collapse — fired when the user clicks the // header collapse/expand button. // `detail.collapsed` carries the // *requested* next state (NOT yet // applied — the host decides whether // to flip the property). // ars-info-tile:edit-save — fired when the user confirms an // inline edit. Detail: { properties }. // ars-info-tile:edit-cancel — fired when the user cancels inline edit. export interface ArsInfoTileProperty { key: string; value: string; } export interface ArsInfoTileData { id?: string; title?: string; subtitle?: string; accentColor?: string; properties?: Record | ArsInfoTileProperty[]; types?: Record; /** Property keys that should be rendered in edit mode but hidden * in view mode. Useful when a value is already surfaced elsewhere * (e.g. a name shown in the title). */ hiddenKeys?: string[]; /** Formatted display values for view mode. If a key is present here * the tile renders it instead of the raw property value, while edit * mode still uses the raw value from `properties`. */ displayValues?: Record; /** Explicit render order for property keys (the *external* ordering of * the node's properties). Keys listed here render in the given sequence; * any property not listed falls back to alphabetical order after them. * This decouples the displayed order from the JSON-object key order, * which is not preserved across (de)serialization. */ order?: string[]; } class ArsInfoTile extends HTMLElement { private _data: ArsInfoTileData = {}; private _activationEventsBound = false; private _editAbortController: AbortController | null = null; /** MutationObserver that watches the engine-written inline `style` * (which carries the scene `scale3d`) so we can keep the CSS * custom property `--counter-scale` in sync while the camera * animates or the user pans/zooms. */ private _scaleObserver: MutationObserver | null = null; static get observedAttributes() { return [ "title", "subtitle", "selected", "dragging", "collapsed", "editing", "not-collapsible", "accent-color", "tile-id", ]; } constructor() { super(); this.attachShadow({ mode: "open" }); } connectedCallback() { this.#render(); this.#bindActivationEvents(); } attributeChangedCallback( name: string, _oldValue: string | null, _newValue: string | null, ) { // For attributes that only affect data-* attributes on the .card // element, update directly without full re-render. This prevents // destroying the button element between mousedown and mouseup, // which would prevent the browser from generating a click event // (mousedown target ≠ mouseup target → no click). if (name === "selected" || name === "dragging") { this.#updateCardDataAttrs(); return; } this.#render(); } get data(): ArsInfoTileData { return { ...this._data }; } set data(value: ArsInfoTileData) { // Guard: skip re-render when data is structurally identical. // The DOMRenderer re-applies ALL properties whenever ANY property // changes (e.g. `selected` is toggled). Without this guard, // re-setting `data` to the same value triggers #render(), which // rebuilds the shadow DOM and destroys the collapse button between // mousedown and mouseup — the browser then suppresses the click // event (different mousedown/mouseup targets) and the toggle // never fires on the first click. const newData = { ...value }; if (JSON.stringify(this._data) === JSON.stringify(newData)) return; this._data = newData; this.#render(); } // Property setter so hosts can toggle selection via // `(el as any).selected = true`. Mirrors the attribute-based path // (`setSelected`) so both integration styles work. get selected(): boolean { return this.hasAttribute("selected"); } set selected(value: boolean) { this.setSelected(!!value); } // Exposes selection changes to host apps without forcing them to manipulate attributes directly. setSelected(isSelected: boolean) { this.toggleAttribute("selected", isSelected); } // Exposes drag state so hosts can reflect movement without re-rendering external wrappers. setDragging(isDragging: boolean) { this.toggleAttribute("dragging", isDragging); } // Symmetric collapse property — hosts toggle it the same way they do // `selected`. The `collapsed` flag is *purely cosmetic* on the tile // itself (it flips the toggle-button caret); any application-level // effect (e.g. hiding a subtree, adjusting layout) is the host's // responsibility. get collapsed(): boolean { return this.hasAttribute("collapsed"); } set collapsed(value: boolean) { this.setCollapsed(!!value); } setCollapsed(isCollapsed: boolean) { this.toggleAttribute("collapsed", isCollapsed); } get editing(): boolean { return this.hasAttribute("editing"); } set editing(value: boolean) { this.toggleAttribute("editing", !!value); } setEditing(isEditing: boolean) { this.toggleAttribute("editing", isEditing); } // Whether the collapse-toggle button is rendered. Stored as the // negative attribute `not-collapsible` so the default (no attribute // present) renders the button — symmetric with how most boolean // HTML attributes work (e.g. `disabled` is opt-in for the unusual // state). Hosts set this via `el.collapsible = false` on leaf // items; the inverse property name keeps the host API positive // ("this tile CAN be collapsed: yes/no") while the attribute stays // attribute-namespace correct. get collapsible(): boolean { return !this.hasAttribute("not-collapsible"); } set collapsible(value: boolean) { this.setCollapsible(!!value); } setCollapsible(isCollapsible: boolean) { this.toggleAttribute("not-collapsible", !isCollapsible); } // Opacity hook for dimming or fading out a tile. Hosts write // through the property setter, which applies the value as an inline // style on the element. Reading the same value back from // `getPropertyValue` keeps the getter consistent with what's on // the element regardless of who set it (host framework vs. // application code). Range is the standard CSS [0, 1]; values // outside are clamped to protect against typo-induced invisibility. get opacity(): number { const raw = this.style.getPropertyValue("opacity"); if (raw === "") return 1; const parsed = Number.parseFloat(raw); return Number.isFinite(parsed) ? parsed : 1; } set opacity(value: number) { const clamped = Math.max(0, Math.min(1, Number(value))); if (!Number.isFinite(clamped) || clamped === 1) { // Treat 1 (or invalid) as the default — remove the inline // style so the element returns to whatever the cascade dictates. this.style.removeProperty("opacity"); } else { this.style.setProperty("opacity", String(clamped)); } } // Reports the card's intrinsic border-box height so host layouts do not confuse stretched containers with content growth. measureIntrinsicHeight(): number { if (!this.shadowRoot) { return 0; } const card = this.shadowRoot.querySelector(".card") as HTMLElement | null; const header = this.shadowRoot.querySelector( ".header", ) as HTMLElement | null; const content = this.shadowRoot.querySelector( ".content", ) as HTMLElement | null; if (!card || !header || !content) { return 0; } const cardStyle = getComputedStyle(card); const borderTop = Number.parseFloat(cardStyle.borderTopWidth || "0") || 0; const borderBottom = Number.parseFloat(cardStyle.borderBottomWidth || "0") || 0; return Math.ceil( header.scrollHeight + content.scrollHeight + borderTop + borderBottom, ); } static #escapeHtml(value: unknown): string { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } // Normalizes either record-based or array-based property payloads into a // single render shape and applies the *external* property ordering. // // A JSON object's key order is not preserved across (de)serialization // (the backend serializes through a BTreeMap), so the displayed order must // be driven by an explicit `order` array rather than the object's own key // sequence. Keys listed in `order` render first, in the given sequence; // any remaining key falls back to alphabetical order after them. This is // what keeps "Name" first and "Starts at" before "Ends at" in edit mode. static #normalizeProperties( properties: ArsInfoTileData["properties"], order: string[] = [], ): ArsInfoTileProperty[] { const raw: ArsInfoTileProperty[] = Array.isArray(properties) ? properties.map((property) => ({ key: String(property.key ?? ""), value: String(property.value ?? ""), })) : properties ? Object.entries(properties).map(([key, value]) => ({ key, value: String(value ?? ""), })) : []; // NOTE: title/subtitle hiding is handled by the consumer's `hiddenKeys` // array, which hides keys in view mode but preserves them in edit mode. // The old unconditional filter here prevented editing the node name. const filtered = raw; // Rank for the explicit order: listed keys take their index; everything // else sinks below them (Number.MAX_SAFE_INTEGER) and ties break A–Z. const rankOf = (key: string): number => { const i = order.indexOf(key); return i === -1 ? Number.MAX_SAFE_INTEGER : i; }; filtered.sort((a, b) => { const ra = rankOf(a.key); const rb = rankOf(b.key); return ra === rb ? a.key.localeCompare(b.key) : ra - rb; }); return filtered; } // Maps a wire-type tag to the HTML type that best suits it. // Defined as an instance method to avoid static-private-method access // issues across different transpiler/runtime combinations. #inferInputType(typeTag: string | undefined): string { switch (typeTag) { case "email": return "email"; case "date": return "date"; case "time": return "time"; case "datetime-local": return "datetime-local"; case "number": return "number"; case "url": return "url"; case "tel": return "tel"; default: return "text"; } } // Convert an ISO-8601 / naive datetime string into the YYYY-MM-DDTHH:MM // format expected by . #toDateTimeLocal(raw: string): string { const m = raw.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})/); return m ? `${m[1]}T${m[2]}` : raw; } // Merges property data with attributes so host frameworks can choose either integration style. #getViewModel() { const title = this._data.title ?? this.getAttribute("title") ?? this._data.id ?? this.getAttribute("tile-id") ?? ""; const accentColor = this._data.accentColor ?? this.getAttribute("accent-color") ?? "var(--arswc-color-accent, #4cc2ff)"; const normalizedProperties = ArsInfoTile.#normalizeProperties( this._data.properties, this._data.order ?? [], ); return { id: this._data.id ?? this.getAttribute("tile-id") ?? "", title, subtitle: this._data.subtitle ?? this.getAttribute("subtitle") ?? "", accentColor, properties: normalizedProperties, types: this._data.types ?? {}, hiddenKeys: this._data.hiddenKeys ?? [], displayValues: this._data.displayValues ?? {}, isSelected: this.hasAttribute("selected"), isDragging: this.hasAttribute("dragging"), isCollapsed: this.hasAttribute("collapsed"), isCollapsible: !this.hasAttribute("not-collapsible"), isEditing: this.hasAttribute("editing"), }; } // Update only the data-* attributes on the .card element without // tearing down the full shadow DOM. Used for `selected` and `dragging` // changes that are purely cosmetic. Prevents button destruction between // mousedown and mouseup, which would suppress the click event. #updateCardDataAttrs(): void { const card = this.shadowRoot?.querySelector(".card"); if (!card) return; card.setAttribute("data-selected", String(this.hasAttribute("selected"))); card.setAttribute("data-dragging", String(this.hasAttribute("dragging"))); } // Builds the edit-mode markup for a single property row. // Text properties use a `; } else if (inputType === "datetime-local") { const dtLocal = ArsInfoTile.#escapeHtml(this.#toDateTimeLocal(property.value)); control = ``; } else { control = ``; } return `
${control}
`; } // Redraws the full shadow DOM because the tile is small and host updates are infrequent. #render() { if (!this.shadowRoot) { return; } const viewModel = this.#getViewModel(); let headerHtml: string; let contentHtml: string; // Whether the body area has no visible content and should collapse. // Always false in edit mode (properties are always rendered). let bodyEmpty = false; if (viewModel.isEditing) { // ── Edit mode ── const propertyInputs = viewModel.properties .map((p) => { const typeTag = viewModel.types[p.key]; return this.#renderEditRow(p, typeTag); }) .join(""); headerHtml = `

${ArsInfoTile.#escapeHtml(viewModel.title)}

`; contentHtml = propertyInputs; } else { // ── Display mode ── const displaySubtitle = viewModel.subtitle; // Hide empty properties and keys marked hiddenKeys in view mode. const visibleProperties = viewModel.properties.filter( (p) => p.value.trim() !== "" && !viewModel.hiddenKeys.includes(p.key), ); // If there are no visible properties, collapse the body. const hasBody = visibleProperties.length > 0; bodyEmpty = !hasBody; const propertiesHtml = hasBody ? visibleProperties .map( (property) => { const displayValue = viewModel.displayValues[property.key] ?? property.value; return `
${ArsInfoTile.#escapeHtml(property.key)} ${ArsInfoTile.#escapeHtml(displayValue)}
`; }, ) .join("") : ""; headerHtml = `

${ArsInfoTile.#escapeHtml(viewModel.title)}

${displaySubtitle ? `
${ArsInfoTile.#escapeHtml(displaySubtitle)}
` : ""}
${ viewModel.isCollapsible ? `` : "" } `; contentHtml = propertiesHtml; bodyEmpty = !hasBody; } this.shadowRoot.innerHTML = `
${headerHtml}
${contentHtml}
`; if (viewModel.isEditing) { this.#startCounterScale(); this.#bindEditListeners(); } else { // Tear down edit listeners so document-level handlers (Escape, // click-outside) don't leak across edit sessions. this._editAbortController?.abort(); this._editAbortController = null; this.#stopCounterScale(); this.#bindCollapseButton(); } } // Wire Enter/Escape keys and click-outside for inline editing. // Uses an AbortController so repeated renders don't accumulate listeners. #bindEditListeners() { if (!this.shadowRoot) return; // Tear down any previous edit listeners (e.g. from a prior render). this._editAbortController?.abort(); this._editAbortController = new AbortController(); const { signal } = this._editAbortController; // Enter to save (when focus is inside the shadow tree). // For