Merge a6dc2dd (shell 1.4.0: shell.port, provides, module-guide contracts) into pco-plan-service-timers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+100
-11
@@ -92,12 +92,13 @@ the app); its settings are kept for a later reinstall.
|
|||||||
| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
|
| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
|
||||||
| `description` | no | Shown in the admin page / picker. |
|
| `description` | no | Shown in the admin page / picker. |
|
||||||
| `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. |
|
| `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. |
|
||||||
|
| `provides` | no | Capabilities other modules can consume — `["timers"]` today. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. |
|
||||||
| `client` | yes | Client entry file, loaded as an ES module. |
|
| `client` | yes | Client entry file, loaded as an ES module. |
|
||||||
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
||||||
| `style` | no | A stylesheet the shell injects once per module. |
|
| `style` | no | A stylesheet the shell injects once per module. |
|
||||||
| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. |
|
| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. |
|
||||||
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
|
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
|
||||||
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). |
|
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. |
|
||||||
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
|
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
|
||||||
|
|
||||||
Both schemas map field names to specs:
|
Both schemas map field names to specs:
|
||||||
@@ -115,8 +116,13 @@ Both schemas map field names to specs:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`boolean` renders as a checkbox; `switch` is the same true/false value shown
|
`boolean` and `switch` are the same true/false value. In a tile's gear popover
|
||||||
as a toggle (use it for a feature on/off that other fields depend on).
|
both render as a switch; in the admin page `switch` is the toggle and
|
||||||
|
`boolean` the plainer checkbox (use `switch` for a feature on/off that other
|
||||||
|
fields depend on). **Label a switch with the noun of what it turns on** —
|
||||||
|
"Seconds", "Top bar", "Section headers" — never "Show seconds": on means
|
||||||
|
shown or enabled, off means hidden or disabled, and the label shouldn't say
|
||||||
|
so twice.
|
||||||
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex
|
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex
|
||||||
default; the tile applies it as a CSS custom property — see `pco-plan`).
|
default; the tile applies it as a CSS custom property — see `pco-plan`).
|
||||||
|
|
||||||
@@ -141,7 +147,15 @@ the group name:
|
|||||||
still empty. Omit it and the group is a plain, always-open block.
|
still empty. Omit it and the group is a plain, always-open block.
|
||||||
- `columns`: 2–4 lays the group's fields out in a grid — the way to keep a
|
- `columns`: 2–4 lays the group's fields out in a grid — the way to keep a
|
||||||
long checklist of booleans compact.
|
long checklist of booleans compact.
|
||||||
- `help`: a line of guidance at the top of the group.
|
- `help`: guidance for the whole group. The admin page shows it as an
|
||||||
|
ⓘ beside the group title (click or hover to read), so a group can carry a
|
||||||
|
paragraph without pushing its fields down the page. Say where a value comes
|
||||||
|
from, not what a field is.
|
||||||
|
- `toggle`: the key of a `switch` field in this group. It renders in the
|
||||||
|
group's title row, at the right, as the group's on/off; the rest of the group
|
||||||
|
dims while it is off, and the field is not repeated in the body. This is how
|
||||||
|
an integration is disabled without unmounting the module — ProPresenter
|
||||||
|
timers, say, on a Sunday when ProPresenter isn't running.
|
||||||
|
|
||||||
`help` on a field puts one line of guidance under its control — allowed
|
`help` on a field puts one line of guidance under its control — allowed
|
||||||
placeholders, where a value comes from. Keep labels short and put the
|
placeholders, where a value comes from. Keep labels short and put the
|
||||||
@@ -341,10 +355,13 @@ own in `stop()` anyway.
|
|||||||
- **Any number of instances.** Two transcript tiles with different filters
|
- **Any number of instances.** Two transcript tiles with different filters
|
||||||
is a feature. Keep all state inside `create()`'s closure — no module-level
|
is a feature. Keep all state inside `create()`'s closure — no module-level
|
||||||
mutable state in `client.js`.
|
mutable state in `client.js`.
|
||||||
- **Remounts are routine.** When the user edits the tile's gear settings the
|
- **Remounts are routine.** Gear settings apply as the user changes them —
|
||||||
shell calls `stop()` and starts a fresh instance. When admin config
|
there is no Apply button — so each change calls `stop()` and starts a fresh
|
||||||
changes, the shell calls your `onConfigChange(cfg)` if you export one,
|
instance (typing is debounced, and an unchanged value remounts nothing).
|
||||||
otherwise it remounts you. Either way you must come back cleanly.
|
Starting cheaply matters: hold connections and timers in the closure and
|
||||||
|
tear them down in `stop()`. When admin config changes, the shell calls your
|
||||||
|
`onConfigChange(cfg)` if you export one, otherwise it remounts you. Either
|
||||||
|
way you must come back cleanly.
|
||||||
- **The tile can be removed at any moment.** `stop()` is your only notice.
|
- **The tile can be removed at any moment.** `stop()` is your only notice.
|
||||||
|
|
||||||
### Rules for the client
|
### Rules for the client
|
||||||
@@ -362,7 +379,11 @@ own in `stop()` anyway.
|
|||||||
palette: `--bg` (page), `--panel` (tile), `--border`, `--text`,
|
palette: `--bg` (page), `--panel` (tile), `--border`, `--text`,
|
||||||
`--muted`, `--accent`, `--danger`, `--warn`. Derive tints with
|
`--muted`, `--accent`, `--danger`, `--warn`. Derive tints with
|
||||||
`color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes
|
`color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes
|
||||||
every module look native on the dark, high-contrast booth theme.
|
every module look native — and what makes the dashboard's **themes**
|
||||||
|
(Admin → Theme) reach into every tile: a theme swaps those variables and
|
||||||
|
nothing else. A colour a tile is configured with (a `color` instance
|
||||||
|
setting, applied as a custom property on the tile) is set closer than the
|
||||||
|
theme and wins over it, by design.
|
||||||
|
|
||||||
3. **No external network calls from the client.** Browsers talk only to
|
3. **No external network calls from the client.** Browsers talk only to
|
||||||
ProdDash. Anything upstream (your device, service, API) goes through your
|
ProdDash. Anything upstream (your device, service, API) goes through your
|
||||||
@@ -382,7 +403,7 @@ ethos; Node 18+ so global `fetch` is available). Exports either or both of:
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config, log, shell }) {
|
||||||
// Called on startup / re-init, BEFORE routes(). Start your upstream
|
// Called on startup / re-init, BEFORE routes(). Start your upstream
|
||||||
// connection here. Return a handle:
|
// connection here. Return a handle:
|
||||||
return {
|
return {
|
||||||
@@ -391,7 +412,7 @@ module.exports = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
routes({ config, log }) {
|
routes({ config, log, shell }) {
|
||||||
// Route table, mounted under /api/modules/<id>/
|
// Route table, mounted under /api/modules/<id>/
|
||||||
return {
|
return {
|
||||||
'GET /state': (req, res) => { … },
|
'GET /state': (req, res) => { … },
|
||||||
@@ -407,6 +428,9 @@ module.exports = {
|
|||||||
- `config` is your admin config: `configSchema` defaults overlaid with what
|
- `config` is your admin config: `configSchema` defaults overlaid with what
|
||||||
the admin page saved — **including password fields**.
|
the admin page saved — **including password fields**.
|
||||||
- `log(...)` prefixes output with your module id.
|
- `log(...)` prefixes output with your module id.
|
||||||
|
- `shell.port` is the port ProdDash is serving on — what you need to read
|
||||||
|
another module's routes over loopback (`http://127.0.0.1:${shell.port}/api/
|
||||||
|
modules/<id>/…`). Never guess it.
|
||||||
- Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is
|
- Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is
|
||||||
the matched remainder (leading `/` included) and `req.search` is the query
|
the matched remainder (leading `/` included) and `req.search` is the query
|
||||||
string (with `?`, or `''`) — so a proxy rebuilds the upstream path as
|
string (with `?`, or `''`) — so a proxy rebuilds the upstream path as
|
||||||
@@ -493,6 +517,71 @@ per-client `res.write` in try/catch (drop the client on failure). The
|
|||||||
`propresenter-now-next` module is the reference implementation of this
|
`propresenter-now-next` module is the reference implementation of this
|
||||||
pattern.
|
pattern.
|
||||||
|
|
||||||
|
## Working with other modules
|
||||||
|
|
||||||
|
Modules don't import each other. When one needs what another knows, it reads
|
||||||
|
that module's **own server routes over loopback**, exactly as a browser would:
|
||||||
|
`http://127.0.0.1:${shell.port}/api/modules/<id>/…`. The PCO Plan module
|
||||||
|
follows ProPresenter this way — it reads `propresenter-now-next`'s `/stream`
|
||||||
|
rather than talking to ProPresenter a second time. A consumer must survive the
|
||||||
|
provider being disabled, uninstalled, or restarting mid-service (404s and
|
||||||
|
dropped connections are normal; retry with backoff and say so in `health()`).
|
||||||
|
|
||||||
|
### Offering timers to the Timers module
|
||||||
|
|
||||||
|
The Timers module shows timers from anywhere: ProPresenter, LTC, its own
|
||||||
|
clock, and any module that offers some. A module offers timers by:
|
||||||
|
|
||||||
|
1. Declaring it — `"provides": ["timers"]` in `module.json`. The shell
|
||||||
|
forwards `provides` in `/api/modules`, which is how the Timers module finds
|
||||||
|
providers without a list anyone maintains.
|
||||||
|
2. Serving `GET /timers` from its server routes, returning a snapshot:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source": "pco-plan",
|
||||||
|
"label": "PCO Plan",
|
||||||
|
"updatedAt": 1757800000000,
|
||||||
|
"timers": [
|
||||||
|
{
|
||||||
|
"id": "service",
|
||||||
|
"label": "Service",
|
||||||
|
"kind": "elapsed",
|
||||||
|
"state": "running",
|
||||||
|
"startedAt": 1757799000000,
|
||||||
|
"elapsedMs": 1000000,
|
||||||
|
"remainingMs": null,
|
||||||
|
"targetMs": 4500000,
|
||||||
|
"status": { "text": "Welcome & Announcements", "tone": "ok" },
|
||||||
|
"detail": "Item 3 of 12"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `source`, `label` | Your module id, and the heading the Timers module puts over your timers. |
|
||||||
|
| `updatedAt` | ms since epoch when this snapshot was taken. Every duration below is *as of this instant*. |
|
||||||
|
| `id` | Stable within your module — the Timers module keys tile settings (shown/hidden, colours) on `source` + `id`. |
|
||||||
|
| `kind` | `elapsed` (counts up), `countdown` (counts down to zero), `clock` (a time of day; put the ISO time in `status.text`). |
|
||||||
|
| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). |
|
||||||
|
| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. |
|
||||||
|
| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. |
|
||||||
|
| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. |
|
||||||
|
| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. |
|
||||||
|
| `detail` | Optional second line. |
|
||||||
|
|
||||||
|
The consumer derives the displayed time locally from `startedAt`/`elapsedMs`
|
||||||
|
and its own clock, so a snapshot every couple of seconds is plenty — `/timers`
|
||||||
|
must be cheap to answer (serve from state you already hold; never call
|
||||||
|
upstream to answer it). Optionally also serve `GET /timers/stream` as SSE
|
||||||
|
(`event: timers`, `data:` the same object, on every change plus a heartbeat
|
||||||
|
comment every 15 s); the consumer uses the stream when it exists and polls
|
||||||
|
`/timers` otherwise. Only offer timers a production tech would want on a
|
||||||
|
wall — the Timers module lets them hide any of them per tile, but every entry
|
||||||
|
you add is a decision someone has to make.
|
||||||
|
|
||||||
## Complete minimal example
|
## Complete minimal example
|
||||||
|
|
||||||
A working clock — copy-paste this as your starter. (A slightly fancier
|
A working clock — copy-paste this as your starter. (A slightly fancier
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
{
|
{
|
||||||
"id": "clock",
|
"id": "clock",
|
||||||
"name": "Clock",
|
"name": "Clock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.1.0",
|
||||||
"description": "Big booth clock — the smallest possible ProdDash module",
|
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
"minSize": { "w": 2, "h": 1 },
|
"minSize": { "w": 2, "h": 1 },
|
||||||
@@ -18,7 +17,7 @@
|
|||||||
{ "value": "12h", "label": "12-hour" }
|
{ "value": "12h", "label": "12-hour" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"showSeconds": { "type": "boolean", "label": "Show seconds", "default": true },
|
"showSeconds": { "type": "boolean", "label": "Seconds", "default": true },
|
||||||
"showDate": { "type": "boolean", "label": "Show date", "default": false }
|
"showDate": { "type": "boolean", "label": "Date", "default": false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
+2
-3
@@ -15,8 +15,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<!-- invisible twin of the logo: holds its place in the bar; the real logo is the handle below -->
|
<span id="brand">Prod<span class="accent">Dash</span></span>
|
||||||
<span id="brand" class="brand-spacer" aria-hidden="true">Prod<span class="accent">Dash</span></span>
|
|
||||||
|
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
|
||||||
@@ -51,7 +50,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar">Prod<span class="accent">Dash</span></button>
|
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar"></button>
|
||||||
|
|
||||||
<main id="gridwrap">
|
<main id="gridwrap">
|
||||||
<div id="grid">
|
<div id="grid">
|
||||||
|
|||||||
+174
-44
@@ -288,11 +288,24 @@ function buildTile(tile) {
|
|||||||
notch.title = 'Hide / show the title bar';
|
notch.title = 'Hide / show the title bar';
|
||||||
notch.addEventListener('click', (e) => {
|
notch.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
// In fullscreen the bars are all folded away for watching; the notch
|
||||||
|
// peeks one open for a moment without touching what this tile saved.
|
||||||
|
if (document.body.classList.contains('fullscreen')) {
|
||||||
|
peekHead(tile, el, !el.classList.contains('head-peek'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
tile.headHidden = !tile.headHidden;
|
tile.headHidden = !tile.headHidden;
|
||||||
el.classList.toggle('head-hidden', tile.headHidden);
|
el.classList.toggle('head-hidden', tile.headHidden);
|
||||||
saveLayout();
|
saveLayout();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A peeked title bar stays while the pointer is on its tile, and starts
|
||||||
|
// counting down as soon as it leaves.
|
||||||
|
el.addEventListener('pointerenter', () => clearPeekTimer(tile.id));
|
||||||
|
el.addEventListener('pointerleave', () => {
|
||||||
|
if (el.classList.contains('head-peek')) schedulePeekHide(tile.id, el);
|
||||||
|
});
|
||||||
|
|
||||||
el.append(head, body, resize, resizeLeft, resizeBottom, notch);
|
el.append(head, body, resize, resizeLeft, resizeBottom, notch);
|
||||||
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
|
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
|
||||||
grid.appendChild(el);
|
grid.appendChild(el);
|
||||||
@@ -518,13 +531,22 @@ function notifyResize(tile) {
|
|||||||
/* ── seam dividers between adjacent tiles ───────────────────────────── */
|
/* ── seam dividers between adjacent tiles ───────────────────────────── */
|
||||||
|
|
||||||
/* Wherever two tiles share a vertical edge, a divider sits on the seam.
|
/* Wherever two tiles share a vertical edge, a divider sits on the seam.
|
||||||
Dragging its pill moves the shared edge, trading width between the two
|
Dragging one moves that column boundary wherever it appears: every pair of
|
||||||
tiles — their combined span never changes, so nothing else can collide
|
tiles meeting at the same x, in every row, moves with it, so a column of
|
||||||
except via the explicit check below (a third tile can sit behind the
|
tiles keeps its edge as one line rather than drifting row by row. Hovering
|
||||||
seam for part of its height). */
|
a seam lights up the others that travel with it.
|
||||||
|
|
||||||
|
Each pair trades width within its own span — the pair's combined span never
|
||||||
|
changes — so nothing else can collide except via the explicit check below
|
||||||
|
(a third tile can sit behind the seam for part of its height). A row where
|
||||||
|
one tile simply spans the boundary has no seam there and stays put. */
|
||||||
|
|
||||||
|
/** Live dividers, so a drag can reposition all of them as the tiles move. */
|
||||||
|
const dividers = [];
|
||||||
|
|
||||||
function rebuildDividers() {
|
function rebuildDividers() {
|
||||||
grid.querySelectorAll('.tile-divider').forEach((el) => el.remove());
|
grid.querySelectorAll('.tile-divider').forEach((el) => el.remove());
|
||||||
|
dividers.length = 0;
|
||||||
for (const L of tiles) {
|
for (const L of tiles) {
|
||||||
for (const R of tiles) {
|
for (const R of tiles) {
|
||||||
if (L === R || L.x + L.w !== R.x) continue;
|
if (L === R || L.x + L.w !== R.x) continue;
|
||||||
@@ -536,10 +558,24 @@ function rebuildDividers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every pair of tiles that meets at this column boundary, in any row. */
|
||||||
|
function seamPairsAt(boundary) {
|
||||||
|
const pairs = [];
|
||||||
|
for (const L of tiles) {
|
||||||
|
if (L.x + L.w !== boundary) continue;
|
||||||
|
for (const R of tiles) {
|
||||||
|
if (R === L || R.x !== boundary) continue;
|
||||||
|
if (Math.min(L.y + L.h, R.y + R.h) <= Math.max(L.y, R.y)) continue;
|
||||||
|
pairs.push({ L, R });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
function buildDivider(L, R, top, bottom) {
|
function buildDivider(L, R, top, bottom) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'tile-divider';
|
el.className = 'tile-divider';
|
||||||
el.title = 'Drag to resize';
|
el.title = 'Drag to resize this column';
|
||||||
|
|
||||||
const place = () => {
|
const place = () => {
|
||||||
const m = cellMetrics();
|
const m = cellMetrics();
|
||||||
@@ -550,43 +586,72 @@ function buildDivider(L, R, top, bottom) {
|
|||||||
};
|
};
|
||||||
place();
|
place();
|
||||||
|
|
||||||
|
const boundary = () => L.x + L.w;
|
||||||
|
const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el);
|
||||||
|
const unlink = () => { for (const d of dividers) d.el.classList.remove('linked'); };
|
||||||
|
|
||||||
|
// Before anything is dragged, show which seams travel with this one.
|
||||||
|
el.addEventListener('pointerenter', () => {
|
||||||
|
if (document.body.classList.contains('fullscreen')) return;
|
||||||
|
for (const other of groupEls()) other.classList.add('linked');
|
||||||
|
});
|
||||||
|
el.addEventListener('pointerleave', unlink);
|
||||||
|
|
||||||
el.addEventListener('pointerdown', (ev) => {
|
el.addEventListener('pointerdown', (ev) => {
|
||||||
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
if (document.body.classList.contains('fullscreen')) return;
|
if (document.body.classList.contains('fullscreen')) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
closeSettingsPopovers();
|
closeSettingsPopovers();
|
||||||
el.setPointerCapture(ev.pointerId);
|
el.setPointerCapture(ev.pointerId);
|
||||||
el.classList.add('active');
|
|
||||||
const m = cellMetrics();
|
const m = cellMetrics();
|
||||||
const sx = ev.clientX;
|
const sx = ev.clientX;
|
||||||
const startBoundary = L.x + L.w;
|
const startBoundary = boundary();
|
||||||
const rightEdge = R.x + R.w; // fixed for the whole drag
|
// Every row that has a seam here moves with it. Each pair's outer edges
|
||||||
const minB = L.x + minSizeOf(L).w;
|
// are fixed for the whole drag, so each row only trades width internally.
|
||||||
const maxB = rightEdge - minSizeOf(R).w;
|
const parts = seamPairsAt(startBoundary).map(({ L: a, R: b }) => ({
|
||||||
|
L: a, R: b, leftX: a.x, rightEdge: b.x + b.w,
|
||||||
|
}));
|
||||||
|
if (!parts.length) return;
|
||||||
|
const moving = parts.flatMap((p) => [p.L.id, p.R.id]);
|
||||||
|
// The whole column stops at whichever row runs out of room first.
|
||||||
|
const minB = Math.max(...parts.map((p) => p.leftX + minSizeOf(p.L).w));
|
||||||
|
const maxB = Math.min(...parts.map((p) => p.rightEdge - minSizeOf(p.R).w));
|
||||||
|
const group = groupEls();
|
||||||
|
for (const other of group) other.classList.add('active');
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
const onMove = (e) => {
|
const onMove = (e) => {
|
||||||
const b = Math.max(minB, Math.min(maxB,
|
const b = Math.max(minB, Math.min(maxB,
|
||||||
startBoundary + Math.round((e.clientX - sx) / (m.cw + m.gap))));
|
startBoundary + Math.round((e.clientX - sx) / (m.cw + m.gap))));
|
||||||
if (b === L.x + L.w) return;
|
if (b === boundary()) return;
|
||||||
const candL = { x: L.x, y: L.y, w: b - L.x, h: L.h };
|
const next = parts.map((p) => ({
|
||||||
const candR = { x: b, y: R.y, w: rightEdge - b, h: R.h };
|
p,
|
||||||
if (!isFreeExcept(candL, [L.id, R.id]) || !isFreeExcept(candR, [L.id, R.id])) return;
|
candL: { x: p.leftX, y: p.L.y, w: b - p.leftX, h: p.L.h },
|
||||||
L.w = candL.w;
|
candR: { x: b, y: p.R.y, w: p.rightEdge - b, h: p.R.h },
|
||||||
R.x = candR.x;
|
}));
|
||||||
R.w = candR.w;
|
// All rows move or none do — a column edge that bends is worse than one
|
||||||
applyRect(L);
|
// that won't budge.
|
||||||
applyRect(R);
|
if (next.some(({ candL, candR }) =>
|
||||||
notifyResize(L);
|
!isFreeExcept(candL, moving) || !isFreeExcept(candR, moving))) return;
|
||||||
notifyResize(R);
|
for (const { p, candL, candR } of next) {
|
||||||
place();
|
p.L.w = candL.w;
|
||||||
|
p.R.x = candR.x;
|
||||||
|
p.R.w = candR.w;
|
||||||
|
applyRect(p.L);
|
||||||
|
applyRect(p.R);
|
||||||
|
notifyResize(p.L);
|
||||||
|
notifyResize(p.R);
|
||||||
|
}
|
||||||
|
for (const d of dividers) d.place();
|
||||||
changed = true;
|
changed = true;
|
||||||
};
|
};
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
el.removeEventListener('pointermove', onMove);
|
el.removeEventListener('pointermove', onMove);
|
||||||
el.removeEventListener('pointerup', onUp);
|
el.removeEventListener('pointerup', onUp);
|
||||||
el.removeEventListener('pointercancel', onUp);
|
el.removeEventListener('pointercancel', onUp);
|
||||||
el.classList.remove('active');
|
for (const other of group) other.classList.remove('active');
|
||||||
|
unlink();
|
||||||
if (changed) saveLayout();
|
if (changed) saveLayout();
|
||||||
rebuildDividers(); // adjacencies may have changed
|
rebuildDividers(); // adjacencies may have changed
|
||||||
};
|
};
|
||||||
@@ -595,6 +660,7 @@ function buildDivider(L, R, top, bottom) {
|
|||||||
el.addEventListener('pointercancel', onUp);
|
el.addEventListener('pointercancel', onUp);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
dividers.push({ el, place, boundary });
|
||||||
grid.appendChild(el);
|
grid.appendChild(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,6 +700,7 @@ function addTile(moduleId, entry = null) {
|
|||||||
function removeTile(id) {
|
function removeTile(id) {
|
||||||
const idx = tiles.findIndex((t) => t.id === id);
|
const idx = tiles.findIndex((t) => t.id === id);
|
||||||
if (idx < 0) return;
|
if (idx < 0) return;
|
||||||
|
clearPeekTimer(id);
|
||||||
unmountModule(tiles[idx]);
|
unmountModule(tiles[idx]);
|
||||||
tiles.splice(idx, 1);
|
tiles.splice(idx, 1);
|
||||||
const entry = tileEls.get(id);
|
const entry = tileEls.get(id);
|
||||||
@@ -928,7 +995,10 @@ function colorHex(value, fallback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeSettingsPopovers() {
|
function closeSettingsPopovers() {
|
||||||
document.querySelectorAll('.tile-settings').forEach((p) => p.remove());
|
document.querySelectorAll('.tile-settings').forEach((p) => {
|
||||||
|
p._cleanup?.();
|
||||||
|
p.remove();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSettingsPopover(tile, el) {
|
function toggleSettingsPopover(tile, el) {
|
||||||
@@ -942,6 +1012,34 @@ function toggleSettingsPopover(tile, el) {
|
|||||||
pop.className = 'tile-settings';
|
pop.className = 'tile-settings';
|
||||||
const inputs = new Map();
|
const inputs = new Map();
|
||||||
|
|
||||||
|
/* These are display choices — a text size, a filter, a colour — so they
|
||||||
|
apply as they are changed and save with the layout. There is nothing to
|
||||||
|
confirm, so there is no Apply. Typing is debounced because applying
|
||||||
|
means remounting the module, which shouldn't happen per keystroke. */
|
||||||
|
let applyTimer = null;
|
||||||
|
const commit = (delay) => {
|
||||||
|
clearTimeout(applyTimer);
|
||||||
|
applyTimer = setTimeout(() => {
|
||||||
|
applyTimer = null;
|
||||||
|
// Only when something really moved: a text field fires `input` and then
|
||||||
|
// `change` on the way out, and a module shouldn't be torn down twice
|
||||||
|
// over for one edit.
|
||||||
|
let changed = false;
|
||||||
|
for (const [key, read] of inputs) {
|
||||||
|
const value = read();
|
||||||
|
if (tile.settings[key] === value) continue;
|
||||||
|
tile.settings[key] = value;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!changed) return;
|
||||||
|
saveLayout();
|
||||||
|
remountTile(tile);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
const live = (input, event = 'change', delay = 0) => {
|
||||||
|
input.addEventListener(event, () => commit(delay));
|
||||||
|
};
|
||||||
|
|
||||||
// Fields render in schema order; a field's `group` starts a labeled
|
// Fields render in schema order; a field's `group` starts a labeled
|
||||||
// subsection that consecutive same-group fields share, and the manifest's
|
// subsection that consecutive same-group fields share, and the manifest's
|
||||||
// optional `instanceGroups[name]` adds { collapsed, columns, help } —
|
// optional `instanceGroups[name]` adds { collapsed, columns, help } —
|
||||||
@@ -999,6 +1097,7 @@ function toggleSettingsPopover(tile, el) {
|
|||||||
input.checked = Boolean(current);
|
input.checked = Boolean(current);
|
||||||
field.append(input, document.createTextNode(label));
|
field.append(input, document.createTextNode(label));
|
||||||
inputs.set(key, () => input.checked);
|
inputs.set(key, () => input.checked);
|
||||||
|
live(input);
|
||||||
} else if (type === 'select' && Array.isArray(spec.options)) {
|
} else if (type === 'select' && Array.isArray(spec.options)) {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
@@ -1012,6 +1111,7 @@ function toggleSettingsPopover(tile, el) {
|
|||||||
select.value = String(current ?? '');
|
select.value = String(current ?? '');
|
||||||
field.append(span, select);
|
field.append(span, select);
|
||||||
inputs.set(key, () => select.value);
|
inputs.set(key, () => select.value);
|
||||||
|
live(select);
|
||||||
} else if (type === 'color') {
|
} else if (type === 'color') {
|
||||||
// A swatch picker; the value is always a #rrggbb string.
|
// A swatch picker; the value is always a #rrggbb string.
|
||||||
field.classList.add('color-field');
|
field.classList.add('color-field');
|
||||||
@@ -1022,6 +1122,7 @@ function toggleSettingsPopover(tile, el) {
|
|||||||
input.value = colorHex(current, colorHex(spec?.default, '#2ee59a'));
|
input.value = colorHex(current, colorHex(spec?.default, '#2ee59a'));
|
||||||
field.append(span, input);
|
field.append(span, input);
|
||||||
inputs.set(key, () => input.value);
|
inputs.set(key, () => input.value);
|
||||||
|
live(input, 'input', 150); // dragging the picker fires continuously
|
||||||
} else {
|
} else {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
@@ -1030,6 +1131,8 @@ function toggleSettingsPopover(tile, el) {
|
|||||||
input.value = current === undefined || current === null ? '' : String(current);
|
input.value = current === undefined || current === null ? '' : String(current);
|
||||||
field.append(span, input);
|
field.append(span, input);
|
||||||
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
|
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
|
||||||
|
live(input, 'input', 400); // mid-word is not the moment to remount
|
||||||
|
live(input, 'change'); // …but blur or Enter is
|
||||||
}
|
}
|
||||||
if (spec?.help) {
|
if (spec?.help) {
|
||||||
const help = document.createElement('small');
|
const help = document.createElement('small');
|
||||||
@@ -1040,26 +1143,17 @@ function toggleSettingsPopover(tile, el) {
|
|||||||
container.appendChild(field);
|
container.appendChild(field);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
// Nothing to press: click away, press Escape, or click the gear again.
|
||||||
actions.className = 'actions';
|
// (The gear's own click stops propagating, so it toggles rather than
|
||||||
actions.style.display = 'flex';
|
// closing and reopening.)
|
||||||
actions.style.justifyContent = 'flex-end';
|
const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); };
|
||||||
actions.style.gap = '8px';
|
const closeOnEscape = (e) => { if (e.key === 'Escape') closeSettingsPopovers(); };
|
||||||
const cancel = document.createElement('button');
|
pop._cleanup = () => {
|
||||||
cancel.className = 'btn';
|
document.removeEventListener('click', closeOnClick);
|
||||||
cancel.textContent = 'Cancel';
|
document.removeEventListener('keydown', closeOnEscape);
|
||||||
cancel.addEventListener('click', () => pop.remove());
|
};
|
||||||
const apply = document.createElement('button');
|
document.addEventListener('click', closeOnClick);
|
||||||
apply.className = 'btn primary';
|
document.addEventListener('keydown', closeOnEscape);
|
||||||
apply.textContent = 'Apply';
|
|
||||||
apply.addEventListener('click', () => {
|
|
||||||
for (const [key, read] of inputs) tile.settings[key] = read();
|
|
||||||
pop.remove();
|
|
||||||
saveLayout();
|
|
||||||
remountTile(tile);
|
|
||||||
});
|
|
||||||
actions.append(cancel, apply);
|
|
||||||
pop.appendChild(actions);
|
|
||||||
|
|
||||||
pop.addEventListener('pointerdown', (e) => e.stopPropagation());
|
pop.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||||
el.appendChild(pop);
|
el.appendChild(pop);
|
||||||
@@ -1222,6 +1316,41 @@ try {
|
|||||||
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
|
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
|
||||||
} catch { /* fine */ }
|
} catch { /* fine */ }
|
||||||
|
|
||||||
|
/* ── fullscreen: peeking at a tile's title bar ──────────────────────── */
|
||||||
|
|
||||||
|
/** How long a peeked title bar stays after the pointer leaves its tile. */
|
||||||
|
const PEEK_HIDE_MS = 10000;
|
||||||
|
const peekTimers = new Map(); // tile id → timeout
|
||||||
|
|
||||||
|
function clearPeekTimer(id) {
|
||||||
|
const timer = peekTimers.get(id);
|
||||||
|
if (timer === undefined) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
peekTimers.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePeekHide(id, el) {
|
||||||
|
clearPeekTimer(id);
|
||||||
|
peekTimers.set(id, setTimeout(() => {
|
||||||
|
peekTimers.delete(id);
|
||||||
|
el.classList.remove('head-peek');
|
||||||
|
}, PEEK_HIDE_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
function peekHead(tile, el, on) {
|
||||||
|
el.classList.toggle('head-peek', on);
|
||||||
|
clearPeekTimer(tile.id);
|
||||||
|
// Opened from a click the pointer is on the tile, so the countdown waits
|
||||||
|
// for it to leave — unless it already has (a keyboard or touch open).
|
||||||
|
if (on && !el.matches(':hover')) schedulePeekHide(tile.id, el);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to a plain screen: no peeks left open, no timers left running. */
|
||||||
|
function clearPeeks() {
|
||||||
|
for (const id of [...peekTimers.keys()]) clearPeekTimer(id);
|
||||||
|
document.querySelectorAll('.tile.head-peek').forEach((el) => el.classList.remove('head-peek'));
|
||||||
|
}
|
||||||
|
|
||||||
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
|
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
|
||||||
|
|
||||||
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
|
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
|
||||||
@@ -1355,6 +1484,7 @@ document.addEventListener('fullscreenchange', () => {
|
|||||||
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
|
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
|
||||||
} else {
|
} else {
|
||||||
setMenuHidden(menuHiddenBeforeFs, { persist: false });
|
setMenuHidden(menuHiddenBeforeFs, { persist: false });
|
||||||
|
clearPeeks();
|
||||||
clearFullscreenFit();
|
clearFullscreenFit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+54
-53
@@ -54,57 +54,43 @@ body.menu-hidden #topbar {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The menubar's show/hide handle IS the logo: a translucent accent pill
|
/* The bar's show/hide handle: a small accent lip on its bottom edge, the
|
||||||
wrapped around "ProdDash" at the left of the bar, "Dash" cut out in the
|
same idea as a tile's notch. It sits at the left — never screen-centre,
|
||||||
background colour. It sits at the left — never screen-centre, where a
|
where a tile's own (centred) tab lands once both bars are hidden — and
|
||||||
tile's own (centred) tab lands once both bars are hidden. When the bar
|
grows under the pointer so it is easy to hit without being loud at rest.
|
||||||
hides, the pill shrinks to a slim lip at the very top edge, clear of a
|
It used to be the ProdDash wordmark on a pill hanging past the bar, which
|
||||||
top-row tile's status dot and title. */
|
read as a step cut out of the bar, and animating its font-size on the way
|
||||||
.brand-spacer { visibility: hidden; }
|
out was the jerkiest thing on the page in Safari. Everything here moves by
|
||||||
|
transform, opacity and its own fixed size: no text metrics, no reflow. */
|
||||||
#topbar-notch {
|
#topbar-notch {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
/* a tab hung from the top of the bar: square top, rounded bottom, hanging
|
|
||||||
8px past the bar's bottom edge; the logo sits centred in the bar part */
|
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 2px; /* the logo text starts at the bar's 12px padding; the tab adds 10px each side */
|
left: 10px;
|
||||||
height: calc(var(--topbar-h, 46px) + 8px);
|
width: 28px;
|
||||||
min-width: 60px;
|
height: 5px;
|
||||||
padding: 0 10px;
|
padding: 0;
|
||||||
font: inherit;
|
|
||||||
font-weight: 800;
|
|
||||||
font-size: 15px;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
line-height: var(--topbar-h, 46px);
|
|
||||||
color: var(--text);
|
|
||||||
background: color-mix(in srgb, var(--accent) 45%, transparent);
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0 0 12px 12px;
|
border-radius: 0 0 5px 5px;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.5;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
transition: height 0.22s ease, line-height 0.22s ease, font-size 0.22s ease, background 0.15s ease;
|
/* Glued to the bar's bottom edge: --topbar-h is the bar's live height, so
|
||||||
|
the lip rides the bar's own collapse instead of running a second,
|
||||||
|
competing animation against it. */
|
||||||
|
transform: translateY(var(--topbar-h, 46px));
|
||||||
|
transition: width 0.16s ease, height 0.16s ease,
|
||||||
|
opacity 0.16s ease, border-radius 0.16s ease;
|
||||||
}
|
}
|
||||||
#topbar-notch .accent { color: var(--bg); } /* the cutout */
|
#topbar-notch:hover,
|
||||||
#topbar-notch:hover { background: color-mix(in srgb, var(--accent) 68%, transparent); }
|
#topbar-notch:focus-visible {
|
||||||
/* bar hidden: the text folds away and the tab becomes a lip at the top edge */
|
width: 60px;
|
||||||
body.menu-hidden #topbar-notch {
|
|
||||||
height: 10px;
|
height: 10px;
|
||||||
line-height: 10px;
|
opacity: 1;
|
||||||
font-size: 0;
|
border-radius: 0 0 8px 8px;
|
||||||
background: color-mix(in srgb, var(--accent) 55%, transparent);
|
|
||||||
}
|
|
||||||
body.menu-hidden #topbar-notch:hover { background: var(--accent); }
|
|
||||||
body.menu-hidden #topbar-notch::after {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
width: 20px;
|
|
||||||
height: 3px;
|
|
||||||
margin: 0 auto;
|
|
||||||
position: relative;
|
|
||||||
top: 3px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
}
|
||||||
|
/* With the bar collapsed --topbar-h is 0, so the lip ends up at the very top
|
||||||
|
edge without a rule of its own. */
|
||||||
|
|
||||||
#brand {
|
#brand {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -387,6 +373,22 @@ body.menu-hidden #topbar-notch::after {
|
|||||||
}
|
}
|
||||||
.tile-notch:hover::after { background: var(--text); }
|
.tile-notch:hover::after { background: var(--text); }
|
||||||
|
|
||||||
|
/* Fullscreen is for watching, not arranging: every title bar folds away, and
|
||||||
|
a tile's own hidden/shown preference is left untouched for when the screen
|
||||||
|
comes back. The notch still peeks one open, and it folds back once the
|
||||||
|
pointer has been off that tile for a while (PEEK_HIDE_MS in shell.js). */
|
||||||
|
body.fullscreen .tile:not(.head-peek) .tile-head {
|
||||||
|
height: 0;
|
||||||
|
border-bottom-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
body.fullscreen .tile:not(.head-peek) .tile-notch { top: 0; }
|
||||||
|
body.fullscreen .tile.head-peek .tile-head {
|
||||||
|
height: 32px;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
|
||||||
|
|
||||||
.tile-body {
|
.tile-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -433,8 +435,9 @@ body.menu-hidden #topbar-notch::after {
|
|||||||
border-radius: 0 0 0 3px;
|
border-radius: 0 0 0 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* bottom-edge pill: drag to move the tile's bottom edge (height only);
|
/* bottom edge: drag to move the tile's bottom edge (height only). Drawn as
|
||||||
sits between the two corner handles */
|
the same 2px line as the corner handles it sits between, on the same
|
||||||
|
4px inset, so the three read as one set of marks along the bottom. */
|
||||||
.tile-resize-bottom {
|
.tile-resize-bottom {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 24px;
|
left: 24px;
|
||||||
@@ -447,6 +450,7 @@ body.menu-hidden #topbar-notch::after {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: ns-resize;
|
cursor: ns-resize;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
color: var(--muted);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
@@ -454,17 +458,13 @@ body.menu-hidden #topbar-notch::after {
|
|||||||
content: "";
|
content: "";
|
||||||
width: 44px;
|
width: 44px;
|
||||||
max-width: 60%;
|
max-width: 60%;
|
||||||
height: 5px;
|
height: 0;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 4px;
|
||||||
border-radius: 999px;
|
border-bottom: 2px solid currentColor;
|
||||||
background: var(--muted);
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
}
|
}
|
||||||
.tile:hover .tile-resize-bottom { opacity: 0.6; }
|
.tile:hover .tile-resize-bottom { opacity: 0.6; }
|
||||||
.tile .tile-resize-bottom:hover,
|
.tile .tile-resize-bottom:hover,
|
||||||
.tile.resizing .tile-resize-bottom { opacity: 1; }
|
.tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); }
|
||||||
.tile .tile-resize-bottom:hover::after,
|
|
||||||
.tile.resizing .tile-resize-bottom::after { background: var(--text); }
|
|
||||||
|
|
||||||
/* ── seam divider between adjacent tiles ───────────────────────────── */
|
/* ── seam divider between adjacent tiles ───────────────────────────── */
|
||||||
|
|
||||||
@@ -494,7 +494,8 @@ body.menu-hidden #topbar-notch::after {
|
|||||||
transition: opacity 0.15s ease, background 0.15s ease;
|
transition: opacity 0.15s ease, background 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-divider:hover::after { opacity: 0.7; }
|
.tile-divider:hover::after,
|
||||||
|
.tile-divider.linked::after { opacity: 0.7; }
|
||||||
.tile-divider.active::after { opacity: 1; background: var(--text); }
|
.tile-divider.active::after { opacity: 1; background: var(--text); }
|
||||||
|
|
||||||
/* Hide the pills while a tile itself is being moved or corner-resized —
|
/* Hide the pills while a tile itself is being moved or corner-resized —
|
||||||
|
|||||||
@@ -373,14 +373,17 @@ function mountModule(id) {
|
|||||||
// Re-require fresh config on every (re)mount; the module code itself stays cached.
|
// Re-require fresh config on every (re)mount; the module code itself stays cached.
|
||||||
const mod = require(serverPath);
|
const mod = require(serverPath);
|
||||||
const config = effectiveConfig(id);
|
const config = effectiveConfig(id);
|
||||||
|
// What a module needs to reach the rest of ProdDash — another module's
|
||||||
|
// routes over loopback, chiefly — without guessing.
|
||||||
|
const shell = { port: PORT };
|
||||||
if (typeof mod.init === 'function') {
|
if (typeof mod.init === 'function') {
|
||||||
entry.handle = mod.init({ config, log }) || null;
|
entry.handle = mod.init({ config, log, shell }) || null;
|
||||||
}
|
}
|
||||||
if (typeof mod.routes === 'function') {
|
if (typeof mod.routes === 'function') {
|
||||||
entry.routes = parseRouteTable(mod.routes({ config, log }));
|
entry.routes = parseRouteTable(mod.routes({ config, log, shell }));
|
||||||
}
|
}
|
||||||
if (typeof mod.tiles === 'function') {
|
if (typeof mod.tiles === 'function') {
|
||||||
entry.tiles = () => mod.tiles({ config, log });
|
entry.tiles = () => mod.tiles({ config, log, shell });
|
||||||
}
|
}
|
||||||
log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : ''));
|
log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : ''));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -510,6 +513,8 @@ async function clientManifest(id) {
|
|||||||
instanceSchema: man.instanceSchema || {},
|
instanceSchema: man.instanceSchema || {},
|
||||||
instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {},
|
instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {},
|
||||||
hasServer: Boolean(man.server),
|
hasServer: Boolean(man.server),
|
||||||
|
// capabilities other modules consume ("timers") — see the module guide
|
||||||
|
provides: Array.isArray(man.provides) ? man.provides.map(String) : [],
|
||||||
config: clientConfig(id),
|
config: clientConfig(id),
|
||||||
tiles: await moduleTiles(id),
|
tiles: await moduleTiles(id),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user