history: tab container updates, EditorPanel interfaces, editor-shell bug fixes, workspace docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rokojori 2026-07-25 22:50:14 +02:00
parent 3e25a419ca
commit dba0fdca1e
15 changed files with 358 additions and 47 deletions

View File

@ -1,3 +1,3 @@
# Roject # 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.

View File

@ -1,4 +1,5 @@
import { Editor } from '../../editor/Editor.js'; import { Editor } from '../../editor/Editor.js';
import { EditorPanelDefinition, FileEditorPanelDefinition } from '../../editor/editor-panel.js';
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
import { csharpMode } from './CSharpMode.js'; import { csharpMode } from './CSharpMode.js';
@ -9,7 +10,9 @@ CodeMirror.defineMode( 'rokojori-cs', () => csharpMode.cmMode );
class CodePanel extends HTMLElement class CodePanel extends HTMLElement
{ {
__interfaces__ = [ EditorPanelDefinition.type, FileEditorPanelDefinition.type ];
currentPath: string | null = null; currentPath: string | null = null;
_dirty = false;
_pinned: boolean = false; _pinned: boolean = false;
_cm: any = null; _cm: any = null;
_initialized = false; _initialized = false;
@ -132,8 +135,14 @@ class CodePanel extends HTMLElement
this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label: '📝 ' + name } } ) ); this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label: '📝 ' + name } } ) );
} }
hasUnsavedChanges(): boolean
{
return this._dirty;
}
_updateButtons( dirty: boolean ): void _updateButtons( dirty: boolean ): void
{ {
this._dirty = dirty;
( this.querySelector( '.cp-save' ) as HTMLButtonElement ).disabled = ! dirty; ( this.querySelector( '.cp-save' ) as HTMLButtonElement ).disabled = ! dirty;
( this.querySelector( '.cp-undo' ) as HTMLButtonElement ).disabled = this._cm.historySize().undo < 1; ( this.querySelector( '.cp-undo' ) as HTMLButtonElement ).disabled = this._cm.historySize().undo < 1;
( this.querySelector( '.cp-redo' ) as HTMLButtonElement ).disabled = this._cm.historySize().redo < 1; ( this.querySelector( '.cp-redo' ) as HTMLButtonElement ).disabled = this._cm.historySize().redo < 1;

View File

@ -2,6 +2,21 @@ import { Editor } from '../../editor/Editor.js';
// ── Layout helpers ──────────────────────────────────────────────────────────── // ── 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; let tcCounter = 0;
function nextTcId(): string { return `tc-${++tcCounter}`; } function nextTcId(): string { return `tc-${++tcCounter}`; }
@ -151,16 +166,51 @@ class EditorShell extends HTMLElement {
private setupSplitListener(): void { private setupSplitListener(): void {
this.addEventListener('tab-container:split', (e: Event) => { 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); const tc = document.getElementById(containerId);
if (!tc) return; if (!tc) return;
const section = tc.closest('.es-section') as HTMLElement | null; const section = tc.closest('.es-section') as HTMLElement | null;
const sections = tc.closest('.es-sections') as HTMLElement | null; if (!section) return;
if (!section || !sections) return;
const newSec = makeSection(); if ( 'vertical' === direction )
const handle = makeResizeHandle('v'); {
sections.insertBefore(handle, section.nextSibling); const newTc = document.createElement('tab-container') as HTMLElement;
sections.insertBefore(newSec, handle.nextSibling); 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) => { this.addEventListener('tab-container:add-panel', (e: Event) => {
@ -245,15 +295,21 @@ class EditorShell extends HTMLElement {
private setupResizeHandler(): void private setupResizeHandler(): void
{ {
const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; const workspace = this.querySelector( '.es-workspace' ) as HTMLElement;
const obs = new ResizeObserver( () =>
const workspaceObs = new ResizeObserver( () =>
{ {
this._redistributeFlex( workspace, '.es-panel' ); 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 private _redistributeFlex( container: HTMLElement, childSelector: string ): void
@ -293,7 +349,15 @@ class EditorShell extends HTMLElement {
const apply = ( portrait: boolean ) => { const apply = ( portrait: boolean ) => {
this.classList.toggle( 'portrait', portrait ); 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 => { btns.querySelectorAll( '.es-pb-btn' ).forEach( btn => {

View File

@ -1,4 +1,5 @@
import { Editor } from '../../editor/Editor.js'; 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 { ContextMenuDirectory, ContextMenuEntry, ContextMenuReadOnlyEntry, ContextMenuSeparator } from '../context-menu/context-menu.js';
import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js'; import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js';
@ -10,6 +11,7 @@ interface FileNode {
} }
class FileTreePanel extends HTMLElement { class FileTreePanel extends HTMLElement {
__interfaces__ = [ EditorPanelDefinition.type ];
selectedPath: string | null = null; selectedPath: string | null = null;
_rootPath: string = ''; _rootPath: string = '';
_initialized = false; _initialized = false;

View File

@ -1,4 +1,5 @@
import { Editor } from '../../editor/Editor.js'; import { Editor } from '../../editor/Editor.js';
import { EditorPanelDefinition, FileEditorPanelDefinition } from '../../editor/editor-panel.js';
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
// ── Block registry ──────────────────────────────────────────────────────────── // ── Block registry ────────────────────────────────────────────────────────────
@ -191,7 +192,9 @@ function wrapSelection( doc: Document, range: Range, tagName: string, attributes
class PageEditorPanel extends HTMLElement class PageEditorPanel extends HTMLElement
{ {
__interfaces__ = [ EditorPanelDefinition.type, FileEditorPanelDefinition.type ];
currentPath: string | null = null; currentPath: string | null = null;
_dirty = false;
_pinned: boolean = false; _pinned: boolean = false;
_iframe: HTMLIFrameElement | null = null; _iframe: HTMLIFrameElement | null = null;
_undoStack: string[] = []; _undoStack: string[] = [];
@ -477,8 +480,14 @@ class PageEditorPanel extends HTMLElement
dir.add( new ContextMenuReadOnlyEntry( dir, this.currentPath ? `Editing: ${ this.currentPath }` : 'No document open' ) ); dir.add( new ContextMenuReadOnlyEntry( dir, this.currentPath ? `Editing: ${ this.currentPath }` : 'No document open' ) );
} }
hasUnsavedChanges(): boolean
{
return this._dirty;
}
_updateButtons( dirty: boolean ): void _updateButtons( dirty: boolean ): void
{ {
this._dirty = dirty;
( this.querySelector( '.pep-save' ) as HTMLButtonElement ).disabled = ! dirty; ( this.querySelector( '.pep-save' ) as HTMLButtonElement ).disabled = ! dirty;
( this.querySelector( '.pep-undo' ) as HTMLButtonElement ).disabled = this._undoStack.length < 2; ( this.querySelector( '.pep-undo' ) as HTMLButtonElement ).disabled = this._undoStack.length < 2;
( this.querySelector( '.pep-redo' ) as HTMLButtonElement ).disabled = this._redoStack.length === 0; ( this.querySelector( '.pep-redo' ) as HTMLButtonElement ).disabled = this._redoStack.length === 0;

View File

@ -1,4 +1,5 @@
import { Editor } from '../../editor/Editor.js'; import { Editor } from '../../editor/Editor.js';
import { EditorPanelDefinition } from '../../editor/editor-panel.js';
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
declare const markdownit: ( options?: Record<string, unknown> ) => { render: ( md: string ) => string }; declare const markdownit: ( options?: Record<string, unknown> ) => { render: ( md: string ) => string };
@ -39,6 +40,7 @@ function parentDir( filePath: string ): string
class RojoChatPanel extends HTMLElement class RojoChatPanel extends HTMLElement
{ {
__interfaces__ = [ EditorPanelDefinition.type ];
_initialized = false; _initialized = false;
_conversationId = ''; _conversationId = '';
_sending = false; _sending = false;

View File

@ -1,4 +1,5 @@
import { Editor } from '../../editor/Editor.js'; import { Editor } from '../../editor/Editor.js';
import { EditorPanelDefinition } from '../../editor/editor-panel.js';
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
const NS_INKSCAPE = 'http://www.inkscape.org/namespaces/inkscape'; const NS_INKSCAPE = 'http://www.inkscape.org/namespaces/inkscape';
@ -111,6 +112,7 @@ async function loadPortraitSvg(): Promise<string>
class RojoSettingsPanel extends HTMLElement class RojoSettingsPanel extends HTMLElement
{ {
__interfaces__ = [ EditorPanelDefinition.type ];
currentPath: string | null = null; currentPath: string | null = null;
_initialized = false; _initialized = false;
_ignoreChange = false; _ignoreChange = false;

View File

@ -1,18 +1,17 @@
import { Editor } from '../../editor/Editor.js'; 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 { ContextMenuDirectory, ContextMenuEntry, ContextMenuSeparator } from '../context-menu/context-menu.js';
import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js';
interface TabEntry { interface TabEntry {
id: string; id: string;
label: string; label: string;
panelType: string; panelType: string;
element: HTMLElement; element: HTMLElement;
dirty: boolean;
factory: () => HTMLElement; factory: () => HTMLElement;
} }
export interface EditorPanel extends HTMLElement { export { EditorPanel };
addContextMenuEntries(dir: ContextMenuDirectory): void;
}
class TabContainer extends HTMLElement { class TabContainer extends HTMLElement {
tabs: TabEntry[] = []; tabs: TabEntry[] = [];
@ -54,17 +53,8 @@ class TabContainer extends HTMLElement {
if ( tab ) { tab.label = ( e as CustomEvent ).detail.label; this.renderBar(); } if ( tab ) { tab.label = ( e as CustomEvent ).detail.label; this.renderBar(); }
} ); } );
Editor.get().onDocumentDirty.addListener( ( e ) => Editor.get().onDocumentDirty.addListener( () => this.renderBar() );
{ Editor.get().onDocumentSaved.addListener( () => this.renderBar() );
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(); }
} );
} }
openMenu(e: MouseEvent): void { openMenu(e: MouseEvent): void {
@ -91,9 +81,26 @@ class TabContainer extends HTMLElement {
root.add(addDir); root.add(addDir);
root.add(new ContextMenuEntry(root, 'Duplicate', () => this.duplicateActive())); 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); const activeTab = this.tabs.find(t => t.id === this.activeId);
if (activeTab) { if (activeTab) {
@ -110,6 +117,32 @@ class TabContainer extends HTMLElement {
root.show(rect.left, rect.bottom); root.show(rect.left, rect.bottom);
} }
async closeContainer(): Promise<void>
{
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 duplicateActive(): void
{ {
if ( ! this.activeId ) if ( ! this.activeId )
@ -133,7 +166,6 @@ class TabContainer extends HTMLElement {
if ( ! this.activeId ) if ( ! this.activeId )
{ {
return; return;
} }
this.extractTab( this.activeId ); this.extractTab( this.activeId );
@ -144,7 +176,7 @@ class TabContainer extends HTMLElement {
const element = factory(); const element = factory();
element.style.display = 'none'; element.style.display = 'none';
this.querySelector('.tc-content')!.appendChild(element); this.querySelector('.tc-content')!.appendChild(element);
this.tabs.push({ ...config, element, dirty: false, factory }); this.tabs.push({ ...config, element, factory });
this.activateTab(config.id); this.activateTab(config.id);
} }
@ -176,15 +208,25 @@ class TabContainer extends HTMLElement {
renderBar(): void { renderBar(): void {
const bar = this.querySelector('.tc-tabs')!; const bar = this.querySelector('.tc-tabs')!;
bar.innerHTML = this.tabs.map(t => ` bar.innerHTML = this.tabs.map(t => {
<div class="tc-tab${t.id === this.activeId ? ' active' : ''}" draggable="true" const unsaved = implementsInterface( t.element, FileEditorPanelDefinition ) &&
data-tab-id="${t.id}" data-source="${this.id}"> ( t.element as FileEditorPanel ).hasUnsavedChanges();
${t.dirty ? '<span class="dirty-dot">●</span>' : ''}${t.label} return `
</div> <div class="tc-tab${t.id === this.activeId ? ' active' : ''}" draggable="true"
`).join(''); data-tab-id="${t.id}" data-source="${this.id}">
${unsaved ? '<span class="dirty-dot">●</span>' : ''}${t.label}
</div>
`;
}).join('');
bar.querySelectorAll('.tc-tab').forEach(el => { bar.querySelectorAll('.tc-tab').forEach(el => {
el.addEventListener('click', () => this.activateTab((el as HTMLElement).dataset.tabId!)); 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) => { el.addEventListener('dragstart', (e: Event) => {
const de = e as DragEvent; const de = e as DragEvent;
const tabId = (el as HTMLElement).dataset.tabId!; const tabId = (el as HTMLElement).dataset.tabId!;

View File

@ -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 );
}

View File

@ -80,8 +80,8 @@
<h3>Step 2 — Check CLAUDE.md</h3> <h3>Step 2 — Check CLAUDE.md</h3>
<p> <p>
Open <code>CLAUDE.md</code> at the project root and verify it still just points Open <code>CLAUDE.md</code> at the project root and verify it still just points
to the outline. If it has drifted and contains duplicated or stale content, to the workspace index. If it has drifted and contains duplicated or stale content,
trim it back so it only references <code>workspace/outline/index.html</code>. trim it back so it only references <code>workspace/index.html</code>.
</p> </p>
</div> </div>

View File

@ -159,6 +159,57 @@
from the shared library — not a plain function property or a custom event. from the shared library — not a plain function property or a custom event.
</p> </p>
</div> </div>
<div class="decision">
<strong>Structural markup — custom element names, not div.class-name</strong>
<p>
Never write <code>&lt;div class="es-header"&gt;</code> or similar. Use a
hyphenated custom element name instead: <code>&lt;editor-shell-header&gt;</code>.
The element does not need to be registered with <code>customElements.define</code>
— 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.
</p>
<pre><code>/* Wrong */
&lt;div class="es-header"&gt;...&lt;/div&gt;
/* Right */
&lt;editor-shell-header&gt;...&lt;/editor-shell-header&gt;</code></pre>
<p>CSS uses the element name as the root selector:</p>
<pre><code>editor-shell-header {
display: flex;
height: 40px;
}</code></pre>
</div>
<div class="decision">
<strong>Web Component interfaces — Definition classes</strong>
<p>
Web Components must extend <code>HTMLElement</code> 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 <code>static readonly type</code> string, and every implementing
class declares an <code>__interfaces__</code> member listing its Definition types.
Use <code>implementsInterface(el, SomeDefinition)</code> for runtime checks —
never spread raw string literals.
</p>
<pre><code>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 ) ) { ... }</code></pre>
</div>
</section> </section>
<section> <section>

View File

@ -158,6 +158,82 @@
</section> </section>
<section>
<h2>Tab container updates</h2>
<div class="card">
<h3>Split submenu + vertical split</h3>
<p>
The single <em>Split</em> menu entry is replaced by a <em>Split &gt;</em> submenu
with <em>↔ Horizontally</em> (adds a new section side by side in <code>.es-sections</code>)
and <em>↕ Vertically</em> (adds a second <code>tab-container</code> + horizontal
resize handle inside the same <code>.es-section</code>). The
<code>tab-container:split</code> event now carries a <code>direction</code> field.
<code>.es-section</code> was already <code>flex-direction: column</code>, so no CSS
changes were needed for vertical split.
</p>
</div>
<div class="card">
<h3>Close Container</h3>
<p>
A <em>Close Container</em> context menu entry fires <code>tab-container:close-container</code>,
handled by <code>editor-shell</code>: if the container is in a vertical split (multiple
<code>tab-container</code> elements in the section), only that container and its adjacent
handle are removed; otherwise the whole section and its adjacent <code>es-v-handle</code>
are removed. The entry is hidden when the container is the last one in its
<code>.es-panel</code> slot. If any tab has unsaved changes,
<code>showConfirmDialog</code> prompts with <em>Don't Close</em> /
<em>Close Without Saving</em> before dispatching the event.
</p>
</div>
<div class="card">
<h3>Middle-mouse tab close</h3>
<p>
Tabs now close on middle-mouse click: a <code>mousedown</code> listener with
<code>e.button === 1</code> calls <code>e.preventDefault()</code> (suppresses the
browser scroll cursor) and extracts the tab immediately.
</p>
</div>
<div class="card">
<h3>EditorPanel / FileEditorPanel interface system</h3>
<p>
New file <code>source/editor/editor-panel.ts</code> defines the panel contract.
<code>EditorPanel</code> requires <code>__interfaces__: string[]</code> and
<code>addContextMenuEntries()</code>. <code>FileEditorPanel extends EditorPanel</code>
adds <code>hasUnsavedChanges(): boolean</code>, replacing the old
<code>TabEntry.dirty</code> flag — <code>renderBar()</code> now queries the panel
live instead of caching a boolean.
Each interface has a companion Definition class with <code>static readonly type</code>;
<code>implementsInterface(el, Def)</code> is the single runtime check function.
All five panels (<code>page-editor-panel</code>, <code>code-panel</code>,
<code>file-tree-panel</code>, <code>rojo-settings-panel</code>,
<code>rojo-chat-panel</code>) updated with <code>__interfaces__</code>.
</p>
</div>
<div class="card">
<h3>Bug fixes</h3>
<p>
<strong>Section resize:</strong> <code>setupResizeHandler</code> 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
<code>.es-sections</code> element directly — when the panel narrows, its sections
container narrows too and the observer redistributes sections correctly.
</p>
<p style="margin-top:0.75rem">
<strong>Portrait → landscape:</strong> <code>showPortraitPanel</code> set inline
<code>display: none</code> on panels and handles, but returning to landscape only
removed the <code>portrait</code> class — the inline styles persisted and kept panels
hidden. Fixed by clearing <code>style.display</code> on all panels and
<code>.es-v-handle</code> elements when <code>apply(false)</code> runs.
</p>
</div>
</section>
<section> <section>
<h2>Key decisions</h2> <h2>Key decisions</h2>

View File

@ -19,6 +19,11 @@
<section> <section>
<h2>2026 — July</h2> <h2>2026 — July</h2>
<div class="card">
<h3><a href="2026/07-July/25-Friday/index.html">Friday, 25 July 2026</a></h3>
<p>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.</p>
</div>
<div class="card"> <div class="card">
<h3><a href="2026/07-July/18-Friday/index.html">Friday, 18 July 2026</a></h3> <h3><a href="2026/07-July/18-Friday/index.html">Friday, 18 July 2026</a></h3>
<p>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.</p> <p>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.</p>

View File

@ -85,9 +85,10 @@
Check Boards for what is active right now. Check Boards for what is active right now.
</p> </p>
<p style="margin-top: 0.75rem"> <p style="margin-top: 0.75rem">
<strong>Agents:</strong> read Outline → Guides → Reference before making changes. <strong>Agents:</strong> read Outline → Guides → Actions → Reference before making changes.
Check Boards and the most recent History entry to understand the current context. 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. 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.
</p> </p>
</div> </div>
</section> </section>

View File

@ -185,6 +185,27 @@
available unpinned non-dirty editor of the correct type is used; a new panel is 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. created in the active section if none qualifies. Pinned panels are never overwritten.
</p> </p>
<p style="margin-top:0.75rem">
<strong>Tab container context menu:</strong>
<em>Add &gt;</em> opens a panel-type submenu. <em>Duplicate</em> clones the active
tab. <em>Split &gt;</em> offers <em>↔ Horizontally</em> (new section side by side)
and <em>↕ Vertically</em> (new tab-container stacked inside the same section).
<em>Close Container</em> 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 (<em>Don't Close</em> / <em>Close Without Saving</em>) appears first.
Tabs can also be closed by middle-mouse click.
</p>
<p style="margin-top:0.75rem">
<strong>Panel interfaces</strong><code>source/editor/editor-panel.ts</code> defines
two interfaces for Web Component panels. <code>EditorPanel</code> (all panels) requires
<code>__interfaces__: string[]</code> and <code>addContextMenuEntries()</code>.
<code>FileEditorPanel extends EditorPanel</code> (file-editing panels only) adds
<code>hasUnsavedChanges(): boolean</code>, replacing the old <code>TabEntry.dirty</code>
flag. Each interface has a companion Definition class
(<code>EditorPanelDefinition</code>, <code>FileEditorPanelDefinition</code>) with a
<code>static readonly type</code> string; use <code>implementsInterface(el, Def)</code>
for runtime checks.
</p>
</div> </div>
<div class="card"> <div class="card">