Compare commits

...
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 f72170b4e1 The folding section reads as a heading first
Theme's title was coming out at the normal weight — <summary> does not
inherit an <h1>'s boldness, so sitting it under .admin-h1 got the size,
colour and spacing but not the type. It now matches the other section
headings exactly.

The caret moved to the right of the words. A marker in front pushes the
heading out of line with every other one on the page; behind it, the section
still reads as "Theme" first and the caret only says there is more.

Written as the pattern rather than as Theme's own styling: any section that
folds is a <details class="admin-fold"> headed by a
<summary class="admin-h1 admin-fold-title">, and the whole row is the target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:22:11 -04:00
Waters ChurchandClaude Opus 5 ea1eac1c28 Twelve themes, and the Theme section folds away
Seven more palettes: Midnight (indigo, violet), Moss (forest floor, lime),
Plum (aubergine, purple), Sepia (warm brown with a cool teal — Ember's
arrangement reversed), Blackout (true black for a dark room, where any glow
off the screen carries), Paper (the warm light one, next to Daylight's cool
white) and Iron, which has no colour of its own: its accent is near-white, so
the only hues on screen are a module's own and the red and amber that mean
something.

Each holds the line the first five set. Text on panel lands between 14.8:1
and 19.8:1, muted between 5.8:1 and 7.4:1, and every accent clears its own
theme's danger red and warning amber by more than Ember already does — the
narrowest of the new ones is Moss at 54 degrees of hue, against Ember's 18.

The Theme section now starts folded, wearing the same disclosure triangle as
a module's config groups. A building picks its palette once and then wants
the rest of the page, which was getting further down the screen with every
swatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:16:47 -04:00
michael 82e6386c73 Merge pull request 'Shell 1.6.0: bars that tuck away, and every edge resizes' (#6) from claude/tile-chrome into main 2026-09-17 12:44:07 -04:00
michael f5eb611872 Merge pull request 'README: the Slack module joins the list of included modules' (#5) from claude/readme-slack-module into main 2026-09-17 12:43:31 -04:00
michael d52cfcc48e Merge pull request 'Slack 1.2.1: a clearer Send tile, and a send target you can see' (#4) from claude/slack-send-target into main 2026-09-17 12:42:48 -04:00
michael 7d46cfbd8f Merge pull request 'ProPresenter 1.1.0: a Now tile, and switches for everything on it' (#3) from claude/propresenter-now into main 2026-09-17 12:41:53 -04:00
michael 7a5b57dc56 Merge pull request 'Admin sits behind a 6-digit PIN' (#1) from claude/admin-pin into main 2026-09-17 12:40:31 -04:00
Waters ChurchandClaude Opus 5 7f50fa0ba9 A pin on the title bar, and no more notch
Pointing at a tile's top edge already brings its bar back, which left the
notch doing half a job: it could still hide a bar, but hiding one tile at a
time stopped being the interesting question once the whole dashboard could
fold its bars at once. It is gone.

In its place, a pin beside each tile's label. Pinned, that tile keeps its
bar while auto-hide is on — for the one or two tiles whose controls are
wanted at a glance. It sits next to the label rather than out with the gear
and ✕ because it is about the bar, not about the tile's contents, and it
stays faint until the tile is pointed at or the pin is on, so a wall of
unpinned tiles is not a wall of pins.

Fullscreen keeps its own rule: every bar folds, pinned or not, because that
is a mode for watching. What it loses with the notch it gets back better —
the same hover at a tile's top edge peeks a bar there too, and now as an
overlay, so peeking no longer pushes the tile's content down and disturbs
the auto-fit. The peek timer the notch needed goes with it.

Per-tile `headHidden` gives way to `headPinned` in the layout, on disk and
on the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:04:59 -04:00
Waters ChurchandClaude Opus 5 68f0ba806d Tiles resize from anywhere, so nothing marks the spots
Every edge and both bottom corners resize now, so the little marks that used
to appear on hover were pointing at the whole rim. They are gone — corners,
edges and the seam pill alike — and the cursor over an edge is what says it
can be dragged, the way a window's own edges do it. The seam still shows
itself while it is actually being dragged, which says what is moving rather
than where you may push.

With nothing drawn, the grips could be made to cover the rim properly: the
top strip now runs corner to corner instead of stopping 24px short, the
sides run from it down to the bottom corners, and the bottom strip fills
between those. No dead spots left along an edge.

The side grips also stopped standing down at a seam. They only did so to
avoid stacking three marks in one place, and it cost real ground: a tile
taller than its neighbour lost its whole side, not just the part the seam
covers. The seam sits above them anyway, so it still wins where it exists
and the rest of the edge now works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:52:45 -04:00
Waters ChurchandClaude Opus 5 8e579ecb34 Shell 1.6.0: bars that tuck away, and every edge resizes
Three things about arranging a dashboard.

**Auto-hide bars.** A new button left of ⛶ folds every tile's title bar away
at once, for a windowed screen that should read like a wall display.
Fullscreen already did this for watching; this is the windowed equivalent,
asked for by hand. It starts off and saves with the layout, so a booth's
named layout loads with the bars already tucked away.

**A hidden bar is not a gone bar.** Folded away — by that switch or by a
tile's own notch — the bar leaves the flow and parks just above its tile, so
the module keeps the whole body and nothing reflows as the bar comes and
goes. Point at the tile's top edge and it slides back in over the content,
where its buttons click and its width drags the tile exactly as before. A
dashboard can now be arranged with every bar out of the way, which is the
point of hiding them.

**Every edge resizes.** The top, left and right edges join the bottom one:
each moves only itself, so the tile grows into the space beside it while the
opposite edge stays put, stopping at a neighbour or at the module's own
minimum. Where two tiles meet, that boundary already belongs to the seam
pill between them — it moves both edges and leaves no gap — so the two
tiles' side marks step aside there rather than offer a second, different
control on the same pixels.

One fix underneath: saving a named layout was dropping each tile's variant,
title, minimum size and hidden-bar state, so a saved "Now" tile came back as
the full Now/Next card and a solo timer came back with a full tile's
settings. The layout file now carries them — and the new switch with them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:39:34 -04:00
Waters ChurchandClaude Opus 5 c379025d30 README: the Slack module joins the list
Every shipped module had an entry under "Included modules" except Slack,
which wasn't mentioned anywhere in the README — so the one module that can
send as the campus was also the one nobody could read about.

Its entry now sits with the others, in their shape: what it does, the tiles
it offers in the + picker (one transcript per configured channel plus the
two single send surfaces), its four admin groups and its tile settings —
all taken from modules/slack/module.json and client.js.

The testing paragraph listed the ProdCom, ProPresenter and PCO mocks but
not the Slack one, though CLAUDE.md has documented it all along; it is now
there with the SLACK_API_BASE line it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:10:03 -04:00
Waters ChurchandClaude Opus 5 f0be37796b Slack 1.2.1: a clearer Send tile, and a send target you can see
The line above the message box said "Sending as Apollo Beach → #ops" with a
Lock button beside it. The box's own placeholder already reads "Message
#ops…" and the title bar already has a Lock, so both went. Quick replies
keeps its line: it has no placeholder, so that is the only place its target
is named.

The bigger thing behind it: until a transcript was clicked, a send went to
the first channel in the admin list whether or not that channel was on the
dashboard — so a board showing only #ops would quietly aim at #general. The
target now falls to the first configured channel whose transcript is
actually up, which with a single transcript is that one. Only when the page
shows no transcript at all does it fall back to the first configured
channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:58:57 -04:00
Waters ChurchandClaude Opus 5 52d700df3d ProPresenter 1.1.0: a Now tile, and switches for everything on it
The + picker now offers the module two ways. "Now / Next" is the card as it
was. "Now" is the live item on its own: the gradient comes off the card and
fills the tile edge to edge, the border, radius and box go, and the playlist
name and live dot float over the colour instead of sitting above it. A wall
of them reads as panels of colour rather than cards on a background.

Every part of the tile is now a switch in a "Show" group in the gear —
playlist, live status, the NOW and NEXT labels, slide count, progress bar,
the next list and its item tags. One or two words each, no descriptions. The
Now entry carries its own six, since the three Next ones have nothing to act
on there.

Two things the tile still insists on. Trouble overrides the switches: the
top row and the live chip come back the moment the feed drops, whatever is
switched off, because a tile that has stopped updating must never look like
one with nothing live. And at the Now tile's shortest sizes the floating top
row would sit on the title, so below the height it needs it goes — the
header dot and the title itself still say what is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:39:55 -04:00
michael 6cca6d4d1a Merge pull request 'The church's own Gitea is where ProdDash lives' (#2) from claude/gitea-is-home into main 2026-09-16 19:00:51 -04:00
Waters ChurchandClaude Opus 5 1be5cbef31 The church's own Gitea is where ProdDash lives
The shipped defaults pointed at GitHub, so a fresh install with no
configuration would have looked there — and that repository is going away.
They now name michael/ProdDash on chronicles-ab.waterschurch.org:3000, which
a fresh install was verified to reach with nothing configured at all.

The hostname rather than the IP, because the UniFi DNS record is
authoritative and this survives the NAS changing address. Both production
machines already carry an explicit setting of their own, so neither is
affected either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:00:22 -04:00
16 changed files with 760 additions and 285 deletions
+86 -24
View File
@@ -102,7 +102,7 @@ with this machine's shell settings — the same keys as the checked-in
| `adminPin` | The admin PIN, as a scrypt hash. **Set it in the admin page's Admin PIN section**, not by hand — 6 digits, and only the hash is stored. Absent means admin is open to anyone on the network. | | `adminPin` | The admin PIN, as a scrypt hash. **Set it in the admin page's Admin PIN section**, not by hand — 6 digits, and only the hash is stored. Absent means admin is open to anyone on the network. |
| `adminPasscode` | An older free-text passcode. Still honoured, so an existing install keeps working; new installs should use `adminPin`. | | `adminPasscode` | An older free-text passcode. Still honoured, so an existing install keeps working; new installs should use `adminPin`. |
| `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. | | `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. |
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). | | `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember`, `daylight`, `midnight`, `moss`, `plum`, `sepia`, `blackout`, `paper` or `iron`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
**Locked out?** Delete `adminPin` from the data directory's `proddash.json` **Locked out?** Delete `adminPin` from the data directory's `proddash.json`
and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode
@@ -140,12 +140,26 @@ ignored from then on.
The whole dashboard — every tile, on every browser — draws with one palette, The whole dashboard — every tile, on every browser — draws with one palette,
chosen in the admin page's **Theme** section and switched live: pick a swatch chosen in the admin page's **Theme** section and switched live: pick a swatch
and open dashboards change without a reload. Five presets ship: **Booth** and open dashboards change without a reload. The section starts folded —
(the default dark, mint accent), **Harbor** (deep navy, cyan), **Graphite** a building picks its palette once — and holds twelve presets:
(neutral charcoal, electric blue), **Ember** (warm broadcast black, orange)
and **Daylight**, a light palette for a front-of-house desk in daytime. Each | | |
keeps the status colours apart — the accent is never near the danger red or |---|---|
the warning amber — and muted text readable in a dark booth. The choice is | **Booth** | the default dark, mint accent |
| **Harbor** | deep navy, cyan |
| **Graphite** | neutral charcoal, electric blue |
| **Ember** | warm broadcast black, orange |
| **Daylight** | a cool light palette, for a front-of-house desk in daytime |
| **Midnight** | indigo night, violet |
| **Moss** | forest floor, lime |
| **Plum** | aubergine, purple |
| **Sepia** | warm brown room, cool teal — Ember's arrangement reversed |
| **Blackout** | true black, cyan, for a dark room where any glow carries |
| **Paper** | the warm light one, indigo accent |
| **Iron** | no colour of its own: a near-white accent, so the only hues on screen are a module's own and the red and amber that mean something |
Each keeps the status colours apart — the accent is never near the danger red
or the warning amber — and muted text readable in a dark booth. The choice is
stored as `theme` in the data directory's `proddash.json` (table above), so stored as `theme` in the data directory's `proddash.json` (table above), so
it holds across restarts and updates; a browser remembers the last theme it it holds across restarts and updates; a browser remembers the last theme it
saw and applies it before the server answers, so nothing flashes. Modules saw and applies it before the server answers, so nothing flashes. Modules
@@ -154,10 +168,11 @@ set on a tile itself (a PCO Plan header colour, say) is deliberately kept.
## Installing modules and updating ## Installing modules and updating
Everything below happens in the **admin page**; ProdDash talks to its GitHub Everything below happens in the **admin page**; ProdDash talks to its git
repository (`michaelmcgary2008/ProdDash`, branch `main` — change with repository — `michael/ProdDash` on the church's own Gitea at
`repo` / `branch` in `proddash.json` or the `PRODDASH_REPO` variable, `chronicles-ab.waterschurch.org:3000`, branch `main`. Change it with `forge`,
`owner/repo#branch`). It downloads one small archive of the branch, at most `repo` and `branch` in `proddash.json`, or the `PRODDASH_FORGE` and
`PRODDASH_REPO` variables (`owner/repo#branch`). It downloads one small archive of the branch, at most
every few minutes, only while the admin page is open or for the update check every few minutes, only while the admin page is open or for the update check
below. Nothing is installed without someone clicking. below. Nothing is installed without someone clicking.
@@ -197,13 +212,17 @@ below. Nothing is installed without someone clicking.
- **+** — add any enabled module; multiple tiles of the same module are - **+** — add any enabled module; multiple tiles of the same 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 **title bar** to move it; drag either **bottom - Drag a tile by its **title bar** to move it; **✕** removes it.
corner** to resize; **✕** removes it. Hover a tile and a small pill - **Resize from any edge or bottom corner**, the way a window resizes:
appears on its **bottom edge** — drag it to change just the height. If nothing is drawn on a tile to mark the spots — the cursor over an edge is
another tile sits directly below, the pill moves the shared edge instead, what says it can be dragged. An edge moves only itself, so the tile grows
trading rows between the two so the column keeps its total height. Tiles or shrinks into the space beside it while the opposite edge stays put,
sit flush — when two share a vertical edge, hover the seam and the same stopping at a neighbour or at the module's own minimum size. Two
kind of pill appears; drag it to trade width (macOS split-view style). exceptions, both of which move a *shared* edge instead: with another tile
directly below, the bottom edge trades rows between the two so the column
keeps its total height; and where two tiles meet side by side, the seam
between them trades width (macOS split-view style), showing itself only
while you drag it.
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its - **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its
green lip at the top-left brings it back, minus the Admin button), the green lip at the top-left brings it back, minus the Admin button), the
occupied columns stretch to the full width, and every column runs to the occupied columns stretch to the full width, and every column runs to the
@@ -213,9 +232,21 @@ below. Nothing is installed without someone clicking.
gap; alone in its columns it stays as it is. Nothing is saved — the layout gap; alone in its columns it stays as it is. Nothing is saved — the layout
reverts when you leave fullscreen. A layout deeper than the screen keeps reverts when you leave fullscreen. A layout deeper than the screen keeps
its normal row height and scrolls. its normal row height and scrolls.
- A tile's actions (menus, text size, clear…) live in its **title bar**; - 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 - **Auto-hide bars** (the button left of ⛶) folds every title bar away, for
for a clean wall-display look. The menu bar's handle is the **ProdDash a windowed screen that should read like a wall display. A folded bar is
not a gone bar: point at the tile's top edge and it slides back in over
the content, where its buttons click and its width drags the tile exactly
as before — so a dashboard can be arranged with every bar out of the way.
The switch is off on a fresh dashboard and saves with the layout —
including a named layout on the server, so a booth loads its screen with
the bars already tucked away. Fullscreen folds every bar regardless, and
the same hover brings one back there too.
- The **pin** beside a tile's title keeps *that* tile's bar showing while
auto-hide is on — for the one or two tiles whose controls are wanted at
a glance. It turns the accent colour when it is on and saves with the
layout. Fullscreen still folds a pinned bar with the rest.
- The menu bar's own handle is the **ProdDash
name itself**, wrapped in a translucent green pill at the left — click it name itself**, wrapped in a translucent green pill at the left — click it
to hide the bar; the slim green lip it leaves at the top-left edge brings to hide the bar; the slim green lip it leaves at the top-left edge brings
the bar back. It stays at the left so it never sits on top of a tile's the bar back. It stays at the left so it never sits on top of a tile's
@@ -239,7 +270,13 @@ below. Nothing is installed without someone clicking.
Tile settings: channel icons, direction and timestamps. Tile settings: channel icons, direction and timestamps.
- **ProPresenter** — what's live and what's next, with slide progress, - **ProPresenter** — what's live and what's next, with slide progress,
driven by the shared `propresenter-core` client (ProPresenter 7/20). driven by the shared `propresenter-core` client (ProPresenter 7/20).
The + picker offers two tiles: **Now / Next**, the full card, and **Now**,
the live item alone with the gradient filling the tile edge to edge — a
wall panel that says one thing.
Admin config: ProPresenter endpoint (host : port), password. Admin config: ProPresenter endpoint (host : port), password.
Tile settings: a **Show** group of switches for the playlist name, live
status, NOW and NEXT labels, slide count, progress bar, the next list and
its item tags, plus tidy titles.
- **Timers** — live ProPresenter timers (countdown, countdown-to-time, - **Timers** — live ProPresenter timers (countdown, countdown-to-time,
elapsed) and LTC timecode as auto-sized cards. The + picker lists every elapsed) and LTC timecode as auto-sized cards. The + picker lists every
discovered timer (and the LTC card) as its own tile, or add "All timers" discovered timer (and the LTC card) as its own tile, or add "All timers"
@@ -273,6 +310,29 @@ below. Nothing is installed without someone clicking.
(gear menu): the top-bar text (placeholders such as (gear menu): the top-bar text (placeholders such as
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a `{series} • {part} | {serviceType} • {date}`), colors, text size, and a
show/hide checklist for every detail. show/hide checklist for every detail.
- **Slack** — live transcripts of this location's Slack channels, plus
PIN-protected sending as the location. The + picker lists one transcript
tile per configured channel, titled `#channel`, and two send surfaces (one
of each per dashboard): **Send message**, a free-text box with the admin's
quick emojis one press away, and **Quick replies**, a grid of preset
buttons. Both post to the channel of whichever transcript was clicked last
— its bottom edge lights up in the accent color — and to the first
configured channel until one is clicked. Messages arrive with avatars,
names and times, Slack's inline formatting and the workspace's custom
emoji; a right-click on one opens the reaction menu, and text size and
jump-to-latest sit in the title bar. Nothing leaves the machine until the
PIN is entered: one PIN unlocks every Slack tile on that dashboard page
until the auto-lock idles out, a reload asks again, and the lock button on
a send tile's title bar ends it early.
Admin config is in four groups: **Slack account** (the location account's
User OAuth Token — `xoxp-…`, so messages are literally from the campus —
or a bot token plus "Send as"), **Channels** (the joined channels to
follow, one transcript tile each), **Transcript** (refresh interval,
messages kept, thread replies) and **Sending** (the switch on the group's
title row, a 4–8 digit PIN, auto-lock minutes, quick replies one per line
as `Label | Message` — add `| #channel` to bind one to a channel — and up
to eight quick emojis).
Tile settings: avatars, timestamps, names and channel notices.
- **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.
@@ -280,10 +340,12 @@ To develop modules against mocks instead of real gear:
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`), `node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on `node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
`127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter `127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter
timers + timecode on `127.0.0.1:1600`) and `node tools/pco-mock.js` timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
(Planning Center Services on `127.0.0.1:24700` — run ProdDash with (Planning Center Services on `127.0.0.1:24700` — run ProdDash with
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`). See the testing section of the `PCO_PLAN_API_BASE=http://127.0.0.1:24700`) and
[module guide](docs/MODULE-GUIDE.md). `node tools/slack-mock.js 24716` (the Slack Web API on `127.0.0.1:24716` —
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`). See the
testing section of the [module guide](docs/MODULE-GUIDE.md).
## Install as a desktop web app ## Install as a desktop web app
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"port": 24500, "port": 24500,
"adminPasscode": "", "adminPasscode": "",
"forge": "github", "forge": "http://chronicles-ab.waterschurch.org:3000",
"repo": "michaelmcgary2008/ProdDash", "repo": "michael/ProdDash",
"branch": "main" "branch": "main"
} }
+4 -3
View File
@@ -367,9 +367,10 @@ fallback), never a "Text size" field in `instanceSchema`; a timestamps or
newest-first toggle in the header means no such switch in the gear. The newest-first toggle in the header means no such switch in the gear. The
gear is for display choices that have no button of their own. gear is for display choices that have no button of their own.
One more thing to know: the user can hide the whole title bar with the One more thing to know: the whole title bar folds away when the dashboard
small notch on its bottom edge (a clean-view mode for wall displays). Your auto-hides it, and always in fullscreen (a clean-view mode for wall
controls disappear with it, so nothing essential — status, reconnects, displays) — it comes back only while the pointer is at the tile's top edge.
Your controls fold away with it, so nothing essential — status, reconnects,
live data — may exist *only* as a header control. live data — may exist *only* as a header control.
`sse(path, handlers)` handlers: `sse(path, handlers)` handlers:
+39 -9
View File
@@ -6,6 +6,12 @@
in /admin; the tile only renders. */ in /admin; the tile only renders. */
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
/* "now" is the live item on its own, filling the tile; "full" is that plus
the rest of the playlist. The picker presets `view`; a tile placed before
this module had a picker has neither, and gets the full card. */
const view = String(moduleApi.instanceSettings.view || moduleApi.variant || 'full');
const nowOnly = view === 'now';
let state = null; let state = null;
let feedOffline = false; let feedOffline = false;
let stream = null; let stream = null;
@@ -33,11 +39,13 @@ export default function create({ root, moduleApi }) {
</div>`; </div>`;
const screenEl = root.querySelector('.nn-screen'); const screenEl = root.querySelector('.nn-screen');
if (nowOnly) screenEl.classList.add('nn-only-now');
const topRow = root.querySelector('.nn-top'); const topRow = root.querySelector('.nn-top');
const playlistNameEl = root.querySelector('.nn-playlist'); const playlistNameEl = root.querySelector('.nn-playlist');
const liveEl = root.querySelector('.nn-live'); const liveEl = root.querySelector('.nn-live');
const liveTextEl = root.querySelector('.nn-live-text'); const liveTextEl = root.querySelector('.nn-live-text');
const nowEl = root.querySelector('.nn-now'); const nowEl = root.querySelector('.nn-now');
const nowLabelEl = root.querySelector('.nn-label');
const nowTitleBox = root.querySelector('.nn-title-box'); const nowTitleBox = root.querySelector('.nn-title-box');
let nowTitleEl = root.querySelector('.nn-title'); let nowTitleEl = root.querySelector('.nn-title');
const nowMetaEl = root.querySelector('.nn-meta'); const nowMetaEl = root.querySelector('.nn-meta');
@@ -47,9 +55,11 @@ export default function create({ root, moduleApi }) {
const nextListEl = root.querySelector('.nn-next-list'); const nextListEl = root.querySelector('.nn-next-list');
const nextMoreEl = root.querySelector('.nn-next-more'); const nextMoreEl = root.querySelector('.nn-next-more');
/** A setting, defaulting to on — a Now tile's schema omits the Next ones. */
function prefs() { function prefs() {
return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles return moduleApi.instanceSettings;
} }
const on = (key) => prefs()[key] !== false;
/* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */ /* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */
@@ -65,7 +75,7 @@ export default function create({ root, moduleApi }) {
} }
function displayName(raw) { function displayName(raw) {
return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim(); return on('cleanTitles') ? cleanItemName(raw) : String(raw || '').trim();
} }
function itemTypeTag(type) { function itemTypeTag(type) {
@@ -307,7 +317,9 @@ export default function create({ root, moduleApi }) {
const total = state.currentSlideTotal || activeItem?.slideCount || 0; const total = state.currentSlideTotal || activeItem?.slideCount || 0;
const current = state.currentSlide || 0; const current = state.currentSlide || 0;
if (total > 0 && current > 0) { if (!on('showSlideCount')) {
nowMetaEl.textContent = '';
} else if (total > 0 && current > 0) {
const remaining = Math.max(0, total - current); const remaining = Math.max(0, total - current);
nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`; nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`;
} else if (total > 0) { } else if (total > 0) {
@@ -318,7 +330,7 @@ export default function create({ root, moduleApi }) {
nowMetaEl.textContent = ''; nowMetaEl.textContent = '';
} }
if (prefs().showProgress && total > 0) { if (on('showProgress') && total > 0) {
progressEl.hidden = false; progressEl.hidden = false;
const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0; const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0;
progressFillEl.style.width = `${pct}%`; progressFillEl.style.width = `${pct}%`;
@@ -349,6 +361,10 @@ export default function create({ root, moduleApi }) {
nameEl.textContent = displayName(item.name) || '(untitled)'; nameEl.textContent = displayName(item.name) || '(untitled)';
row.appendChild(nameEl); row.appendChild(nameEl);
if (!on('showTags')) {
nextListEl.appendChild(row);
continue;
}
if (item.type === 'presentation' && Number.isInteger(item.slideCount)) { if (item.type === 'presentation' && Number.isInteger(item.slideCount)) {
const chip = document.createElement('div'); const chip = document.createElement('div');
chip.className = 'nn-count-chip'; chip.className = 'nn-count-chip';
@@ -366,7 +382,7 @@ export default function create({ root, moduleApi }) {
nextListEl.appendChild(row); nextListEl.appendChild(row);
} }
nextLabelEl.hidden = upcoming.length === 0; nextLabelEl.hidden = upcoming.length === 0 || !on('showNextLabel');
nextMoreEl.hidden = true; nextMoreEl.hidden = true;
markNextOverflow(); markNextOverflow();
requestAnimationFrame(markNextOverflow); requestAnimationFrame(markNextOverflow);
@@ -374,12 +390,22 @@ export default function create({ root, moduleApi }) {
function render() { function render() {
screenEl.classList.toggle('is-offline', feedOffline); screenEl.classList.toggle('is-offline', feedOffline);
const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled; // Trouble always shows, whatever is switched off: a tile that has stopped
topRow.classList.toggle('hidden-row', !showTop); // updating must never look like one with nothing live.
playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : ''; const trouble = feedOffline || !state?.reachable || !state?.enabled;
const wantPlaylist = on('showPlaylist');
const wantLive = on('showLive') || trouble;
topRow.classList.toggle('hidden-row', !wantPlaylist && !wantLive);
liveEl.hidden = !wantLive;
// Hidden, not just emptied: it is flex:1, so an emptied one still holds
// the row open and pushes the live dot off the right edge.
playlistNameEl.hidden = !wantPlaylist;
playlistNameEl.textContent = state?.playlistName || '';
nowLabelEl.hidden = !on('showNowLabel');
screenEl.classList.toggle('nn-no-next', nowOnly || !on('showNext'));
renderLive(); renderLive();
renderNow(); renderNow();
renderNext(); if (!nowOnly && on('showNext')) renderNext();
} }
/* ── sizing: --unit scales the whole card to the tile ───────────── */ /* ── sizing: --unit scales the whole card to the tile ───────────── */
@@ -387,6 +413,10 @@ export default function create({ root, moduleApi }) {
function applyUnit() { function applyUnit() {
const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032)); const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032));
screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px'); screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px');
// On a Now tile the top row floats over the gradient, so at the shortest
// sizes it would sit on the title. Below the height it needs, it goes —
// the header dot and the title itself still report trouble.
screenEl.classList.toggle('nn-tight', nowOnly && root.clientHeight < unit * 9);
} }
/* ── live feed ──────────────────────────────────────────────────── */ /* ── live feed ──────────────────────────────────────────────────── */
+15 -6
View File
@@ -1,8 +1,8 @@
{ {
"id": "propresenter-now-next", "id": "propresenter-now-next",
"name": "ProPresenter", "name": "ProPresenter",
"version": "1.0.1", "version": "1.1.0",
"proddash": ">=1.1.0", "proddash": ">=1.5.0",
"description": "What is live in ProPresenter and what comes next", "description": "What is live in ProPresenter and what comes next",
"permissions": [ "permissions": [
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" } { "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
@@ -20,13 +20,22 @@
}, },
"password": { "password": {
"type": "password", "type": "password",
"label": "API password (only if set in ProPresenter)", "label": "Password",
"default": "" "default": ""
} }
}, },
"instanceSchema": { "instanceSchema": {
"showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true }, "cleanTitles": { "type": "switch", "label": "Tidy titles", "default": true },
"showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true }, "showPlaylist": { "type": "switch", "label": "Playlist", "default": true, "group": "Show" },
"cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true } "showLive": { "type": "switch", "label": "Live status", "default": true, "group": "Show" },
"showNowLabel": { "type": "switch", "label": "Now label", "default": true, "group": "Show" },
"showSlideCount": { "type": "switch", "label": "Slide count", "default": true, "group": "Show" },
"showProgress": { "type": "switch", "label": "Progress bar", "default": true, "group": "Show" },
"showNext": { "type": "switch", "label": "Next list", "default": true, "group": "Show" },
"showNextLabel": { "type": "switch", "label": "Next label", "default": true, "group": "Show" },
"showTags": { "type": "switch", "label": "Item tags", "default": true, "group": "Show" }
},
"instanceGroups": {
"Show": { "collapsed": false, "columns": 2 }
} }
} }
+37
View File
@@ -108,6 +108,43 @@ module.exports = {
}; };
}, },
/**
* Two ways to place this module. "Now / Next" is the full card — what is
* live, then the rest of the playlist. "Now" is the same live item alone,
* edge to edge with the gradient filling the tile: a wall display that says
* one thing. The Now entry offers only the settings it can act on, so its
* gear carries no Next switches.
*/
tiles() {
return [
{
id: 'full',
name: 'Now / Next',
description: 'What is live, and the rest of the playlist',
defaultSize: { w: 4, h: 5 },
minSize: { w: 3, h: 3 },
settings: { view: 'full' },
},
{
id: 'now',
name: 'Now',
description: 'Only what is live, filling the tile',
defaultSize: { w: 4, h: 2 },
minSize: { w: 2, h: 1 },
settings: { view: 'now' },
instanceSchema: {
cleanTitles: { type: 'switch', label: 'Tidy titles', default: true },
showPlaylist: { type: 'switch', label: 'Playlist', default: true, group: 'Show' },
showLive: { type: 'switch', label: 'Live status', default: true, group: 'Show' },
showNowLabel: { type: 'switch', label: 'Now label', default: true, group: 'Show' },
showSlideCount: { type: 'switch', label: 'Slide count', default: true, group: 'Show' },
showProgress: { type: 'switch', label: 'Progress bar', default: true, group: 'Show' },
},
instanceGroups: { Show: { collapsed: false, columns: 2 } },
},
];
},
routes() { routes() {
return { return {
'GET /state': (req, res) => { 'GET /state': (req, res) => {
+48
View File
@@ -21,6 +21,51 @@
justify-content: center; justify-content: center;
} }
/* NEXT switched off, or a Now tile: the list goes and the card takes the room. */
[data-module="propresenter-now-next"] .nn-screen.nn-no-next .nn-next-wrap { display: none; }
[data-module="propresenter-now-next"] .nn-screen.nn-no-next { justify-content: center; }
/* ── the "Now" tile ──
Only what is live, edge to edge: the gradient moves off the card and onto
the whole tile, the card loses its border, radius and box, and the title
sits in the middle of everything. Nothing is inset, so a wall of these
reads as panels of colour rather than cards on a background. */
[data-module="propresenter-now-next"] .nn-screen.nn-only-now {
gap: 0;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--accent) 14%, transparent),
color-mix(in srgb, var(--bg) 70%, transparent)
);
}
[data-module="propresenter-now-next"] .nn-only-now .nn-now {
flex: 1;
height: auto;
min-height: 0;
justify-content: center;
border: none;
border-radius: 0;
background: none;
padding: 0;
}
/* The live dot and playlist ride over the gradient rather than above it. */
[data-module="propresenter-now-next"] .nn-only-now .nn-top {
position: absolute;
inset: var(--nn-pad) var(--nn-pad) auto;
pointer-events: none;
}
[data-module="propresenter-now-next"] .nn-only-now .nn-title-box { margin-top: 0; }
/* The overlay floats, so it takes no room — this keeps the title clear of it
and still centred, by holding back as much at the bottom as at the top. */
[data-module="propresenter-now-next"] .nn-only-now:not(.nn-tight) .nn-now {
padding: calc(var(--unit) * 1.4) 0;
}
/* Too short for the overlay: the title gets the whole panel to itself. */
[data-module="propresenter-now-next"] .nn-only-now.nn-tight .nn-top,
[data-module="propresenter-now-next"] .nn-only-now.nn-tight.is-offline .nn-top {
display: none !important;
}
/* ── top row ── */ /* ── top row ── */
[data-module="propresenter-now-next"] .nn-top { [data-module="propresenter-now-next"] .nn-top {
@@ -59,6 +104,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
[data-module="propresenter-now-next"] .nn-playlist[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live { [data-module="propresenter-now-next"] .nn-live {
display: flex; display: flex;
@@ -71,6 +117,7 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
} }
[data-module="propresenter-now-next"] .nn-live[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live-dot { [data-module="propresenter-now-next"] .nn-live-dot {
width: calc(var(--unit) * 0.6); width: calc(var(--unit) * 0.6);
@@ -122,6 +169,7 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--accent); color: var(--accent);
} }
[data-module="propresenter-now-next"] .nn-label[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-title-box { [data-module="propresenter-now-next"] .nn-title-box {
flex: 1; flex: 1;
+16 -5
View File
@@ -141,13 +141,17 @@ const sendTarget = (() => {
/** /**
* Where a send goes, given the configured channels in admin order: the * Where a send goes, given the configured channels in admin order: the
* selection while it is still configured and its transcript is on the * selection while it is still configured and its transcript is on the
* page; otherwise the first configured channel. * page, else the first configured channel that IS on this page — with one
* transcript up, that one — and only if the page shows none, the first
* configured channel. A send must never quietly go to a channel the
* operator cannot see.
*/ */
resolve(configured) { resolve(configured) {
const list = Array.isArray(configured) ? configured : []; const list = Array.isArray(configured) ? configured : [];
if (!list.length) return ''; if (!list.length) return '';
if (id && list.includes(id) && this.hasTranscript(id)) return id; if (id && list.includes(id) && this.hasTranscript(id)) return id;
return list[0]; const onPage = list.find((c) => this.hasTranscript(c));
return onPage || list[0];
}, },
on(fn) { on(fn) {
bus.addEventListener('change', fn); bus.addEventListener('change', fn);
@@ -1100,7 +1104,7 @@ function createSendTile({ root, moduleApi, mode }) {
if (stopped || !state) return; if (stopped || !state) return;
if (token) { if (token) {
if (composeEls) { if (composeEls) {
composeEls.line.textContent = sendingLine(); if (composeEls.line) composeEls.line.textContent = sendingLine();
if (composeEls.input) composeEls.input.placeholder = placeholder(); if (composeEls.input) composeEls.input.placeholder = placeholder();
} }
} else renderGate(); } else renderGate();
@@ -1177,14 +1181,21 @@ function createSendTile({ root, moduleApi, mode }) {
let composeEls = null; let composeEls = null;
function buildCompose() { function buildCompose() {
compose.textContent = ''; compose.textContent = '';
// Send message names the channel in the box's own placeholder and locks
// from the title bar, so a line above the field would only say both
// again. Quick replies has no placeholder — there it is the only place
// the target is named, so it stays.
let line = null;
if (mode !== 'send') {
const head = el('div', 'sl-compose-head'); const head = el('div', 'sl-compose-head');
const line = el('span', 'sl-sending', sendingLine()); line = el('span', 'sl-sending', sendingLine());
const lockLink = el('button', 'sl-lock-link', 'Lock'); const lockLink = el('button', 'sl-lock-link', 'Lock');
lockLink.type = 'button'; lockLink.type = 'button';
lockLink.title = 'Lock now'; lockLink.title = 'Lock now';
lockLink.addEventListener('click', () => lock('Locked')); lockLink.addEventListener('click', () => lock('Locked'));
head.append(line, lockLink); head.append(line, lockLink);
compose.appendChild(head); compose.appendChild(head);
}
const foot = el('div', 'sl-compose-foot'); const foot = el('div', 'sl-compose-foot');
const msg = el('div', 'sl-msg'); const msg = el('div', 'sl-msg');
@@ -1462,7 +1473,7 @@ function createSendTile({ root, moduleApi, mode }) {
if (!s.sending?.enabled || !s.sending?.pinSet) return void lock(''); if (!s.sending?.enabled || !s.sending?.pinSet) return void lock('');
if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared
if (composeEls) { if (composeEls) {
composeEls.line.textContent = sendingLine(); if (composeEls.line) composeEls.line.textContent = sendingLine();
if (composeEls.input) composeEls.input.placeholder = placeholder(); if (composeEls.input) composeEls.input.placeholder = placeholder();
} }
} else renderGate(); } else renderGate();
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"id": "slack", "id": "slack",
"name": "Slack", "name": "Slack",
"version": "1.2.0", "version": "1.2.1",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location", "description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
"client": "client.js", "client": "client.js",
@@ -21,7 +21,7 @@
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location." "help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
}, },
"Channels": { "Channels": {
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and to the first channel here until one is clicked. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit." "help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and until one is clicked, to the first channel here whose transcript is on that dashboard: with a single transcript up, that one. A send never goes to a channel the dashboard is not showing. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
}, },
"Sending": { "Sending": {
"toggle": "sendEnabled", "toggle": "sendEnabled",
@@ -49,7 +49,7 @@
"default": [], "default": [],
"group": "Channels", "group": "Channels",
"optionsRoute": "/channels", "optionsRoute": "/channels",
"help": "One transcript tile per channel; the first one is where sends go until a transcript is clicked. Only channels the account has joined are listed — join one in Slack first." "help": "One transcript tile per channel; until a transcript is clicked, sends go to the first channel here that the dashboard is actually showing. Only channels the account has joined are listed — join one in Slack first."
}, },
"pollSeconds": { "pollSeconds": {
"type": "number", "type": "number",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.5.0", "version": "1.6.0",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
+30
View File
@@ -451,6 +451,36 @@ a.btn { text-decoration: none; }
/* one swatch per preset: a miniature dashboard (page, a tile on it, the /* one swatch per preset: a miniature dashboard (page, a tile on it, the
accent as a status dot) and the name; the current one wears the accent */ accent as a status dot) and the name; the current one wears the accent */
/* ── a collapsible admin section ─────────────────────────────────────
The pattern for any section of this page that folds. The markup is a
<details class="admin-fold"> holding the section's contents, headed by a
<summary class="admin-h1 admin-fold-title">: the title keeps the same
type as every other section heading — a <summary> would otherwise come
out at the normal weight — and the caret follows the words, so a folded
section still reads as a heading first. The whole row is the hit target. */
details.admin-fold > summary.admin-fold-title {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 8px;
font-weight: 700; /* matches h1.admin-h1; summary defaults to normal */
user-select: none;
}
details.admin-fold > summary.admin-fold-title::-webkit-details-marker { display: none; }
/* after the title, not before it — the heading reads first, the caret says
there is more behind it */
details.admin-fold > summary.admin-fold-title::after {
content: "";
width: 0; height: 0;
border-left: 6px solid var(--muted);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
transition: transform 0.15s;
}
details.admin-fold[open] > summary.admin-fold-title::after { transform: rotate(90deg); }
details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
.theme-swatches { .theme-swatches {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
+3 -1
View File
@@ -41,9 +41,11 @@
</section> </section>
<section id="theme-section" hidden> <section id="theme-section" hidden>
<h1 class="admin-h1">Theme</h1> <details class="admin-fold">
<summary class="admin-h1 admin-fold-title">Theme</summary>
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div> <div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
<div class="admin-status" id="theme-status"></div> <div class="admin-status" id="theme-status"></div>
</details>
</section> </section>
<section id="modules-section" hidden> <section id="modules-section" hidden>
+8
View File
@@ -19,6 +19,14 @@
<span class="spacer"></span> <span class="spacer"></span>
<button class="tb-btn icon" id="autohide-btn" title="Auto-hide bars" aria-label="Auto-hide tile title bars" aria-pressed="false">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 6l3-3 3 3"/>
<rect x="4" y="10" width="16" height="10" rx="1.5"/>
<path d="M4 14h16"/>
</svg>
</button>
<button class="tb-btn icon" id="fs-btn" title="Fullscreen" aria-label="Fullscreen"> <button class="tb-btn icon" id="fs-btn" title="Fullscreen" aria-label="Fullscreen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/> <path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/>
+211 -92
View File
@@ -24,6 +24,7 @@ function toast(msg, isError = false) {
toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200); toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200);
} }
const autoHideBtn = document.getElementById('autohide-btn');
const modalBackdrop = document.getElementById('modal-backdrop'); const modalBackdrop = document.getElementById('modal-backdrop');
const modalEl = document.getElementById('modal'); const modalEl = document.getElementById('modal');
@@ -161,18 +162,25 @@ async function loadRegistry() {
let tiles = []; let tiles = [];
const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance } const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
/* Whether tile title bars fold away until you point at them. It is part of
how a dashboard is arranged rather than a preference of this browser, so
it saves with the layout — including a named layout on the server — and a
fresh dashboard starts with the bars showing. Fullscreen folds every bar
away regardless; this is the windowed equivalent, asked for by hand. */
let autoHideHeads = false;
function saveLayout() { function saveLayout() {
try { try {
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, tiles })); localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, autoHideHeads, tiles }));
} catch { /* private mode / full — layout just won't persist */ } } catch { /* private mode / full — layout just won't persist */ }
} }
function loadLayoutFromStorage() { function loadLayoutFromStorage() {
try { try {
const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null'); const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null');
if (raw && Array.isArray(raw.tiles)) return raw.tiles; if (raw && Array.isArray(raw.tiles)) return raw;
} catch { /* corrupted — start fresh */ } } catch { /* corrupted — start fresh */ }
return []; return { tiles: [] };
} }
function sanitizeTile(t) { function sanitizeTile(t) {
@@ -185,7 +193,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), headPinned: Boolean(t.headPinned),
// set when the tile was added from a module's tile list (multi-tile modules) // set when the tile was added from a module's tile list (multi-tile modules)
variant: t.variant ? String(t.variant) : '', variant: t.variant ? String(t.variant) : '',
title: t.title ? String(t.title) : '', title: t.title ? String(t.title) : '',
@@ -249,9 +257,48 @@ function minSizeOf(tile) {
}; };
} }
/* ── title bars that fold away ──────────────────────────────────────── */
/* A bar is "off" when it is folded out of sight: the dashboard has auto-hide
on and this tile is not pinned, or the screen is in fullscreen, which folds
every bar for watching whatever the tiles say. Off is never gone — the top
edge of the tile brings it back for as long as the pointer is there. */
/** How close to the tile's top edge the pointer opens a folded-away bar. */
const HEAD_REVEAL_PX = 22;
/** How far down it may wander before that bar folds away again (bar + a little). */
const HEAD_HOLD_PX = 38;
function headIsOff(tile) {
if (document.body.classList.contains('fullscreen')) return true;
return autoHideHeads && !tile.headPinned;
}
function applyHeadState(tile, el = tileEls.get(tile.id)?.el) {
if (!el) return;
const off = headIsOff(tile);
el.classList.toggle('head-off', off);
if (!off) el.classList.remove('head-peek');
}
/** Re-read every tile's bar — after the toggle, or after fullscreen changes. */
function applyAllHeadStates() {
for (const tile of tiles) applyHeadState(tile);
}
function setAutoHideHeads(on, { save = true } = {}) {
autoHideHeads = Boolean(on);
document.body.classList.toggle('autohide-heads', autoHideHeads);
autoHideBtn?.classList.toggle('is-on', autoHideHeads);
autoHideBtn?.setAttribute('aria-pressed', autoHideHeads ? 'true' : 'false');
applyAllHeadStates();
if (save) saveLayout();
}
/* ── tile DOM ───────────────────────────────────────────────────────── */ /* ── tile DOM ───────────────────────────────────────────────────────── */
const CLOSE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>'; const CLOSE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
const PIN_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 16.5V22"/><path class="pin-head" d="M8.5 3h7l-1.2 7 3.2 3.2H6.5L9.7 10 8.5 3z"/></svg>';
const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>'; const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>';
function buildTile(tile) { function buildTile(tile) {
@@ -278,7 +325,34 @@ function buildTile(tile) {
const controls = document.createElement('span'); const controls = document.createElement('span');
controls.className = 'tile-controls'; controls.className = 'tile-controls';
head.append(dot, title, controls); /* Pinned tiles keep their bar through auto-hide. The switch sits right
beside the label rather than out with the actions, because it is about
this bar rather than about the tile's contents. */
const pin = document.createElement('button');
pin.className = 'tile-btn pin';
pin.innerHTML = PIN_SVG;
const paintPin = () => {
const on = Boolean(tile.headPinned);
pin.classList.toggle('is-pinned', on);
pin.setAttribute('aria-pressed', on ? 'true' : 'false');
// Instant tooltips read `title` the first time and `data-tip` after.
pin.title = on ? 'Unpin bar' : 'Pin bar';
pin.setAttribute('data-tip', pin.title);
};
paintPin();
pin.addEventListener('click', (e) => {
e.stopPropagation();
tile.headPinned = !tile.headPinned;
paintPin();
applyHeadState(tile, el);
saveLayout();
});
// Keeps the label hugging the pin however wide the tile gets.
const gap = document.createElement('span');
gap.className = 'tile-head-gap';
head.append(dot, title, pin, gap, controls);
// Every tile has something to set: its own settings when the module // Every tile has something to set: its own settings when the module
// offers any, and the tint either way. // offers any, and the tint either way.
@@ -307,45 +381,47 @@ function buildTile(tile) {
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'tile-body'; body.className = 'tile-body';
const resize = document.createElement('div'); /* The rim: both bottom corners resize in two directions, the bottom edge
resize.className = 'tile-resize'; moves that edge (trading rows with tiles below), and the other three
resize.title = 'Drag to resize'; move only themselves — the top edge changes where the tile starts, the
sides how wide it is — each stopping where a neighbour or the tile's own
minimum says it must. None of them draw anything: the cursor over an
edge is what says it can be dragged, as on a window. */
const grip = (cls) => {
const gripEl = document.createElement('div');
gripEl.className = cls;
return gripEl;
};
const resize = grip('tile-resize');
const resizeLeft = grip('tile-resize-left');
const resizeBottom = grip('tile-resize-bottom');
const edgeTop = grip('tile-resize-top');
const edgeRight = grip('tile-resize-right');
const edgeLeft = grip('tile-resize-side');
const resizeLeft = document.createElement('div'); // A folded-away bar is still reachable: point at the tile's top edge and it
resizeLeft.className = 'tile-resize-left'; // slides in over the content, where its buttons click and its whole width
resizeLeft.title = 'Drag to resize'; // drags the tile exactly as when it is showing.
el.addEventListener('pointermove', (ev) => {
// pill on the bottom edge: moves that edge (trading rows with tiles below) if (!headIsOff(tile)) return;
const resizeBottom = document.createElement('div'); // Mid-drag the tile moves under the pointer, and mid-resize the edges do
resizeBottom.className = 'tile-resize-bottom'; // — neither is a moment to take the bar away.
resizeBottom.title = 'Drag to adjust height'; if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
const y = ev.clientY - el.getBoundingClientRect().top;
// notch on the title bar's bottom edge: hides/shows the bar const shown = el.classList.contains('head-peek');
const notch = document.createElement('button'); // It takes less to hold the bar open than to open it, so a bar that
notch.className = 'tile-notch'; // appears under the pointer cannot flicker straight back out.
notch.title = 'Hide / show the title bar'; if (!shown && y <= HEAD_REVEAL_PX) el.classList.add('head-peek');
notch.addEventListener('click', (e) => { else if (shown && y > HEAD_HOLD_PX) el.classList.remove('head-peek');
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;
el.classList.toggle('head-hidden', tile.headHidden);
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', () => { el.addEventListener('pointerleave', () => {
if (el.classList.contains('head-peek')) schedulePeekHide(tile.id, el); if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
el.classList.remove('head-peek');
}); });
el.append(head, body, resize, resizeLeft, resizeBottom, notch); el.append(head, body, resize, resizeLeft, resizeBottom, edgeTop, edgeRight, edgeLeft);
el.classList.toggle('head-hidden', Boolean(tile.headHidden)); applyHeadState(tile, el);
grid.appendChild(el); grid.appendChild(el);
const entry = { el, body, dot, titleEl: title, controls, instance: null }; const entry = { el, body, dot, titleEl: title, controls, instance: null };
@@ -356,6 +432,9 @@ function buildTile(tile) {
wireResize(tile, el, resize, false); wireResize(tile, el, resize, false);
wireResize(tile, el, resizeLeft, true); wireResize(tile, el, resizeLeft, true);
wireBottomResize(tile, el, resizeBottom); wireBottomResize(tile, el, resizeBottom);
wireEdgeResize(tile, el, edgeTop, 'n');
wireEdgeResize(tile, el, edgeRight, 'e');
wireEdgeResize(tile, el, edgeLeft, 'w');
mountModule(tile); mountModule(tile);
return entry; return entry;
@@ -369,7 +448,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') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return; if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu')) return;
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();
@@ -486,6 +565,80 @@ function wireResize(tile, el, handle, fromLeft) {
}); });
} }
/* ── drag the top, left or right edge ───────────────────────────────── */
/* One edge moves and the opposite one stays where it is, so the tile grows
or shrinks into the space beside it. Unlike the bottom-edge pill these do
not trade rows or columns with a neighbour: the edge simply stops when it
meets one, which is what "resize this tile" should mean. */
function wireEdgeResize(tile, el, handle, edge) {
handle.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault();
ev.stopPropagation();
closeSettingsPopovers();
handle.setPointerCapture(ev.pointerId);
const m = cellMetrics();
const sx = ev.clientX;
const sy = ev.clientY;
const startX = tile.x;
const startY = tile.y;
const startW = tile.w;
const startH = tile.h;
const min = minSizeOf(tile);
el.classList.add('resizing');
grid.classList.add('no-dividers');
let changed = false;
const onMove = (e) => {
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
let cand;
if (edge === 'n') {
// the top edge moves; the bottom edge stays put
const y = Math.max(0, Math.min(startY + startH - min.h, startY + dRows));
cand = { x: tile.x, y, w: tile.w, h: startY + startH - y };
} else if (edge === 'w') {
// the left edge moves; the right edge stays put
const x = Math.max(0, Math.min(startX + startW - min.w, startX + dCols));
cand = { x, y: tile.y, w: startX + startW - x, h: tile.h };
} else {
cand = {
x: tile.x,
y: tile.y,
w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)),
h: tile.h,
};
}
if (cand.x === tile.x && cand.y === tile.y && cand.w === tile.w && cand.h === tile.h) return;
if (!isFree(cand, tile.id)) return;
tile.x = cand.x;
tile.y = cand.y;
tile.w = cand.w;
tile.h = cand.h;
applyRect(tile);
changed = true;
notifyResize(tile);
};
const onUp = () => {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onUp);
el.classList.remove('resizing');
grid.classList.remove('no-dividers');
if (changed) {
saveLayout();
rebuildDividers();
}
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
});
}
/* ── drag the bottom edge: height only ──────────────────────────────── */ /* ── drag the bottom edge: height only ──────────────────────────────── */
/* The pill on a tile's bottom edge moves that edge. When other tiles sit /* The pill on a tile's bottom edge moves that edge. When other tiles sit
@@ -613,7 +766,6 @@ function seamPairsAt(boundary) {
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 this column';
const place = () => { const place = () => {
const m = cellMetrics(); const m = cellMetrics();
@@ -626,14 +778,6 @@ function buildDivider(L, R, top, bottom) {
const boundary = () => L.x + L.w; const boundary = () => L.x + L.w;
const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el); 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;
@@ -689,7 +833,6 @@ function buildDivider(L, R, top, bottom) {
el.removeEventListener('pointerup', onUp); el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp); el.removeEventListener('pointercancel', onUp);
for (const other of group) other.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
}; };
@@ -727,7 +870,7 @@ function addTile(moduleId, entry = null) {
if (spec && 'default' in spec) settings[key] = spec.default; if (spec && 'default' in spec) settings[key] = spec.default;
} }
Object.assign(settings, entry?.settings || {}); Object.assign(settings, entry?.settings || {});
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings }; const tile = { id: newId(), module: moduleId, ...spot, ...size, settings, headPinned: false };
if (entry) { if (entry) {
tile.variant = String(entry.id); tile.variant = String(entry.id);
tile.title = String(entry.name || ''); tile.title = String(entry.name || '');
@@ -743,7 +886,6 @@ 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);
@@ -997,7 +1139,7 @@ function buildModuleApi(tile, man, entry) {
/** /**
* The tile's title bar. Controls added here live next to the gear/close * The tile's title bar. Controls added here live next to the gear/close
* buttons and are removed automatically when the instance stops. The * buttons and are removed automatically when the instance stops. The
* whole bar can be hidden by the user via the notch, so nothing * whole bar folds away when the dashboard auto-hides it, so nothing
* essential should exist ONLY as a header control. * essential should exist ONLY as a header control.
*/ */
header: { header: {
@@ -1712,9 +1854,13 @@ function remountTile(tile) {
/* ── layout apply / reset ───────────────────────────────────────────── */ /* ── layout apply / reset ───────────────────────────────────────────── */
function renderLayout(list) { function renderLayout(layout) {
// Callers pass a whole layout; an array on its own is still understood so
// "reset" can simply hand over no tiles.
const list = Array.isArray(layout) ? layout : (layout?.tiles || []);
clearAllTiles(); clearAllTiles();
tiles = list.map(sanitizeTile); tiles = list.map(sanitizeTile);
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
for (const tile of tiles) buildTile(tile); for (const tile of tiles) buildTile(tile);
refreshEmptyState(); refreshEmptyState();
rebuildDividers(); rebuildDividers();
@@ -1794,7 +1940,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
const res = await fetch('/api/layouts/' + encodeURIComponent(name), { const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: { tiles } }), body: JSON.stringify({ layout: { autoHideHeads, tiles } }),
}); });
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')'); if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
@@ -1838,7 +1984,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')'); if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
// Loading copies the layout into this browser's own state — it // Loading copies the layout into this browser's own state — it
// does not live-link browsers together. // does not live-link browsers together.
renderLayout(body.layout.tiles || []); renderLayout(body.layout);
saveLayout(); saveLayout();
toast(`Loaded “${l.name}”`); toast(`Loaded “${l.name}”`);
} catch (e) { } catch (e) {
@@ -1850,7 +1996,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
menu.insertBefore(block, reset); menu.insertBefore(block, reset);
}); });
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */ /* ── menu bar show/hide (its own notch, at the top-left) ────────────── */
const topbar = document.getElementById('topbar'); const topbar = document.getElementById('topbar');
const topbarNotch = document.getElementById('topbar-notch'); const topbarNotch = document.getElementById('topbar-notch');
@@ -1877,41 +2023,6 @@ 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
@@ -2026,6 +2137,13 @@ function clearFullscreenFit() {
} }
} }
autoHideBtn?.addEventListener('click', () => {
setAutoHideHeads(!autoHideHeads);
toast(autoHideHeads
? 'Title bars hide until you point at a tile\u2019s top edge'
: 'Title bars stay put');
});
fsBtn.addEventListener('click', () => { fsBtn.addEventListener('click', () => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
document.documentElement.requestFullscreen?.().catch(() => {}); document.documentElement.requestFullscreen?.().catch(() => {});
@@ -2039,14 +2157,15 @@ document.addEventListener('fullscreenchange', () => {
document.body.classList.toggle('fullscreen', active); document.body.classList.toggle('fullscreen', active);
if (active) { if (active) {
closeSettingsPopovers(); closeSettingsPopovers();
applyAllHeadStates(); // fullscreen has its own rule for the bars
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden'); menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
// let the collapsed menubar's space settle before measuring // let the collapsed menubar's space settle before measuring
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit)); requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
} else { } else {
setMenuHidden(menuHiddenBeforeFs, { persist: false }); setMenuHidden(menuHiddenBeforeFs, { persist: false });
clearPeeks();
clearFullscreenFit(); clearFullscreenFit();
applyAllHeadStates();
} }
}); });
+216 -120
View File
@@ -126,6 +126,14 @@ body.menu-hidden #topbar {
line-height: 1; line-height: 1;
} }
.tb-btn.icon svg { display: block; } .tb-btn.icon svg { display: block; }
/* A top-bar button that is a switch rather than an action lights up the same
way an open tile button does, so "on" reads the same everywhere. */
.tb-btn.is-on,
.tb-btn.is-on:hover {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
}
/* ── dropdown menus ────────────────────────────────────────────────── */ /* ── dropdown menus ────────────────────────────────────────────────── */
@@ -270,11 +278,33 @@ body.menu-hidden #topbar {
transition: height 0.22s ease, border-bottom-width 0.22s ease; transition: height 0.22s ease, border-bottom-width 0.22s ease;
} }
.tile.dragging .tile-head { cursor: grabbing; } .tile.dragging .tile-head { cursor: grabbing; }
/* hidden = collapsed, not removed, so the notch toggle animates */
.tile.head-hidden .tile-head { /* Folded away: the bar leaves the flow and parks just above the tile, so the
height: 0; module keeps the whole body and nothing reflows as the bar comes and goes.
border-bottom-width: 0; Pointing at the tile's top edge slides it back in over the content
overflow: hidden; (shell.js adds .head-peek) — and because it is the same element, its
buttons click and its width drags the tile just as they always did. */
.tile.head-off > .tile-head {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 7;
background: color-mix(in srgb, var(--panel) 90%, transparent);
-webkit-backdrop-filter: blur(7px);
backdrop-filter: blur(7px);
transform: translateY(-100%);
opacity: 0;
pointer-events: none;
transition: transform 0.18s ease, opacity 0.18s ease;
}
.tile.head-off.head-peek > .tile-head {
transform: none;
opacity: 1;
pointer-events: auto;
}
.tile.is-tinted.head-off > .tile-head {
background: color-mix(in srgb, var(--tile-tint) 20%, var(--panel));
} }
.tile-head .dot { .tile-head .dot {
@@ -290,7 +320,7 @@ body.menu-hidden #topbar {
.tile-head .dot.error { background: var(--danger); } .tile-head .dot.error { background: var(--danger); }
.tile-title { .tile-title {
flex: 1; flex: 0 1 auto;
min-width: 0; min-width: 0;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@@ -321,6 +351,33 @@ body.menu-hidden #topbar {
.tile-btn.is-open, .tile-btn.is-open,
.tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); } .tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); }
/* The pin: a state, not an action, so it stays quiet until it is on and
sits closer to the label than to the buttons at the other end. It fades
in with the rest of the tile's chrome so a wall of unpinned tiles is not
a wall of pins. */
.tile-btn.pin {
width: 18px;
height: 18px;
margin-left: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.tile-btn.pin svg { width: 12px; height: 12px; }
.tile:hover .tile-btn.pin,
.tile-btn.pin:focus-visible { opacity: 0.75; }
.tile-btn.pin:hover { opacity: 1; }
/* Pinned reads at a glance, whether or not anything is being pointed at. */
.tile-btn.pin.is-pinned,
.tile:hover .tile-btn.pin.is-pinned {
opacity: 1;
color: var(--accent);
}
.tile-btn.pin.is-pinned .pin-head { fill: currentColor; }
.tile-btn.pin.is-pinned:hover { background: var(--border); }
/* Keeps the label hugging its pin however wide the tile is. */
.tile-head-gap { flex: 1 1 auto; min-width: 0; }
/* the shell's tooltip: shown at once under the button the pointer is on */ /* the shell's tooltip: shown at once under the button the pointer is on */
#tip { #tip {
position: fixed; position: fixed;
@@ -374,55 +431,6 @@ body.menu-hidden #topbar {
} }
.tile-menu[hidden] { display: none; } .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.22s 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); }
/* 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;
@@ -431,80 +439,67 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
overflow: hidden; overflow: hidden;
} }
/* ── resizing: the whole rim of a tile, drawn nowhere ────────────────
Every edge and both bottom corners resize, so there is nothing to point
at — the grips are invisible and the cursor says what each one does, the
way a window's own edges do. Together they tile the tile's outer 8px
without a gap: the top strip runs corner to corner, the sides run between
it and the bottom corners, and the bottom strip fills between those. */
.tile-resize,
.tile-resize-left,
.tile-resize-bottom,
.tile-resize-top,
.tile-resize-right,
.tile-resize-side {
position: absolute;
z-index: 5;
touch-action: none;
}
/* the two bottom corners: both directions at once */
.tile-resize, .tile-resize,
.tile-resize-left { .tile-resize-left {
position: absolute;
bottom: 0; bottom: 0;
width: 20px; width: 20px;
height: 20px; height: 20px;
touch-action: none;
z-index: 5;
color: var(--muted);
opacity: 0; /* corner indicators only show while hovering the tile */
transition: opacity 0.15s ease;
} }
.tile-resize { right: 0; cursor: nwse-resize; } .tile-resize { right: 0; cursor: nwse-resize; }
.tile-resize-left { left: 0; cursor: nesw-resize; } .tile-resize-left { left: 0; cursor: nesw-resize; }
.tile:hover .tile-resize,
.tile:hover .tile-resize-left { opacity: 0.6; }
.tile .tile-resize:hover,
.tile .tile-resize-left:hover { opacity: 1; color: var(--text); }
.tile-resize::after,
.tile-resize-left::after {
content: "";
position: absolute;
bottom: 4px;
width: 9px;
height: 9px;
border-bottom: 2px solid currentColor;
}
.tile-resize::after {
right: 4px;
border-right: 2px solid currentColor;
border-radius: 0 0 3px 0;
}
.tile-resize-left::after {
left: 4px;
border-left: 2px solid currentColor;
border-radius: 0 0 0 3px;
}
/* bottom edge: drag to move the tile's bottom edge (height only). Drawn as /* top and bottom: height only */
the same 2px line as the corner handles it sits between, on the same .tile-resize-top,
4px inset, so the three read as one set of marks along the bottom. */
.tile-resize-bottom { .tile-resize-bottom {
position: absolute; height: 8px;
left: 24px;
right: 24px;
bottom: 0;
height: 10px;
z-index: 5;
display: flex;
align-items: flex-end;
justify-content: center;
cursor: ns-resize; cursor: ns-resize;
touch-action: none;
color: var(--muted);
opacity: 0;
transition: opacity 0.15s ease;
} }
.tile-resize-bottom::after { .tile-resize-top {
content: ""; top: 0;
width: 44px; left: 0;
max-width: 60%; right: 0;
height: 0; /* Above a revealed title bar: the bar slides in under the pointer here,
margin-bottom: 4px; and this strip has to stay the top edge rather than become part of it. */
border-bottom: 2px solid currentColor; z-index: 8;
} }
.tile:hover .tile-resize-bottom { opacity: 0.6; } .tile-resize-bottom { bottom: 0; left: 20px; right: 20px; }
.tile .tile-resize-bottom:hover,
.tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); } /* left and right: width only, between the top strip and the bottom corners */
.tile-resize-right,
.tile-resize-side {
top: 8px;
bottom: 20px;
width: 8px;
cursor: ew-resize;
}
.tile-resize-right { right: 0; }
.tile-resize-side { left: 0; }
/* ── seam divider between adjacent tiles ───────────────────────────── */ /* ── seam divider between adjacent tiles ───────────────────────────── */
/* When two tiles share a vertical edge, an invisible strip sits on the seam; /* When two tiles share a vertical edge, an invisible strip sits on the seam.
hovering reveals a small macOS-split-view-style grab pill. Dragging it Dragging it trades width between the two tiles rather than resizing one,
trades width between the two tiles. */ so it sits above their own edge grips. Like them it draws nothing at rest
— only while it is being dragged, to show which seam is moving. */
.tile-divider { .tile-divider {
position: absolute; position: absolute;
width: 14px; width: 14px;
@@ -528,22 +523,24 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
transition: opacity 0.15s ease, background 0.15s ease; transition: opacity 0.15s ease, background 0.15s ease;
} }
.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 — /* Drop the seams while a tile itself is being moved or resized — they're
they're rebuilt for the new geometry on release. */ rebuilt for the new geometry on release. */
#grid.no-dividers .tile-divider { display: none; } #grid.no-dividers .tile-divider { display: none; }
/* ── fullscreen mode ───────────────────────────────────────────────── */ /* ── fullscreen mode ───────────────────────────────────────────────── */
/* Fullscreen is a viewing mode: the layout auto-fits the screen, so the /* Fullscreen is a viewing mode: the layout auto-fits the screen, so the
editing affordances (corner handles, seam pills) are parked. */ editing affordances (the rim grips and the seams) are parked. */
body.fullscreen .tile-resize, body.fullscreen .tile-resize,
body.fullscreen .tile-resize-left, body.fullscreen .tile-resize-left,
body.fullscreen .tile-resize-bottom, body.fullscreen .tile-resize-bottom,
body.fullscreen .tile-resize-top,
body.fullscreen .tile-resize-right,
body.fullscreen .tile-resize-side,
body.fullscreen .tile-divider, body.fullscreen .tile-divider,
body.fullscreen #autohide-btn,
body.fullscreen #admin-link { display: none; } body.fullscreen #admin-link { display: none; }
body.fullscreen #grid { padding-bottom: 0; } body.fullscreen #grid { padding-bottom: 0; }
#gridwrap.no-scroll { overflow-y: hidden; } #gridwrap.no-scroll { overflow-y: hidden; }
@@ -944,8 +941,10 @@ body.fullscreen #grid { padding-bottom: 0; }
every shell element follows, and a colour a tile is configured with (set every shell element follows, and a colour a tile is configured with (set
on the tile itself) still wins. The :root block at the top of this file is on the tile itself) still wins. The :root block at the top of this file is
"booth", the default; the list of ids and names lives in server.js "booth", the default; the list of ids and names lives in server.js
(GET /api/theme). All four are booth-grade: high contrast, the accent (GET /api/theme). Every one is booth-grade: high contrast, the accent
nowhere near the danger red or the warning amber, muted still readable. */ nowhere near the danger red or the warning amber, muted still readable.
Each sets --accent-text too when the default (a near-black green, for
booth's mint) would look wrong against its own accent. */
/* Harbor — deep navy, cyan accent */ /* Harbor — deep navy, cyan accent */
html[data-theme="harbor"] { html[data-theme="harbor"] {
@@ -996,3 +995,100 @@ html[data-theme="daylight"] {
--danger: #d1242f; --danger: #d1242f;
--warn: #b26a00; --warn: #b26a00;
} }
/* Midnight — indigo night, violet accent */
html[data-theme="midnight"] {
--bg: #0a0b18;
--panel: #14162b;
--border: #272a4a;
--text: #e8e9fb;
--muted: #9a9cc8;
--accent: #9d8cff;
--accent-text: #12103a;
--danger: #ff5a72;
--warn: #f5b93a;
}
/* Moss — forest floor, lime accent (warn pushed orange so the two greens
and the amber never read as one another) */
html[data-theme="moss"] {
--bg: #08120c;
--panel: #101e15;
--border: #1f3728;
--text: #e9f4ea;
--muted: #8fb09a;
--accent: #8fdb3f;
--accent-text: #15250a;
--danger: #ff5a52;
--warn: #ff9f1c;
}
/* Plum — aubergine, purple accent */
html[data-theme="plum"] {
--bg: #110a17;
--panel: #1d1228;
--border: #332042;
--text: #f2e9f8;
--muted: #b195c2;
--accent: #c084fc;
--accent-text: #260a37;
--danger: #ff4d4f;
--warn: #f7b53b;
}
/* Sepia — warm brown room, cool teal accent: the opposite arrangement to
Ember, which is warm throughout */
html[data-theme="sepia"] {
--bg: #12100b;
--panel: #1d1913;
--border: #33291d;
--text: #f4ede0;
--muted: #b3a48b;
--accent: #5eead4;
--accent-text: #06231e;
--danger: #ff5a52;
--warn: #ffc247;
}
/* Blackout — true black for a dark room, where any glow off the screen
carries; everything else is as bright as it can be against it */
html[data-theme="blackout"] {
--bg: #000000;
--panel: #0a0a0a;
--border: #1f1f1f;
--text: #ffffff;
--muted: #9e9e9e;
--accent: #00e5ff;
--accent-text: #00212a;
--danger: #ff3b30;
--warn: #ffcc00;
}
/* Paper — the warm light one, next to Daylight's cool white. Danger and warn
darken, as they must on a light background, and the indigo accent takes
white text. */
html[data-theme="paper"] {
--bg: #f5f1e8;
--panel: #fffdf8;
--border: #ddd5c4;
--text: #1c1a15;
--muted: #6b6455;
--accent: #4f46e5;
--accent-text: #ffffff;
--danger: #c8102e;
--warn: #9a6700;
}
/* Iron — no colour of its own: the accent is near-white, so the only hues on
screen are a module's own and the red/amber that mean something */
html[data-theme="iron"] {
--bg: #0c0c0d;
--panel: #161617;
--border: #2b2b2d;
--text: #f2f2f3;
--muted: #9a9a9d;
--accent: #d8d8dc;
--accent-text: #131314;
--danger: #ff4d4f;
--warn: #ffb224;
}
+25 -3
View File
@@ -712,6 +712,13 @@ const THEMES = [
{ id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } }, { id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } },
{ id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } }, { id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } },
{ id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } }, { id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } },
{ id: 'midnight', name: 'Midnight', colors: { bg: '#0a0b18', panel: '#14162b', accent: '#9d8cff' } },
{ id: 'moss', name: 'Moss', colors: { bg: '#08120c', panel: '#101e15', accent: '#8fdb3f' } },
{ id: 'plum', name: 'Plum', colors: { bg: '#110a17', panel: '#1d1228', accent: '#c084fc' } },
{ id: 'sepia', name: 'Sepia', colors: { bg: '#12100b', panel: '#1d1913', accent: '#5eead4' } },
{ id: 'blackout', name: 'Blackout', colors: { bg: '#000000', panel: '#0a0a0a', accent: '#00e5ff' } },
{ id: 'paper', name: 'Paper', colors: { bg: '#f5f1e8', panel: '#fffdf8', accent: '#4f46e5' } },
{ id: 'iron', name: 'Iron', colors: { bg: '#0c0c0d', panel: '#161617', accent: '#d8d8dc' } },
]; ];
const DEFAULT_THEME = THEMES[0].id; const DEFAULT_THEME = THEMES[0].id;
@@ -767,7 +774,7 @@ function parseRepoSetting(value, fallbackBranch) {
} }
const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch) const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch)
|| parseRepoSetting('michaelmcgary2008/ProdDash', 'main'); || parseRepoSetting('michael/ProdDash', 'main');
/** /**
* Which server the code comes from. GitHub unless `forge` names another — * Which server the code comes from. GitHub unless `forge` names another —
@@ -1539,7 +1546,11 @@ function layoutPath(name) {
function sanitizeLayout(raw) { function sanitizeLayout(raw) {
const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : []; const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : [];
return { return {
tiles: tiles.map((t) => ({ // Whether tile title bars auto-hide is part of how a dashboard is
// arranged, so it travels with the layout like the tiles themselves.
autoHideHeads: Boolean(raw?.autoHideHeads),
tiles: tiles.map((t) => {
const tile = {
id: String(t?.id || ''), id: String(t?.id || ''),
module: String(t?.module || ''), module: String(t?.module || ''),
x: Number(t?.x) || 0, x: Number(t?.x) || 0,
@@ -1547,7 +1558,18 @@ function sanitizeLayout(raw) {
w: Number(t?.w) || 1, w: Number(t?.w) || 1,
h: Number(t?.h) || 1, h: Number(t?.h) || 1,
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {}, settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
})), // Which picker entry the tile came from, and what it was called.
// Without these a saved "Now" tile comes back as the full Now/Next
// card, and a solo timer comes back with a full tile's settings.
variant: String(t?.variant || ''),
title: String(t?.title || ''),
headPinned: Boolean(t?.headPinned),
};
if (Number(t?.minSize?.w) > 0 && Number(t?.minSize?.h) > 0) {
tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
}
return tile;
}),
}; };
} }