Friday, 31 July 2026

Session History

File tree UX improvements, page editor full redesign (unified toolbar, floating block menu, insertion triggers, drag-to-reorder), InputDialog component, EditorConsole, console-panel tab.

What we built

File tree: open-state preservation on refresh

Before this fix, calling refresh() on file-tree-panel collapsed all open directories. The fix records which ftp-dir elements carry the open class (via data-path on their ftp-dir-label) before rebuilding the HTML, then re-adds open to the matching labels after the new tree renders.

File tree: "Mark As Root Directory" moved to context menu

The double-click listener on ftp-dir-label was removed. "As Root Directory" is now a context menu entry that appears when right-clicking a directory, inside the existing showItemMenu() method. isDir detection uses targetPath.endsWith('/').

File tree: "Open >" submenu for alternate editors

When right-clicking a file that has a known editor, the context menu shows an Open > submenu listing the default editor plus all registered alternatives. Selecting an entry calls _openFileIn(path, editorTag), which focuses an existing tab of that type or creates a new one.

Two static maps drive this: _panelTypeMap (editorTag → panelType + label) and _editorAlternatives (defaultEditorTag → alternativeEditorTags[]). _openFileDefault (single click) focuses the file in any open editor regardless of type; _openFileIn (context menu) focuses only in the specified editor type.

File tree: context menu label truncation

The context menu title now shows only the filename (not the full path), truncated to a configurable menuLabelMaxChars (20). Names longer than the limit are shown as ...last-20-chars.

Page editor: mode buttons moved into toolbar

The left sidebar (.pep-sidebar) and its wrapper (.pep-main) were removed. The Blocks (⊞) and Areas (T) mode buttons were moved directly into .pep-toolbar, separated from the left-side toolbar items by a .pep-toolbar-sep spacer (flex: 1) that pushes them to the right. page-editor-panel now uses flex-direction: column with three direct children: toolbar, mode panel, iframe.

EditorConsole — centralised message system

New singleton EditorConsole (source/editor/EditorConsole.ts) holds a capped ring of 500 ConsoleMessage objects (text, type: 'info'|'error'|'hint', timestamp) and dispatches them via onMessage: EventSlot. showHover / hideHover dispatch a secondary onHover: EventSlot<string | null> for future tooltip use.

editor-shell subscribes to onMessage and shows incoming messages in a new .es-info element in the header — fades in for 5 s then fades out. Portrait mode hides the inline element and would show a fixed bottom bar instead. Editor.get().onFileTypeUnknown is now handled here, logging to EditorConsole rather than as an inline error in file-tree-panel.

console-panel — new editor tab

console-panel is a new tab that renders all messages from EditorConsole. Custom elements: conp-header, conp-title, conp-list, conp-entry, conp-time, conp-text. On connect it pre-populates from EditorConsole.get().messages, then appends new entries as they arrive. Time format: HH:MM:SS. Entry classes (conp-entry-error, conp-entry-hint) colour conp-text accordingly. Implements EditorPanelDefinition.type via __interfaces__.

Page editor: unified toolbar with icon + label buttons

The two-mode system (Blocks / Areas toggle) was removed. All formatting and block actions now live in a single toolbar row: [ BLOCK ] | [ H1 ] [ H2 ] [ H3 ] | [ LINK ] [ MARK ] | [ BOLD ] [ ITALIC ] [ UNDER ]. Each button shows a 24×24 SVG icon on top and a short uppercase label (max 6 characters, ~10 px) below. SVG placeholders are in source/components/page-editor-panel/icons/. mousedown → preventDefault on all format buttons preserves the iframe contenteditable selection when clicking the toolbar.

Page editor: floating block menu

Clicking the BLOCK button opens a floating overlay (.pep-block-menu) positioned absolutely within the component. Default placement: below the anchor; flips above when too close to the bottom edge. An ✕ button closes the menu; clicking outside also closes it. The menu is shown offscreen first to measure its height before final positioning.

Page editor: block insertion triggers and drag-to-reorder

Thin .pep-insert-trigger overlays (10 px click area, 5 px visible via ::before) are injected before every page-block and after the last one. Interaction is split on mousedown:

  • Click (no movement) — opens the block menu to insert before that block (or append for the end trigger).
  • Drag (> 4 px movement) — a snapping bar (.pep-drag-bar) appears in the outer component and follows the cursor, snapping to the nearest trigger's Y position. The nearest trigger gets .pep-drop-target (brighter highlight). On mouseup the dragged block is moved to the drop position. No-op guards prevent moving a block before or immediately after itself.

The end trigger is click-only — no block follows it, so drag is a no-op there. A fallback mouseup on the outer document handles release outside the iframe.

Page editor: block deletion

A .pep-delete-btn (✕) is injected top-right inside every page-block. On landscape (@media (hover: hover)) it appears on block hover via CSS. On portrait, tapping anywhere on a block (no contenteditable required) adds .pep-block-active via event delegation on page-root; all other blocks lose it. Tapping in another block or outside all blocks (body listener) dismisses the active state. The delete button removes the block, records an undo entry, then rebuilds overlays.

Page editor: LINK and MARK formats, styled custom elements

Two new toolbar actions: LINK wraps the selection in <a href="..."> after prompting for a URL (selected text is used as the default if it starts with https?://). MARK wraps in <marked-text>.

CSS for both is injected into the iframe and saved in the page file: marked-text — bold, hsl(190, 80%, 90%); ahsl(200, 80%, 70%).

InputDialog component

New InputDialog class added to confirm-dialog.ts, following the same visual pattern as ConfirmDialog (backdrop, titlebar with icon / title / ✕, body, footer). Body contains a label and a full-width text input. Enter submits, Escape cancels. Returns Promise<string | null>. Exported as showInputDialog(options). The confirm-dialog, input-dialog CSS selector gives both dialogs position: fixed; inset: 0 centering.

Bug fix: openDocumentIn ignored the target panel type

Editor.openDocumentIn(filePath, panelElement) was re-resolving editorTag from the FileEditorRegistry, which always returned the default editor for the file type (e.g. page-editor-panel for .page files). The code-panel listener checked if ('code-panel' !== e.editorTag) return and bailed, so the file appeared to open but showed no content.

Fix: derive editorTag from panelElement.tagName.toLowerCase() directly, removing the registry lookup entirely from openDocumentIn. The caller already decided which panel to target; the dispatch should respect that decision.

Key decisions

EditorConsole as a standalone singleton, not part of Editor. Keeping it separate means any component (including future non-editor panels) can import just EditorConsole without pulling in the full editor state. Editor.onFileTypeUnknown is bridged to it in editor-shell, the single component that knows about both.

Two open-file methods instead of one. _openFileDefault (single click) focuses the file in any editor; _openFileIn (context menu "Open >") focuses only in the specified editor type and forces that type when creating a new tab. This makes the intent explicit without adding a flag parameter.

document.cloneNode(true) for HTML capture. Previously, _captureHtml() temporarily removed injected overlay elements from the live DOM, serialised, then re-added them — causing flickering and complexity. Cloning the document first and stripping overlays from the clone leaves the live DOM untouched entirely.

Manual _onContentChanged() before _refreshIframeOverlays() in insert and delete. Calling MutationObserver.disconnect() inside _refreshIframeOverlays discards any pending (not yet delivered) mutation callbacks — meaning a block insert or delete would not be recorded in the undo stack. The fix is to call _onContentChanged() synchronously first, then refresh overlays.

Event delegation on page-root for portrait block-tap. Attaching per-block click listeners in _refreshIframeOverlays would stack duplicate listeners across repeated refresh calls (blocks are not replaced, only triggers and delete buttons are). A single delegated listener on page-root — removed and re-added each refresh — avoids the accumulation.