Skip to content
Closed
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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=<lat>&Y=<lon>&proposals=1`
- Departures: `https://api.ruter.no/v2/travel/getdepartures/<stopId>`

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`)
53 changes: 53 additions & 0 deletions config/config.js
Original file line number Diff line number Diff line change
@@ -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; }
77 changes: 77 additions & 0 deletions config/custom.css
Original file line number Diff line number Diff line change
@@ -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);
}
74 changes: 74 additions & 0 deletions modules/MMM-PortraitBoard/MMM-PortraitBoard.css
Original file line number Diff line number Diff line change
@@ -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; }
}
Loading