diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 3b12394..bb55b86 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -197,6 +197,8 @@ for a clock's format switches — give it a schema of what applies. `showWhen: ""` hides a field until the boolean/`switch` field named `` is on — e.g. the audio device and channel appear only once the LTC listener switch is enabled. The hidden field still keeps its stored value. +It works the same in a tile's gear (`instanceSchema`): "Short day" and +"Short month" appear once the clock's "Date" is on. A `select` whose choices are only knowable at runtime (audio devices, serial ports, discovered sources) sets `optionsRoute` instead of — or as a fallback diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index 111c567..5a8149e 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -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 } } diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index 2825eac..e236599 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -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" + } } } diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index 741f456..5b1dba6 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -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. */ diff --git a/modules/propresenter-timers/style.css b/modules/propresenter-timers/style.css index d3a0c6b..c951199 100644 --- a/modules/propresenter-timers/style.css +++ b/modules/propresenter-timers/style.css @@ -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; diff --git a/public/shell.js b/public/shell.js index 5ca3b32..fc2a73f 100644 --- a/public/shell.js +++ b/public/shell.js @@ -286,7 +286,7 @@ function buildTile(tile) { gear.innerHTML = GEAR_SVG; gear.addEventListener('click', (e) => { e.stopPropagation(); - toggleSettingsPopover(tile, el); + toggleSettingsPopover(tile, el, gear); }); head.appendChild(gear); } @@ -1108,6 +1108,7 @@ function tileSchema(tile, man) { function closeSettingsPopovers() { document.querySelectorAll('.tile-settings').forEach((p) => { p._cleanup?.(); + p._button?.classList.remove('is-open'); p.remove(); }); } @@ -1143,7 +1144,8 @@ function clampPopover(pop) { pop.style.top = Math.min(Math.max(pad, pop.offsetTop), maxTop) + 'px'; } -function toggleSettingsPopover(tile, el) { +/** Open (or close) the settings window for `tile`, beside its element `el`; `button` is the gear that asked. */ +function toggleSettingsPopover(tile, el, button = null) { const existing = document.querySelector(`.tile-settings[data-tile-id="${tile.id}"]`); closeSettingsPopovers(); if (existing) return; @@ -1156,6 +1158,11 @@ function toggleSettingsPopover(tile, el) { pop.dataset.tileId = tile.id; pop.setAttribute('role', 'dialog'); pop.setAttribute('aria-label', `${tile.title || man.name} settings`); + // The gear that opened it wears the window's colour while it is open. + if (button) { + button.classList.add('is-open'); + pop._button = button; + } // A slim bar to move it by — nothing else. No title (the window sits beside // the tile it belongs to) and no close button: a click anywhere else, or @@ -1168,6 +1175,7 @@ function toggleSettingsPopover(tile, el) { pop.append(bar, body); const inputs = new Map(); + const shownWhen = []; // fields that appear only while another has a given value /* Display choices apply as they change and save with the layout — nothing to confirm, so no Apply. Typing is debounced because applying means @@ -1446,6 +1454,8 @@ function toggleSettingsPopover(tile, el) { field.appendChild(help); } + if (typeof spec?.showWhen === 'string' && spec.showWhen) shownWhen.push({ field, key: spec.showWhen }); + if (current && switchInput && key === current.toggleKey) { // The group's on/off lives in its title row, never in the body, and a // click on it must not fold a
. @@ -1461,7 +1471,21 @@ function toggleSettingsPopover(tile, el) { } } - // Nothing to press: click away, press Escape, the ✕, or the gear again. + // "Short day" appears once "Date" is on: a field with showWhen:"K" follows + // K's switch, live — the same rule the admin page applies. + if (shownWhen.length) { + const refresh = () => { + for (const rule of shownWhen) { + const read = inputs.get(rule.key); + rule.field.hidden = read ? !read() : false; + } + }; + refresh(); + body.addEventListener('change', refresh); + body.addEventListener('input', refresh); + } + + // Nothing to press: click away, press Escape, or the gear again. // (The gear's own click stops propagating, so it toggles rather than // closing and reopening.) const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); }; diff --git a/public/style.css b/public/style.css index 8accbef..ddb4f95 100644 --- a/public/style.css +++ b/public/style.css @@ -306,6 +306,8 @@ body.menu-hidden #topbar { .tile-btn:hover { background: var(--border); color: var(--text); } .tile-btn.close:hover { color: var(--danger); } .tile-btn.active { color: var(--accent); } +.tile-btn.is-open, +.tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); } .tile-btn svg { width: 13px; height: 13px; display: block; } /* controls a module adds to its own title bar (moduleApi.header) */ @@ -554,15 +556,15 @@ body.fullscreen #grid { padding-bottom: 0; } border-radius: 10px; box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55); } -/* the grab bar: a slim strip with a grip mark, nothing to read or press */ +/* the grab bar: a slim accent strip with a grip mark, nothing to read or + press — the gear that opened the window wears the same colour meanwhile */ .tile-settings-bar { flex: none; height: 14px; display: flex; align-items: center; justify-content: center; - background: rgba(255, 255, 255, 0.04); - border-bottom: 1px solid var(--border); + background: var(--accent); cursor: grab; user-select: none; -webkit-user-select: none; @@ -573,11 +575,12 @@ body.fullscreen #grid { padding-bottom: 0; } width: 34px; height: 4px; border-radius: 2px; - background: var(--muted); - opacity: 0.55; + background: var(--accent-text); + opacity: 0.7; transition: opacity 0.12s; } -.tile-settings-bar:hover::before { opacity: 0.9; } +.tile-settings-bar:hover::before { opacity: 1; } +.tile-settings .field[hidden] { display: none; } .tile-settings-bar:active { cursor: grabbing; } .tile-settings-body { flex: 1;