From aec9c47ed42be702f3a097fdc1ff5355ea215df7 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 27 Sep 2026 08:31:26 -0400 Subject: [PATCH] PCO Plan 1.8.0: the plan follows AbleSet, and auto-scroll stays in its tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, the first on its own. **Auto-scroll moves the tile, not the board.** `scrollIntoView` walks up and scrolls every ancestor that can scroll, so bringing the current item into view dragged the whole dashboard with it whenever the board was taller than the window. The list's own `scrollTop` is moved instead, measured from rectangles because the rows' offset parent is the tile rather than the list. **The current item fills as its song plays.** When an AbleSet module is installed, configured and playing a song that IS the item on screen, that item's background fills left to right with the song. The song is tied to a plan item by the same name matching the live ProPresenter document already uses, so a bar can never quietly fill the sermon while the band rehearses something else. **Its sections slide past, bottom right of that item.** Every section of the song as a strip, the played ones dimmed, the one playing filling in turn and kept centred — by moving that strip's own `scrollLeft`, for the same reason as above. The chips are rebuilt only when the song changes; moving to the next section just moves the classes, so the strip keeps its place. Both are switches in the tile's gear, **Song progress** and **Song sections**, and both draw nothing at all unless there is an AbleSet module to follow: the server reports `available: false` for no module, a disabled one, or one with no host, and re-checks every 15 s so installing AbleSet later lights it up without a restart. The server follows the AbleSet module over loopback exactly as it follows ProPresenter's, and republishes a small slice — song, sections, playhead in beats with a stamp — as its own `ableset` SSE event. Its own event because it moves five times a second and must never drag the plan (items, teams) onto the wire with it; the tile carries the playhead forward from the tempo between packets. Co-Authored-By: Claude Opus 5 --- README.md | 9 ++ modules/pco-plan/client.js | 143 +++++++++++++++++++++- modules/pco-plan/module.json | 14 ++- modules/pco-plan/server.js | 223 ++++++++++++++++++++++++++++++++++- modules/pco-plan/style.css | 68 +++++++++++ 5 files changed, 453 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 70b747b..85baf5e 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,15 @@ below. Nothing is installed without someone clicking. `{series} • {part} | {serviceType} • {date}`), colors, text size, and a show/hide checklist for every detail. + With an **AbleSet** module installed and configured, the Plan tile also + follows the band: while AbleSet plays a song that is the item on screen, + that item's background fills as the song goes, and its sections slide past + in a strip at the bottom right of the item, the one playing filling in + turn. Both are switches in the tile's gear (**Song progress**, **Song + sections**), and both are simply absent when there is no AbleSet module, it + is switched off, or it has no host — nothing to turn off and nothing to + explain. + **Teams** shows the same plan's team and position assignments — each team as a band of its people, with their photo (fetched through ProdDash, never by the browser; initials when Planning Center has none) and a dot for diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js index fb6e1a3..451bdda 100644 --- a/modules/pco-plan/client.js +++ b/modules/pco-plan/client.js @@ -34,6 +34,7 @@ function createPlan({ root, moduleApi }) { let ticker = null; let lastCurrentId = null; let lastListKey = ''; + let absView = null; // what the server says AbleSet is doing, if anything let userScrolledAt = 0; root.innerHTML = ` @@ -493,6 +494,133 @@ function createPlan({ root, moduleApi }) { renderLiveMarks(); } + /* ── AbleSet: the song playing under the current item ────────────── + Everything here draws only when the server says there is an AbleSet + module, it is configured and reachable, and the song it is playing is + the plan item on screen. Any of those missing and the tile looks + exactly as it did before AbleSet existed. */ + + /** The playhead now, in beats, carried forward from the last packet. */ + function absBeatsNow() { + if (!absView || absView.beats == null) return null; + if (!absView.playing || !absView.tempo) return absView.beats; + const since = Math.max(0, serverNow() - absView.at) / 1000; + return absView.beats + since * (absView.tempo / 60); + } + + function absFraction(part) { + const beats = absBeatsNow(); + if (beats == null || !part || part.start == null || part.end == null) return null; + const span = part.end - part.start; + if (!(span > 0)) return null; + return Math.max(0, Math.min(1, (beats - part.start) / span)); + } + + /** The row AbleSet's song belongs to — only ever the current one. */ + function absRow() { + const a = absView; + if (!a || !a.available || !a.reachable || !a.song || !a.itemId) return null; + if (a.itemId !== lastCurrentId) return null; + return listEl.querySelector(`.pp-row[data-id="${CSS.escape(a.itemId)}"]`) || null; + } + + /** Keep the playing section visible in the strip — this strip, nothing else. */ + function absScrollSections(strip) { + const active = strip.querySelector('.pp-abs-sec.is-now'); + if (!active) return; + const box = strip.getBoundingClientRect(); + const rect = active.getBoundingClientRect(); + const delta = (rect.left - box.left) - (strip.clientWidth - rect.width) / 2; + if (Math.abs(delta) < 1) return; + strip.scrollTo({ left: strip.scrollLeft + delta, behavior: 'smooth' }); + } + + function renderAbleSet() { + const row = absRow(); + + // Anything left on a row that is no longer the one goes. + for (const stale of listEl.querySelectorAll('.pp-fill, .pp-abs-sections')) { + if (!row || !row.contains(stale)) stale.remove(); + } + if (!row) return; + + if (on('absProgress')) { + const fraction = absFraction(absView.song); + if (fraction == null) { + row.querySelector('.pp-fill')?.remove(); + } else { + let fill = row.querySelector('.pp-fill'); + if (!fill) { + fill = document.createElement('div'); + fill.className = 'pp-fill'; + row.prepend(fill); // behind the row's content, which the stylesheet lifts + } + fill.style.width = `${(fraction * 100).toFixed(2)}%`; + } + } else { + row.querySelector('.pp-fill')?.remove(); + } + + if (on('absSections') && absView.sections.length) { + const main = row.querySelector('.pp-col-main'); + let strip = row.querySelector('.pp-abs-sections'); + if (!strip && main) { + strip = document.createElement('div'); + strip.className = 'pp-abs-sections'; + main.appendChild(strip); + } + if (strip) { + // The chips are rebuilt only when the song's sections change. Moving + // on to the next section just moves the classes: rebuilding would + // throw the strip's scroll away thirteen times a song. + const key = absView.sections.map((s) => s.name).join('\u0000'); + if (strip.dataset.key !== key) { + strip.dataset.key = key; + strip.dataset.now = ''; + strip.innerHTML = ''; + for (const section of absView.sections) { + const chip = document.createElement('span'); + chip.className = 'pp-abs-sec'; + chip.textContent = section.name; + strip.appendChild(chip); + } + } + const now = absView.section?.index ?? -1; + if (strip.dataset.now !== String(now)) { + strip.dataset.now = String(now); + [...strip.children].forEach((chip, i) => { + chip.classList.toggle('is-now', i === now); + chip.classList.toggle('is-done', i < now); + if (i !== now) chip.style.removeProperty('--pp-sec-fill'); + }); + requestAnimationFrame(() => absScrollSections(strip)); + } + const active = strip.children[now]; + if (active) { + const fraction = absFraction(absView.section); + active.style.setProperty('--pp-sec-fill', fraction == null ? '0%' : `${(fraction * 100).toFixed(1)}%`); + } + } + } else { + row.querySelector('.pp-abs-sections')?.remove(); + } + } + + /** + * Centre a row in this tile's own list. `scrollIntoView` would walk up and + * scroll every ancestor that can scroll with it, which on a dashboard taller + * than the window drags the whole board; moving `listEl.scrollTop` moves + * this tile and nothing else. Measured from rectangles rather than + * `offsetTop` because the rows' offset parent is the tile, not the list. + */ + function keepInView(row) { + const list = listEl.getBoundingClientRect(); + const rect = row.getBoundingClientRect(); + const delta = (rect.top - list.top) - (listEl.clientHeight - rect.height) / 2; + if (Math.abs(delta) < 1) return; + listEl.scrollTo({ top: listEl.scrollTop + delta, behavior: 'smooth' }); + } + /** Current / done classes and the elapsed timers, without rebuilding rows. */ function renderLiveMarks() { const live = state?.live || {}; @@ -531,11 +659,12 @@ function createPlan({ root, moduleApi }) { } if (!h.endedAt) elapsedEl.classList.add('is-running'); } + renderAbleSet(); if (currentId !== lastCurrentId) { lastCurrentId = currentId; if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) { const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`); - row?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + if (row) keepInView(row); } } } @@ -610,6 +739,7 @@ function createPlan({ root, moduleApi }) { } function tick() { + renderAbleSet(); if (!state?.plan) return; renderClock(); if (state.live?.following && on('showItemRuntime')) renderLiveMarks(); @@ -621,6 +751,7 @@ function createPlan({ root, moduleApi }) { if (!payload || typeof payload !== 'object' || !payload.state) return; state = payload.state; if (Number.isFinite(payload.now)) skew = payload.now - Date.now(); + if (payload.ableset) absView = payload.ableset; feedOffline = false; render(); } @@ -641,6 +772,16 @@ function createPlan({ root, moduleApi }) { accept(JSON.parse(ev.data)); } catch { /* a bad frame is not fatal */ } }, + // Its own event, several a second while a song plays: redraw that one + // row rather than the whole list. + ableset(ev) { + try { + absView = JSON.parse(ev.data); + } catch { + return; + } + if (state) renderAbleSet(); + }, }, }); } diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json index 6c0d9f6..efd7ecd 100644 --- a/modules/pco-plan/module.json +++ b/modules/pco-plan/module.json @@ -1,7 +1,7 @@ { "id": "pco-plan", "name": "PCO Plan", - "version": "1.7.0", + "version": "1.8.0", "proddash": ">=1.4.0", "description": "Order of service and team assignments from Planning Center Services", "provides": [ @@ -382,6 +382,18 @@ "label": "Match line", "default": true, "group": "Show" + }, + "absProgress": { + "type": "boolean", + "label": "Song progress", + "default": true, + "group": "Show" + }, + "absSections": { + "type": "boolean", + "label": "Song sections", + "default": true, + "group": "Show" } }, "tiles": [ diff --git a/modules/pco-plan/server.js b/modules/pco-plan/server.js index d1136bd..89ef471 100644 --- a/modules/pco-plan/server.js +++ b/modules/pco-plan/server.js @@ -9,6 +9,10 @@ * * GET /state → { state, now } (instant paint for new tiles) * GET /stream → SSE, `state` events (change-detected, heartbeated) + * plus `ableset` events: the song AbleSet is playing, its + * sections and playhead, when there is an AbleSet module + * to follow (see below). Its own event so the playhead + * never drags the plan onto the wire with it. * POST /refresh → re-read the plan now * GET /service-types → { options } for the admin "Service type" select, * grouped by Planning Center folder path @@ -33,6 +37,13 @@ * GET /service_types/{st}/item_note_categories (note category names) * GET …/plans/{id}/team_members?include=team,person (who is scheduled) * + * Following AbleSet: the same loopback trick, entirely optional. When this + * ProdDash has an AbleSet module that is configured and answering, its song, + * sections and playhead are passed to the tiles, and the song is matched to a + * plan item by name so a tile can fill that item as the song plays. With no + * such module — not installed, disabled, no host — tiles are told + * `available: false` and draw none of it. + * * Following ProPresenter: instead of talking to ProPresenter a second time, * this module reads the propresenter-now-next module's own SSE feed over * loopback (/api/modules/propresenter-now-next/stream, on shell.port) and @@ -59,7 +70,7 @@ const http = require('http'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching'); +const { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching'); const { MINUTE, serviceTimes, @@ -85,6 +96,12 @@ const HEARTBEAT_MS = 15000; /** The Now/Next module whose feed we follow. */ const NOW_NEXT_ID = 'propresenter-now-next'; const NOW_NEXT_RETRY_MS = 3000; +/** The AbleSet module, followed the same way — entirely optional (see below). */ +const ABLESET_ID = 'ableset'; +const ABLESET_RETRY_MS = 3000; +const ABLESET_MISSING_RETRY_MS = 15000; +/** Fastest the playhead is passed on; AbleSet offers more than a tile can draw. */ +const ABLESET_POS_MS = 200; const NOW_NEXT_MISSING_RETRY_MS = 15000; /** How many distinct documents seen live are remembered for the admin selects. */ const SEEN_DOCUMENTS_MAX = 200; @@ -153,6 +170,28 @@ function clearedLive() { }; } +/** + * What tiles are told about AbleSet. `available` false covers every way there + * may be nothing to draw — no module installed, disabled, or not answering — + * and the tiles then show none of it at all. + */ +function clearedAbleSet() { + return { + available: false, + configured: false, + reachable: false, + reason: '', + playing: false, + tempo: null, + song: null, + section: null, + sections: [], + beats: null, + itemId: '', + at: 0, + }; +} + /** The service that owns the clock, as tiles and the timers see it. */ function clearedService() { return { @@ -1258,6 +1297,170 @@ module.exports = { const badPorts = new Set(); /** Fallback only: a browser reached us on this Host — normally the shell's real port. */ + + /* — AbleSet, over loopback like the ProPresenter feed above — */ + + /** + * The AbleSet module, if this ProdDash has one. Nothing here is required: + * a dashboard with no AbleSet module, a disabled one, or one with no host + * configured simply reports `available: false` and the tiles draw none of + * it. Installing AbleSet later lights it up without a restart, because a + * 404 is retried on the slow timer like any other missing feed. + * + * What tiles get is deliberately small — the song, its sections, and the + * playhead in beats with a stamp — because it moves five times a second + * and must never drag the plan (items, teams) onto the wire with it. It + * rides its own `ableset` event on the module's one stream. + */ + let absRequest = null; + let absRetryTimer = null; + let absPosTimer = null; + let absLastSent = 0; + let absLastStill = ''; + let abs = clearedAbleSet(); + + function absPart(p) { + if (!p || typeof p !== 'object') return null; + const name = String(p.name || '').trim(); + if (!name) return null; + const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : null); + return { name, index: n(p.index), start: n(p.start), end: n(p.end) }; + } + + /** + * Which plan item AbleSet's song is, by the same name matching the live + * ProPresenter document uses. Without this a progress bar would happily + * fill the sermon while the band rehearses the next song: the tile only + * draws progress when the song AbleSet is playing IS the item on screen. + */ + function absItemFor(songName) { + if (!songName) return ''; + let best = null; + for (const item of state.items) { + if (item.itemType === 'header') continue; + const score = Math.max( + similarity(songName, item.title), + item.song ? similarity(songName, item.song.title) : 0 + ); + if (score >= MATCH_THRESHOLD && (!best || score > best.score)) best = { id: item.id, score }; + } + return best ? best.id : ''; + } + + function applyAbleSet(view) { + const song = absPart(view?.song); + abs = { + available: true, + configured: Boolean(view?.configured), + reachable: Boolean(view?.reachable), + playing: Boolean(view?.playing), + tempo: Number.isFinite(Number(view?.tempo)) ? Number(view.tempo) : null, + song, + section: absPart(view?.section), + sections: Array.isArray(view?.sections) + ? view.sections.slice(0, 60).map((s) => ({ name: String(s?.name || ''), color: s?.color || null })) + : [], + beats: Number.isFinite(Number(view?.pos?.beats)) ? Number(view.pos.beats) : null, + itemId: song ? absItemFor(song.name) : '', + at: Date.now(), + }; + broadcastAbleSet(); + } + + /** Only the playhead moved: keep everything else and restamp. */ + function applyAbleSetPos(pos) { + if (!abs.available) return; + abs = { + ...abs, + beats: Number.isFinite(Number(pos?.beats)) ? Number(pos.beats) : abs.beats, + at: Date.now(), + }; + broadcastAbleSet(); + } + + /** + * Fan the slice out. A change in anything but the playhead goes at once; + * the playhead itself is throttled, since AbleSet offers more packets + * than any tile can draw. + */ + function broadcastAbleSet(force) { + const { beats, at, ...still } = abs; + const frame = JSON.stringify(still); + const changed = frame !== absLastStill; + absLastStill = frame; + const now = Date.now(); + if (!force && !changed && now - absLastSent < ABLESET_POS_MS) { + // Due soon anyway — make sure the last packet of a burst still lands. + if (!absPosTimer) { + absPosTimer = setTimeout(() => { absPosTimer = null; broadcastAbleSet(true); }, ABLESET_POS_MS); + absPosTimer.unref?.(); + } + return; + } + clearTimeout(absPosTimer); + absPosTimer = null; + absLastSent = now; + if (!streams.size) return; + writeAll(streams, `event: ableset\ndata: ${JSON.stringify(abs)}\n\n`); + } + + function absUnavailable(reason, retryMs) { + if (abs.available || abs.reason !== reason) { + abs = { ...clearedAbleSet(), reason }; + broadcastAbleSet(true); + } + if (stopped) return; + clearTimeout(absRetryTimer); + absRetryTimer = setTimeout(connectAbleSet, retryMs); + absRetryTimer.unref?.(); + } + + function connectAbleSet() { + if (stopped) return; + const port = nnPort; + const req = http.get( + { host: '127.0.0.1', port, path: `/api/modules/${ABLESET_ID}/stream`, headers: { Accept: 'text/event-stream' } }, + (res) => { + if (res.statusCode !== 200) { + res.resume(); + return absUnavailable( + res.statusCode === 404 ? 'no AbleSet module here' : `AbleSet module feed answered ${res.statusCode}`, + ABLESET_MISSING_RETRY_MS + ); + } + res.setEncoding('utf8'); + let buffer = ''; + res.on('data', (chunk) => { + buffer += chunk; + let idx; + while ((idx = buffer.indexOf('\n\n')) >= 0) { + const block = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + let event = 'message'; + let data = ''; + for (const line of block.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) data += line.slice(5).trim(); + } + if (!data) continue; + try { + if (event === 'state') applyAbleSet(JSON.parse(data).state ?? JSON.parse(data)); + else if (event === 'pos') applyAbleSetPos(JSON.parse(data)); + } catch { /* a bad frame is not fatal */ } + } + }); + res.on('end', () => absUnavailable('AbleSet module feed ended', ABLESET_RETRY_MS)); + res.on('error', () => absUnavailable('AbleSet module feed dropped', ABLESET_RETRY_MS)); + } + ); + req.on('error', (err) => { + if (req !== absRequest) return; // superseded by a reconnect on the learned port + absUnavailable(`ProdDash loopback failed: ${err.message}`, ABLESET_RETRY_MS); + }); + req.setTimeout(45000, () => req.destroy(new Error('feed went silent'))); + absRequest = req; + } + function learnPort(hostHeader) { if (shellPort) return; // the shell told us; nothing to learn const port = portFromHost(hostHeader); @@ -1269,6 +1472,10 @@ module.exports = { try { nnRequest?.destroy(); } catch { /* gone */ } nnRequest = null; connectNowNext(); + clearTimeout(absRetryTimer); + try { absRequest?.destroy(); } catch { /* gone */ } + absRequest = null; + connectAbleSet(); } /** @@ -1527,6 +1734,9 @@ module.exports = { state.live.reason = 'Connecting to the ProPresenter module feed'; connectNowNext(); } + // Always: whether there is an AbleSet module to follow is its answer, not + // an admin setting here. + connectAbleSet(); mirrorService(); const heartbeat = setInterval(() => { @@ -1559,6 +1769,7 @@ module.exports = { timerStreams, pco, getState: () => state, + getAbleSet: () => abs, learnPort, refresh() { clearTimeout(pollTimer); @@ -1580,7 +1791,10 @@ module.exports = { clearInterval(ticker); clearTimeout(pollTimer); clearTimeout(nnRetryTimer); + clearTimeout(absRetryTimer); + clearTimeout(absPosTimer); try { nnRequest?.destroy(); } catch { /* gone */ } + try { absRequest?.destroy(); } catch { /* gone */ } for (const set of [streams, timerStreams]) { for (const res of set) { try { res.end(); } catch { /* already gone */ } @@ -1622,7 +1836,11 @@ module.exports = { return { 'GET /state': (req, res) => { current?.learnPort(req.headers.host); - json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() }); + json(res, 200, { + state: current ? current.getState() : clearedState(), + ableset: current ? current.getAbleSet() : clearedAbleSet(), + now: Date.now(), + }); }, 'GET /stream': (req, res) => { @@ -1630,6 +1848,7 @@ module.exports = { sseHead(res); const state = current ? current.getState() : clearedState(); res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`); + res.write(`event: ableset\ndata: ${JSON.stringify(current ? current.getAbleSet() : clearedAbleSet())}\n\n`); if (!current) return void res.end(); const { streams } = current; try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } diff --git a/modules/pco-plan/style.css b/modules/pco-plan/style.css index 3b8561f..284f37c 100644 --- a/modules/pco-plan/style.css +++ b/modules/pco-plan/style.css @@ -86,6 +86,7 @@ } [data-module="pco-plan"] .pp-row { + position: relative; display: flex; align-items: flex-start; gap: 0.6em; @@ -589,3 +590,70 @@ [data-module="pco-plan"] .tm-wrap.is-offline .tm-head { border-bottom-color: color-mix(in srgb, var(--danger) 50%, transparent); } [data-module="pco-plan"] .tm-wrap.is-offline .tm-body { opacity: 0.6; } + + +/* ── AbleSet under the current item ─────────────────────────────────── + Drawn only while an AbleSet module is configured, reachable, and playing + the song this item is. The fill is a layer behind the row's own content; + everything else in the row is lifted over it. */ + +[data-module="pco-plan"] .pp-fill { + position: absolute; + top: 0; + bottom: 0; + left: 0; + z-index: 0; + width: 0; + border-radius: 0.5em 0 0 0.5em; + background: color-mix(in srgb, var(--pp-current) 26%, transparent); + /* Packets arrive about five times a second; the glide covers the gaps. */ + transition: width 0.22s linear; + pointer-events: none; +} +[data-module="pco-plan"] .pp-row > *:not(.pp-fill) { position: relative; z-index: 1; } + +/* The sections strip, bottom right of the current item. */ +[data-module="pco-plan"] .pp-abs-sections { + display: flex; + gap: 0.3em; + /* Bottom right of the item: hug the content and sit against the right edge + until there are more sections than fit, when it fills the width and + scrolls instead. */ + width: fit-content; + max-width: 100%; + margin-top: 0.35em; + margin-left: auto; + padding-bottom: 0.1em; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + overscroll-behavior: contain; + -webkit-mask-image: linear-gradient(90deg, transparent, #000 1.2em, #000 calc(100% - 1.2em), transparent); + mask-image: linear-gradient(90deg, transparent, #000 1.2em, #000 calc(100% - 1.2em), transparent); +} +[data-module="pco-plan"] .pp-abs-sections::-webkit-scrollbar { display: none; } + +[data-module="pco-plan"] .pp-abs-sec { + flex: none; + padding: 0.1em 0.5em; + border-radius: 999px; + border: 1px solid var(--border); + font-size: 0.72em; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; + color: var(--muted); + transition: color 0.2s ease, border-color 0.2s ease; +} +[data-module="pco-plan"] .pp-abs-sec.is-done { opacity: 0.45; } + +/* The section now playing fills left to right as it goes, like the row. */ +[data-module="pco-plan"] .pp-abs-sec.is-now { + color: var(--text); + border-color: color-mix(in srgb, var(--pp-current) 60%, transparent); + background: + linear-gradient(90deg, + color-mix(in srgb, var(--pp-current) 40%, transparent) var(--pp-sec-fill, 0%), + transparent var(--pp-sec-fill, 0%)); +}