@acms-subir/core 0.3.49 → 0.3.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ // Words of the TV display (/display/…). Dates, weekdays and times come from
2
+ // Intl in the display's language; these are the rest. Plain strings with
3
+ // {slots} filled by `fill`. Other languages fall back to English.
4
+ export interface DisplayStrings {
5
+ thisWeek: string;
6
+ /** a rolling week (start=today) or list: "Next 7 days" */
7
+ nextDays: string;
8
+ today: string;
9
+ upcoming: string;
10
+ allDay: string;
11
+ /** shown while offline: when the events were last fetched */
12
+ updated: string;
13
+ now: string;
14
+ next: string;
15
+ nothingNow: string;
16
+ nothingNext: string;
17
+ noEvents: string;
18
+ more: string;
19
+ moved: string;
20
+ /** a moved date's old time */
21
+ was: string;
22
+ /** appointments from Google booking pages (#google-booking) */
23
+ booked: string;
24
+ cancelled: string;
25
+ scan: string;
26
+ tomorrow: string;
27
+ notFound: string;
28
+ notFoundNote: string;
29
+ }
30
+
31
+ const STRINGS: Record<string, DisplayStrings> = {
32
+ en: {
33
+ thisWeek: 'This week', nextDays: 'Next {n} days', today: 'Today', upcoming: 'Coming up', allDay: 'All day',
34
+ updated: 'Updated {time}', now: 'Now', next: 'Next', nothingNow: 'Nothing right now', nothingNext: 'Nothing else today',
35
+ noEvents: 'No events', more: '+{n} more', moved: 'Moved', was: 'was {time}', booked: 'Booked', cancelled: 'Cancelled',
36
+ scan: 'Scan for the calendar', tomorrow: 'Tomorrow',
37
+ notFound: 'This screen isn’t available', notFoundNote: 'Its link may have changed. Copy the new one from Admin → Calendar → Screens.',
38
+ },
39
+ es: {
40
+ thisWeek: 'Esta semana', nextDays: 'Próximos {n} días', today: 'Hoy', upcoming: 'Próximamente', allDay: 'Todo el día',
41
+ updated: 'Actualizado {time}', now: 'Ahora', next: 'Después', nothingNow: 'Nada en este momento', nothingNext: 'Nada más hoy',
42
+ noEvents: 'Sin eventos', more: '+{n} más', moved: 'Cambiado', was: 'antes {time}', booked: 'Reservado', cancelled: 'Cancelado',
43
+ scan: 'Escanea para ver el calendario', tomorrow: 'Mañana',
44
+ notFound: 'Esta pantalla no está disponible', notFoundNote: 'Puede que su enlace haya cambiado. Copia el nuevo en Admin → Calendario → Pantallas.',
45
+ },
46
+ };
47
+
48
+ export const displayStrings = (lang: string | null | undefined): DisplayStrings =>
49
+ STRINGS[(lang || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
50
+
51
+ export const fill = (s: string, slots: Record<string, string | number>) =>
52
+ s.replace(/\{(\w+)\}/g, (m, k: string) => (k in slots ? String(slots[k]) : m));
@@ -0,0 +1,30 @@
1
+ ---
2
+ import DisplayPage from '../../components/display/DisplayPage.astro';
3
+ import { getRepo } from '../../lib/repo';
4
+ import { CALENDAR } from '../../lib/calendar/config';
5
+ import { I18N, localePath, requestLocale } from '../../lib/i18n';
6
+ import { siteOrigin } from '../../lib/seo';
7
+ import { siteLang } from '../../lib/dates';
8
+ import { readDisplaySettings } from '../../lib/display/settings';
9
+ import { loadDisplayBoard } from '../../lib/display/board';
10
+ import { displayHeaders } from '../../lib/display/http';
11
+
12
+ // A TV display from a quick link: everything about it is in the address
13
+ // (/display/calendar?view=week&hours=8-22…, /es/display/calendar for
14
+ // Spanish). Nothing is saved, and whatever the parameters say it shows only
15
+ // what calendar.ics shows. Saved screens with private details live at
16
+ // /display/s/<key> (Admin → Calendar → Screens).
17
+ if (!CALENDAR.enabled) return new Response(null, { status: 404 });
18
+ const repo = await getRepo(Astro.locals);
19
+ const site = await repo.getSettings();
20
+ const settings = readDisplaySettings(Astro.url.searchParams);
21
+ const lang = (I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '') || siteLang(site);
22
+ const board = await loadDisplayBoard(repo, site, {
23
+ settings, privateDetails: false, lang,
24
+ phoneLink: siteOrigin(site, Astro.url) + localePath(CALENDAR.base, lang),
25
+ });
26
+ displayHeaders(Astro.response.headers, { shared: true });
27
+ const prefix = I18N.enabled && lang !== I18N.defaultLocale ? `/${lang}` : '';
28
+ ---
29
+
30
+ <DisplayPage site={site} board={board} settings={settings} title={settings.title || site.siteTitle} lang={lang} live sw={`${prefix}/display/sw.js`} />
@@ -0,0 +1,71 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { CALENDAR } from '../../lib/calendar/config';
3
+
4
+ // The TV display's service worker (registered by DisplayPage.astro, scope
5
+ // /display/, or /es/display/ through the language rewrite). AbleSign reloads
6
+ // a website every time its playlist plays it and keeps no offline copy, so
7
+ // during an outage a reload would show an error page. Network first: each
8
+ // display page that loads (or refreshes its board) is kept, and served when a
9
+ // page load can't reach the site or gets a server error. A page that answers
10
+ // 404 (a rotated or removed screen) loses its copy, so an old link stops
11
+ // showing events. At most 20 pages are kept, none older than a week is served.
12
+ const SCRIPT = `
13
+ var CACHE = 'acms-display-v1';
14
+ var KEEP = 20, MAX_AGE = 7 * 86400000;
15
+ var scope = new URL(self.registration.scope).pathname;
16
+ self.addEventListener('install', function () { self.skipWaiting(); });
17
+ self.addEventListener('activate', function (e) { e.waitUntil(self.clients.claim()); });
18
+ function keep(key, res) {
19
+ return caches.open(CACHE).then(function (c) {
20
+ return c.put(key, res).then(function () { return c.keys(); }).then(function (keys) {
21
+ // the oldest first: at most KEEP pages (a player adding ?t= to each load can't fill the disk)
22
+ return Promise.all(keys.slice(0, Math.max(0, keys.length - KEEP)).map(function (k) { return c.delete(k); }));
23
+ });
24
+ });
25
+ }
26
+ function kept(key) {
27
+ return caches.open(CACHE).then(function (c) { return c.match(key); }).then(function (hit) {
28
+ var at = hit && Date.parse(hit.headers.get('date') || '');
29
+ // a week-old board is worse than an error: it would show the wrong week
30
+ return hit && (!at || Date.now() - at < MAX_AGE) ? hit : null;
31
+ });
32
+ }
33
+ self.addEventListener('fetch', function (e) {
34
+ var req = e.request;
35
+ if (req.method !== 'GET') return;
36
+ var url = new URL(req.url);
37
+ if (url.origin !== location.origin || url.pathname.indexOf(scope) !== 0 || /\\/sw\\.js$/.test(url.pathname)) return;
38
+ var key = url.origin + url.pathname + url.search;
39
+ // only a page load falls back: the page's own refresh must fail, so it
40
+ // keeps its board and says when it was last updated
41
+ var page = req.mode === 'navigate';
42
+ e.respondWith(fetch(req).then(function (res) {
43
+ if (res.ok && res.type === 'basic') {
44
+ e.waitUntil(keep(key, res.clone()));
45
+ return res;
46
+ }
47
+ if (res.status === 404 || res.status === 410) {
48
+ e.waitUntil(caches.open(CACHE).then(function (c) { return c.delete(key); }));
49
+ return res;
50
+ }
51
+ // a deploy, an outage or a rate limit: the last board beats an error page
52
+ if (page && (res.status >= 500 || res.status === 429)) return kept(key).then(function (hit) { return hit || res; });
53
+ return res;
54
+ }, function (err) {
55
+ if (!page) throw err;
56
+ return kept(key).then(function (hit) { if (hit) return hit; throw err; });
57
+ }));
58
+ });
59
+ `;
60
+
61
+ export const GET: APIRoute = () => {
62
+ if (!CALENDAR.enabled) return new Response(null, { status: 404 });
63
+ return new Response(SCRIPT, {
64
+ headers: {
65
+ 'Content-Type': 'text/javascript; charset=utf-8',
66
+ // browsers check for a new worker on every registration; never keep an old one
67
+ 'Cache-Control': 'no-cache',
68
+ 'X-Content-Type-Options': 'nosniff',
69
+ },
70
+ });
71
+ };
@@ -0,0 +1,195 @@
1
+ /* The TV display (/display/…; lib/display/render.ts). Inlined into the page
2
+ before the theme's CSS, so a theme restyles it with equal-specificity rules
3
+ or, better, by setting these variables (core never sets them itself):
4
+ --display-bg, --display-fg, --display-muted, --display-line,
5
+ --display-panel, --display-today, --display-now, --display-event,
6
+ --display-font, --display-heading-font, --display-radius
7
+ e.g. :root { --display-bg: #101820 } or
8
+ .acms-display[data-theme='light'] { --display-bg: #fffaf2 }
9
+ Every length is a multiple of --dsp-u (viewport height × text size), matching
10
+ METRICS in render.ts. Flat blocks only: no accent stripes, no gradients. */
11
+
12
+ html, body { margin: 0; height: 100%; overflow: hidden; }
13
+ body { background: #000; }
14
+
15
+ .acms-display {
16
+ --dsp-u: calc(min(1vh, 1.7778vw) * var(--dsp-scale, 1));
17
+ --d-bg: #0c0e13; --d-fg: #f3f4f7; --d-muted: #9ca3af; --d-line: rgba(255, 255, 255, 0.09);
18
+ --d-panel: rgba(255, 255, 255, 0.06); --d-today: rgba(255, 255, 255, 0.055); --d-now: #ff5a4f; --d-event: #4f6bed;
19
+ --dsp-bg: var(--display-bg, var(--d-bg));
20
+ --dsp-fg: var(--display-fg, var(--d-fg));
21
+ --dsp-muted: var(--display-muted, var(--d-muted));
22
+ --dsp-line: var(--display-line, var(--d-line));
23
+ --dsp-r: var(--display-radius, calc(0.7 * var(--dsp-u)));
24
+ position: fixed;
25
+ inset: 0;
26
+ box-sizing: border-box;
27
+ padding: calc(2.5 * var(--dsp-u)) calc(3 * var(--dsp-u));
28
+ display: flex;
29
+ flex-direction: column;
30
+ gap: calc(2 * var(--dsp-u));
31
+ background: var(--dsp-bg);
32
+ color: var(--dsp-fg);
33
+ font-family: var(--display-font, var(--font-body, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif));
34
+ font-size: calc(2.4 * var(--dsp-u));
35
+ line-height: 1.25;
36
+ overflow: hidden;
37
+ cursor: none;
38
+ -webkit-font-smoothing: antialiased;
39
+ user-select: none;
40
+ }
41
+ .acms-display[data-theme='light'] {
42
+ --d-bg: #f6f6f3; --d-fg: #15171c; --d-muted: #5b6270; --d-line: rgba(0, 0, 0, 0.1);
43
+ --d-panel: rgba(0, 0, 0, 0.045); --d-today: rgba(0, 0, 0, 0.045); --d-now: #d9342b;
44
+ }
45
+ .acms-display *, .acms-display *::before { box-sizing: border-box; }
46
+
47
+ /* header: heading, offline note, QR, clock (8u tall) */
48
+ .display-head { flex: 0 0 calc(8 * var(--dsp-u)); display: flex; align-items: center; gap: calc(3 * var(--dsp-u)); min-height: 0; }
49
+ .display-heading { flex: 1 1 auto; min-width: 0; }
50
+ .display-title {
51
+ margin: 0; font-size: calc(4.4 * var(--dsp-u)); line-height: 1.1; font-weight: 700; letter-spacing: -0.01em;
52
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
53
+ font-family: var(--display-heading-font, var(--font-heading, inherit));
54
+ }
55
+ .display-sub { margin: calc(0.3 * var(--dsp-u)) 0 0; font-size: calc(2.4 * var(--dsp-u)); color: var(--dsp-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
56
+ .display-clock { margin: 0; font-size: calc(4.4 * var(--dsp-u)); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
57
+ .display-status {
58
+ margin: 0; padding: calc(0.5 * var(--dsp-u)) calc(1.4 * var(--dsp-u)); border-radius: 999px; white-space: nowrap;
59
+ font-size: calc(2 * var(--dsp-u)); color: var(--dsp-muted); background: var(--display-panel, var(--d-panel));
60
+ }
61
+ .display-status[hidden], .display-now[hidden], .display-page[hidden] { display: none; }
62
+ .display-qr { display: flex; align-items: center; gap: calc(1.2 * var(--dsp-u)); height: 100%; }
63
+ .display-qr svg { height: 100%; width: auto; display: block; background: #fff; padding: calc(0.5 * var(--dsp-u)); border-radius: calc(0.5 * var(--dsp-u)); }
64
+ .display-qr-note { font-size: calc(1.7 * var(--dsp-u)); color: var(--dsp-muted); max-width: calc(13 * var(--dsp-u)); line-height: 1.2; }
65
+
66
+ .display-board { flex: 1 1 0; min-height: 0; position: relative; }
67
+ .display-legend { flex: 0 0 calc(3 * var(--dsp-u)); display: flex; align-items: center; gap: calc(2.6 * var(--dsp-u)); font-size: calc(2 * var(--dsp-u)); color: var(--dsp-muted); overflow: hidden; white-space: nowrap; }
68
+ .display-key { display: inline-flex; align-items: center; gap: calc(0.8 * var(--dsp-u)); }
69
+
70
+ /* shared by every event shape */
71
+ .display-event { min-width: 0; }
72
+ .display-event.is-past { opacity: 0.45; }
73
+ .display-event.is-cancelled { opacity: 0.6; }
74
+ .display-event.is-cancelled .display-event-title { text-decoration: line-through; }
75
+ .display-event-title { display: block; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
76
+ .display-event-detail, .display-event-meta { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
77
+ .display-event-meta { opacity: 0.85; }
78
+ .display-flag { font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.88em; }
79
+ .display-was { opacity: 0.9; }
80
+ .display-dot { display: inline-block; flex: none; width: calc(1.4 * var(--dsp-u)); height: calc(1.4 * var(--dsp-u)); border-radius: calc(0.35 * var(--dsp-u)); background: var(--ev, var(--display-event, var(--d-event))); }
81
+
82
+ /* hour grids: week, day, rooms */
83
+ .display-grid { --axis: calc(7.5 * var(--dsp-u)); height: 100%; display: flex; flex-direction: column; }
84
+ .display-heads, .display-top, .display-cols { display: grid; grid-template-columns: repeat(var(--cols), minmax(0, 1fr)); }
85
+ .display-heads { flex: 0 0 calc(4.5 * var(--dsp-u)); margin-left: var(--axis); gap: calc(0.4 * var(--dsp-u)); }
86
+ .display-dayhead {
87
+ display: flex; align-items: baseline; justify-content: center; gap: 0.35em; min-width: 0; padding: 0 0.4em;
88
+ border-radius: var(--dsp-r); font-size: calc(2.2 * var(--dsp-u)); color: var(--dsp-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
89
+ align-self: stretch; align-items: center;
90
+ }
91
+ .display-dayname { overflow: hidden; text-overflow: ellipsis; }
92
+ .display-daynum { font-size: calc(2.8 * var(--dsp-u)); font-weight: 700; color: var(--dsp-fg); }
93
+ .display-dayhead.is-today { color: var(--dsp-fg); font-weight: 700; background: var(--display-today, var(--d-today)); }
94
+ .display-dayhead.is-past { opacity: 0.6; }
95
+ .display-roomhead { color: var(--dsp-fg); font-weight: 700; background: var(--display-panel, var(--d-panel)); }
96
+ .display-top {
97
+ flex: none; margin: calc(0.6 * var(--dsp-u)) 0 0 var(--axis);
98
+ grid-template-rows: repeat(var(--lines), calc(3.3 * var(--dsp-u))); gap: 0 calc(0.4 * var(--dsp-u));
99
+ }
100
+ .display-hours { flex: 1 1 0; min-height: 0; display: flex; position: relative; margin-top: calc(0.8 * var(--dsp-u)); }
101
+ .display-axis { flex: 0 0 var(--axis); position: relative; }
102
+ .display-hour { position: absolute; right: calc(1 * var(--dsp-u)); transform: translateY(-50%); font-size: calc(1.8 * var(--dsp-u)); color: var(--dsp-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
103
+ .display-cols { flex: 1 1 auto; position: relative; gap: calc(0.4 * var(--dsp-u)); }
104
+ .display-lines { position: absolute; inset: 0; pointer-events: none; }
105
+ .display-line { position: absolute; left: 0; right: 0; border-top: 1px solid var(--dsp-line); }
106
+ .display-col { position: relative; min-width: 0; border-radius: var(--dsp-r); }
107
+ .display-col.is-today { background: var(--display-today, var(--d-today)); }
108
+ .display-grid.is-rooms .display-col.is-today { background: none; }
109
+ /* timed blocks: render.ts gives each only the lines it can show whole
110
+ (is-mid: one title line and the meta; is-short: the title alone) */
111
+ .display-block {
112
+ position: absolute; display: flex; flex-direction: column;
113
+ padding: calc(0.35 * var(--dsp-u)) calc(0.7 * var(--dsp-u)); overflow: hidden;
114
+ border-radius: var(--dsp-r); border: calc(0.15 * var(--dsp-u)) solid var(--dsp-bg);
115
+ background: var(--ev, var(--display-event, var(--d-event))); color: var(--ev-ink, #fff);
116
+ font-size: calc(1.7 * var(--dsp-u)); line-height: 1.1;
117
+ }
118
+ .display-block .display-event-title { font-size: calc(2.15 * var(--dsp-u)); line-height: 1.05; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
119
+ .display-block.is-mid .display-event-title, .display-block.is-short .display-event-title { display: block; white-space: nowrap; }
120
+ .display-block[data-cut='start'], .display-block[data-cut='both'] { border-top-left-radius: 0; border-top-right-radius: 0; }
121
+ .display-block[data-cut='end'], .display-block[data-cut='both'] { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
122
+ .display-cut { font-size: 0.75em; margin: 0 0.3em; opacity: 0.9; }
123
+ .display-bar, .display-chip {
124
+ margin: calc(0.25 * var(--dsp-u)) 0; padding: 0 calc(0.6 * var(--dsp-u)); border-radius: calc(0.5 * var(--dsp-u));
125
+ font-size: calc(1.9 * var(--dsp-u)); line-height: calc(2.8 * var(--dsp-u)); z-index: 1; overflow: hidden;
126
+ }
127
+ .display-bar { background: var(--ev, var(--display-event, var(--d-event))); color: var(--ev-ink, #fff); }
128
+ .display-bar .display-event-title, .display-chip .display-event-title { font-weight: 600; }
129
+ .display-bar-time, .display-chip-time { font-weight: 500; opacity: 0.85; font-variant-numeric: tabular-nums; }
130
+ .display-chip { display: flex; align-items: center; gap: calc(0.6 * var(--dsp-u)); }
131
+ .display-chip::before { content: ''; flex: none; width: calc(1.1 * var(--dsp-u)); height: calc(1.1 * var(--dsp-u)); border-radius: calc(0.3 * var(--dsp-u)); background: var(--ev, var(--display-event, var(--d-event))); }
132
+ .display-more { font-size: calc(1.8 * var(--dsp-u)); color: var(--dsp-muted); padding: 0 calc(0.6 * var(--dsp-u)); align-self: center; z-index: 1; white-space: nowrap; }
133
+ /* the now line keeps an outline in the page colour, so it shows over a block of its own colour */
134
+ .display-now {
135
+ position: absolute; left: 0; right: 0; height: calc(0.35 * var(--dsp-u)); margin-top: calc(-0.175 * var(--dsp-u)); z-index: 3; pointer-events: none;
136
+ background: var(--display-now, var(--d-now)); box-shadow: 0 0 0 calc(0.2 * var(--dsp-u)) var(--dsp-bg);
137
+ }
138
+ .display-now::before {
139
+ content: ''; position: absolute; left: calc(-0.7 * var(--dsp-u)); top: calc(-0.5 * var(--dsp-u)); width: calc(1.35 * var(--dsp-u)); height: calc(1.35 * var(--dsp-u));
140
+ border-radius: 50%; background: var(--display-now, var(--d-now)); box-shadow: 0 0 0 calc(0.2 * var(--dsp-u)) var(--dsp-bg);
141
+ }
142
+
143
+ /* month: rows fill the height; each row has a date line and --lines lines */
144
+ .display-month { height: 100%; display: grid; grid-template-rows: calc(3.5 * var(--dsp-u)) repeat(var(--rows), minmax(0, 1fr)); }
145
+ .display-dows, .display-weekrow { display: grid; grid-template-columns: repeat(var(--cols), minmax(0, 1fr)); }
146
+ .display-dows { align-items: center; font-size: calc(1.9 * var(--dsp-u)); color: var(--dsp-muted); text-transform: uppercase; letter-spacing: 0.05em; }
147
+ .display-dow { padding: 0 calc(0.6 * var(--dsp-u)); }
148
+ .display-weekrow {
149
+ grid-template-rows: calc(3.4 * var(--dsp-u)) repeat(var(--lines), calc(3.3 * var(--dsp-u))) minmax(0, 1fr);
150
+ border-top: 1px solid var(--dsp-line); overflow: hidden; min-height: 0;
151
+ }
152
+ .display-cell { grid-row: 1 / -1; padding: calc(0.3 * var(--dsp-u)) calc(0.6 * var(--dsp-u)); border-left: 1px solid var(--dsp-line); min-width: 0; }
153
+ .display-cell:first-child { border-left: 0; }
154
+ .display-cell.is-today { background: var(--display-today, var(--d-today)); }
155
+ .display-cell.is-other .display-date { opacity: 0.4; }
156
+ .display-cell.is-past .display-date { opacity: 0.6; }
157
+ .display-date { font-size: calc(2.2 * var(--dsp-u)); font-weight: 600; font-variant-numeric: tabular-nums; }
158
+ .display-cell.is-today .display-date { color: var(--display-now, var(--d-now)); font-weight: 800; }
159
+ .display-month .display-bar, .display-month .display-chip { margin-left: calc(0.3 * var(--dsp-u)); margin-right: calc(0.3 * var(--dsp-u)); }
160
+
161
+ /* list: columns, then pages (the script turns them) */
162
+ .display-list { height: 100%; overflow: hidden; }
163
+ .display-list-cols { height: 100%; column-count: var(--dsp-list-cols, 3); column-gap: calc(3.5 * var(--dsp-u)); column-fill: auto; }
164
+ .display-day { margin: 0 0 calc(2.2 * var(--dsp-u)); }
165
+ .display-day-title { margin: 0 0 calc(0.6 * var(--dsp-u)); font-size: calc(2.3 * var(--dsp-u)); font-weight: 700; color: var(--dsp-muted); break-after: avoid; }
166
+ .display-day.is-today .display-day-title { color: var(--dsp-fg); }
167
+ .display-row {
168
+ display: grid; grid-template-columns: calc(1.4 * var(--dsp-u)) calc(9.5 * var(--dsp-u)) minmax(0, 1fr); gap: calc(1.2 * var(--dsp-u));
169
+ align-items: baseline; padding: calc(0.75 * var(--dsp-u)) 0; border-top: 1px solid var(--dsp-line); break-inside: avoid;
170
+ }
171
+ .display-row .display-dot { align-self: center; }
172
+ .display-row-time { color: var(--dsp-muted); font-size: calc(2.1 * var(--dsp-u)); white-space: nowrap; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; }
173
+ .display-row-body { min-width: 0; }
174
+ .display-row .display-event-title { font-size: calc(2.4 * var(--dsp-u)); white-space: normal; }
175
+ .display-row .display-event-meta, .display-row .display-event-detail { font-size: calc(1.9 * var(--dsp-u)); color: var(--dsp-muted); opacity: 1; }
176
+ .display-page { position: absolute; right: 0; bottom: calc(-0.5 * var(--dsp-u)); font-size: calc(1.8 * var(--dsp-u)); color: var(--dsp-muted); }
177
+
178
+ /* now and next */
179
+ .display-next { height: 100%; display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: calc(3 * var(--dsp-u)); }
180
+ .display-slot { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: calc(1.4 * var(--dsp-u)); overflow: hidden; }
181
+ .display-slot-title { margin: 0; font-size: calc(2.6 * var(--dsp-u)); font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--dsp-muted); }
182
+ .display-card {
183
+ display: flex; flex-direction: column; gap: calc(0.6 * var(--dsp-u)); padding: calc(2 * var(--dsp-u)) calc(2.4 * var(--dsp-u));
184
+ border-radius: calc(1.2 * var(--dsp-u)); background: var(--ev, var(--display-event, var(--d-event))); color: var(--ev-ink, #fff);
185
+ }
186
+ .display-card .display-event-title { font-size: calc(3.4 * var(--dsp-u)); line-height: 1.15; white-space: normal; }
187
+ .display-card.is-big .display-event-title { font-size: calc(6 * var(--dsp-u)); line-height: 1.08; }
188
+ .display-card .display-event-meta, .display-card .display-event-detail { font-size: calc(2.4 * var(--dsp-u)); white-space: normal; }
189
+ .display-card.is-big .display-event-meta { font-size: calc(3 * var(--dsp-u)); }
190
+ .display-empty { margin: 0; color: var(--dsp-muted); font-size: calc(3 * var(--dsp-u)); }
191
+ .display-empty.is-board { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
192
+
193
+ /* a screen that isn't there (wrong or rotated key) */
194
+ .display-missing { justify-content: center; align-items: center; text-align: center; }
195
+ .display-missing p { margin: 0; max-width: 60ch; color: var(--dsp-muted); }