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
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 { 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;

View File

@ -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 => {

View File

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

View File

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

View File

@ -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<string, unknown> ) => { 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;

View File

@ -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<string>
class RojoSettingsPanel extends HTMLElement
{
__interfaces__ = [ EditorPanelDefinition.type ];
currentPath: string | null = null;
_initialized = false;
_ignoreChange = false;

View File

@ -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<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
{
if ( ! this.activeId )
@ -133,7 +166,6 @@ class TabContainer extends HTMLElement {
if ( ! this.activeId )
{
return;
}
this.extractTab( this.activeId );
@ -144,7 +176,7 @@ class TabContainer extends HTMLElement {
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 => `
<div class="tc-tab${t.id === this.activeId ? ' active' : ''}" draggable="true"
data-tab-id="${t.id}" data-source="${this.id}">
${t.dirty ? '<span class="dirty-dot">●</span>' : ''}${t.label}
</div>
`).join('');
bar.innerHTML = this.tabs.map(t => {
const unsaved = implementsInterface( t.element, FileEditorPanelDefinition ) &&
( t.element as FileEditorPanel ).hasUnsavedChanges();
return `
<div class="tc-tab${t.id === this.activeId ? ' active' : ''}" draggable="true"
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 => {
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!;

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>
<p>
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,
trim it back so it only references <code>workspace/outline/index.html</code>.
to the workspace index. If it has drifted and contains duplicated or stale content,
trim it back so it only references <code>workspace/index.html</code>.
</p>
</div>

View File

@ -159,6 +159,57 @@
from the shared library — not a plain function property or a custom event.
</p>
</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>

View File

@ -158,6 +158,82 @@
</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>
<h2>Key decisions</h2>

View File

@ -19,6 +19,11 @@
<section>
<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">
<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>

View File

@ -85,9 +85,10 @@
Check Boards for what is active right now.
</p>
<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.
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>
</div>
</section>

View File

@ -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.
</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 class="card">