From 4e5b60e82b2a1f6e8e30e101e6f4183d505e024d Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 27 Sep 2026 11:18:25 -0400 Subject: [PATCH 1/4] PCO Plan 1.9.0: a Song Key tile, and AbleSet-shaped section chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Song Key**, a third entry in the + picker: the key of the item ProPresenter has live, as large as the tile allows. It is set as a note — B♭, F♯m rather than Bb and F#m — with the song's name and the next keyed song under it. All three are tile settings: the colour, the name and the next key. There is no new connection; it reads the same streamed state the Plan tile does. The accidental and the quality sit on the same line as the letter. Raising the accidental read as two stacked characters at tile sizes, which is worse than plain at a glance from the back of a room. When the ProPresenter feed that picks the item goes away the key stays up — the band still needs it — but faded, and the tile's dot says it is the last item rather than the current one. **The section chips** on the Plan tile's current item are rounded squares now, with the tinted ground and transparent border the AbleSet module gives its own section pills, so the two read as one family. The chip playing keeps its own ground so its progress fills against the chip rather than against the row behind it. Co-Authored-By: Claude Opus 5 --- README.md | 11 +- modules/pco-plan/client.js | 10 +- modules/pco-plan/key-client.js | 186 +++++++++++++++++++++++++++++++++ modules/pco-plan/module.json | 34 +++++- modules/pco-plan/style.css | 92 ++++++++++++++-- 5 files changed, 319 insertions(+), 14 deletions(-) create mode 100644 modules/pco-plan/key-client.js diff --git a/README.md b/README.md index f425f4b..2d9f047 100644 --- a/README.md +++ b/README.md @@ -325,8 +325,9 @@ below. Nothing is installed without someone clicking. session (Start ProdDash.command) and approve the one-time prompt; a server started over SSH or by bare launchd silently captures zeros. (Engine self-test: `node modules/propresenter-timers/ltc.selftest.js`.) -- **PCO Plan** — Planning Center Services, as two tiles in the + picker: - **Plan** (the order of service) and **Teams** (who is scheduled). +- **PCO Plan** — Planning Center Services, as three tiles in the + picker: + **Plan** (the order of service), **Teams** (who is scheduled) and **Song + Key** (the key of the item on screen). **Plan** shows every plan item with its scheduled start time, length, type, song key, @@ -357,6 +358,12 @@ below. Nothing is installed without someone clicking. is switched off, or it has no host — nothing to turn off and nothing to explain. + **Song Key** is one value, as large as the tile allows: the key of the item + ProPresenter has live, set as a note (B♭, F♯m), with the song's name and the + next key under it — both switches, as is the colour. When the feed that + picks the item is gone the key stays up, faded, and the tile's dot says so + rather than pretending to be current. + **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 451bdda..ca2f29d 100644 --- a/modules/pco-plan/client.js +++ b/modules/pco-plan/client.js @@ -1,8 +1,8 @@ /* PCO Plan — client part. - The module presents two tiles and this is the entry for both: the Teams - tile (picker entry "teams") is built by teams-client.js, everything below - draws the Plan tile. + The module presents three tiles and this is the entry for all of them: the + Teams tile (picker entry "teams") is built by teams-client.js and the Song + Key tile ("key") by key-client.js; everything below draws the Plan tile. The server part reads Planning Center and (optionally) follows the ProPresenter module's live item; this tile only renders the plan it is @@ -13,6 +13,7 @@ the connection settings. */ import createTeams from "./teams-client.js"; +import createKey from "./key-client.js"; const REFRESH_SVG = ''; @@ -21,8 +22,9 @@ const MINUS_SVG = ' +
+
+
+ `; + + const wrap = root.querySelector('.pk-wrap'); + const keyEl = root.querySelector('.pk-key'); + const nameEl = root.querySelector('.pk-name'); + const nextEl = root.querySelector('.pk-next'); + + const cfg = () => moduleApi.instanceSettings || {}; + const on = (key) => cfg()[key] !== false; // unset = shown + + /* ── the item on screen, and the one after it ───────────────────── */ + + function items() { + return Array.isArray(state?.items) ? state.items : []; + } + + function currentItem() { + const id = state?.live?.following ? state.live.currentItemId : ''; + return id ? items().find((it) => it.id === id) || null : null; + } + + /** The next item that actually has a key — a talk between two songs is skipped. */ + function nextKeyed(after) { + const list = items(); + const from = after ? list.findIndex((it) => it.id === after.id) + 1 : 0; + if (from <= 0) return null; + for (let i = from; i < list.length; i += 1) { + if (list[i].keyName) return list[i]; + } + return null; + } + + function titleOf(item) { + return item ? (item.song?.title || item.title || '') : ''; + } + + /* ── render ─────────────────────────────────────────────────────── */ + + function renderStatus() { + if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…'); + if (!state) return moduleApi.setStatus('connecting', 'Connecting…'); + if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin'); + if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin'); + if (!state.reachable) { + return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…'); + } + const item = currentItem(); + if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current'); + // The key on screen stays — the band still needs it — but a tile must + // never look confidently live while the feed that chose it is gone. + if (!state.live.available) { + return moduleApi.setStatus('error', `${state.live.reason || 'ProPresenter feed unavailable'} — showing the last item`); + } + if (!state.live.ppReachable) return moduleApi.setStatus('error', 'ProPresenter unreachable — showing the last item'); + if (!item) return moduleApi.setStatus('ok', 'Waiting for an item to go live'); + moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${item.keyName}` : ' — no key in the plan'}`); + } + + /** "Bb" → "B♭", "F#m" → "F♯m" — for the plain-text line under the key. */ + function prettyKey(text) { + const match = /^([A-G])([#b])(.*)$/.exec(String(text || '').trim()); + return match ? `${match[1]}${match[2] === '#' ? '♯' : '♭'}${match[3]}` : String(text || ''); + } + + /** + * A key is a note, so it is worth setting properly: "Bb" and "F#m" read + * better with the accidental raised and the minor kept small. + */ + function drawKey(text) { + keyEl.innerHTML = ''; + const match = /^([A-G])([#b♯♭]?)(.*)$/.exec(String(text).trim()); + if (!match) { + keyEl.textContent = text; + return; + } + const [, note, accidental, rest] = match; + keyEl.appendChild(document.createTextNode(note)); + if (accidental) { + const sup = document.createElement('span'); + sup.className = 'pk-accidental'; + sup.textContent = accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental; + keyEl.appendChild(sup); + } + if (rest) { + const tail = document.createElement('span'); + tail.className = 'pk-quality'; + tail.textContent = rest; + keyEl.appendChild(tail); + } + } + + function render() { + wrap.classList.toggle('is-offline', feedOffline); + const live = state?.live; + wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable))); + const color = String(cfg().keyColor || '').trim(); + wrap.style.setProperty('--pk-color', /^[#a-zA-Z0-9(),.%\s-]+$/.test(color) && color ? color : '#4ea1ff'); + renderStatus(); + + const item = currentItem(); + const key = item?.keyName || ''; + wrap.classList.toggle('has-key', Boolean(key)); + + if (!state || !state.configured || !state.serviceTypeSet) { + drawKey('—'); + nameEl.textContent = state ? 'Set up PCO Plan in Admin' : 'Connecting…'; + nextEl.textContent = ''; + return; + } + if (!item) { + drawKey('—'); + nameEl.textContent = state.live?.following ? 'Nothing live' : 'Not following ProPresenter'; + nextEl.textContent = ''; + return; + } + + drawKey(key || '—'); + nameEl.textContent = on('showSongName') ? titleOf(item) : ''; + nameEl.hidden = !nameEl.textContent; + + const next = on('showNextKey') ? nextKeyed(item) : null; + nextEl.textContent = next ? `Next: ${prettyKey(next.keyName)} · ${titleOf(next)}` : ''; + nextEl.hidden = !nextEl.textContent; + } + + /* ── live feed ──────────────────────────────────────────────────── */ + + function accept(payload) { + if (!payload || typeof payload !== 'object' || !payload.state) return; + state = payload.state; + feedOffline = false; + render(); + } + + function connect() { + stream = moduleApi.sse('/stream', { + open() { feedOffline = false; render(); }, + error() { feedOffline = true; render(); }, + events: { + state(ev) { + try { + accept(JSON.parse(ev.data)); + } catch { /* a bad frame is not fatal */ } + }, + }, + }); + } + + /* ── lifecycle ──────────────────────────────────────────────────── */ + + return { + start() { + moduleApi.setStatus('connecting', 'Connecting…'); + moduleApi.fetch('/state') + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { if (body && !state) accept(body); }) + .catch(() => { /* the stream will cover it */ }); + connect(); + render(); + }, + stop() { + stream?.close(); + stream = null; + root.innerHTML = ''; + }, + onConfigChange() { + // Admin holds the connection only; the server re-inits and re-sends. + }, + }; +} diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json index efd7ecd..1b17e5b 100644 --- a/modules/pco-plan/module.json +++ b/modules/pco-plan/module.json @@ -1,9 +1,9 @@ { "id": "pco-plan", "name": "PCO Plan", - "version": "1.8.0", + "version": "1.9.0", "proddash": ">=1.4.0", - "description": "Order of service and team assignments from Planning Center Services", + "description": "Order of service, team assignments and the song key from Planning Center Services", "provides": [ "timers" ], @@ -621,6 +621,36 @@ "columns": 2 } } + }, + { + "id": "key", + "name": "Song Key", + "description": "The key of the item on screen, big", + "defaultSize": { + "w": 2, + "h": 2 + }, + "minSize": { + "w": 1, + "h": 1 + }, + "instanceSchema": { + "keyColor": { + "type": "color", + "label": "Key", + "default": "#4ea1ff" + }, + "showSongName": { + "type": "boolean", + "label": "Song name", + "default": true + }, + "showNextKey": { + "type": "boolean", + "label": "Next key", + "default": true + } + } } ] } diff --git a/modules/pco-plan/style.css b/modules/pco-plan/style.css index 284f37c..952d4fd 100644 --- a/modules/pco-plan/style.css +++ b/modules/pco-plan/style.css @@ -635,25 +635,105 @@ [data-module="pco-plan"] .pp-abs-sec { flex: none; - padding: 0.1em 0.5em; - border-radius: 999px; - border: 1px solid var(--border); + padding: 0.14em 0.55em; + /* The same rounded square, tinted ground and transparent border the AbleSet + module gives its own section pills, so the two read as one family. */ + border-radius: 0.35em; + border: 1px solid transparent; + background: color-mix(in srgb, var(--text) 8%, var(--panel)); 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; + transition: color 0.2s ease, border-color 0.2s ease, background 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. */ +/* The section playing keeps its own ground and fills across it, so the + progress reads against the chip rather than against the row behind it. */ [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%)); + color-mix(in srgb, var(--pp-current) 45%, var(--panel)) var(--pp-sec-fill, 0%), + color-mix(in srgb, var(--text) 8%, var(--panel)) var(--pp-sec-fill, 0%)); } + +/* ── the Song Key tile ──────────────────────────────────────────────── + One value, as large as the tile allows. Sizes come from the tile itself + (container units), like the AbleSet module's single readouts, so a small + tile and a wall-sized one both read properly. */ + +[data-module="pco-plan"] .pk-wrap { + --pk-color: #4ea1ff; + position: absolute; + inset: 0; + container-type: size; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.2em; + padding: 4%; + text-align: center; + user-select: none; +} + +[data-module="pco-plan"] .pk-key { + font-size: clamp(18px, 46cqmin, 260px); + font-weight: 800; + line-height: 1; + letter-spacing: -0.01em; + color: var(--muted); + white-space: nowrap; +} +[data-module="pco-plan"] .pk-wrap.has-key .pk-key { color: var(--pk-color); } + +/* A key is a note, but it has to read as one word at a glance from the back + of a room: the accidental and the quality sit on the same line as the + letter, only smaller. Raising the accidental looked like two stacked + characters at tile sizes. */ +[data-module="pco-plan"] .pk-accidental { + font-size: 0.68em; + margin-left: 0.01em; +} +[data-module="pco-plan"] .pk-quality { + font-size: 0.5em; + font-weight: 700; + margin-left: 0.05em; + opacity: 0.85; +} + +[data-module="pco-plan"] .pk-name { + max-width: 100%; + font-size: clamp(9px, 9cqmin, 30px); + font-weight: 600; + line-height: 1.2; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="pco-plan"] .pk-name[hidden] { display: none; } + +[data-module="pco-plan"] .pk-next { + max-width: 100%; + font-size: clamp(8px, 6.5cqmin, 20px); + font-weight: 600; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="pco-plan"] .pk-next[hidden] { display: none; } + +[data-module="pco-plan"] .pk-wrap.is-offline { opacity: 0.6; } +/* The feed that picks the item is gone: the key stays readable, but faded, + so nobody trusts it as current without looking at the status dot. */ +[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; } +[data-module="pco-plan"] .pk-wrap.is-stale .pk-name, +[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; } -- 2.54.0 From 06066529bcde6c5aab54b605cc1b7f4d35a7b9da Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 27 Sep 2026 11:27:31 -0400 Subject: [PATCH 2/4] Keys read as notes on the Plan tile's badge too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The item badge still showed Planning Center's raw "Bb" and "F#m" while the Song Key tile beside it showed B♭ and F♯m. One module should not spell the same thing two ways. The notation moves into key-notation.js and both tiles import it, rather than each keeping its own copy of the parse: the day one of them changes, they all should. Co-Authored-By: Claude Opus 5 --- README.md | 12 ++++++++---- modules/pco-plan/client.js | 4 +++- modules/pco-plan/key-client.js | 31 +++++++++++++------------------ modules/pco-plan/key-notation.js | 25 +++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 23 deletions(-) create mode 100644 modules/pco-plan/key-notation.js diff --git a/README.md b/README.md index 2d9f047..0efedf6 100644 --- a/README.md +++ b/README.md @@ -359,10 +359,14 @@ below. Nothing is installed without someone clicking. explain. **Song Key** is one value, as large as the tile allows: the key of the item - ProPresenter has live, set as a note (B♭, F♯m), with the song's name and the - next key under it — both switches, as is the colour. When the feed that - picks the item is gone the key stays up, faded, and the tile's dot says so - rather than pretending to be current. + ProPresenter has live, with the song's name and the next key under it — both + switches, as is the colour. When the feed that picks the item is gone the key + stays up, faded, and the tile's dot says so rather than pretending to be + current. + + Keys are written as notes wherever the module shows one: Planning Center's + `Bb` and `F#m` read as B♭ and F♯m, on the Plan tile's item badge and in the + Song Key tile alike. **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 diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js index ca2f29d..685bc75 100644 --- a/modules/pco-plan/client.js +++ b/modules/pco-plan/client.js @@ -14,6 +14,7 @@ import createTeams from "./teams-client.js"; import createKey from "./key-client.js"; +import { prettyKey } from "./key-notation.js"; const REFRESH_SVG = ''; @@ -369,7 +370,8 @@ function createPlan({ root, moduleApi }) { if (on('showKey') && item.keyName) { const key = document.createElement('span'); key.className = 'pp-key'; - key.textContent = item.keyName; + // B♭, F♯m — the same notation the Song Key tile uses. + key.textContent = prettyKey(item.keyName); titleLine.appendChild(key); } if (on('showNowIndicator')) { diff --git a/modules/pco-plan/key-client.js b/modules/pco-plan/key-client.js index eda8e5b..1297227 100644 --- a/modules/pco-plan/key-client.js +++ b/modules/pco-plan/key-client.js @@ -4,6 +4,8 @@ streamed state the Plan tile does, so it needs no connection of its own; which item is current comes from the ProPresenter module, as ever. */ +import { parseKey, prettyKey } from "./key-notation.js"; + export default function createKey({ root, moduleApi }) { let state = null; let stream = null; @@ -69,13 +71,7 @@ export default function createKey({ root, moduleApi }) { } if (!state.live.ppReachable) return moduleApi.setStatus('error', 'ProPresenter unreachable — showing the last item'); if (!item) return moduleApi.setStatus('ok', 'Waiting for an item to go live'); - moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${item.keyName}` : ' — no key in the plan'}`); - } - - /** "Bb" → "B♭", "F#m" → "F♯m" — for the plain-text line under the key. */ - function prettyKey(text) { - const match = /^([A-G])([#b])(.*)$/.exec(String(text || '').trim()); - return match ? `${match[1]}${match[2] === '#' ? '♯' : '♭'}${match[3]}` : String(text || ''); + moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${prettyKey(item.keyName)}` : ' — no key in the plan'}`); } /** @@ -84,23 +80,22 @@ export default function createKey({ root, moduleApi }) { */ function drawKey(text) { keyEl.innerHTML = ''; - const match = /^([A-G])([#b♯♭]?)(.*)$/.exec(String(text).trim()); - if (!match) { + const parts = parseKey(text); + if (!parts) { keyEl.textContent = text; return; } - const [, note, accidental, rest] = match; - keyEl.appendChild(document.createTextNode(note)); - if (accidental) { - const sup = document.createElement('span'); - sup.className = 'pk-accidental'; - sup.textContent = accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental; - keyEl.appendChild(sup); + keyEl.appendChild(document.createTextNode(parts.note)); + if (parts.accidental) { + const acc = document.createElement('span'); + acc.className = 'pk-accidental'; + acc.textContent = parts.accidental; + keyEl.appendChild(acc); } - if (rest) { + if (parts.quality) { const tail = document.createElement('span'); tail.className = 'pk-quality'; - tail.textContent = rest; + tail.textContent = parts.quality; keyEl.appendChild(tail); } } diff --git a/modules/pco-plan/key-notation.js b/modules/pco-plan/key-notation.js new file mode 100644 index 0000000..f5b7dba --- /dev/null +++ b/modules/pco-plan/key-notation.js @@ -0,0 +1,25 @@ +/* PCO Plan — how a song key is written. + + Planning Center stores keys as typed: "Bb", "F#m", "Ab". Every tile in this + module shows them as notes instead — B♭, F♯m, A♭ — so the plan, the badge + on an item and the Song Key tile all agree. Shared rather than repeated + because the day one of them changes, they all should. */ + +/** "F#m" → { note: 'F', accidental: '♯', quality: 'm' }; anything odd → null. */ +export function parseKey(raw) { + const match = /^([A-G])\s*([#b♯♭]?)\s*(.*)$/.exec(String(raw || '').trim()); + if (!match) return null; + const [, note, accidental, quality] = match; + return { + note, + accidental: accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental, + quality: quality.trim(), + }; +} + +/** The same thing as one string, for a badge or a caption. */ +export function prettyKey(raw) { + const parts = parseKey(raw); + if (!parts) return String(raw || '').trim(); + return `${parts.note}${parts.accidental}${parts.quality}`; +} -- 2.54.0 From 32180bf2995eec0c602921f15284592c9d07e852 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 27 Sep 2026 11:32:10 -0400 Subject: [PATCH 3/4] Centre the text in the song-section chips The chips looked top-heavy, and measuring said so: the capitals sat 5.12px below the top of the chip and 4.22px above the bottom. Uppercase text has no descenders, so the font's descent allowance below the baseline is empty space the letters never use, while the gap between the ascender line and the cap height sits above them. The padding is now a touch shallower on top and deeper below, which puts the letters themselves in the middle: 4.67px above, 4.68px below. In em, so it holds wherever the tile's text size is set. Co-Authored-By: Claude Opus 5 --- modules/pco-plan/style.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/pco-plan/style.css b/modules/pco-plan/style.css index 952d4fd..a4e037b 100644 --- a/modules/pco-plan/style.css +++ b/modules/pco-plan/style.css @@ -635,7 +635,12 @@ [data-module="pco-plan"] .pp-abs-sec { flex: none; - padding: 0.14em 0.55em; + /* Uppercase text has no descenders, so the font's descent allowance below + the baseline leaves the caps sitting high in the box — measured at 0.9px + too much headroom. The padding is a touch shallower on top and deeper + below to centre the letters themselves; in em, so it holds at any text + size. */ + padding: 0.095em 0.55em 0.185em; /* The same rounded square, tinted ground and transparent border the AbleSet module gives its own section pills, so the two read as one family. */ border-radius: 0.35em; -- 2.54.0 From ece2c0a2c31309a97a40bc011a6bd8081c681417 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Tue, 29 Sep 2026 14:57:01 -0400 Subject: [PATCH 4/4] Centre the NOW tag; the type badge was already fine Re-measuring the badges properly changed the answer. The earlier reading that said the type badge sat 0.52px high was measuring the wrong glyphs: it fed the canvas the element's raw text ("Song", "Media") while the page renders text-transform: uppercase ("SONG", "MEDIA"), and a lowercase 'd' or 'g' has an ascender and a descender the capitals never draw. Measured against the text as actually rendered, the type badge is within 0.10px of centred and wants nothing. The NOW tag was the one off, by 0.59px, the same way the section chips were. So: the badge padding added in the last commit is reverted, and the NOW tag gets the correction instead. That line now reads, in px off centre: NOW 0.01, type badge 0.10, key badge 0.16, section chip -0.01. Co-Authored-By: Claude Opus 5 --- modules/pco-plan/style.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/pco-plan/style.css b/modules/pco-plan/style.css index a4e037b..035ee48 100644 --- a/modules/pco-plan/style.css +++ b/modules/pco-plan/style.css @@ -206,7 +206,9 @@ [data-module="pco-plan"] .pp-now-tag { display: none; - padding: 0.05em 0.45em; + /* Optically centred like the section chips: uppercase has no descenders, + so the capitals sat 0.59px low in the pill. */ + padding: 0.018em 0.45em 0.082em; border-radius: 999px; background: var(--pp-current); color: var(--bg); -- 2.54.0