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
Open Step 2 β Check CLAUDE.md
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.
+ 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 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 ) ) { ... }
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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__.
+
+ 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.
+
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.
+ 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.
+