Timers: the gear chooses what a card shows, not which timers; a 24-hour switch; a date that fits

Which timers appear is the header's Timers ▾ menu's job, so the gear's
source switches (ProPresenter / LTC / module timers, the Clock on/off)
are gone. In their place the Show group offers the card's elements —
Names, Status lines, Detail lines, Seconds, Leading zeros, Source
headings; without seconds a countdown reads in whole minutes, rounded
up. The clock's format select is a 24-hour switch (an older layout's
"12h" choice is honoured); the clock card has no CLOCK label; Short day
and Short month appear once Date is on, and the date fits its card — one
line when it can, else a break after the day and a size that fits the
longer half. Solo tiles get the same element switches.

Shell: the settings window's grab bar is the accent colour and the gear
that opened it wears the same colour while it is open; showWhen works in
a tile's gear as it does on the admin page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-15 09:53:41 -04:00
co-authored by Claude Fable 5.1
parent 1db6c2c89f
commit efbcf960dd
7 changed files with 319 additions and 95 deletions
+125 -47
View File
@@ -41,8 +41,6 @@
theme; left at their defaults they follow the theme's variables. */
/** Which instance switch governs a source kind. */
const SOURCE_SWITCH = { propresenter: 'showProPresenter', ltc: 'showLtc', clock: 'showClock', module: 'showModules' };
/** Colour settings → custom property, and the schema default (module.json) that means "follow the theme". */
const COLOR_SETTINGS = [
['runningColor', '--tm-ok', '#2ee59a'],
@@ -65,15 +63,17 @@ const LTC_KEY = 'ltc:ltc';
switches default to on, the clock to 24-hour with seconds and no date —
the same values module.json gives the full "All timers" schema. */
const DEFAULTS = {
showClock: true,
showProPresenter: true,
showLtc: true,
showModules: true,
showNames: true,
showHeadings: true,
showStatus: true,
clockFormat: '24h',
showDetail: true,
showSeconds: true,
leadingZeros: false,
clock24h: true,
clockSeconds: true,
clockDate: false,
dateShortDay: false,
dateShortMonth: false,
};
/* ── SMPTE timecode math (module-scope, exported for tests) ─────────
@@ -140,14 +140,26 @@ export function valueAt(timer, sourceUpdatedAt, now) {
}
/** Signed ms → "M:SS" / "H:MM:SS" (whole seconds, floor of the magnitude). */
export function fmtDuration(ms) {
/**
* A duration as digits. `seconds: false` shows minutes only — a countdown
* rounds up (3:59 left reads "4", 0:30 over reads "-1"), an elapsed timer
* rounds down; `zeros` pads the leading field to two digits ("05:07").
*/
export function fmtDuration(ms, { seconds = true, zeros = false, up = false } = {}) {
const sign = ms < 0 ? '-' : '';
const s = Math.floor(Math.abs(ms) / 1000);
const total = Math.abs(ms) / 1000;
const pad = (v) => String(v).padStart(2, '0');
if (!seconds) {
const mins = up ? Math.ceil(total / 60) : Math.floor(total / 60);
const h = Math.floor(mins / 60);
const m = mins % 60;
return h ? `${sign}${zeros ? pad(h) : h}:${pad(m)}` : `${sign}${zeros ? pad(m) : m}`;
}
const s = Math.floor(total);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (v) => String(v).padStart(2, '0');
return h ? `${sign}${h}:${pad(m)}:${pad(sec)}` : `${sign}${m}:${pad(sec)}`;
return h ? `${sign}${zeros ? pad(h) : h}:${pad(m)}:${pad(sec)}` : `${sign}${zeros ? pad(m) : m}:${pad(sec)}`;
}
function warnWindow(targetMs) {
@@ -198,7 +210,10 @@ export default function create({ root, moduleApi }) {
whatever its (possibly slimmer) schema would or wouldn't default. */
const pref = (key) => {
const v = settings()[key];
return v === undefined || v === null ? DEFAULTS[key] : v;
if (v !== undefined && v !== null) return v;
// A layout from before the 24-hour switch chose a "clockFormat".
if (key === 'clock24h' && settings().clockFormat === '12h') return false;
return DEFAULTS[key];
};
const keyOf = (source, timer) => `${source.id}:${timer.id}`;
@@ -348,11 +363,6 @@ export default function create({ root, moduleApi }) {
/* ── state → what this tile shows ───────────────────────────────── */
function sourceShown(source) {
const key = SOURCE_SWITCH[source.kind] || 'showModules';
return pref(key) !== false;
}
/** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */
function visibleGroups() {
if (!state) return [];
@@ -372,7 +382,6 @@ export default function create({ root, moduleApi }) {
}
const groups = [];
for (const source of state.sources) {
if (!sourceShown(source)) continue;
const items = [];
for (const timer of source.timers) {
const key = keyOf(source, timer);
@@ -418,6 +427,7 @@ export default function create({ root, moduleApi }) {
/** The parts of a card that only change with the snapshot (label, kind). */
function updateCard(card, item) {
card.item = item;
card.isClock = !item.missing && item.source?.kind === 'clock';
const isLtc = item.source?.kind === 'ltc';
const badge = card.nameEl.querySelector('.tm-ltc-badge');
if (isLtc && !badge) {
@@ -428,7 +438,7 @@ export default function create({ root, moduleApi }) {
} else if (!isLtc && badge) {
badge.remove();
}
const label = item.missing ? (lastSoloLabel || 'Timer') : item.timer.label;
const label = item.missing ? (lastSoloLabel || 'Timer') : card.isClock ? '' : item.timer.label;
if (card.labelEl.textContent !== label) {
setText(card.labelEl, label);
needFit = true; // the title's size follows its length
@@ -454,7 +464,7 @@ export default function create({ root, moduleApi }) {
/* ── clocks ─────────────────────────────────────────────────────── */
function clockOptions() {
const h12 = pref('clockFormat') === '12h';
const h12 = pref('clock24h') === false;
return {
hour12: h12,
hour: h12 ? 'numeric' : '2-digit',
@@ -467,8 +477,43 @@ export default function create({ root, moduleApi }) {
return new Date().toLocaleTimeString([], clockOptions());
}
function dateText() {
return new Date().toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' });
/** The date as the day and the rest — the break, when one is needed, falls between them. */
function dateParts() {
const d = new Date();
return {
day: d.toLocaleDateString([], { weekday: pref('dateShortDay') ? 'short' : 'long' }),
rest: d.toLocaleDateString([], { month: pref('dateShortMonth') ? 'short' : 'long', day: 'numeric' }),
};
}
/**
* The clock's date under its digits: one line when it fits the card,
* otherwise a line break after the day and a size that fits the longer
* half. Re-done only when the text or the card width changes.
*/
function renderDate(card, d) {
const cw = lastFit.cw;
const lineSize = lastFit.meta * LINE_SCALE;
const whole = `${d.day}, ${d.rest}`;
const key = `${whole}|${Math.round(cw)}|${lineSize}`;
if (card.dateKey === key) return;
card.dateKey = key;
let px = lineSize;
let wrapIt = false;
if (textWidth(whole, lineSize, 0.1) > cw) {
wrapIt = true;
const widest = Math.max(textWidth(`${d.day},`, lineSize, 0.1), textWidth(d.rest, lineSize, 0.1));
px = Math.max(8, Math.floor(lineSize * Math.min(1, cw / widest)));
}
const el = card.statusEl;
el.textContent = '';
const a = document.createElement('span');
a.textContent = `${d.day},`;
const b = document.createElement('span');
b.textContent = d.rest;
el.append(a, document.createTextNode(' '), b);
el.style.fontSize = px === lineSize ? '' : `${px}px`;
card.dateCls = wrapIt ? ' is-date is-wrap' : ' is-date';
}
/** A provider's kind:"clock" timer carries an ISO time in status.text as of its snapshot. */
@@ -559,6 +604,8 @@ export default function create({ root, moduleApi }) {
if (!state) return;
const now = serverNow();
const showStatus = pref('showStatus') !== false;
const showDetail = pref('showDetail') !== false;
const digits = { seconds: pref('showSeconds') !== false, zeros: pref('leadingZeros') === true };
let charsChanged = false;
for (const card of cards.values()) {
const { item } = card;
@@ -573,6 +620,7 @@ export default function create({ root, moduleApi }) {
let statusTone = '';
let detailText = '';
let extra = '';
let date = null;
if (timer.kind === 'clock') {
if (source.kind === 'ltc') {
const l = timer.ltc || {};
@@ -588,7 +636,8 @@ export default function create({ root, moduleApi }) {
} else if (source.kind === 'clock') {
text = wallClockText();
tone = 'plain';
statusText = pref('clockDate') ? dateText() : '';
date = pref('clockDate') ? dateParts() : null;
statusText = date ? `${date.day}, ${date.rest}` : '';
statusTone = 'muted';
} else {
text = providerClockText(timer, source, now);
@@ -598,7 +647,7 @@ export default function create({ root, moduleApi }) {
}
} else {
const value = valueAt(timer, source.updatedAt, now);
text = value === null ? '—' : fmtDuration(value);
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
tone = toneFor(timer, value);
statusText = timer.status?.text || defaultStatus(timer, value, tone);
statusTone = timer.status?.tone || tone;
@@ -615,12 +664,18 @@ export default function create({ root, moduleApi }) {
}
// Notes that must survive the compact look: an unavailable LTC input.
const isNote = extra === ' is-unavailable';
setText(card.statusEl, statusText);
setText(card.statusEl, statusText); // a no-op over the date's two spans: same text
card.statusEl.hidden = !statusText || (!showStatus && !isNote);
card.statusEl.className = 'tm-status' + (isNote ? ' is-note' : '');
if (date && !card.statusEl.hidden) renderDate(card, date);
else if (card.dateCls) {
card.dateCls = '';
card.dateKey = '';
card.statusEl.style.fontSize = '';
}
card.statusEl.className = 'tm-status' + (isNote ? ' is-note' : '') + (card.dateCls || '');
card.statusEl.dataset.tone = statusTone;
setText(card.detailEl, detailText);
card.detailEl.hidden = !detailText || !showStatus;
card.detailEl.hidden = !detailText || !showDetail;
const kindCls = source.kind === 'ltc' ? ' tm-card-ltc' : source.kind === 'clock' ? ' tm-card-clock' : '';
setCardClass(card, `tm-card is-${tone}${extra}${kindCls}`);
}
@@ -694,9 +749,17 @@ export default function create({ root, moduleApi }) {
needFit = true;
}
const showHeadings = !solo && pref('showHeadings') !== false;
const showStatus = pref('showStatus') !== false;
wrap.classList.toggle('tm-headings', showHeadings);
wrap.classList.toggle('tm-nostatus', !showStatus);
for (const [cls, on] of [
['tm-nostatus', pref('showStatus') === false],
['tm-nodetail', pref('showDetail') === false],
['tm-nonames', pref('showNames') === false],
]) {
if (wrap.classList.contains(cls) !== on) {
wrap.classList.toggle(cls, on);
needFit = true;
}
}
let hint = '';
const groups = visibleGroups();
@@ -779,20 +842,23 @@ export default function create({ root, moduleApi }) {
/** Status and detail lines are drawn at this share of the label's size (style.css agrees). */
const LINE_SCALE = 0.8;
/* The title fits its card: measured off-screen, it shrinks (down to a
floor) before the card would cut it off. Uppercase and letter-spaced
like the CSS draws it. */
/* Text measured off-screen the way the CSS draws it (bold, uppercase,
letter-spaced), so a title or the date can shrink before the card
would cut it off. */
const measurer = document.createElement('canvas').getContext('2d');
let labelFamily = '';
function labelWidth(text, px) {
if (!labelFamily) labelFamily = getComputedStyle(root).fontFamily || 'sans-serif';
measurer.font = `700 ${px}px ${labelFamily}`;
let textFamily = '';
function textWidth(text, px, spacingEm) {
if (!textFamily) textFamily = getComputedStyle(root).fontFamily || 'sans-serif';
measurer.font = `700 ${px}px ${textFamily}`;
const t = String(text || '').toUpperCase();
return measurer.measureText(t).width + 0.08 * px * Math.max(0, t.length - 1);
return measurer.measureText(t).width + spacingEm * px * Math.max(0, t.length - 1);
}
/** The last fit's card width and label size — what the date is fitted against. */
let lastFit = { cw: 200, meta: 12 };
function fitLabel(card, meta, avail) {
if (card.isClock) return; // the clock has no label line
const room = Math.max(24, avail - (card.nameEl.querySelector('.tm-ltc-badge') ? 34 : 0));
const natural = labelWidth(card.labelEl.textContent, meta);
const natural = textWidth(card.labelEl.textContent, meta, 0.08);
const px = natural > room ? Math.max(8, Math.floor((meta * room) / natural)) : meta;
card.labelEl.style.fontSize = px === meta ? '' : `${px}px`;
}
@@ -810,15 +876,25 @@ export default function create({ root, moduleApi }) {
const groups = [...groupEls.values()].filter((g) => g.el.isConnected);
const headings = wrap.classList.contains('tm-headings') ? groups.length : 0;
const showStatus = !wrap.classList.contains('tm-nostatus');
// Lines under the digits besides the label: status, and detail if any card has one.
const metaLines = showStatus ? 1 + (list.some((c) => !c.detailEl.hidden) ? 1 : 0) : 0;
const showDetail = !wrap.classList.contains('tm-nodetail');
const namesOn = !wrap.classList.contains('tm-nonames');
// Lines under the digits: status, and detail if any card has one.
const metaLines = (showStatus ? 1 : 0) + (showDetail && list.some((c) => !c.detailEl.hidden) ? 1 : 0);
const date = showStatus && pref('clockDate') && list.some((c) => c.isClock) ? dateParts() : null;
const dateWhole = date ? `${date.day}, ${date.rest}` : '';
// For each column count, budget a card's full stack (label, digits,
// lines) and score by the digit size it affords — the SMALLEST card's,
// so no timer ends up unreadable. Cards too short for the full stack
// drop to a compact look (label + digits, the colour carries the
// state), but any layout with a readable full stack beats every
// compact one.
// compact one. The clock has no label line, and its date may take two.
const budgetFor = (c, ch, meta, mode, dateWrap) => {
const nameLine = namesOn && !c.isClock ? 1.3 * meta : 0;
if (mode === 'compact') return ch - nameLine;
const lines = metaLines + (c.isClock && dateWrap ? 1 : 0);
return ch - nameLine - lines * 1.3 * meta * LINE_SCALE;
};
let best = null;
for (let cols = 1; cols <= n; cols += 1) {
const rows = headings
@@ -828,19 +904,18 @@ export default function create({ root, moduleApi }) {
const ch = (H - PAD * 2 - headings * HEAD_H - GAP * (rows - 1) - (headings ? (headings - 1) * GAP : 0)) / rows - CARD_PAD;
if (cw <= 0 || ch <= 0) continue;
const meta = Math.max(9, Math.min(22, Math.floor(ch * 0.16)));
const fullBudget = ch - 1.3 * meta - metaLines * 1.3 * meta * LINE_SCALE;
const compactBudget = ch - 1.3 * meta;
const dateWrap = Boolean(dateWhole) && textWidth(dateWhole, meta * LINE_SCALE, 0.1) > cw;
let full = Infinity;
let compact = Infinity;
for (const c of list) {
const byWidth = (cw * 1.55) / c.chars;
full = Math.min(full, byWidth, fullBudget);
compact = Math.min(compact, byWidth, compactBudget);
full = Math.min(full, byWidth, budgetFor(c, ch, meta, 'full', dateWrap));
compact = Math.min(compact, byWidth, budgetFor(c, ch, meta, 'compact', dateWrap));
}
const mode = full >= 14 ? 'full' : 'compact';
const size = mode === 'full' ? full : compact;
const score = (mode === 'full' ? 1000 : 0) + size;
if (!best || score > best.score) best = { cols, meta, mode, cw, budget: mode === 'full' ? fullBudget : compactBudget, score };
if (!best || score > best.score) best = { cols, meta, mode, cw, ch, dateWrap, score };
}
if (!best) return;
for (const g of groups) {
@@ -853,10 +928,13 @@ export default function create({ root, moduleApi }) {
wrap.style.setProperty('--tm-gap', GAP + 'px');
wrap.style.setProperty('--tm-meta-size', best.meta + 'px');
wrap.classList.toggle('tm-compact', best.mode === 'compact');
lastFit = { cw: best.cw, meta: best.meta };
for (const c of list) {
const px = Math.max(11, Math.floor(Math.min((best.cw * 1.55) / c.chars, best.budget)));
const budget = budgetFor(c, best.ch, best.meta, best.mode, best.dateWrap);
const px = Math.max(11, Math.floor(Math.min((best.cw * 1.55) / c.chars, budget)));
c.timeEl.style.fontSize = px + 'px';
fitLabel(c, best.meta, best.cw);
c.dateKey = ''; // the date re-fits to the new card width on the next paint
}
}
+130 -31
View File
@@ -4,16 +4,30 @@
"version": "1.3.0",
"proddash": ">=1.4.0",
"description": "Timers, Clocks, and LTC From configured Modules",
"provides": ["timers"],
"provides": [
"timers"
],
"permissions": [
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
{
"kind": "microphone",
"reason": "Decoding SMPTE LTC timecode from an audio input on this machine"
},
{
"kind": "local-network",
"reason": "Reaching ProPresenter on this network"
}
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 2, "h": 1 },
"defaultSize": { "w": 4, "h": 3 },
"minSize": {
"w": 2,
"h": 1
},
"defaultSize": {
"w": 4,
"h": 3
},
"configGroups": {
"ProPresenter": {
"toggle": "ppEnabled",
@@ -39,7 +53,10 @@
"type": "endpoint",
"label": "ProPresenter",
"group": "ProPresenter",
"default": { "host": "", "port": 1025 }
"default": {
"host": "",
"port": 1025
}
},
"password": {
"type": "password",
@@ -82,14 +99,26 @@
"group": "Display",
"default": "default",
"options": [
{ "value": "default", "label": "Default (system)" },
{ "value": "monospace", "label": "Monospace — steadier changing digits" }
{
"value": "default",
"label": "Default (system)"
},
{
"value": "monospace",
"label": "Monospace — steadier changing digits"
}
]
}
},
"instanceGroups": {
"Show": { "collapsed": false, "columns": 2 },
"Clock": { "collapsed": false, "toggle": "showClock" },
"Show": {
"collapsed": false,
"columns": 2
},
"Clock": {
"collapsed": false,
"columns": 2
},
"Colours": {
"collapsed": true,
"columns": 2,
@@ -97,27 +126,97 @@
}
},
"instanceSchema": {
"showProPresenter": { "type": "boolean", "label": "ProPresenter timers", "default": true, "group": "Show" },
"showLtc": { "type": "boolean", "label": "LTC timecode", "default": true, "group": "Show" },
"showModules": { "type": "boolean", "label": "Module timers", "default": true, "group": "Show" },
"showHeadings": { "type": "boolean", "label": "Source headings", "default": true, "group": "Show" },
"showStatus": { "type": "boolean", "label": "Status lines", "default": true, "group": "Show" },
"showClock": { "type": "switch", "label": "Clock", "default": true, "group": "Clock" },
"clockFormat": {
"type": "select",
"label": "Clock format",
"default": "24h",
"group": "Clock",
"options": [
{ "value": "24h", "label": "24-hour" },
{ "value": "12h", "label": "12-hour" }
]
"showNames": {
"type": "switch",
"label": "Names",
"default": true,
"group": "Show"
},
"clockSeconds": { "type": "boolean", "label": "Seconds", "default": true, "group": "Clock" },
"clockDate": { "type": "boolean", "label": "Date", "default": false, "group": "Clock" },
"runningColor": { "type": "color", "label": "Running", "default": "#2ee59a", "group": "Colours" },
"warningColor": { "type": "color", "label": "Warning", "default": "#f0b429", "group": "Colours" },
"overrunColor": { "type": "color", "label": "Overrun", "default": "#f4433c", "group": "Colours" },
"idleColor": { "type": "color", "label": "Idle", "default": "#8b98a5", "group": "Colours" }
"showStatus": {
"type": "switch",
"label": "Status lines",
"default": true,
"group": "Show"
},
"showDetail": {
"type": "switch",
"label": "Detail lines",
"default": true,
"group": "Show"
},
"showSeconds": {
"type": "switch",
"label": "Seconds",
"default": true,
"group": "Show"
},
"leadingZeros": {
"type": "switch",
"label": "Leading zeros",
"default": false,
"group": "Show"
},
"showHeadings": {
"type": "switch",
"label": "Source headings",
"default": true,
"group": "Show"
},
"clock24h": {
"type": "switch",
"label": "24-hour",
"default": true,
"group": "Clock"
},
"clockSeconds": {
"type": "switch",
"label": "Seconds",
"default": true,
"group": "Clock"
},
"clockDate": {
"type": "switch",
"label": "Date",
"default": false,
"group": "Clock"
},
"dateShortDay": {
"type": "switch",
"label": "Short day",
"default": false,
"group": "Clock",
"showWhen": "clockDate"
},
"dateShortMonth": {
"type": "switch",
"label": "Short month",
"default": false,
"group": "Clock",
"showWhen": "clockDate"
},
"runningColor": {
"type": "color",
"label": "Running",
"default": "#2ee59a",
"group": "Colours"
},
"warningColor": {
"type": "color",
"label": "Warning",
"default": "#f0b429",
"group": "Colours"
},
"overrunColor": {
"type": "color",
"label": "Overrun",
"default": "#f4433c",
"group": "Colours"
},
"idleColor": {
"type": "color",
"label": "Idle",
"default": "#8b98a5",
"group": "Colours"
}
}
}
+10 -7
View File
@@ -911,20 +911,23 @@ function gearOf(keys, { loose = [], groups = {} } = {}) {
}
const COLOURS = ['runningColor', 'warningColor', 'overrunColor', 'idleColor'];
/** What a timer card can show or hide of itself (which timers appear is the header menu's job). */
const TIMER_ELEMENTS = ['showNames', 'showStatus', 'showDetail', 'showSeconds', 'leadingZeros'];
const CLOCK_ELEMENTS = ['clock24h', 'clockSeconds', 'clockDate', 'dateShortDay', 'dateShortMonth'];
/** With one card there is little else to see: the colours start unfolded. */
const OPEN_COLOURS = { groups: { Colours: { collapsed: false } } };
const GEAR = {
/** Every source, the headings, the clock's format, the tones. */
/** Every card's elements, the headings, how the clock reads, the tones. */
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
/** One ProPresenter or module timer: its lines, and the tone its digits take per state. */
timer: gearOf(['showStatus', ...COLOURS], { loose: ['showStatus'], ...OPEN_COLOURS }),
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns. */
ltc: gearOf(['showStatus', 'runningColor', 'warningColor', 'idleColor'], { loose: ['showStatus'], ...OPEN_COLOURS }),
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
/** The time of day: plain digits, so only how they read. */
clock: gearOf(['clockFormat', 'clockSeconds', 'clockDate']),
clock: gearOf(CLOCK_ELEMENTS),
/** A clock another module offers: read like the clock, muted while it isn't running. */
providerClock: gearOf(['showStatus', 'clockFormat', 'clockSeconds', 'idleColor'], { loose: ['showStatus'], ...OPEN_COLOURS }),
providerClock: gearOf(['showNames', 'showStatus', 'clock24h', 'clockSeconds', 'idleColor'], OPEN_COLOURS),
};
/** The gear a solo card of this timer gets — by what the card can show. */
+16 -1
View File
@@ -132,7 +132,10 @@
[data-module="propresenter-timers"] .tm-compact .tm-status:not(.is-note),
[data-module="propresenter-timers"] .tm-compact .tm-detail,
[data-module="propresenter-timers"] .tm-nostatus .tm-status:not(.is-note),
[data-module="propresenter-timers"] .tm-nostatus .tm-detail { display: none; }
[data-module="propresenter-timers"] .tm-nodetail .tm-detail { display: none; }
/* "Names" off hides every title; the clock never carries one — its digits say what it is */
[data-module="propresenter-timers"] .tm-nonames .tm-name,
[data-module="propresenter-timers"] .tm-card-clock .tm-name { display: none; }
/* ── one timer card ── */
@@ -221,6 +224,18 @@
[data-module="propresenter-timers"] .tm-status[data-tone="danger"] { color: var(--tm-danger); }
[data-module="propresenter-timers"] .tm-status[data-tone="muted"] { color: var(--tm-muted); }
/* The clock's date: one line when it fits, else the day on its own line
(renderDate() sizes it to the longer half). */
[data-module="propresenter-timers"] .tm-status.is-date {
white-space: normal;
overflow: visible;
text-overflow: clip;
text-align: center;
line-height: 1.15;
}
[data-module="propresenter-timers"] .tm-status.is-date span { white-space: nowrap; }
[data-module="propresenter-timers"] .tm-status.is-date.is-wrap span { display: block; }
/* Explanatory notes read as sentences, not badges. */
[data-module="propresenter-timers"] .tm-status.is-note {
text-transform: none;