Icon topbar, module title-bar controls, hideable title bars, renames

Topbar: the add button is now just + and the layout button a 3-segment
grid icon (two squares + one tall rectangle). Modules no longer build
their own toolbars — moduleApi.header.addButton/addMenu put controls in
the tile's title bar next to the gear/close (menus are fixed-positioned
by the shell so a tile's overflow can't clip them, and everything a
module adds is removed automatically on unmount). Each title bar grows a
notch on its bottom edge that hides/shows the bar, ProdCom-Listener
style; the choice persists with the layout. Modules renamed: ProdCom and
ProPresenter. The ProdCom tile regains the original listener tools as
icon buttons in its title bar: timestamps toggle (clock), A−/A+ text
size, jump-to-latest, and a circle-with-cross clear button, plus the
Channels menu. MODULE-GUIDE documents the header API; README updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
Claude
2026-08-30 16:39:22 +00:00
parent af46499d92
commit 54338ddf16
9 changed files with 330 additions and 97 deletions
+19 -15
View File
@@ -60,29 +60,33 @@ are written by the app itself.
## Using the dashboard ## Using the dashboard
- **+ Add tile** — add any enabled module; multiple tiles of the same - **+** — add any enabled module; multiple tiles of the same module are
module are fine (e.g. two transcripts filtered to different channels). fine (e.g. two transcripts filtered to different channels).
- Drag a tile by its **header** to move it; drag the **corner handle** to - Drag a tile by its **title bar** to move it; drag the **corner handle**
resize; **✕** removes it. Tiles sit flush — when two share an edge, hover to resize; **✕** removes it. Tiles sit flush — when two share an edge,
the seam and a small grab pill appears; drag it to trade width between hover the seam and a small grab pill appears; drag it to trade width
the two tiles (macOS split-view style). between the two tiles (macOS split-view style).
- A tile's actions (menus, text size, clear…) live in its **title bar**;
the small **notch** on the bar's bottom edge hides/shows the whole bar
for a clean wall-display look.
- The **gear** on a tile holds per-tile settings (filters, text size, - The **gear** on a tile holds per-tile settings (filters, text size,
display options) — these belong to this browser's layout, not the server. display options) — these belong to this browser's layout, not the server.
- **Layout ▾** — save the current arrangement to the server under a name, - The **grid icon** — save the current arrangement to the server under a
load a named layout (it becomes a local copy — browsers are never linked), name, load a named layout (it becomes a local copy — browsers are never
or reset this browser's dashboard. linked), or reset this browser's dashboard.
- Every tile has a **status dot**: green = live, amber = connecting, - Every tile has a **status dot**: green = live, amber = connecting,
red = upstream unreachable (hover it for details). Tiles reconnect red = upstream unreachable (hover it for details). Tiles reconnect
automatically. automatically.
## Included modules ## Included modules
- **ProdCom Transcript** — live transcript stream with in-place updates of - **ProdCom** — live transcript stream with in-place updates of in-progress
in-progress speech, channel colors, per-tile channel visibility toggles speech, channel colors, per-tile channel visibility toggles and group
and group presets. Admin config: ProdCom URL, API key. presets, timestamps, text size and jump-to-latest in the title bar.
- **ProPresenter Now / Next** — what's live and what's next, with slide Admin config: ProdCom URL, API key.
progress, driven by the shared `propresenter-core` client (ProPresenter - **ProPresenter** — what's live and what's next, with slide progress,
7/20). Admin config: host, port, password. driven by the shared `propresenter-core` client (ProPresenter 7/20).
Admin config: host, port, password.
- **Clock** — a big booth clock; also the smallest possible module and the - **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors. reference for module authors.
+37
View File
@@ -113,6 +113,43 @@ export default function create({ root, moduleApi }) {
| `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules/<id>/state`. | | `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules/<id>/state`. |
| `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. | | `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. |
| `setStatus(state, msg)` | Drives the tile's status dot: `'ok'`, `'connecting'` or `'error'`, plus a hover message. Call it honestly — during a live service the dot is how an operator spots a dead feed. | | `setStatus(state, msg)` | Drives the tile's status dot: `'ok'`, `'connecting'` or `'error'`, plus a hover message. Call it honestly — during a live service the dot is how an operator spots a dead feed. |
| `header.addButton(…)` | Put a small button in the tile's own title bar (see below). |
| `header.addMenu(…)` | Put a dropdown menu button there (see below). |
### Title-bar controls
Modules don't build their own toolbars — actions live in the tile's title
bar, next to the shell's gear/close buttons:
```js
const btn = moduleApi.header.addButton({
icon: '<svg …>…</svg>', // inline SVG string, and/or
label: 'A+', // short text
title: 'Larger text', // hover tooltip
onClick() { … },
});
btn.classList.add('active'); // toggle styling for on/off buttons
const menu = moduleApi.header.addMenu({
label: 'Channels ▾',
title: 'Choose visible channels',
build(menuEl) {
// called on every open with an emptied menu element — fill it with
// buttons (give them your scoped classes; the container is styled and
// fixed-positioned by the shell, so the tile's overflow can't clip it)
},
});
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
```
Everything you add is removed automatically when your instance stops, so a
remount never duplicates controls. Keep them compact (icon buttons, short
labels): the title bar is 32 px tall and shared with the tile's name.
One more thing to know: the user can hide the whole title bar with the
small notch on its bottom edge (a clean-view mode for wall displays). Your
controls disappear with it, so nothing essential — status, reconnects,
live data — may exist *only* as a header control.
`sse(path, handlers)` handlers: `sse(path, handlers)` handlers:
+70 -28
View File
@@ -22,14 +22,6 @@ export default function create({ root, moduleApi }) {
root.innerHTML = ` root.innerHTML = `
<div class="pt-wrap"> <div class="pt-wrap">
<div class="pt-bar">
<div class="pt-dd">
<button class="pt-btn pt-channels-btn" title="Choose visible channels">Channels ▾</button>
<div class="pt-menu" hidden></div>
</div>
<span class="pt-spacer"></span>
<button class="pt-btn pt-clear" title="Clear this tile (does not affect ProdCom)">Clear</button>
</div>
<div class="pt-stream"> <div class="pt-stream">
<div class="pt-empty">Waiting for transcript…</div> <div class="pt-empty">Waiting for transcript…</div>
<div class="pt-entries"></div> <div class="pt-entries"></div>
@@ -42,14 +34,66 @@ export default function create({ root, moduleApi }) {
const entriesEl = root.querySelector('.pt-entries'); const entriesEl = root.querySelector('.pt-entries');
const emptyEl = root.querySelector('.pt-empty'); const emptyEl = root.querySelector('.pt-empty');
const jumpBtn = root.querySelector('.pt-jump'); const jumpBtn = root.querySelector('.pt-jump');
const channelBtn = root.querySelector('.pt-channels-btn');
const channelMenu = root.querySelector('.pt-menu'); /* ── title-bar controls ─────────────────────────────────────────── */
const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>';
const CLEAR_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>';
let channelMenuEl = null; // the open menu's element (filled on each open)
const channelsMenu = moduleApi.header.addMenu({
label: 'Channels ▾',
title: 'Choose visible channels',
build(menu) {
menu.classList.add('pt-menu');
channelMenuEl = menu;
rebuildChannelMenu();
},
});
channelsMenu.button.classList.add('pt-channels-btn');
let timeBtn = null;
timeBtn = moduleApi.header.addButton({
icon: CLOCK_SVG,
title: 'Show / hide timestamps',
onClick() {
moduleApi.saveInstanceSettings({ showTimes: !moduleApi.instanceSettings.showTimes });
applyInstanceSettings();
},
});
function bumpTextSize(delta) {
const current = Number(moduleApi.instanceSettings.textSize) || 15;
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
applyInstanceSettings();
}
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) });
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) });
moduleApi.header.addButton({
icon: JUMP_SVG,
title: 'Jump to latest',
onClick() {
pinnedToBottom = true;
jumpBtn.hidden = true;
scrollToBottom();
},
});
const clearBtn = moduleApi.header.addButton({
icon: CLEAR_SVG,
title: 'Clear this tile (does not affect ProdCom)',
onClick: clearView,
});
clearBtn.classList.add('pt-clear');
function applyInstanceSettings() { function applyInstanceSettings() {
const s = moduleApi.instanceSettings; const s = moduleApi.instanceSettings;
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15)); const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
wrap.style.setProperty('--pt-size', size + 'px'); wrap.style.setProperty('--pt-size', size + 'px');
wrap.classList.toggle('show-times', Boolean(s.showTimes)); wrap.classList.toggle('show-times', Boolean(s.showTimes));
timeBtn?.classList.toggle('active', Boolean(s.showTimes));
} }
applyInstanceSettings(); applyInstanceSettings();
@@ -106,7 +150,20 @@ export default function create({ root, moduleApi }) {
); );
} }
function updateChannelsLabel() {
let visibleCount = 0;
for (const id of channels.keys()) {
if (!hiddenChannels.has(id)) visibleCount++;
}
channelsMenu.setLabel(visibleCount === channels.size
? 'Channels ▾'
: `Channels (${visibleCount}/${channels.size}) ▾`);
}
function rebuildChannelMenu() { function rebuildChannelMenu() {
updateChannelsLabel();
const channelMenu = channelMenuEl;
if (!channelMenu) return; // menu not opened yet — the label is enough
channelMenu.innerHTML = ''; channelMenu.innerHTML = '';
if (groups.size) { if (groups.size) {
@@ -134,10 +191,8 @@ export default function create({ root, moduleApi }) {
channelMenu.appendChild(divider); channelMenu.appendChild(divider);
} }
let visibleCount = 0;
for (const [id, info] of channels) { for (const [id, info] of channels) {
const on = !hiddenChannels.has(id); const on = !hiddenChannels.has(id);
if (on) visibleCount++;
const item = document.createElement('button'); const item = document.createElement('button');
item.className = 'pt-channel-item' + (on ? ' on' : ''); item.className = 'pt-channel-item' + (on ? ' on' : '');
item.setAttribute('role', 'menuitemcheckbox'); item.setAttribute('role', 'menuitemcheckbox');
@@ -158,27 +213,15 @@ export default function create({ root, moduleApi }) {
}); });
channelMenu.appendChild(item); channelMenu.appendChild(item);
} }
channelBtn.textContent = visibleCount === channels.size
? 'Channels ▾'
: `Channels (${visibleCount}/${channels.size}) ▾`;
} }
channelBtn.addEventListener('click', (e) => {
e.stopPropagation();
channelMenu.hidden = !channelMenu.hidden;
});
const onDocClick = (e) => {
if (!channelMenu.hidden && !e.target.closest('.pt-dd')) channelMenu.hidden = true;
};
document.addEventListener('click', onDocClick);
/* ── clear (local view only) ────────────────────────────────────── */ /* ── clear (local view only) ────────────────────────────────────── */
root.querySelector('.pt-clear').addEventListener('click', () => { function clearView() {
entryEls.forEach((el) => el.remove()); entryEls.forEach((el) => el.remove());
entryEls.clear(); entryEls.clear();
emptyEl.hidden = false; emptyEl.hidden = false;
}); }
/* ── scrolling ──────────────────────────────────────────────────── */ /* ── scrolling ──────────────────────────────────────────────────── */
@@ -393,8 +436,7 @@ export default function create({ root, moduleApi }) {
stopped = true; stopped = true;
clearTimeout(bootTimer); clearTimeout(bootTimer);
stream?.close(); stream?.close();
document.removeEventListener('click', onDocClick); root.innerHTML = ''; // header controls are removed by the shell
root.innerHTML = '';
}, },
onResize() { onResize() {
if (pinnedToBottom) scrollToBottom(); if (pinnedToBottom) scrollToBottom();
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"id": "prodcom-transcript", "id": "prodcom-transcript",
"name": "ProdCom Transcript", "name": "ProdCom",
"version": "1.0.0", "version": "1.0.0",
"description": "Live ProdCom transcript stream", "description": "Live ProdCom transcript stream",
"client": "client.js", "client": "client.js",
+3 -43
View File
@@ -9,49 +9,9 @@
flex-direction: column; flex-direction: column;
} }
/* ── toolbar ── */ /* ── channels menu ──
The menu container is created and positioned by the shell (.tile-menu);
[data-module="prodcom-transcript"] .pt-bar { it lives inside the tile, so [data-module] scoping still applies. */
flex: none;
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-bottom: 1px solid var(--border);
}
[data-module="prodcom-transcript"] .pt-spacer { flex: 1; }
[data-module="prodcom-transcript"] .pt-btn {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 8px;
font-size: 11px;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
}
[data-module="prodcom-transcript"] .pt-btn:hover { border-color: var(--muted); }
[data-module="prodcom-transcript"] .pt-dd { position: relative; }
[data-module="prodcom-transcript"] .pt-menu {
position: absolute;
top: calc(100% + 5px);
left: 0;
z-index: 30;
min-width: 170px;
max-height: 45vh;
overflow-y: auto;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
}
[data-module="prodcom-transcript"] .pt-menu[hidden] { display: none; }
[data-module="prodcom-transcript"] .pt-channel-item { [data-module="prodcom-transcript"] .pt-channel-item {
display: flex; display: flex;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"id": "propresenter-now-next", "id": "propresenter-now-next",
"name": "ProPresenter Now / Next", "name": "ProPresenter",
"version": "1.0.0", "version": "1.0.0",
"description": "What is live in ProPresenter and what comes next", "description": "What is live in ProPresenter and what comes next",
"client": "client.js", "client": "client.js",
+8 -2
View File
@@ -20,12 +20,18 @@
<span class="spacer"></span> <span class="spacer"></span>
<div class="dropdown" id="add-dd"> <div class="dropdown" id="add-dd">
<button class="tb-btn" id="add-btn" title="Add a module tile">+ Add tile ▾</button> <button class="tb-btn icon" id="add-btn" title="Add a module tile" aria-label="Add tile">+</button>
<div class="menu" id="add-menu" hidden></div> <div class="menu" id="add-menu" hidden></div>
</div> </div>
<div class="dropdown" id="layout-dd"> <div class="dropdown" id="layout-dd">
<button class="tb-btn" id="layout-btn" title="Save, load or reset layouts">Layout ▾</button> <button class="tb-btn icon" id="layout-btn" title="Save, load or reset layouts" aria-label="Layouts">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="3" y="3" width="8" height="8" rx="1.5"/>
<rect x="3" y="13" width="8" height="8" rx="1.5"/>
<rect x="13" y="3" width="8" height="18" rx="1.5"/>
</svg>
</button>
<div class="menu" id="layout-menu" hidden></div> <div class="menu" id="layout-menu" hidden></div>
</div> </div>
+111 -5
View File
@@ -150,6 +150,7 @@ function sanitizeTile(t) {
w, w,
h: Math.max(1, Number(t.h) || 2), h: Math.max(1, Number(t.h) || 2),
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {}, settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
headHidden: Boolean(t.headHidden),
}; };
} }
@@ -229,7 +230,11 @@ function buildTile(tile) {
title.className = 'tile-title'; title.className = 'tile-title';
title.textContent = man ? man.name : tile.module + ' (not installed)'; title.textContent = man ? man.name : tile.module + ' (not installed)';
head.append(dot, title); // where moduleApi.header puts a module's own buttons and menus
const controls = document.createElement('span');
controls.className = 'tile-controls';
head.append(dot, title, controls);
const hasSettings = man && man.instanceSchema && Object.keys(man.instanceSchema).length; const hasSettings = man && man.instanceSchema && Object.keys(man.instanceSchema).length;
if (hasSettings) { if (hasSettings) {
@@ -261,10 +266,22 @@ function buildTile(tile) {
resize.className = 'tile-resize'; resize.className = 'tile-resize';
resize.title = 'Drag to resize'; resize.title = 'Drag to resize';
el.append(head, body, resize); // notch on the title bar's bottom edge: hides/shows the bar
const notch = document.createElement('button');
notch.className = 'tile-notch';
notch.title = 'Hide / show the title bar';
notch.addEventListener('click', (e) => {
e.stopPropagation();
tile.headHidden = !tile.headHidden;
el.classList.toggle('head-hidden', tile.headHidden);
saveLayout();
});
el.append(head, body, resize, notch);
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
grid.appendChild(el); grid.appendChild(el);
const entry = { el, body, dot, titleEl: title, instance: null }; const entry = { el, body, dot, titleEl: title, controls, instance: null };
tileEls.set(tile.id, entry); tileEls.set(tile.id, entry);
applyRect(tile); applyRect(tile);
@@ -283,7 +300,7 @@ function refreshEmptyState() {
function wireDrag(tile, el, handle) { function wireDrag(tile, el, handle) {
handle.addEventListener('pointerdown', (ev) => { handle.addEventListener('pointerdown', (ev) => {
if (ev.target.closest('.tile-btn')) return; if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return;
if (ev.button !== 0 && ev.pointerType === 'mouse') return; if (ev.button !== 0 && ev.pointerType === 'mouse') return;
ev.preventDefault(); ev.preventDefault();
closeSettingsPopovers(); closeSettingsPopovers();
@@ -555,6 +572,8 @@ function tileMessage(entry, text) {
*/ */
function buildModuleApi(tile, man, entry) { function buildModuleApi(tile, man, entry) {
const sseHandles = new Set(); const sseHandles = new Set();
const headerEls = new Set(); // controls this instance added to the title bar
const docListeners = new Set(); // outside-click closers for header menus
const api = { const api = {
id: man.id, id: man.id,
@@ -636,9 +655,96 @@ function buildModuleApi(tile, man, entry) {
entry.dot.title = msg; entry.dot.title = msg;
}, },
/* shell-internal: safety net so a forgotten stream can't outlive the tile */ /**
* The tile's title bar. Controls added here live next to the gear/close
* buttons and are removed automatically when the instance stops. The
* whole bar can be hidden by the user via the notch, so nothing
* essential should exist ONLY as a header control.
*/
header: {
/** A small button. icon = inline SVG string, label = short text (either or both). */
addButton({ icon = '', label = '', title = '', onClick } = {}) {
const btn = document.createElement('button');
btn.className = 'tile-btn module-btn';
btn.title = title || label;
if (icon) btn.innerHTML = icon;
if (label) btn.appendChild(document.createTextNode(label));
if (typeof onClick === 'function') {
btn.addEventListener('click', (e) => {
e.stopPropagation();
onClick(e);
});
}
entry.controls.appendChild(btn);
headerEls.add(btn);
return btn;
},
/**
* A dropdown menu button. build(menuEl) runs on every open and fills
* the (emptied) menu element. Returns { button, menu, setLabel, close }.
*/
addMenu({ icon = '', label = '', title = '', build } = {}) {
const wrap = document.createElement('span');
wrap.className = 'tile-dd';
const btn = document.createElement('button');
btn.className = 'tile-btn module-btn';
btn.title = title || label;
if (icon) btn.innerHTML = icon;
if (label) btn.appendChild(document.createTextNode(label));
const menu = document.createElement('div');
menu.className = 'tile-menu';
menu.hidden = true;
const open = () => {
if (typeof build === 'function') {
menu.innerHTML = '';
build(menu);
}
menu.hidden = false;
// fixed positioning (escapes the tile's overflow): under the
// button, right-aligned, clamped to the viewport
const r = btn.getBoundingClientRect();
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
menu.style.left = '0px';
const w = menu.offsetWidth;
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
};
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.hidden) open();
else menu.hidden = true;
});
const onDocClick = (e) => {
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
};
document.addEventListener('click', onDocClick);
docListeners.add(onDocClick);
wrap.append(btn, menu);
entry.controls.appendChild(wrap);
headerEls.add(wrap);
return {
button: btn,
menu,
setLabel(text) {
btn.textContent = text;
},
close() {
menu.hidden = true;
},
};
},
},
/* shell-internal: safety net so a forgotten stream, header control or
listener can't outlive the tile */
_closeAll() { _closeAll() {
for (const handle of [...sseHandles]) handle.close(); for (const handle of [...sseHandles]) handle.close();
for (const el of headerEls) el.remove();
headerEls.clear();
for (const fn of docListeners) document.removeEventListener('click', fn);
docListeners.clear();
}, },
}; };
return api; return api;
+80 -2
View File
@@ -71,6 +71,14 @@ button { font-family: inherit; }
} }
.tb-btn:hover { border-color: var(--muted); } .tb-btn:hover { border-color: var(--muted); }
.tb-btn:active { background: var(--border); } .tb-btn:active { background: var(--border); }
.tb-btn.icon {
padding: 6px 8px;
min-width: 30px;
justify-content: center;
font-size: 14px;
line-height: 1;
}
.tb-btn.icon svg { display: block; }
/* ── dropdown menus ────────────────────────────────────────────────── */ /* ── dropdown menus ────────────────────────────────────────────────── */
@@ -191,7 +199,8 @@ button { font-family: inherit; }
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 5px 8px; height: 32px; /* fixed so the notch below can sit at a known offset */
padding: 0 8px;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
cursor: grab; cursor: grab;
@@ -200,6 +209,7 @@ button { font-family: inherit; }
touch-action: none; touch-action: none;
} }
.tile.dragging .tile-head { cursor: grabbing; } .tile.dragging .tile-head { cursor: grabbing; }
.tile.head-hidden .tile-head { display: none; }
.tile-head .dot { .tile-head .dot {
flex: none; flex: none;
@@ -241,8 +251,76 @@ button { font-family: inherit; }
} }
.tile-btn:hover { background: var(--border); color: var(--text); } .tile-btn:hover { background: var(--border); color: var(--text); }
.tile-btn.close:hover { color: var(--danger); } .tile-btn.close:hover { color: var(--danger); }
.tile-btn.active { color: var(--accent); }
.tile-btn svg { width: 13px; height: 13px; display: block; } .tile-btn svg { width: 13px; height: 13px; display: block; }
/* controls a module adds to its own title bar (moduleApi.header) */
.tile-controls {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
overflow: hidden;
}
.tile-btn.module-btn {
width: auto;
min-width: 22px;
padding: 0 5px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.tile-dd { position: relative; display: inline-flex; }
/* a module header menu — fixed-positioned by the shell so the tile's
overflow:hidden can't clip it */
.tile-menu {
position: fixed;
z-index: 70;
min-width: 180px;
max-height: 60vh;
overflow-y: auto;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
}
.tile-menu[hidden] { display: none; }
/* notch on the title bar's bottom edge: click to hide/show the bar
(same idea as the ProdCom Listener's pull-down menubar handle) */
.tile-notch {
position: absolute;
top: 32px;
left: 50%;
transform: translateX(-50%);
width: 46px;
height: 13px;
padding: 0;
background: var(--panel);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 10px 10px;
cursor: pointer;
z-index: 6;
opacity: 0;
transition: opacity 0.15s ease, top 0.15s ease;
}
.tile:hover .tile-notch { opacity: 0.6; }
.tile .tile-notch:hover { opacity: 1; }
.tile.head-hidden .tile-notch { top: 0; }
.tile-notch::after {
content: "";
display: block;
width: 18px;
height: 3px;
margin: 4px auto 0;
border-radius: 999px;
background: var(--muted);
}
.tile-notch:hover::after { background: var(--text); }
.tile-body { .tile-body {
flex: 1; flex: 1;
position: relative; position: relative;
@@ -331,7 +409,7 @@ button { font-family: inherit; }
.tile-settings { .tile-settings {
position: absolute; position: absolute;
top: 30px; top: 36px;
right: 6px; right: 6px;
z-index: 45; z-index: 45;
min-width: 220px; min-width: 220px;