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:
@@ -60,29 +60,33 @@ are written by the app itself.
|
||||
|
||||
## Using the dashboard
|
||||
|
||||
- **+ Add tile** — add any enabled module; multiple tiles of the same
|
||||
module are fine (e.g. two transcripts filtered to different channels).
|
||||
- Drag a tile by its **header** to move it; drag the **corner handle** to
|
||||
resize; **✕** removes it. Tiles sit flush — when two share an edge, hover
|
||||
the seam and a small grab pill appears; drag it to trade width between
|
||||
the two tiles (macOS split-view style).
|
||||
- **+** — add any enabled module; multiple tiles of the same module are
|
||||
fine (e.g. two transcripts filtered to different channels).
|
||||
- Drag a tile by its **title bar** to move it; drag the **corner handle**
|
||||
to resize; **✕** removes it. Tiles sit flush — when two share an edge,
|
||||
hover the seam and a small grab pill appears; drag it to trade width
|
||||
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,
|
||||
display options) — these belong to this browser's layout, not the server.
|
||||
- **Layout ▾** — save the current arrangement to the server under a name,
|
||||
load a named layout (it becomes a local copy — browsers are never linked),
|
||||
or reset this browser's dashboard.
|
||||
- The **grid icon** — save the current arrangement to the server under a
|
||||
name, load a named layout (it becomes a local copy — browsers are never
|
||||
linked), or reset this browser's dashboard.
|
||||
- Every tile has a **status dot**: green = live, amber = connecting,
|
||||
red = upstream unreachable (hover it for details). Tiles reconnect
|
||||
automatically.
|
||||
|
||||
## Included modules
|
||||
|
||||
- **ProdCom Transcript** — live transcript stream with in-place updates of
|
||||
in-progress speech, channel colors, per-tile channel visibility toggles
|
||||
and group presets. Admin config: ProdCom URL, API key.
|
||||
- **ProPresenter Now / Next** — what's live and what's next, with slide
|
||||
progress, driven by the shared `propresenter-core` client (ProPresenter
|
||||
7/20). Admin config: host, port, password.
|
||||
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
||||
speech, channel colors, per-tile channel visibility toggles and group
|
||||
presets, timestamps, text size and jump-to-latest in the title bar.
|
||||
Admin config: ProdCom URL, API key.
|
||||
- **ProPresenter** — what's live and what's next, with slide progress,
|
||||
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
|
||||
reference for module authors.
|
||||
|
||||
|
||||
@@ -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`. |
|
||||
| `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. |
|
||||
| `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:
|
||||
|
||||
|
||||
@@ -22,14 +22,6 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
root.innerHTML = `
|
||||
<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-empty">Waiting for transcript…</div>
|
||||
<div class="pt-entries"></div>
|
||||
@@ -42,14 +34,66 @@ export default function create({ root, moduleApi }) {
|
||||
const entriesEl = root.querySelector('.pt-entries');
|
||||
const emptyEl = root.querySelector('.pt-empty');
|
||||
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() {
|
||||
const s = moduleApi.instanceSettings;
|
||||
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
||||
wrap.style.setProperty('--pt-size', size + 'px');
|
||||
wrap.classList.toggle('show-times', Boolean(s.showTimes));
|
||||
timeBtn?.classList.toggle('active', Boolean(s.showTimes));
|
||||
}
|
||||
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() {
|
||||
updateChannelsLabel();
|
||||
const channelMenu = channelMenuEl;
|
||||
if (!channelMenu) return; // menu not opened yet — the label is enough
|
||||
channelMenu.innerHTML = '';
|
||||
|
||||
if (groups.size) {
|
||||
@@ -134,10 +191,8 @@ export default function create({ root, moduleApi }) {
|
||||
channelMenu.appendChild(divider);
|
||||
}
|
||||
|
||||
let visibleCount = 0;
|
||||
for (const [id, info] of channels) {
|
||||
const on = !hiddenChannels.has(id);
|
||||
if (on) visibleCount++;
|
||||
const item = document.createElement('button');
|
||||
item.className = 'pt-channel-item' + (on ? ' on' : '');
|
||||
item.setAttribute('role', 'menuitemcheckbox');
|
||||
@@ -158,27 +213,15 @@ export default function create({ root, moduleApi }) {
|
||||
});
|
||||
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) ────────────────────────────────────── */
|
||||
|
||||
root.querySelector('.pt-clear').addEventListener('click', () => {
|
||||
function clearView() {
|
||||
entryEls.forEach((el) => el.remove());
|
||||
entryEls.clear();
|
||||
emptyEl.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── scrolling ──────────────────────────────────────────────────── */
|
||||
|
||||
@@ -393,8 +436,7 @@ export default function create({ root, moduleApi }) {
|
||||
stopped = true;
|
||||
clearTimeout(bootTimer);
|
||||
stream?.close();
|
||||
document.removeEventListener('click', onDocClick);
|
||||
root.innerHTML = '';
|
||||
root.innerHTML = ''; // header controls are removed by the shell
|
||||
},
|
||||
onResize() {
|
||||
if (pinnedToBottom) scrollToBottom();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "prodcom-transcript",
|
||||
"name": "ProdCom Transcript",
|
||||
"name": "ProdCom",
|
||||
"version": "1.0.0",
|
||||
"description": "Live ProdCom transcript stream",
|
||||
"client": "client.js",
|
||||
|
||||
@@ -9,49 +9,9 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── toolbar ── */
|
||||
|
||||
[data-module="prodcom-transcript"] .pt-bar {
|
||||
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; }
|
||||
/* ── channels menu ──
|
||||
The menu container is created and positioned by the shell (.tile-menu);
|
||||
it lives inside the tile, so [data-module] scoping still applies. */
|
||||
|
||||
[data-module="prodcom-transcript"] .pt-channel-item {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "propresenter-now-next",
|
||||
"name": "ProPresenter Now / Next",
|
||||
"name": "ProPresenter",
|
||||
"version": "1.0.0",
|
||||
"description": "What is live in ProPresenter and what comes next",
|
||||
"client": "client.js",
|
||||
|
||||
+8
-2
@@ -20,12 +20,18 @@
|
||||
<span class="spacer"></span>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
+111
-5
@@ -150,6 +150,7 @@ function sanitizeTile(t) {
|
||||
w,
|
||||
h: Math.max(1, Number(t.h) || 2),
|
||||
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.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;
|
||||
if (hasSettings) {
|
||||
@@ -261,10 +266,22 @@ function buildTile(tile) {
|
||||
resize.className = 'tile-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);
|
||||
|
||||
const entry = { el, body, dot, titleEl: title, instance: null };
|
||||
const entry = { el, body, dot, titleEl: title, controls, instance: null };
|
||||
tileEls.set(tile.id, entry);
|
||||
applyRect(tile);
|
||||
|
||||
@@ -283,7 +300,7 @@ function refreshEmptyState() {
|
||||
|
||||
function wireDrag(tile, el, handle) {
|
||||
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;
|
||||
ev.preventDefault();
|
||||
closeSettingsPopovers();
|
||||
@@ -555,6 +572,8 @@ function tileMessage(entry, text) {
|
||||
*/
|
||||
function buildModuleApi(tile, man, entry) {
|
||||
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 = {
|
||||
id: man.id,
|
||||
@@ -636,9 +655,96 @@ function buildModuleApi(tile, man, entry) {
|
||||
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() {
|
||||
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;
|
||||
|
||||
+80
-2
@@ -71,6 +71,14 @@ button { font-family: inherit; }
|
||||
}
|
||||
.tb-btn:hover { border-color: var(--muted); }
|
||||
.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 ────────────────────────────────────────────────── */
|
||||
|
||||
@@ -191,7 +199,8 @@ button { font-family: inherit; }
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: grab;
|
||||
@@ -200,6 +209,7 @@ button { font-family: inherit; }
|
||||
touch-action: none;
|
||||
}
|
||||
.tile.dragging .tile-head { cursor: grabbing; }
|
||||
.tile.head-hidden .tile-head { display: none; }
|
||||
|
||||
.tile-head .dot {
|
||||
flex: none;
|
||||
@@ -241,8 +251,76 @@ button { font-family: inherit; }
|
||||
}
|
||||
.tile-btn:hover { background: var(--border); color: var(--text); }
|
||||
.tile-btn.close:hover { color: var(--danger); }
|
||||
.tile-btn.active { color: var(--accent); }
|
||||
.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 {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
@@ -331,7 +409,7 @@ button { font-family: inherit; }
|
||||
|
||||
.tile-settings {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
top: 36px;
|
||||
right: 6px;
|
||||
z-index: 45;
|
||||
min-width: 220px;
|
||||
|
||||
Reference in New Issue
Block a user