From dba0fdca1e85cf45e87bf257b895382b45785027 Mon Sep 17 00:00:00 2001
From: Rokojori
Date: Sat, 25 Jul 2026 22:50:14 +0200
Subject: [PATCH] history: tab container updates, EditorPanel interfaces,
editor-shell bug fixes, workspace docs
Co-Authored-By: Claude Sonnet 4.6
---
CLAUDE.md | 2 +-
source/components/code-panel/code-panel.ts | 9 ++
.../components/editor-shell/editor-shell.ts | 92 +++++++++++++---
.../file-tree-panel/file-tree-panel.ts | 2 +
.../page-editor-panel/page-editor-panel.ts | 9 ++
.../rojo-chat-panel/rojo-chat-panel.ts | 2 +
.../rojo-settings-panel.ts | 2 +
.../components/tab-container/tab-container.ts | 100 +++++++++++++-----
source/editor/editor-panel.ts | 27 +++++
workspace/actions/update-outline/index.html | 4 +-
.../guides/writing-typescript-code/index.html | 51 +++++++++
.../history/2026/07-July/25-Friday/index.html | 76 +++++++++++++
workspace/history/index.html | 5 +
workspace/index.html | 3 +-
workspace/outline/index.html | 21 ++++
15 files changed, 358 insertions(+), 47 deletions(-)
create mode 100644 source/editor/editor-panel.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index 565d682..99be1c4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,3 +1,3 @@
# Roject
-See **[workspace/outline/index.html](workspace/outline/index.html)** for the full developer documentation β project outline, tech stack, coding conventions, and repeatable actions.
+See **[workspace/index.html](workspace/index.html)** for the full developer documentation β project outline, tech stack, coding conventions, and repeatable actions.
diff --git a/source/components/code-panel/code-panel.ts b/source/components/code-panel/code-panel.ts
index 0aaeae2..3228d94 100644
--- a/source/components/code-panel/code-panel.ts
+++ b/source/components/code-panel/code-panel.ts
@@ -1,4 +1,5 @@
import { Editor } from '../../editor/Editor.js';
+import { EditorPanelDefinition, FileEditorPanelDefinition } from '../../editor/editor-panel.js';
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
import { csharpMode } from './CSharpMode.js';
@@ -9,7 +10,9 @@ CodeMirror.defineMode( 'rokojori-cs', () => csharpMode.cmMode );
class CodePanel extends HTMLElement
{
+ __interfaces__ = [ EditorPanelDefinition.type, FileEditorPanelDefinition.type ];
currentPath: string | null = null;
+ _dirty = false;
_pinned: boolean = false;
_cm: any = null;
_initialized = false;
@@ -132,8 +135,14 @@ class CodePanel extends HTMLElement
this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label: 'π ' + name } } ) );
}
+ hasUnsavedChanges(): boolean
+ {
+ return this._dirty;
+ }
+
_updateButtons( dirty: boolean ): void
{
+ this._dirty = dirty;
( this.querySelector( '.cp-save' ) as HTMLButtonElement ).disabled = ! dirty;
( this.querySelector( '.cp-undo' ) as HTMLButtonElement ).disabled = this._cm.historySize().undo < 1;
( this.querySelector( '.cp-redo' ) as HTMLButtonElement ).disabled = this._cm.historySize().redo < 1;
diff --git a/source/components/editor-shell/editor-shell.ts b/source/components/editor-shell/editor-shell.ts
index 7c80097..6d37855 100644
--- a/source/components/editor-shell/editor-shell.ts
+++ b/source/components/editor-shell/editor-shell.ts
@@ -2,6 +2,21 @@ import { Editor } from '../../editor/Editor.js';
// ββ Layout helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function removeAdjacentHandle( el: HTMLElement, handleClass: string ): void
+{
+ const next = el.nextElementSibling as HTMLElement | null;
+ if ( next && next.classList.contains( handleClass ) )
+ {
+ next.remove();
+ return;
+ }
+ const prev = el.previousElementSibling as HTMLElement | null;
+ if ( prev && prev.classList.contains( handleClass ) )
+ {
+ prev.remove();
+ }
+}
+
let tcCounter = 0;
function nextTcId(): string { return `tc-${++tcCounter}`; }
@@ -151,16 +166,51 @@ class EditorShell extends HTMLElement {
private setupSplitListener(): void {
this.addEventListener('tab-container:split', (e: Event) => {
- const { containerId } = (e as CustomEvent).detail as { containerId: string };
+ const { containerId, direction } = (e as CustomEvent).detail as { containerId: string; direction: 'horizontal' | 'vertical' };
const tc = document.getElementById(containerId);
if (!tc) return;
const section = tc.closest('.es-section') as HTMLElement | null;
- const sections = tc.closest('.es-sections') as HTMLElement | null;
- if (!section || !sections) return;
- const newSec = makeSection();
- const handle = makeResizeHandle('v');
- sections.insertBefore(handle, section.nextSibling);
- sections.insertBefore(newSec, handle.nextSibling);
+ if (!section) return;
+
+ if ( 'vertical' === direction )
+ {
+ const newTc = document.createElement('tab-container') as HTMLElement;
+ newTc.id = nextTcId();
+ const handle = makeResizeHandle('h');
+ section.appendChild(handle);
+ section.appendChild(newTc);
+ }
+ else
+ {
+ const sections = section.closest('.es-sections') as HTMLElement | null;
+ if (!sections) return;
+ const newSec = makeSection();
+ const handle = makeResizeHandle('v');
+ sections.insertBefore(handle, section.nextSibling);
+ sections.insertBefore(newSec, handle.nextSibling);
+ }
+ });
+
+ this.addEventListener('tab-container:close-container', (e: Event) => {
+ const { containerId } = (e as CustomEvent).detail as { containerId: string };
+ const tc = document.getElementById(containerId) as HTMLElement | null;
+ if (!tc) return;
+ const section = tc.closest('.es-section') as HTMLElement | null;
+ if (!section) return;
+
+ const tcsInSection = section.querySelectorAll(':scope > tab-container');
+ if ( tcsInSection.length > 1 )
+ {
+ removeAdjacentHandle(tc, 'es-h-handle');
+ tc.remove();
+ }
+ else
+ {
+ const sections = section.closest('.es-sections') as HTMLElement | null;
+ if (!sections) return;
+ removeAdjacentHandle(section, 'es-v-handle');
+ section.remove();
+ }
});
this.addEventListener('tab-container:add-panel', (e: Event) => {
@@ -245,15 +295,21 @@ class EditorShell extends HTMLElement {
private setupResizeHandler(): void
{
const workspace = this.querySelector( '.es-workspace' ) as HTMLElement;
- const obs = new ResizeObserver( () =>
+
+ const workspaceObs = new ResizeObserver( () =>
{
this._redistributeFlex( workspace, '.es-panel' );
- workspace.querySelectorAll( '.es-sections' ).forEach( container =>
- {
- this._redistributeFlex( container as HTMLElement, '.es-section' );
- } );
} );
- obs.observe( workspace );
+ workspaceObs.observe( workspace );
+
+ const sectionsObs = new ResizeObserver( ( entries ) =>
+ {
+ for ( const entry of entries )
+ {
+ this._redistributeFlex( entry.target as HTMLElement, '.es-section' );
+ }
+ } );
+ workspace.querySelectorAll( '.es-sections' ).forEach( s => sectionsObs.observe( s ) );
}
private _redistributeFlex( container: HTMLElement, childSelector: string ): void
@@ -293,7 +349,15 @@ class EditorShell extends HTMLElement {
const apply = ( portrait: boolean ) => {
this.classList.toggle( 'portrait', portrait );
- if ( portrait ) this.showPortraitPanel( this.activePortraitPanel );
+ if ( portrait )
+ {
+ this.showPortraitPanel( this.activePortraitPanel );
+ }
+ else
+ {
+ this.querySelectorAll( '.es-panel' ).forEach( p => { ( p as HTMLElement ).style.display = ''; } );
+ this.querySelectorAll( '.es-v-handle' ).forEach( h => { ( h as HTMLElement ).style.display = ''; } );
+ }
};
btns.querySelectorAll( '.es-pb-btn' ).forEach( btn => {
diff --git a/source/components/file-tree-panel/file-tree-panel.ts b/source/components/file-tree-panel/file-tree-panel.ts
index a19f57b..a4351fe 100644
--- a/source/components/file-tree-panel/file-tree-panel.ts
+++ b/source/components/file-tree-panel/file-tree-panel.ts
@@ -1,4 +1,5 @@
import { Editor } from '../../editor/Editor.js';
+import { EditorPanelDefinition } from '../../editor/editor-panel.js';
import { ContextMenuDirectory, ContextMenuEntry, ContextMenuReadOnlyEntry, ContextMenuSeparator } from '../context-menu/context-menu.js';
import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js';
@@ -10,6 +11,7 @@ interface FileNode {
}
class FileTreePanel extends HTMLElement {
+ __interfaces__ = [ EditorPanelDefinition.type ];
selectedPath: string | null = null;
_rootPath: string = '';
_initialized = false;
diff --git a/source/components/page-editor-panel/page-editor-panel.ts b/source/components/page-editor-panel/page-editor-panel.ts
index 5e68806..eda5490 100644
--- a/source/components/page-editor-panel/page-editor-panel.ts
+++ b/source/components/page-editor-panel/page-editor-panel.ts
@@ -1,4 +1,5 @@
import { Editor } from '../../editor/Editor.js';
+import { EditorPanelDefinition, FileEditorPanelDefinition } from '../../editor/editor-panel.js';
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
// ββ Block registry ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -191,7 +192,9 @@ function wrapSelection( doc: Document, range: Range, tagName: string, attributes
class PageEditorPanel extends HTMLElement
{
+ __interfaces__ = [ EditorPanelDefinition.type, FileEditorPanelDefinition.type ];
currentPath: string | null = null;
+ _dirty = false;
_pinned: boolean = false;
_iframe: HTMLIFrameElement | null = null;
_undoStack: string[] = [];
@@ -477,8 +480,14 @@ class PageEditorPanel extends HTMLElement
dir.add( new ContextMenuReadOnlyEntry( dir, this.currentPath ? `Editing: ${ this.currentPath }` : 'No document open' ) );
}
+ hasUnsavedChanges(): boolean
+ {
+ return this._dirty;
+ }
+
_updateButtons( dirty: boolean ): void
{
+ this._dirty = dirty;
( this.querySelector( '.pep-save' ) as HTMLButtonElement ).disabled = ! dirty;
( this.querySelector( '.pep-undo' ) as HTMLButtonElement ).disabled = this._undoStack.length < 2;
( this.querySelector( '.pep-redo' ) as HTMLButtonElement ).disabled = this._redoStack.length === 0;
diff --git a/source/components/rojo-chat-panel/rojo-chat-panel.ts b/source/components/rojo-chat-panel/rojo-chat-panel.ts
index 54a15a8..437ede0 100644
--- a/source/components/rojo-chat-panel/rojo-chat-panel.ts
+++ b/source/components/rojo-chat-panel/rojo-chat-panel.ts
@@ -1,4 +1,5 @@
import { Editor } from '../../editor/Editor.js';
+import { EditorPanelDefinition } from '../../editor/editor-panel.js';
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
declare const markdownit: ( options?: Record ) => { render: ( md: string ) => string };
@@ -39,6 +40,7 @@ function parentDir( filePath: string ): string
class RojoChatPanel extends HTMLElement
{
+ __interfaces__ = [ EditorPanelDefinition.type ];
_initialized = false;
_conversationId = '';
_sending = false;
diff --git a/source/components/rojo-settings-panel/rojo-settings-panel.ts b/source/components/rojo-settings-panel/rojo-settings-panel.ts
index bef5a75..4c0ae6f 100644
--- a/source/components/rojo-settings-panel/rojo-settings-panel.ts
+++ b/source/components/rojo-settings-panel/rojo-settings-panel.ts
@@ -1,4 +1,5 @@
import { Editor } from '../../editor/Editor.js';
+import { EditorPanelDefinition } from '../../editor/editor-panel.js';
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
const NS_INKSCAPE = 'http://www.inkscape.org/namespaces/inkscape';
@@ -111,6 +112,7 @@ async function loadPortraitSvg(): Promise
class RojoSettingsPanel extends HTMLElement
{
+ __interfaces__ = [ EditorPanelDefinition.type ];
currentPath: string | null = null;
_initialized = false;
_ignoreChange = false;
diff --git a/source/components/tab-container/tab-container.ts b/source/components/tab-container/tab-container.ts
index bdfc689..7a5e6bd 100644
--- a/source/components/tab-container/tab-container.ts
+++ b/source/components/tab-container/tab-container.ts
@@ -1,18 +1,17 @@
import { Editor } from '../../editor/Editor.js';
+import { EditorPanel, EditorPanelDefinition, FileEditorPanel, FileEditorPanelDefinition, implementsInterface } from '../../editor/editor-panel.js';
import { ContextMenuDirectory, ContextMenuEntry, ContextMenuSeparator } from '../context-menu/context-menu.js';
+import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js';
interface TabEntry {
id: string;
label: string;
panelType: string;
element: HTMLElement;
- dirty: boolean;
factory: () => HTMLElement;
}
-export interface EditorPanel extends HTMLElement {
- addContextMenuEntries(dir: ContextMenuDirectory): void;
-}
+export { EditorPanel };
class TabContainer extends HTMLElement {
tabs: TabEntry[] = [];
@@ -54,17 +53,8 @@ class TabContainer extends HTMLElement {
if ( tab ) { tab.label = ( e as CustomEvent ).detail.label; this.renderBar(); }
} );
- Editor.get().onDocumentDirty.addListener( ( e ) =>
- {
- const tab = this.tabs.find( t => ( t.element as any ).currentPath === e.path );
- if ( tab ) { tab.dirty = true; this.renderBar(); }
- } );
-
- Editor.get().onDocumentSaved.addListener( ( e ) =>
- {
- const tab = this.tabs.find( t => ( t.element as any ).currentPath === e.path );
- if ( tab ) { tab.dirty = false; this.renderBar(); }
- } );
+ Editor.get().onDocumentDirty.addListener( () => this.renderBar() );
+ Editor.get().onDocumentSaved.addListener( () => this.renderBar() );
}
openMenu(e: MouseEvent): void {
@@ -91,9 +81,26 @@ class TabContainer extends HTMLElement {
root.add(addDir);
root.add(new ContextMenuEntry(root, 'Duplicate', () => this.duplicateActive()));
- root.add(new ContextMenuEntry(root, 'Split', () => {
- this.dispatchEvent(new CustomEvent('tab-container:split', { bubbles: true, detail: { containerId: this.id } }));
+
+ const splitDir = new ContextMenuDirectory(root, 'Split');
+ splitDir.add(new ContextMenuEntry(splitDir, 'β Horizontally', () => {
+ this.dispatchEvent(new CustomEvent('tab-container:split', {
+ bubbles: true,
+ detail: { containerId: this.id, direction: 'horizontal' },
+ }));
}));
+ splitDir.add(new ContextMenuEntry(splitDir, 'β Vertically', () => {
+ this.dispatchEvent(new CustomEvent('tab-container:split', {
+ bubbles: true,
+ detail: { containerId: this.id, direction: 'vertical' },
+ }));
+ }));
+ root.add(splitDir);
+
+ const panelCount = this.closest('.es-panel')?.querySelectorAll('tab-container').length ?? 1;
+ if (panelCount > 1) {
+ root.add(new ContextMenuEntry(root, 'Close Container', () => this.closeContainer()));
+ }
const activeTab = this.tabs.find(t => t.id === this.activeId);
if (activeTab) {
@@ -110,6 +117,32 @@ class TabContainer extends HTMLElement {
root.show(rect.left, rect.bottom);
}
+ async closeContainer(): Promise
+ {
+ const hasUnsaved = this.tabs.some( t =>
+ implementsInterface( t.element, FileEditorPanelDefinition ) &&
+ ( t.element as FileEditorPanel ).hasUnsavedChanges()
+ );
+
+ if ( hasUnsaved )
+ {
+ const confirmed = await showConfirmDialog( {
+ title: 'Close Container',
+ message: 'Some tabs have unsaved changes.',
+ confirmLabel: 'Close Without Saving',
+ cancelLabel: "Don't Close",
+ danger: true,
+ } );
+
+ if ( !confirmed ) return;
+ }
+
+ this.dispatchEvent( new CustomEvent( 'tab-container:close-container', {
+ bubbles: true,
+ detail: { containerId: this.id },
+ } ) );
+ }
+
duplicateActive(): void
{
if ( ! this.activeId )
@@ -128,23 +161,22 @@ class TabContainer extends HTMLElement {
this.addTab( { id: newId, label: tab.label, panelType: tab.panelType }, tab.factory );
}
- closeActive(): void
+ closeActive(): void
{
- if ( ! this.activeId )
+ if ( ! this.activeId )
{
- return;
-
+ return;
}
this.extractTab( this.activeId );
}
- addTab(config: { id: string; label: string; panelType: string }, factory: () => HTMLElement): void
+ addTab(config: { id: string; label: string; panelType: string }, factory: () => HTMLElement): void
{
const element = factory();
element.style.display = 'none';
this.querySelector('.tc-content')!.appendChild(element);
- this.tabs.push({ ...config, element, dirty: false, factory });
+ this.tabs.push({ ...config, element, factory });
this.activateTab(config.id);
}
@@ -176,15 +208,25 @@ class TabContainer extends HTMLElement {
renderBar(): void {
const bar = this.querySelector('.tc-tabs')!;
- bar.innerHTML = this.tabs.map(t => `
-
- ${t.dirty ? 'β' : ''}${t.label}
-
- `).join('');
+ bar.innerHTML = this.tabs.map(t => {
+ const unsaved = implementsInterface( t.element, FileEditorPanelDefinition ) &&
+ ( t.element as FileEditorPanel ).hasUnsavedChanges();
+ return `
+
+ ${unsaved ? 'β' : ''}${t.label}
+
+ `;
+ }).join('');
bar.querySelectorAll('.tc-tab').forEach(el => {
el.addEventListener('click', () => this.activateTab((el as HTMLElement).dataset.tabId!));
+ el.addEventListener('mousedown', (e: Event) => {
+ const me = e as MouseEvent;
+ if ( me.button !== 1 ) return;
+ me.preventDefault();
+ this.extractTab( (el as HTMLElement).dataset.tabId! );
+ });
el.addEventListener('dragstart', (e: Event) => {
const de = e as DragEvent;
const tabId = (el as HTMLElement).dataset.tabId!;
diff --git a/source/editor/editor-panel.ts b/source/editor/editor-panel.ts
new file mode 100644
index 0000000..22693d8
--- /dev/null
+++ b/source/editor/editor-panel.ts
@@ -0,0 +1,27 @@
+import { ContextMenuDirectory } from '../components/context-menu/context-menu.js';
+
+export class EditorPanelDefinition
+{
+ static readonly type = 'EditorPanel';
+}
+
+export class FileEditorPanelDefinition
+{
+ static readonly type = 'FileEditorPanel';
+}
+
+export interface EditorPanel extends HTMLElement
+{
+ __interfaces__: string[];
+ addContextMenuEntries( dir: ContextMenuDirectory ): void;
+}
+
+export interface FileEditorPanel extends EditorPanel
+{
+ hasUnsavedChanges(): boolean;
+}
+
+export function implementsInterface( el: any, def: { type: string } ): boolean
+{
+ return Array.isArray( el.__interfaces__ ) && el.__interfaces__.includes( def.type );
+}
diff --git a/workspace/actions/update-outline/index.html b/workspace/actions/update-outline/index.html
index f3beccc..fd6a600 100644
--- a/workspace/actions/update-outline/index.html
+++ b/workspace/actions/update-outline/index.html
@@ -80,8 +80,8 @@
Step 2 β Check CLAUDE.md
Open CLAUDE.md at the project root and verify it still just points
- to the outline. If it has drifted and contains duplicated or stale content,
- trim it back so it only references workspace/outline/index.html.
+ to the workspace index. If it has drifted and contains duplicated or stale content,
+ trim it back so it only references workspace/index.html.
diff --git a/workspace/guides/writing-typescript-code/index.html b/workspace/guides/writing-typescript-code/index.html
index 36329b2..4c151b0 100644
--- a/workspace/guides/writing-typescript-code/index.html
+++ b/workspace/guides/writing-typescript-code/index.html
@@ -159,6 +159,57 @@
from the shared library β not a plain function property or a custom event.
+
+
+
Structural markup β custom element names, not div.class-name
+
+ Never write <div class="es-header"> or similar. Use a
+ hyphenated custom element name instead: <editor-shell-header>.
+ The element does not need to be registered with customElements.define
+ β it can be an anonymous container used purely as a CSS selector. This makes
+ the DOM tree readable in DevTools without decoding class names, and keeps
+ component markup self-documenting.
+
+
/* Wrong */
+<div class="es-header">...</div>
+
+/* Right */
+<editor-shell-header>...</editor-shell-header>
+
CSS uses the element name as the root selector:
+
editor-shell-header {
+ display: flex;
+ height: 40px;
+}
+
+
+
+
Web Component interfaces β Definition classes
+
+ Web Components must extend HTMLElement and cannot use class
+ inheritance for shared contracts β interfaces are the justified exception to
+ the "classes not interfaces" rule. Each interface has a companion Definition
+ class with a static readonly type string, and every implementing
+ class declares an __interfaces__ member listing its Definition types.
+ Use implementsInterface(el, SomeDefinition) for runtime checks β
+ never spread raw string literals.
+
+
export class EditorPanelDefinition
+{
+ static readonly type = 'EditorPanel';
+}
+
+export interface EditorPanel extends HTMLElement
+{
+ __interfaces__: string[];
+ addContextMenuEntries( dir: ContextMenuDirectory ): void;
+}
+
+// implementing class:
+__interfaces__ = [ EditorPanelDefinition.type ];
+
+// runtime check:
+if ( implementsInterface( panel, EditorPanelDefinition ) ) { ... }
+
diff --git a/workspace/history/2026/07-July/25-Friday/index.html b/workspace/history/2026/07-July/25-Friday/index.html
index 154f48a..616ab9d 100644
--- a/workspace/history/2026/07-July/25-Friday/index.html
+++ b/workspace/history/2026/07-July/25-Friday/index.html
@@ -158,6 +158,82 @@
+
+ Tab container updates
+
+
+
Split submenu + vertical split
+
+ The single Split menu entry is replaced by a Split > submenu
+ with β Horizontally (adds a new section side by side in .es-sections)
+ and β Vertically (adds a second tab-container + horizontal
+ resize handle inside the same .es-section). The
+ tab-container:split event now carries a direction field.
+ .es-section was already flex-direction: column, so no CSS
+ changes were needed for vertical split.
+
+
+
+
+
Close Container
+
+ A Close Container context menu entry fires tab-container:close-container,
+ handled by editor-shell: if the container is in a vertical split (multiple
+ tab-container elements in the section), only that container and its adjacent
+ handle are removed; otherwise the whole section and its adjacent es-v-handle
+ are removed. The entry is hidden when the container is the last one in its
+ .es-panel slot. If any tab has unsaved changes,
+ showConfirmDialog prompts with Don't Close /
+ Close Without Saving before dispatching the event.
+
+
+
+
+
Middle-mouse tab close
+
+ Tabs now close on middle-mouse click: a mousedown listener with
+ e.button === 1 calls e.preventDefault() (suppresses the
+ browser scroll cursor) and extracts the tab immediately.
+
+
+
+
+
EditorPanel / FileEditorPanel interface system
+
+ New file source/editor/editor-panel.ts defines the panel contract.
+ EditorPanel requires __interfaces__: string[] and
+ addContextMenuEntries(). FileEditorPanel extends EditorPanel
+ adds hasUnsavedChanges(): boolean, replacing the old
+ TabEntry.dirty flag β renderBar() now queries the panel
+ live instead of caching a boolean.
+ Each interface has a companion Definition class with static readonly type;
+ implementsInterface(el, Def) is the single runtime check function.
+ All five panels (page-editor-panel, code-panel,
+ file-tree-panel, rojo-settings-panel,
+ rojo-chat-panel) updated with __interfaces__.
+
+
+
+
+
Bug fixes
+
+ Section resize: setupResizeHandler previously observed
+ only the workspace element, so section redistribution never fired when a panel was
+ dragged (workspace size doesn't change on panel drag). Fixed by observing each
+ .es-sections element directly β when the panel narrows, its sections
+ container narrows too and the observer redistributes sections correctly.
+
+
+ Portrait β landscape: showPortraitPanel set inline
+ display: none on panels and handles, but returning to landscape only
+ removed the portrait class β the inline styles persisted and kept panels
+ hidden. Fixed by clearing style.display on all panels and
+ .es-v-handle elements when apply(false) runs.
+
+
+
+
+
Key decisions
diff --git a/workspace/history/index.html b/workspace/history/index.html
index 1a35830..ab64f3a 100644
--- a/workspace/history/index.html
+++ b/workspace/history/index.html
@@ -19,6 +19,11 @@
2026 β July
+
+
+
Tab container: split submenu (horizontal/vertical), close container with unsaved-changes dialog, middle-mouse tab close. EditorPanel/FileEditorPanel interface system replacing TabEntry.dirty. Section resize and portraitβlandscape bug fixes. TypeScript guide: custom element names + web component interface pattern. CLAUDE.md and workspace index corrected.
+
+
CodeMirror syntax highlighting: vendor modes (clike, python, shell, yaml) + custom C# mode built on BrowserLexer + CodeMirrorLexerMode with dynamic keyword sets. File tree single-click smart open (focus existing, skip pinned). rokojori-auth login fix: removed broken refresh-session redirect. Electron local dev fixes: Bearer-before-cookie token extraction, JWT_CLOCK_TOLERANCE clock skew escape hatch, startup token refresh, quit-on-login race fix, credential persistence with remember-me. Mobile nav z-index fix.
diff --git a/workspace/index.html b/workspace/index.html
index bb13426..4001500 100644
--- a/workspace/index.html
+++ b/workspace/index.html
@@ -85,9 +85,10 @@
Check Boards for what is active right now.
- Agents: read Outline β Guides β Reference before making changes.
+ Agents: read Outline β Guides β Actions β Reference before making changes.
Check Boards and the most recent History entry to understand the current context.
Follow all coding conventions in the Guides β do not infer style from existing code alone.
+ After completing work, run the Update Outline and Update History actions.
diff --git a/workspace/outline/index.html b/workspace/outline/index.html
index 3a43860..6bd24e1 100644
--- a/workspace/outline/index.html
+++ b/workspace/outline/index.html
@@ -185,6 +185,27 @@
available unpinned non-dirty editor of the correct type is used; a new panel is
created in the active section if none qualifies. Pinned panels are never overwritten.
+
+ Tab container context menu:
+ Add > opens a panel-type submenu. Duplicate clones the active
+ tab. Split > offers β Horizontally (new section side by side)
+ and β Vertically (new tab-container stacked inside the same section).
+ Close Container removes the container and its adjacent resize handle; it is
+ hidden when the container is the last one in its slot. If any tab has unsaved changes,
+ a confirm dialog (Don't Close / Close Without Saving) appears first.
+ Tabs can also be closed by middle-mouse click.
+
+
+ Panel interfaces β source/editor/editor-panel.ts defines
+ two interfaces for Web Component panels. EditorPanel (all panels) requires
+ __interfaces__: string[] and addContextMenuEntries().
+ FileEditorPanel extends EditorPanel (file-editing panels only) adds
+ hasUnsavedChanges(): boolean, replacing the old TabEntry.dirty
+ flag. Each interface has a companion Definition class
+ (EditorPanelDefinition, FileEditorPanelDefinition) with a
+ static readonly type string; use implementsInterface(el, Def)
+ for runtime checks.
+