diff --git a/.gitignore b/.gitignore index e1365c2fe6..71066f61dd 100644 --- a/.gitignore +++ b/.gitignore @@ -56,11 +56,16 @@ Temporary Items # Ignore all modules /modules/* +!/modules/MMM-RuterDepartures +!/modules/MMM-YrMeteogram +!/modules/MMM-PortraitBoard # Ignore users config file but keep the samples. config !config/config.js.sample !config/custom.css.sample +!config/config.js +!config/custom.css # Vim ## swap diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..aa39f7c5e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,101 @@ +# MagicMirror — Oslo Edition + +Custom MagicMirror setup for a Raspberry Pi behind a physical mirror. Oslo-focused: local time, Ruter public transport departures, Norwegian holidays, and weather. + +Upstream: [MagicMirrorOrg/MagicMirror](https://github.com/MagicMirrorOrg/MagicMirror). Fork lives at `nrkno/MagicMirror`. + +## Project goals + +- Display Ruter departures for a specific stop (coordinates-based) +- Oslo clock, Norwegian holidays calendar, later: Yr weather +- Keep the UI minimal — no clutter, dark mirror aesthetic + +## Running the app + +### First time setup + +```sh +npm run install-mm +``` + +Use `install-mm` instead of plain `npm install` — it omits dev dependencies and avoids engine version conflicts with ESLint plugins that require a newer Node patch than what's installed. + +### On macOS (local dev) + +```sh +npm run server +``` + +Then open http://localhost:8080 in any browser. No Electron needed. + +If port 8080 is in use, find and kill the occupying process first: +```sh +lsof -ti :8080 | xargs kill +npm run server +``` + +To open a native Electron window instead of the browser: +```sh +./node_modules/.bin/electron js/electron.js +``` + +### On the Pi (with a real screen) + +```sh +npm start +``` + +This runs `start:wayland`. Requires a display attached to the Pi. + +### On the Pi (headless, for testing) + +`npm start` fails without a Wayland compositor. Use Xvfb: +```sh +xvfb-run -a env DISPLAY=:99 ELECTRON_OZONE_PLATFORM_HINT=x11 ./node_modules/.bin/electron . --no-sandbox +``` +Requires `apt install xvfb`. + +## Config + +Config lives at `config/config.js`. Create it from the sample if it doesn't exist: +``` +cp config/config.js.sample config/config.js +``` + +Key settings for this setup: +- Timezone: `Europe/Oslo` +- Language: `nb` (Norwegian Bokmål) or `en` +- Norwegian holiday calendar: + `https://calendar.google.com/calendar/ical/nb.norwegian%23holiday%40group.v.calendar.google.com/public/basic.ics` + +## Custom modules + +Custom modules go in `modules/`. Each module is a directory: +``` +modules/ + MMM-RuterDepartures/ + MMM-RuterDepartures.js # module definition (browser-side) + node_helper.js # server-side helper (optional, for API calls) + MMM-RuterDepartures.css # styles +``` + +### Ruter departures module (in progress) + +Target stop: coordinates `59.932267, 10.724083` (configurable in `config/config.js`). + +Ruter's public API endpoint for stop discovery and departures: +- Stop by coordinates: `https://api.ruter.no/v2/travel/getStopsByCoordinates/?X=&Y=&proposals=1` +- Departures: `https://api.ruter.no/v2/travel/getdepartures/` + +The module should fetch from `node_helper.js` (server side) and pass results to the frontend module for rendering — do not embed the Ruter monitor website (`mon.ruter.no`) in an iframe. + +## Node / npm + +Node 24 via nvm is required. The system Node on Linux was too old — install nvm and `nvm use 24` before running `npm install`. + +## Repo hygiene + +- `config/config.js` is safe to commit as long as it contains no secrets (API keys, tokens). The current Oslo config has none, so commit it freely. +- If you add a private API key later, use `config/config.js.template` instead — put `${MY_SECRET}` placeholders in it and MagicMirror will compile it to `config.js` at startup using environment variables. Commit the template, not the compiled file. +- `modules/` third-party modules should each be gitignored or tracked separately +- Keep `node_modules/` out of commits (already in `.gitignore`) diff --git a/config/config.js b/config/config.js new file mode 100644 index 0000000000..ecf6107ab3 --- /dev/null +++ b/config/config.js @@ -0,0 +1,53 @@ +let config = { + address: "localhost", + port: 8080, + basePath: "/", + ipWhitelist: ["127.0.0.1", "::ffff:127.0.0.1", "::1"], + + useHttps: false, + httpsPrivateKey: "", + httpsCertificate: "", + + language: "nb", + locale: "nb-NO", + + logLevel: ["INFO", "LOG", "WARN", "ERROR"], + timeFormat: 24, + units: "metric", + + modules: [ + { + module: "MMM-PortraitBoard", + position: "fullscreen_above", + config: { + timezone: "Europe/Oslo", + homeHour: 14, + homeMinute: 30, + walkMinutes: { Marienlyst: 5, Majorstuen: 10 } + } + }, + { + module: "calendar", + // Data source only. Add the shared Google Calendar's public/private iCal URL when available. + config: { + calendars: [] + } + }, + { + module: "MMM-RuterDepartures", + // Data source only. The portrait module renders the departures. + config: { + lat: 59.932267, + lon: 10.724083, + maxStops: 3, + maxDepartures: 16, + updateInterval: 30 * 1000, + excludeStops: ["Vestre Aker kirke"], + walkMinutes: { Marienlyst: 5, Majorstuen: 10 } + } + }, + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { module.exports = config; } diff --git a/config/custom.css b/config/custom.css new file mode 100644 index 0000000000..0165e22085 --- /dev/null +++ b/config/custom.css @@ -0,0 +1,77 @@ +/* NRK-styled MagicMirror + * + * Applies NRK Sans typography and the periwinkle accent from the NRK design system. + * Background stays pure #000 — required for the two-way mirror glass. + */ + +@import url("https://static.nrk.no/nrk-sans/latest/nrk-sans.min.css"); + +:root { + /* NRK periwinkle accent for interactive/highlight text on dark surfaces. */ + --nrk-accent: #8bb5eb; + + --color-text: #a7b3c2; + --color-text-dimmed: #5a6676; + --color-text-bright: #ffffff; + --color-background: #000; + + --font-primary: "NRK Sans", "Inter", -apple-system, BlinkMacSystemFont, sans-serif; + --font-secondary: "NRK Sans", "Inter", sans-serif; + + --font-size: 20px; + --font-size-small: 0.875rem; + + --gap-body-top: 60px; + --gap-body-right: 60px; + --gap-body-bottom: 60px; + --gap-body-left: 60px; + + --gap-modules: 36px; +} + +/* NRK hierarchy: heavy weight for primary labels, restrained sizes. */ +header.module-header { + font-weight: 500; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-text-dimmed); + border-bottom: none; + padding-bottom: 0; +} + +/* Clock — big and bold like NRK editorial headlines. */ +.clock .time { + font-weight: 800; + letter-spacing: -0.02em; +} + +.clock .date { + font-weight: 400; + color: var(--color-text); +} + +/* Weather — larger, cleaner current temperature. */ +.weather .large.light { + font-weight: 300; +} + +/* Keep radii subtle (4px NRK standard). */ +.module, +.module-content, +table { + border-radius: 0.25rem; +} + +/* News ticker — brighter headline, smaller source label. */ +.newsfeed-source { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-dimmed); +} + +.newsfeed-title { + font-weight: 400; + color: var(--color-text-bright); +} diff --git a/modules/MMM-PortraitBoard/MMM-PortraitBoard.css b/modules/MMM-PortraitBoard/MMM-PortraitBoard.css new file mode 100644 index 0000000000..d8b41e5d28 --- /dev/null +++ b/modules/MMM-PortraitBoard/MMM-PortraitBoard.css @@ -0,0 +1,74 @@ +/* Dedicated fullscreen layout: other MagicMirror positions are unused in portrait mode. */ +html:has(.portrait-board) { overflow: hidden; } +body:has(.portrait-board) { margin: 0; width: 100%; height: 100%; position: absolute; } +body:has(.portrait-board) .region.fullscreen.above { inset: 0; } +body:has(.portrait-board) .region.fullscreen.above > .container { height: 100%; display: flex; justify-content: center; } +body:has(.portrait-board) .region.fullscreen.above .module { margin: 0; width: min(100vw, 56.25vh); height: 100vh; } +body:has(.portrait-board) .region.fullscreen.above .module-content { height: 100%; } + +.portrait-board { --portrait-primary: #fff; --portrait-secondary: #b8bec8; --portrait-rule: #3b414a; box-sizing: border-box; container-type: inline-size; width: 100%; height: 100%; overflow: hidden; background: #05080e; color: var(--portrait-primary); font-family: "NRK Sans", Arial, sans-serif; display: grid; grid-template-rows: auto 1fr; text-align: left; line-height: 1.2; } +.portrait-board * { box-sizing: border-box; } +.portrait-masthead { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 2cqw; padding: 5cqw 5cqw 0; border: 0; margin: 0; line-height: 1.2; text-transform: none; } +.portrait-date { grid-column: 1; grid-row: 2; color: var(--portrait-secondary); font-size: 3.7cqw; white-space: nowrap; margin-top: 1cqw; } +.portrait-clock { grid-column: 1; grid-row: 1; color: var(--portrait-primary); font-size: 11cqw; font-weight: 750; line-height: .94; letter-spacing: -.045em; font-variant-numeric: tabular-nums; } +.portrait-header-greeting { grid-column: 2; grid-row: 1 / 3; align-self: center; justify-self: end; margin: 0; font-size: 7cqw; line-height: 1.05; letter-spacing: -.04em; font-weight: 750; color: var(--portrait-primary); text-align: right; } +.portrait-body { display: flex; flex-direction: column; min-height: 0; padding: 3cqw 5cqw; gap: 1cqw; } +.portrait-panel { border-top: 1px solid var(--portrait-rule); padding-top: 2cqw; } +.portrait-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 2cqw; } +.portrait-heading h2 { margin: 0; font-size: 5cqw; line-height: 1.2; font-weight: 750; color: var(--portrait-secondary); } +.portrait-aside { color: var(--portrait-secondary); font-size: 2.5cqw; font-weight: 700; letter-spacing: .08em; white-space: nowrap; } +.portrait-weather-main { display: flex; align-items: center; gap: 4cqw; margin-top: 0; } +.portrait-weather-symbol { width: 13cqw; font-size: 11cqw; line-height: 1; color: var(--portrait-primary); text-align: center; } +.portrait-temperature { font-size: 15cqw; line-height: 1; font-weight: 650; letter-spacing: -.065em; } +.portrait-summary { font-size: 3.9cqw; line-height: 1.22; margin: 1cqw 0 0; color: var(--portrait-primary); } +.portrait-event { display: flex; gap: 5cqw; align-items: baseline; padding: 3cqw 0; border-bottom: 1px solid var(--portrait-rule); font-size: 4.4cqw; } +.portrait-event time { color: var(--portrait-primary); font-weight: 750; white-space: nowrap; font-variant-numeric: tabular-nums; } +.portrait-event div { display: flex; flex-direction: column; gap: .6cqw; min-width: 0; } +.portrait-event strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.portrait-event span { font-size: 3cqw; color: var(--portrait-secondary); } +.portrait-departures { margin-top: 1cqw; } +.portrait-home .portrait-departures { margin-top: 0; } +.portrait-departure-group { margin-top: 2cqw; } +.portrait-departure-group + .portrait-departure-group { margin-top: 2cqw; } +.portrait-group-header { display: flex; justify-content: space-between; align-items: baseline; gap: 2cqw; margin-bottom: 1cqw; } +.portrait-group-heading { margin: 0; font-size: 3.8cqw; line-height: 1.2; font-weight: 700; color: var(--portrait-secondary); } +.portrait-track-heading { font-size: 2.8cqw; font-weight: 700; color: var(--portrait-secondary); text-align: center; width: 7cqw; flex: none; } +.portrait-departure { display: grid; grid-template-columns: 13cqw minmax(0, 1fr) auto; align-items: center; gap: 2cqw; padding: 1cqw 0; border-bottom: 1px solid var(--portrait-rule); font-size: 5cqw; } +.portrait-departure-metro { grid-template-columns: 13cqw minmax(0, 1fr) auto 7cqw; } +.portrait-track { text-align: center; font-size: 4.4cqw; font-weight: 750; font-variant-numeric: tabular-nums; } +.portrait-badge { width: 11cqw; text-align: center; padding: 1cqw 0; font-size: 4.4cqw; line-height: 1; font-weight: 800; background: #2772b6; } +.portrait-bus { background: #d64846; } +.portrait-metro { background: #2772b6; } +/* Colours sampled from the T-bane network map; line numbers are white on every badge. */ +.portrait-metro-line-1 { background: #53abde; color: #fff; } +.portrait-metro-line-2 { background: #e27938; color: #fff; } +.portrait-metro-line-3 { background: #ae7cb0; color: #fff; } +.portrait-metro-line-4 { background: #235ca5; color: #fff; } +.portrait-metro-line-5 { background: #6cb358; color: #fff; } +.portrait-destination { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.portrait-minutes { color: var(--portrait-primary); font-size: 5cqw; font-weight: 750; font-variant-numeric: tabular-nums; white-space: nowrap; } +.portrait-unavailable { font-size: 4cqw; color: var(--portrait-secondary); } + +/* Offline: only a large centred clock and date, so no stale data is shown. */ +.portrait-offline { grid-template-rows: none; align-content: center; justify-items: center; text-align: center; } +.portrait-offline .portrait-clock { font-size: 26cqw; } +.portrait-offline .portrait-date { font-size: 6cqw; margin-top: 3cqw; } +.portrait-offline-note { grid-row: 3; margin-top: 8cqw; color: var(--portrait-rule); font-size: 3cqw; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } + +/* Side-by-side greeting comparison, opt-in via ?preview=messages. */ +body:has(.portrait-comparison) .region.fullscreen.above .module { width: 100vw; } +.portrait-comparison { box-sizing: border-box; width: 100%; height: 100%; overflow-y: auto; padding: 24px; background: #05080e; color: #fff; font-family: "NRK Sans", Arial, sans-serif; } +.portrait-comparison-title { margin: 0 0 4px; font-size: 30px; } +.portrait-comparison-note { margin: 0 0 24px; color: #b8bec8; font-size: 16px; } +.portrait-comparison-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; max-width: 1500px; margin: 0 auto; } +.portrait-comparison-card { min-width: 0; container-type: inline-size; } +.portrait-comparison-label { margin: 0 0 8px; color: #b8bec8; font-size: 16px; font-weight: 700; } +.portrait-comparison-card .portrait-board { height: auto; aspect-ratio: 9 / 16; border: 1px solid #3b414a; } +@media (max-width: 700px) { + .portrait-comparison-grid { grid-template-columns: 1fr; } + .portrait-comparison { padding: 16px; } +} + +@media (orientation: portrait) { + body:has(.portrait-board):not(:has(.portrait-comparison)) .region.fullscreen.above .module { width: 100vw; } +} diff --git a/modules/MMM-PortraitBoard/MMM-PortraitBoard.js b/modules/MMM-PortraitBoard/MMM-PortraitBoard.js new file mode 100644 index 0000000000..cf2e861a5e --- /dev/null +++ b/modules/MMM-PortraitBoard/MMM-PortraitBoard.js @@ -0,0 +1,254 @@ +Module.register("MMM-PortraitBoard", { + defaults: { + timezone: "Europe/Oslo", + homeHour: 14, + homeMinute: 30, + lat: 59.9139, + lon: 10.7522, + weatherUpdateInterval: 30 * 60 * 1000, + weatherMaxAge: 90 * 60 * 1000, + departuresMaxAge: 2 * 60 * 1000, + walkMinutes: { Marienlyst: 5, Majorstuen: 10 }, + maxBusDepartures: 5, + maxMetroDepartures: 6, + // When the internet is unreachable, the board shows only the clock and date. + connectivityUrl: "https://api.entur.io/", + connectivityInterval: 30 * 1000, + failuresBeforeOffline: 2 + }, + + getStyles() { + return ["MMM-PortraitBoard.css"]; + }, + + start() { + this.weather = null; + this.weatherReceivedAt = 0; + this.departures = []; + this.departuresReceivedAt = 0; + this.events = []; + this.online = true; + this.sendSocketNotification("PORTRAIT_CONNECTIVITY_START", { + url: this.config.connectivityUrl, + interval: this.config.connectivityInterval, + failuresBeforeOffline: this.config.failuresBeforeOffline + }); + this.sendSocketNotification("PORTRAIT_WEATHER_START", { + identifier: this.identifier, + lat: this.config.lat, + lon: this.config.lon, + updateInterval: this.config.weatherUpdateInterval + }); + this.timer = setInterval(() => this.updateDom(0), 30 * 1000); + }, + + socketNotificationReceived(notification, payload) { + if (notification === "PORTRAIT_WEATHER" && payload.identifier === this.identifier) { + this.weather = payload.weather; + this.weatherReceivedAt = Date.now(); + this.updateDom(0); + } else if (notification === "PORTRAIT_CONNECTIVITY") { + this.online = payload.online; + this.updateDom(0); + } + }, + + notificationReceived(notification, payload) { + if (notification === "PORTRAIT_DEPARTURES") { + this.departures = payload.stops || []; + this.departuresReceivedAt = payload.receivedAt; + this.updateDom(0); + } else if (notification === "CALENDAR_EVENTS") { + this.events = payload || []; + this.updateDom(0); + } + }, + + localParts(date) { + const parts = new Intl.DateTimeFormat("nb-NO", { + timeZone: this.config.timezone, weekday: "long", day: "numeric", month: "long", + hour: "2-digit", minute: "2-digit", hourCycle: "h23" + }).formatToParts(date); + return Object.fromEntries(parts.map(({ type, value }) => [type, value])); + }, + + greetingFor(minutes, weekday) { + if (minutes <= 7 * 60 + 30) return "Tidlig ute i dag? :)"; + if (minutes <= 9 * 60 + 30) return "God morgen"; + if (minutes < 12 * 60) return "God formiddag"; + if (minutes < this.config.homeHour * 60 + this.config.homeMinute) return "Ha en fin dag :)"; + return weekday.toLowerCase() === "fredag" ? "God helg" : "God tur hjem"; + }, + + masthead(parts) { + const minutes = Number(parts.hour) * 60 + Number(parts.minute); + const header = this.element("header", "portrait-masthead"); + header.appendChild(this.element("time", "portrait-clock", `${parts.hour}:${parts.minute}`)); + header.appendChild(this.element("span", "portrait-date", `${parts.weekday} ${parts.day}. ${parts.month}`)); + header.appendChild(this.element("p", "portrait-header-greeting", this.greetingFor(minutes, parts.weekday))); + return header; + }, + + element(tag, className, text) { + const el = document.createElement(tag); + if (className) el.className = className; + if (text !== undefined) el.textContent = text; + return el; + }, + + section(title, aside) { + const section = this.element("section", "portrait-panel"); + const heading = this.element("div", "portrait-heading"); + heading.appendChild(this.element("h2", "", title)); + if (aside) heading.appendChild(this.element("span", "portrait-aside", aside)); + section.appendChild(heading); + return section; + }, + + weatherSection(home, now) { + if (!this.weather || now - this.weatherReceivedAt > this.config.weatherMaxAge) return null; + const section = this.section("Været"); + section.classList.add("portrait-weather"); + const main = this.element("div", "portrait-weather-main"); + main.appendChild(this.element("span", "portrait-weather-symbol", this.weather.symbol)); + main.appendChild(this.element("strong", "portrait-temperature", `${Math.round(this.weather.temperature)}°`)); + section.appendChild(main); + const summary = home ? this.weather.homeSummary : this.weather.summary; + section.appendChild(this.element("p", "portrait-summary", summary)); + return section; + }, + + eventsSection(now) { + const today = new Intl.DateTimeFormat("en-CA", { timeZone: this.config.timezone, year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(now)); + const events = this.events.filter((event) => { + const start = Number(event.startDate); + const end = Number(event.endDate); + return Number.isFinite(start) && Number.isFinite(end) && end > now && + new Intl.DateTimeFormat("en-CA", { timeZone: this.config.timezone, year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(start)) === today; + }).slice(0, 2); + if (!events.length) return null; + const section = this.section("Fellesmøter i dag"); + section.classList.add("portrait-events"); + for (const event of events) { + const row = this.element("div", "portrait-event"); + row.appendChild(this.element("time", "", event.fullDayEvent ? "I dag" : new Intl.DateTimeFormat("nb-NO", { timeZone: this.config.timezone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(Number(event.startDate))))); + const detail = this.element("div"); + detail.appendChild(this.element("strong", "", event.title)); + if (event.location) detail.appendChild(this.element("span", "", event.location)); + row.appendChild(detail); + section.appendChild(row); + } + return section; + }, + + departuresFor(stopName, mode, now, limit) { + const walk = this.config.walkMinutes[stopName] || 0; + return this.departures + .filter((stop) => stop.name.toLowerCase().includes(stopName.toLowerCase())) + .flatMap((stop) => stop.departures) + .filter((dep) => dep.mode === mode && (new Date(dep.expectedTime).getTime() - now) / 60000 >= walk) + .sort((a, b) => new Date(a.expectedTime) - new Date(b.expectedTime)) + .slice(0, limit); + }, + + departureGroup(title, stopName, mode, now) { + const group = this.element("div", "portrait-departure-group"); + const heading = this.element("div", "portrait-group-header"); + heading.appendChild(this.element("h3", "portrait-group-heading", `${title} · ${stopName}`)); + if (mode === "metro") heading.appendChild(this.element("span", "portrait-track-heading", "Spor")); + group.appendChild(heading); + const limit = mode === "metro" ? this.config.maxMetroDepartures : this.config.maxBusDepartures; + const reachable = this.departuresFor(stopName, mode, now, limit); + if (!reachable.length) group.appendChild(this.element("p", "portrait-unavailable", "Ingen avganger innen rekkevidde")); + for (const dep of reachable) { + const row = this.element("div", `portrait-departure${mode === "metro" ? " portrait-departure-metro" : ""}`); + const metroLine = mode === "metro" && ["1", "2", "3", "4", "5"].includes(String(dep.line)) ? ` portrait-metro-line-${dep.line}` : ""; + row.appendChild(this.element("span", `portrait-badge portrait-${mode}${metroLine}`, dep.line)); + row.appendChild(this.element("strong", "portrait-destination", dep.destination)); + row.appendChild(this.element("span", "portrait-minutes", `${Math.max(0, Math.ceil((new Date(dep.expectedTime) - now) / 60000))} min`)); + if (mode === "metro") row.appendChild(this.element("span", "portrait-track", dep.track || "–")); + group.appendChild(row); + } + return group; + }, + + departuresSection(now) { + const section = this.section("Avganger"); + section.classList.add("portrait-departures"); + if (now - this.departuresReceivedAt > this.config.departuresMaxAge) { + section.appendChild(this.element("p", "portrait-unavailable", "Avganger ikke tilgjengelige nå")); + return section; + } + section.appendChild(this.departureGroup("Buss", "Marienlyst", "bus", now)); + section.appendChild(this.departureGroup("T-bane", "Majorstuen", "metro", now)); + return section; + }, + + messagesPreview() { + const preview = this.element("main", "portrait-comparison"); + preview.appendChild(this.element("h1", "portrait-comparison-title", "Hilsener gjennom dagen")); + preview.appendChild(this.element("p", "portrait-comparison-note", "Utsnitt av dashbordet · eksempelvær · klokkeslett i Europe/Oslo")); + const grid = this.element("div", "portrait-comparison-grid"); + const cases = [ + ["00:00–07:30", "2026-10-21T07:30:00+02:00"], + ["07:31–09:30", "2026-10-21T09:00:00+02:00"], + ["09:31–11:59", "2026-10-21T10:30:00+02:00"], + ["12:00–14:29", "2026-10-21T13:00:00+02:00"], + ["14:30–23:59 · øvrige dager", "2026-10-21T15:00:00+02:00"], + ["14:30–23:59 · fredag", "2026-10-23T15:00:00+02:00"] + ]; + for (const [label, date] of cases) { + const card = this.element("div", "portrait-comparison-card"); + card.appendChild(this.element("h2", "portrait-comparison-label", label)); + const parts = this.localParts(new Date(date)); + const home = Number(parts.hour) * 60 + Number(parts.minute) >= this.config.homeHour * 60 + this.config.homeMinute; + const board = this.element("div", `portrait-board ${home ? "portrait-home" : "portrait-workday"}`); + board.appendChild(this.masthead(parts)); + const body = this.element("div", "portrait-body"); + const weather = this.section("Været"); + weather.classList.add("portrait-weather"); + const main = this.element("div", "portrait-weather-main"); + main.appendChild(this.element("span", "portrait-weather-symbol", "☁")); + main.appendChild(this.element("strong", "portrait-temperature", "12°")); + weather.appendChild(main); + weather.appendChild(this.element("p", "portrait-summary", home ? "Opphold på vei hjem" : "Opphold utover dagen")); + body.appendChild(weather); + board.appendChild(body); + card.appendChild(board); + grid.appendChild(card); + } + preview.appendChild(grid); + return preview; + }, + + offlineBoard(parts) { + const board = this.element("main", "portrait-board portrait-offline"); + board.appendChild(this.element("time", "portrait-clock", `${parts.hour}:${parts.minute}`)); + board.appendChild(this.element("span", "portrait-date", `${parts.weekday} ${parts.day}. ${parts.month}`)); + board.appendChild(this.element("span", "portrait-offline-note", "Frakoblet")); + return board; + }, + + getDom() { + if (new URLSearchParams(window.location.search).get("preview") === "messages") return this.messagesPreview(); + const now = Date.now(); + // Preview only changes the displayed clock/date and view; live data is still filtered against real time. + const preview = new URLSearchParams(window.location.search).get("preview"); + if (!this.online || preview === "offline") return this.offlineBoard(this.localParts(new Date(now))); + const parts = this.localParts(preview === "wednesday" ? new Date("2026-10-21T15:00:00+02:00") : new Date(now)); + if (preview === "morning") { + parts.hour = "09"; + parts.minute = "00"; + } + const minutes = Number(parts.hour) * 60 + Number(parts.minute); + const home = minutes >= this.config.homeHour * 60 + this.config.homeMinute; + const board = this.element("main", "portrait-board portrait-home"); + board.appendChild(this.masthead(parts)); + const body = this.element("div", "portrait-body"); + const weather = this.weatherSection(home, now); + if (weather) body.appendChild(weather); + body.appendChild(this.departuresSection(now)); + board.appendChild(body); + return board; + } +}); diff --git a/modules/MMM-PortraitBoard/README.md b/modules/MMM-PortraitBoard/README.md new file mode 100644 index 0000000000..8763aa8743 --- /dev/null +++ b/modules/MMM-PortraitBoard/README.md @@ -0,0 +1,21 @@ +# Portrait board in MagicMirror + +Configured in `config/config.js` as the only visible module. Open `http://localhost:8080` after `npm run server`. The page fills a portrait display (2160 × 3840 on a typical 4K portrait screen); on a landscape browser it is centered at 9:16 for preview. The operating system must rotate the physical monitor separately. + +For a temporary going-home layout preview with **Wednesday, 21 October, 15:00** and **God tur hjem**, open `http://localhost:8080/?preview=wednesday`. This only changes the displayed date/time and view in that browser tab. Weather and departures are still live and departure minutes refer to the real current time. Remove the query string to return to the normal schedule. + +To inspect the dashboard at a morning time, open `http://localhost:8080/?preview=morning`. It shows **09:00** on today's date with live weather and departures; departure minutes refer to the real current time. + +To compare all six greetings side by side using the dashboard's actual header and weather styling, open `http://localhost:8080/?preview=messages`. This comparison uses example weather and dates, and does not affect the normal display. + +The Wednesday preview uses the normal number of rows. Bus and T-bane departure times remain live. + +- All day: the former going-home layout with compact weather and separate **Buss · Marienlyst** (up to five) and **T-bane · Majorstuen** (up to six) groups, sorted by departure time within each group. Other modes at each stop are excluded. The weather summary changes at 14:30 **Europe/Oslo** to describe the trip home. The view and clock update every 30 seconds without reloading. Shared meetings are not shown in this departures-focused layout. +- Top-right greeting (Europe/Oslo): **Tidlig ute i dag? :)** from 00:00–07:30; **God morgen** from 07:31–09:30; **God formiddag** from 09:31–11:59; **Ha en fin dag :)** from 12:00–14:29; **God helg** from 14:30–23:59 on Fridays and **God tur hjem** at those times on other days. +- T-bane rows show the platform number (**Spor**) from Entur's `quay.publicCode` at the right; missing numbers show a dash. Bus rows do not show a platform column. +- T-bane badges for lines 1–5 use colours sampled from the [official network map](https://cdn.sanity.io/files/5a84xxkm/prod/e46cf566dd5b9cc4e323b70b168115d7bc3c911f.pdf): 1 light blue, 2 orange, 3 purple, 4 dark blue, 5 green. These are map-derived screen colours, not published brand specifications; the line number remains visible independently of colour. +- Missing or old weather disappears. If departure data is unavailable or old, the screen says so instead of showing outdated times. + +The existing `MMM-RuterDepartures` module fetches Entur data and broadcasts it to this module; it has no position, so its original display is hidden. The built-in `calendar` module remains configured as a data source for future use, but meetings are not currently displayed. If a shared Google Calendar iCal URL is added later, do not commit a private feed URL containing a secret token. + +Weather uses the Norwegian Meteorological Institute's locationforecast API (the data behind Yr), fetched by this module's node helper. This is intentionally a short weather summary, not the Yr meteogram. The sample design at `prototypes/portrait-board/index.html` remains independent and uses example data. This implementation does **not** change the physical display orientation or resolution. diff --git a/modules/MMM-PortraitBoard/node_helper.js b/modules/MMM-PortraitBoard/node_helper.js new file mode 100644 index 0000000000..ec77e87584 --- /dev/null +++ b/modules/MMM-PortraitBoard/node_helper.js @@ -0,0 +1,79 @@ +const NodeHelper = require("node_helper"); + +module.exports = NodeHelper.create({ + start() { + this.timers = {}; + }, + + socketNotificationReceived(notification, payload) { + if (notification === "PORTRAIT_CONNECTIVITY_START") { + this.startConnectivity(payload); + return; + } + if (notification !== "PORTRAIT_WEATHER_START") return; + clearTimeout(this.timers[payload.identifier]); + this.fetchWeather(payload); + }, + + // One shared check for all connected browsers; a new browser just gets the current state. + startConnectivity(config) { + if (this.connectivity) { + this.sendSocketNotification("PORTRAIT_CONNECTIVITY", { online: this.connectivity.online }); + return; + } + this.connectivity = { online: true, failures: 0 }; + this.checkConnectivity(config); + }, + + async checkConnectivity(config) { + let reachable = false; + try { + // Any HTTP response, even an error status, proves the internet is reachable. + await fetch(config.url, { method: "HEAD", signal: AbortSignal.timeout(10 * 1000) }); + reachable = true; + } catch { + reachable = false; + } + + const state = this.connectivity; + state.failures = reachable ? 0 : state.failures + 1; + // Go offline only after several failures in a row, but come back on the first success. + const online = reachable || (state.online && state.failures < config.failuresBeforeOffline); + if (online !== state.online) { + state.online = online; + console.log(`[MMM-PortraitBoard] Connection ${online ? "restored" : "lost"}`); + this.sendSocketNotification("PORTRAIT_CONNECTIVITY", { online }); + } + setTimeout(() => this.checkConnectivity(config), config.interval); + }, + + async fetchWeather(config) { + try { + const url = new URL("https://api.met.no/weatherapi/locationforecast/2.0/compact"); + url.searchParams.set("lat", config.lat); + url.searchParams.set("lon", config.lon); + const response = await fetch(url, { headers: { "User-Agent": "MagicMirror-Oslo-Edition/1.0 (https://github.com/nrkno/MagicMirror)" } }); + if (!response.ok) throw new Error(`Yr returned HTTP ${response.status}`); + const data = await response.json(); + const series = data.properties.timeseries; + const current = series[0]; + const symbol = current.data.next_1_hours?.summary?.symbol_code || current.data.next_6_hours?.summary?.symbol_code || ""; + const next = series.find((point) => new Date(point.time) > Date.now() + 3 * 60 * 60 * 1000); + const nextSymbol = next?.data.next_1_hours?.summary?.symbol_code || ""; + const rainy = /rain|sleet|snow|showers/.test(symbol); + const laterRain = /rain|sleet|snow|showers/.test(nextSymbol); + this.sendSocketNotification("PORTRAIT_WEATHER", { + identifier: config.identifier, + weather: { + temperature: current.data.instant.details.air_temperature, + symbol: rainy ? "☂" : /clearsky|fair/.test(symbol) ? "☀" : "☁", + summary: rainy ? "Nedbør nå" : laterRain ? "Nedbør senere i dag" : "Opphold utover dagen", + homeSummary: rainy ? "Nedbør på vei hjem" : "Opphold på vei hjem" + } + }); + } catch (error) { + console.error("[MMM-PortraitBoard] Yr:", error.message); + } + this.timers[config.identifier] = setTimeout(() => this.fetchWeather(config), Math.max(10 * 60 * 1000, config.updateInterval)); + } +}); diff --git a/modules/MMM-RuterDepartures/MMM-RuterDepartures.css b/modules/MMM-RuterDepartures/MMM-RuterDepartures.css new file mode 100644 index 0000000000..690bc7c407 --- /dev/null +++ b/modules/MMM-RuterDepartures/MMM-RuterDepartures.css @@ -0,0 +1,46 @@ +.ruter-stop { + margin-bottom: 16px; +} + +.ruter-stop-name { + margin-bottom: 6px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-text-dimmed, #666); +} + +.ruter-row { + display: grid; + grid-template-columns: 2.5em 1fr 4em; + gap: 10px; + align-items: baseline; + padding: 4px 0; +} + +.ruter-line { + text-align: right; + font-weight: 700; + color: var(--color-text-bright, #fff); + letter-spacing: 0.01em; +} + +.ruter-destination { + color: var(--color-text, #a7b3c2); + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ruter-time { + text-align: right; + font-weight: 400; + color: var(--color-text, #a7b3c2); +} + +/* Periwinkle accent for live/realtime departures — NRK's on-dark accent color. */ +.ruter-time.realtime { + color: var(--nrk-accent, #8bb5eb); +} diff --git a/modules/MMM-RuterDepartures/MMM-RuterDepartures.js b/modules/MMM-RuterDepartures/MMM-RuterDepartures.js new file mode 100644 index 0000000000..c80b9b8986 --- /dev/null +++ b/modules/MMM-RuterDepartures/MMM-RuterDepartures.js @@ -0,0 +1,97 @@ +Module.register("MMM-RuterDepartures", { + defaults: { + lat: 59.932267, + lon: 10.724083, + maxStops: 3, + maxDepartures: 6, + updateInterval: 30 * 1000, + walkMinutes: {}, + defaultWalkMinutes: 0, + }, + + getStyles() { + return ["MMM-RuterDepartures.css"]; + }, + + start() { + this.stops = []; + this.sendSocketNotification("RUTER_GET_DEPARTURES", { + ...this.config, + identifier: this.identifier, + }); + }, + + socketNotificationReceived(notification, payload) { + if (notification === "RUTER_DEPARTURES" && payload.identifier === this.identifier) { + this.stops = payload.stops; + this.sendNotification("PORTRAIT_DEPARTURES", { stops: this.stops, receivedAt: Date.now() }); + this.updateDom(); + } + }, + + getDom() { + const wrapper = document.createElement("div"); + + if (!this.stops.length) { + wrapper.className = "dimmed small"; + wrapper.textContent = "Laster avganger…"; + return wrapper; + } + + for (const stop of this.stops) { + const walkMins = this.walkMinutesFor(stop.name); + const now = new Date(); + + const reachable = stop.departures.filter((dep) => { + const mins = Math.round((new Date(dep.expectedTime) - now) / 60000); + return mins >= walkMins; + }).slice(0, this.config.maxDepartures); + + const section = document.createElement("div"); + section.className = "ruter-stop"; + + const header = document.createElement("div"); + header.className = "ruter-stop-name dimmed xsmall"; + header.textContent = stop.name; + section.appendChild(header); + + if (!reachable.length) { + const empty = document.createElement("div"); + empty.className = "dimmed xsmall"; + empty.textContent = "Ingen avganger innen rekkevidde"; + section.appendChild(empty); + } else { + const list = document.createElement("div"); + list.className = "ruter-departures small"; + + for (const dep of reachable) { + const mins = Math.round((new Date(dep.expectedTime) - now) / 60000); + const row = document.createElement("div"); + row.className = "ruter-row"; + row.innerHTML = ` + ${dep.line} + ${dep.destination} + ${mins} min + `; + list.appendChild(row); + } + + section.appendChild(list); + } + + wrapper.appendChild(section); + } + + return wrapper; + }, + + walkMinutesFor(stopName) { + const map = this.config.walkMinutes; + for (const key of Object.keys(map)) { + if (stopName.toLowerCase().includes(key.toLowerCase())) { + return map[key]; + } + } + return this.config.defaultWalkMinutes; + }, +}); diff --git a/modules/MMM-RuterDepartures/node_helper.js b/modules/MMM-RuterDepartures/node_helper.js new file mode 100644 index 0000000000..abb0d4270b --- /dev/null +++ b/modules/MMM-RuterDepartures/node_helper.js @@ -0,0 +1,122 @@ +const NodeHelper = require("node_helper"); + +const ENTUR_URL = "https://api.entur.io/journey-planner/v3/graphql"; +const CLIENT_NAME = "nrkno-magicmirror"; + +module.exports = NodeHelper.create({ + start() { + this.timers = {}; + }, + + socketNotificationReceived(notification, payload) { + if (notification === "RUTER_GET_DEPARTURES") { + clearTimeout(this.timers[payload.identifier]); + this.run(payload); + } + }, + + async run(config) { + const { lat, lon, maxStops, excludeStops = [], maxDepartures, updateInterval, identifier } = config; + + try { + const allStops = await this.fetchNearbyStops(lat, lon, maxStops); + const stops = allStops.filter( + (s) => !excludeStops.some((ex) => s.name.toLowerCase().includes(ex.toLowerCase())) + ); + const stopsWithDepartures = stops.length + ? await this.fetchDepartures(stops, maxDepartures) + : []; + this.sendSocketNotification("RUTER_DEPARTURES", { identifier, stops: stopsWithDepartures }); + } catch (err) { + console.error("[MMM-RuterDepartures]", err.message); + } + + this.timers[identifier] = setTimeout(() => this.run(config), updateInterval); + }, + + async fetchNearbyStops(lat, lon, maxStops) { + const query = `{ + nearest( + latitude: ${lat} + longitude: ${lon} + maximumDistance: 500 + maximumResults: ${maxStops} + filterByPlaceTypes: [stopPlace] + ) { + edges { + node { + place { + ... on StopPlace { + id + name + } + } + } + } + } + }`; + + const data = await this.graphql(query); + return data.data.nearest.edges.map((e) => e.node.place); + }, + + async fetchDepartures(stops, maxDepartures) { + // Busy hubs like Majorstuen have many lines departing within minutes, so a + // small fetch count combined with a walk-time filter drops everything. + // Fetch a generous pool so the window reaches well past typical walk times. + const fetchCount = Math.max(maxDepartures * 15, 50); + const fields = stops + .map( + (stop, i) => ` + stop${i}: stopPlace(id: "${stop.id}") { + name + estimatedCalls(timeRange: 72100, numberOfDepartures: ${fetchCount}) { + realtime + expectedDepartureTime + cancellation + quay { publicCode } + destinationDisplay { frontText } + serviceJourney { + journeyPattern { + line { + publicCode + transportMode + } + } + } + } + }` + ) + .join("\n"); + + const data = await this.graphql(`{ ${fields} }`); + + return stops.map((stop, i) => { + const s = data.data[`stop${i}`]; + const departures = (s ? s.estimatedCalls : []) + .filter((c) => !c.cancellation) + .map((c) => ({ + line: c.serviceJourney.journeyPattern.line.publicCode, + mode: c.serviceJourney.journeyPattern.line.transportMode, + destination: c.destinationDisplay.frontText, + expectedTime: c.expectedDepartureTime, + track: c.quay?.publicCode || null, + realtime: c.realtime, + })) + .sort((a, b) => new Date(a.expectedTime) - new Date(b.expectedTime)); + return { name: s ? s.name : stop.name, departures }; + }); + }, + + async graphql(query) { + const res = await fetch(ENTUR_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "ET-Client-Name": CLIENT_NAME, + }, + body: JSON.stringify({ query }), + }); + return res.json(); + }, +}); diff --git a/modules/MMM-YrMeteogram/MMM-YrMeteogram.css b/modules/MMM-YrMeteogram/MMM-YrMeteogram.css new file mode 100644 index 0000000000..31b458e8c6 --- /dev/null +++ b/modules/MMM-YrMeteogram/MMM-YrMeteogram.css @@ -0,0 +1,7 @@ +.module.MMM-YrMeteogram { + transform: translateY(-150px); +} + +.MMM-YrMeteogram img { + display: block; +} diff --git a/modules/MMM-YrMeteogram/MMM-YrMeteogram.js b/modules/MMM-YrMeteogram/MMM-YrMeteogram.js new file mode 100644 index 0000000000..91f5776888 --- /dev/null +++ b/modules/MMM-YrMeteogram/MMM-YrMeteogram.js @@ -0,0 +1,38 @@ +Module.register("MMM-YrMeteogram", { + defaults: { + locationId: "1-72837", + lang: "nb", + updateInterval: 30 * 60 * 1000, // 30 minutes + width: "400px", + }, + + getStyles() { + return ["MMM-YrMeteogram.css"]; + }, + + start() { + this.scheduleUpdate(); + }, + + scheduleUpdate() { + this.updateDom(); + setInterval(() => this.updateDom(), this.config.updateInterval); + }, + + getDom() { + const wrapper = document.createElement("div"); + const { locationId, lang, width } = this.config; + + const langPath = lang === "nb" ? "nb/innhold" : `${lang}/content`; + const url = `https://www.yr.no/${langPath}/${locationId}/meteogram.svg?mode=dark&_=${Date.now()}`; + + const img = document.createElement("img"); + img.src = url; + img.style.width = width; + img.style.display = "block"; + img.alt = "Yr meteogram"; + + wrapper.appendChild(img); + return wrapper; + }, +}); diff --git a/prototypes/portrait-board/README.md b/prototypes/portrait-board/README.md new file mode 100644 index 0000000000..3a8f595369 --- /dev/null +++ b/prototypes/portrait-board/README.md @@ -0,0 +1,9 @@ +# Portrait information screen — design prototype + +Open `index.html` in a browser (double-click it in Finder). This is a **standalone mockup**; it does not change the existing MagicMirror page at `http://localhost:8080` and requires no server or npm command. + +Use **Før 14:00** and **Etter 14:00** to preview the two modes. Uncheck **Vis møter i dag** to see the workday layout without shared events. All times, dates, weather, meetings and departures are **example data**, not live information; there is no automatic switch at 14:00 yet. + +The canvas is 9:16 (2160 × 3840 on a 4K portrait screen) and scales to fit the available browser height on a landscape monitor. For a full-size preview, use browser device emulation at 2160 × 3840 or a portrait monitor. The controls sit outside the canvas so they would not appear on the actual display. + +Next steps after design review: choose content hierarchy and type sizes at real viewing distance; connect concise weather to Yr, shared events to Google Calendar and departures to Ruter; then add Oslo-time scheduling, empty states and stale-data handling. Keep live data separate from the preview until the design is approved. diff --git a/prototypes/portrait-board/index.html b/prototypes/portrait-board/index.html new file mode 100644 index 0000000000..f750a84e82 --- /dev/null +++ b/prototypes/portrait-board/index.html @@ -0,0 +1,65 @@ + + + + + + Inforskjerm — portrettskisse + + + + +
+ DESIGNSKISSE · EKSEMPELDATA +
+ + +
+ +
+ +
+
+
+

Fredag 16. oktober

+
+ +
+ +
+
+

God morgen

+
+ +
+
+

Været

+
+
+ +
11°
+
+

Lettskyet nå.
Opphold utover dagen.

+
Ta med en lett jakke
+
+ +
+

Fellesmøter i dag

+
AllmøteAuditoriet
+
Ledelsen informererStorsalen
+
+ + +
+
+ + diff --git a/prototypes/portrait-board/preview.js b/prototypes/portrait-board/preview.js new file mode 100644 index 0000000000..cdfab83d05 --- /dev/null +++ b/prototypes/portrait-board/preview.js @@ -0,0 +1,20 @@ +// Static design review only: no live departures, weather, calendar or automatic scheduling. +const board = document.querySelector(".board"); +const buttons = document.querySelectorAll("button[data-mode]"); +const eventsToggle = document.querySelector("#show-events"); + +function render(mode) { + const goingHome = mode === "home"; + board.dataset.mode = mode; + board.dataset.events = eventsToggle.checked ? "some" : "none"; + for (const button of buttons) button.setAttribute("aria-pressed", String(button.dataset.mode === mode)); + document.querySelector("#clock").textContent = goingHome ? "16:10" : "09:36"; + document.querySelector("#hero-title").textContent = goingHome ? "God tur hjem" : "God morgen"; + document.querySelector("#weather-summary").innerHTML = goingHome ? "Opphold på vei hjem." : "Lettskyet nå.
Opphold utover dagen."; + document.querySelector("#events-panel").hidden = goingHome || !eventsToggle.checked; + document.querySelector("#departures-panel").hidden = !goingHome; +} + +for (const button of buttons) button.addEventListener("click", () => render(button.dataset.mode)); +eventsToggle.addEventListener("change", () => render(board.dataset.mode)); +render("workday"); diff --git a/prototypes/portrait-board/style.css b/prototypes/portrait-board/style.css new file mode 100644 index 0000000000..353f095187 --- /dev/null +++ b/prototypes/portrait-board/style.css @@ -0,0 +1,69 @@ +@import url("https://static.nrk.no/nrk-sans/latest/nrk-sans.min.css"); + +:root { font-family: "NRK Sans", Arial, sans-serif; color: #fff; background: #202936; } +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; } +button, input { font: inherit; } + +.preview-controls { position: fixed; z-index: 2; top: 20px; left: 20px; padding: 16px; background: #121b2a; border: 1px solid #55657a; border-radius: 8px; display: grid; gap: 12px; font-size: 14px; } +.preview-label { font-size: 11px; letter-spacing: .08em; color: #b6c3d8; } +.control-row { display: flex; gap: 6px; } +.control-row button { padding: 8px 12px; border: 1px solid #7795b7; background: transparent; color: #fff; border-radius: 4px; cursor: pointer; } +.control-row button[aria-pressed="true"] { background: #fff; color: #103774; } +.control-row button:focus-visible, .calendar-control:focus-within { outline: 3px solid #ffd45f; outline-offset: 3px; } +.calendar-control { display: flex; gap: 8px; align-items: center; cursor: pointer; } + +/* 9:16 portrait canvas: 2160 × 3840 at native 4K; scales as one unit for laptop preview. */ +.board { width: min(100vw, 56.25vh); aspect-ratio: 9 / 16; flex: none; container-type: inline-size; background: #05080e; display: grid; grid-template-rows: 11% 1fr; overflow: hidden; box-shadow: 0 14px 60px #0008; } +.masthead { background: #103774; display: flex; justify-content: space-between; align-items: center; gap: 3cqw; padding: 3cqw 5cqw; } +.heading-aside { letter-spacing: .08em; } +.masthead-date { font-size: 3.7cqw; margin: 0; white-space: nowrap; } +.clock { font-size: 11cqw; font-weight: 750; line-height: .94; letter-spacing: -.045em; font-variant-numeric: tabular-nums; } +.board-body { display: flex; flex-direction: column; min-height: 0; padding: 4cqw 5cqw 3cqw; gap: 5cqw; } +.hero { flex: none; } +h1 { font-size: 11cqw; line-height: 1.05; letter-spacing: -.04em; margin: 0; } +.weather-panel, .events-panel, .departures-panel { border-top: 1px solid #60728b; padding-top: 3.4cqw; } +.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 2cqw; } +h2 { margin: 0; font-size: 5cqw; font-weight: 750; } +.heading-aside { color: #a5b9d3; font-size: 2.5cqw; font-weight: 700; white-space: nowrap; } +.weather-main { display: flex; align-items: center; gap: 4cqw; margin-top: 1cqw; } +.weather-icon { width: 20cqw; height: 20cqw; color: #b6d4f8; } +.temperature { font-size: 23cqw; line-height: 1; font-weight: 650; letter-spacing: -.065em; } +.temperature span { color: #8bb5eb; } +.weather-summary { font-size: 5cqw; line-height: 1.22; margin: 2cqw 0 0; } +.weather-summary strong { font-weight: 750; } +.weather-detail { margin-top: 5cqw; } +.weather-detail strong { font-size: 3.6cqw; } +.events-panel { padding-bottom: 3cqw; } +.event { display: flex; gap: 5cqw; align-items: baseline; padding: 3cqw 0; border-bottom: 1px solid #263345; font-size: 4.4cqw; } +.event time { color: #8bb5eb; font-weight: 750; font-variant-numeric: tabular-nums; } +.event div { display: flex; flex-direction: column; gap: .6cqw; } +.event span { font-size: 3cqw; color: #a5b9d3; } + +.board[data-mode="home"] .board-body { gap: 3cqw; } +.board[data-mode="home"] .weather-main { margin-top: 0; } +.board[data-mode="home"] .weather-icon { width: 13cqw; height: 13cqw; } +.board[data-mode="home"] .temperature { font-size: 15cqw; } +.board[data-mode="home"] .weather-summary { font-size: 3.9cqw; } +.board[data-mode="home"] .weather-detail { display: none; } +.board[data-mode="home"] .departure { padding: 2cqw 0; } +.board[data-mode="workday"][data-events="none"] .weather-panel { margin-top: 8cqw; } +.board[data-mode="workday"][data-events="none"] .weather-icon { width: 24cqw; height: 24cqw; } +.board[data-mode="workday"][data-events="none"] .temperature { font-size: 27cqw; } +.board[data-mode="workday"][data-events="none"] .weather-summary { font-size: 6cqw; } +.board[data-mode="workday"][data-events="none"] .weather-detail { margin-top: 9cqw; } +.departures-panel { margin-top: 1cqw; } +.departure { display: grid; grid-template-columns: 13cqw minmax(0, 1fr) auto; align-items: center; gap: 2cqw; padding: 3.4cqw 0; border-bottom: 1px solid #415167; font-size: 5cqw; } +.departure strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.line-badge { width: 11cqw; text-align: center; padding: 1cqw 0; font-size: 4.4cqw; line-height: 1; font-weight: 800; color: #fff; } +.line-badge.tram { background: #2772b6; } +.line-badge.bus { background: #d64846; } +.minutes { color: #ffd45f; font-size: 7cqw; font-weight: 750; font-variant-numeric: tabular-nums; white-space: nowrap; } +.minutes small { font-size: 3cqw; color: #d5dbe5; font-weight: 500; } +[hidden] { display: none !important; } + +@media (max-width: 800px) and (orientation: portrait) { + body { display: block; } + .preview-controls { position: relative; inset: auto; border-radius: 0; } + .board { width: 100vw; box-shadow: none; } +}