From 84cc5d619ea06a67703519a06bb9aa80aa0d4cb8 Mon Sep 17 00:00:00 2001 From: marc Date: Mon, 24 Aug 2026 14:45:55 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20HoneyOS=20=E2=80=93=20Retro-Schreibtisc?= =?UTF-8?q?h=20und=20Leiste=20f=C3=BCr=20die=20eigenen=20Apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Statische Oberfläche vor Aether und InkNote: kein Backend, kein Build, nginx liefert web/ aus. Jede App bleibt ihre eigene PWA auf ihrer eigenen Domain – HoneyOS ist Startpunkt und Umschalter, kein iframe-Rahmen. - zwei Betriebsarten aus einem Datenstand: Terminal (Kacheln, Tastatur) und Desktop (Symbole, verschiebbare Fenster, Taskleiste) - honeybar.js als Drop-in für die Apps, rendert im Shadow DOM, damit Tailwind und die InkNote-Palette einander nicht in die Quere kommen - Konfiguration vollständig aus der .env: das Skript in /docker-entrypoint.d/ schreibt config.json beim Containerstart - optionaler _ME-Pfad pro App zeigt an, wer angemeldet ist; ohne CORS bleibt die Anzeige leer, ohne dass etwas kaputtgeht Co-Authored-By: Claude Opus 5 --- .env.example | 88 ++ .gitignore | 3 + Dockerfile | 20 + README.md | 164 ++++ build.sh | 32 + docker-compose.dev.yml | 11 + docker-compose.yml | 59 ++ docker-entrypoint.d/20-honeyos-config.sh | 79 ++ nginx.conf | 30 + web/config.json | 26 + web/honeybar/demo.html | 41 + web/honeybar/honeybar.js | 290 ++++++ web/index.html | 86 ++ web/looks/system7.html | 184 ++++ web/looks/win95.html | 225 +++++ web/os.js | 1024 ++++++++++++++++++++++ web/style.css | 764 ++++++++++++++++ 17 files changed, 3126 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100755 build.sh create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100755 docker-entrypoint.d/20-honeyos-config.sh create mode 100644 nginx.conf create mode 100644 web/config.json create mode 100644 web/honeybar/demo.html create mode 100644 web/honeybar/honeybar.js create mode 100644 web/index.html create mode 100644 web/looks/system7.html create mode 100644 web/looks/win95.html create mode 100644 web/os.js create mode 100644 web/style.css diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a38c3d1 --- /dev/null +++ b/.env.example @@ -0,0 +1,88 @@ +# ───────────────────────────────────────────────────────────────────────────── +# HoneyOS — Konfiguration +# +# cp .env.example .env && anpassen +# docker compose pull && docker compose up -d +# +# Alles hier landet beim Containerstart in config.json und wird vom Browser +# gelesen. Das heißt: **es ist öffentlich.** Adressen und Namen gehören hier +# hinein, Geheimnisse niemals — HoneyOS hat kein Backend, das eines hüten +# könnte. Nach jeder Änderung: docker compose up -d (Neustart genügt). +# ───────────────────────────────────────────────────────────────────────────── + +HONEYOS_PORT=8088 + +# Anzeigename, solange keine App einen besseren nennt (siehe _ME weiter unten). +HONEYOS_USER=marc +HONEYOS_HOSTNAME=honeyos +HONEYOS_VERSION=0.1 + +# ── Apps ───────────────────────────────────────────────────────────────────── +# Durchnummeriert ab 1. Eine App entsteht durch ihre _URL — fehlt die, wird die +# Nummer übersprungen. Bis zu 12 Stück. +# +# _URL Pflicht. Wohin der Start springt. +# _ID Kurzname, klein. Muss zu data-app der Leiste passen. Standard: appN +# _NAME Beschriftung. Standard: die ID in Großbuchstaben +# _SUBTITLE Zeile darunter auf der Kachel +# _GLYPH Symbol: cloud | note | box | chip | folder | gear +# _KEY Zifferntaste zum Starten im Terminal-Modus +# _ME optional, siehe „Anmeldung" unten +# _TOKEN_KEY optional, siehe „Anmeldung" unten + +HONEYOS_APP_1_ID=aether +HONEYOS_APP_1_NAME=AETHER +HONEYOS_APP_1_SUBTITLE=Cloud & Dateien +HONEYOS_APP_1_URL=https://cloud.honeyy.duckdns.org +HONEYOS_APP_1_GLYPH=cloud +HONEYOS_APP_1_KEY=1 +HONEYOS_APP_1_ME=/api/v1/auth/me + +HONEYOS_APP_2_ID=inknote +HONEYOS_APP_2_NAME=INKNOTE +HONEYOS_APP_2_SUBTITLE=Notizen & Zeichnen +HONEYOS_APP_2_URL=https://notes.honeyy.duckdns.org +HONEYOS_APP_2_GLYPH=note +HONEYOS_APP_2_KEY=2 +HONEYOS_APP_2_ME=/api/auth/me +HONEYOS_APP_2_TOKEN_KEY=inknote_token + +# HONEYOS_APP_3_ID=paperwall +# HONEYOS_APP_3_URL=https://… +# HONEYOS_APP_3_GLYPH=box +# HONEYOS_APP_3_KEY=3 + +# ───────────────────────────────────────────────────────────────────────────── +# Anmeldung — was HoneyOS kann und was nicht +# ───────────────────────────────────────────────────────────────────────────── +# +# HoneyOS meldet NIEMANDEN an. Es ist eine statische Seite; ein OIDC-Client +# bräuchte ein Backend und ein Client-Geheimnis, und beides gibt es hier nicht +# — absichtlich. Die Anmeldung bleibt Sache von Aether und InkNote, die beide +# an PocketID hängen. +# +# Was HoneyOS kann: **anzeigen**, wer angemeldet ist. Dafür ist _ME da — der +# Pfad, unter dem die App „wer bin ich" beantwortet. Relativ zu _URL oder +# absolut. Aus der Antwort wird der Anzeigename gefischt (display_name, +# displayName, name, username), verschachtelt unter `user` wird auch gefunden. +# +# Zwei Wege, wie das gelesen wird — und sie verhalten sich unterschiedlich: +# +# 1. Die Leiste IN der App (honeybar.js) fragt ihre eigene App. +# Gleicher Ursprung, also **funktioniert das sofort**, ohne dass an Aether +# oder InkNote etwas geändert werden muss. +# +# 2. Der Desktop auf os.… fragt über die Domaingrenze. +# Dafür muss die App diesen Ursprung erlauben, sonst blockt der Browser: +# InkNote: ALLOWED_ORIGINS=https://os.honeyy.duckdns.org +# Aether: CORS mit Access-Control-Allow-Credentials — noch nicht drin +# Ohne das bleibt die Anzeige einfach leer. Nichts geht kaputt, die +# Lampe „online" funktioniert weiter. +# +# _TOKEN_KEY brauchen nur Apps, die einen Bearer-Token im localStorage halten +# statt eines Cookies (InkNote tut das). Der Token ist pro Domain sichtbar — +# von os.… aus also NICHT lesbar. Für die Leiste in der App reicht es. +# +# Und wichtig: alle Adressen müssen unter derselben Domain liegen +# (…​.honeyy.duckdns.org). Sonst behandelt der Browser sie als fremde Site und +# schickt die Sitzung gar nicht erst mit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d4cd07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +*.tmp +shots/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..15078a5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# HoneyOS — statisch. Kein Build, kein Backend: nginx liefert web/ aus. +# +# Die Konfiguration entsteht beim Start aus der .env — das Skript in +# /docker-entrypoint.d/ schreibt config.json, bevor nginx hochkommt. +FROM nginx:1.27-alpine + +LABEL org.opencontainers.image.title="HoneyOS" \ + org.opencontainers.image.description="Retro-Schreibtisch und Umschalter für die eigenen Apps" \ + org.opencontainers.image.source="https://gitea.honeyy.duckdns.org/Docker/HoneyOS" + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY docker-entrypoint.d/20-honeyos-config.sh /docker-entrypoint.d/20-honeyos-config.sh +COPY web/ /usr/share/nginx/html/ + +RUN chmod +x /docker-entrypoint.d/20-honeyos-config.sh + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ + CMD wget -qO- http://127.0.0.1/healthz || exit 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..de51015 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# HoneyOS + +Ein Retro-Schreibtisch vor den eigenen Apps. Amber-CRT, Boot-Log, zwei Betriebsarten — +und dieselbe Leiste zum Einhängen in Aether und InkNote. + +**Was es ist:** eine Oberfläche davor. Jede App bleibt ihre eigene PWA auf ihrer eigenen +Domain mit ihrer eigenen Arbeitsumgebung und ihrer eigenen Anmeldung. HoneyOS ist der +Startpunkt, der Umschalter und die gemeinsame Leiste — **kein Rahmen, der die Apps +einsperrt** (kein iframe). + +**Was es nicht ist:** eine Anmeldung. HoneyOS ist eine statische Seite ohne Backend; ein +OIDC-Client bräuchte ein Client-Geheimnis und eine Sitzung, und beides gehört nicht in +etwas, das der Browser vollständig lesen kann. Angemeldet wird bei Aether und InkNote, +die beide an PocketID hängen. HoneyOS kann den Stand nur **anzeigen** — siehe unten. + +## Betrieb + +```bash +cp .env.example .env # Adressen und Namen eintragen +docker login gitea.honeyy.duckdns.org +docker compose pull && docker compose up -d +``` + +Es gibt nichts zu sichern: keine Datenbank, kein Volume. Der ganze Zustand ist die `.env` — +und was sich der Browser pro Gerät merkt (Betriebsart, Röhrenfarbe). + +Hinter deinem Reverse Proxy erwartet HoneyOS nichts Besonderes: `proxy_pass` auf den Port, +fertig. `/healthz` antwortet mit `ok`. + +## Konfiguration + +Alles kommt aus der `.env`. Beim Containerstart schreibt +[`docker-entrypoint.d/20-honeyos-config.sh`](docker-entrypoint.d/20-honeyos-config.sh) +daraus `config.json`, die der Browser liest. Nach jeder Änderung `docker compose up -d`. + +**Alles darin ist öffentlich.** Adressen und Namen gehören hinein, Geheimnisse niemals. + +Eine App entsteht durch ihre `_URL`; fehlt die, wird die Nummer übersprungen (bis 12): + +```dotenv +HONEYOS_APP_1_ID=aether +HONEYOS_APP_1_NAME=AETHER +HONEYOS_APP_1_SUBTITLE=Cloud & Dateien +HONEYOS_APP_1_URL=https://cloud.honeyy.duckdns.org +HONEYOS_APP_1_GLYPH=cloud # cloud | note | box | chip | folder | gear +HONEYOS_APP_1_KEY=1 # Zifferntaste im Terminal-Modus +HONEYOS_APP_1_ME=/api/v1/auth/me # optional, siehe „Wer ist angemeldet" +``` + +Ohne `HONEYOS_APP_1_URL` bleibt die eingebackene `web/config.json` gültig — ein +`docker run` ohne `.env` zeigt also die Standard-Apps. Adressen lassen sich zusätzlich im +Browser unter *Einstellungen* überschreiben; das gilt dann nur für dieses Gerät. + +## Zwei Betriebsarten, ein Datenstand + +| | Terminal-Modus | Desktop-Modus | +|---|---|---| +| Apps | Kacheln nebeneinander | Symbole auf der Fläche, Doppelklick öffnet | +| Fenster | Dialog in der Mitte | verschiebbar, minimierbar | +| unten | Statuszeile mit Tastenhinweisen | Taskleiste mit Startmenü | + +Umschalten mit **`M`**, über *Einstellungen → Betriebsart* oder im Startmenü. + +Gleich bleiben in beiden: die Menüleiste oben, die Palette, die Einstellungen, `config.json`. +`renderTiles()` und `renderDeskIcons()` in `os.js` zeichnen aus denselben Daten — eine neue +App erscheint ohne Zutun in beiden. + +**Tasten:** `1` `2` App starten · `M` Betriebsart · `S` Einstellungen · `?` Hilfe · +`Esc` schließt Startmenü, oberstes Fenster oder Dialog — in dieser Reihenfolge. + +## Die Leiste in Aether / InkNote + +Eine Zeile ins `frontend/index.html` der App: + +```html + +``` + +`data-app` ist die `id` aus der Konfiguration — danach markiert sich die Leiste selbst als +aktiv. Die App-Liste holt sie von allein neben ihrem eigenen Skript. + +Sie rendert in einem **Shadow DOM**: Tailwind (Aether) und die InkNote-Palette kommen nicht +hinein, ihre Stile nicht hinaus. Sie schiebt den Seiteninhalt um 32 px nach unten +(`data-push="none"` legt sie stattdessen darüber). Rechnet die App mit voller Höhe: + +```css +.app-shell { height: calc(100vh - var(--honeybar-h, 0px)); } +``` + +Vorschau ohne Einbau: `/honeybar/demo.html`. + +## Wer ist angemeldet + +`_ME` ist der Pfad, unter dem eine App „wer bin ich" beantwortet (relativ zur `_URL` oder +absolut). Aus der Antwort wird der Anzeigename gefischt — `display_name`, `displayName`, +`name`, `username`, auch verschachtelt unter `user`. + +Gelesen wird das an zwei Stellen, und die verhalten sich **unterschiedlich**: + +1. **Die Leiste in der App** fragt ihre eigene App. Gleicher Ursprung → funktioniert + sofort, ohne Änderung an Aether oder InkNote. +2. **Der Desktop auf `os.…`** fragt über die Domaingrenze. Dafür muss die App diesen + Ursprung erlauben: + - InkNote: `ALLOWED_ORIGINS=https://os.honeyy.duckdns.org` + - Aether: CORS mit `Access-Control-Allow-Credentials` — noch nicht vorhanden + +Fehlt das, bleibt die Anzeige leer und sonst passiert nichts. Die Lampe „online" +funktioniert unabhängig davon weiter. + +`_TOKEN_KEY` brauchen nur Apps, die einen Bearer-Token im localStorage halten statt eines +Cookies (InkNote: `inknote_token`). Der ist pro Domain sichtbar — von `os.…` aus also nicht +lesbar, für die Leiste in der App dagegen schon. + +**Voraussetzung für all das:** alle Adressen liegen unter derselben Domain +(`*.honeyy.duckdns.org`). Sonst behandelt der Browser sie als fremde Site und schickt die +Sitzung gar nicht erst mit. + +## Entwickeln + +```bash +docker compose -f docker-compose.dev.yml up -d --build # → http://localhost:8088 +``` + +`web/` ist gemountet: Datei ändern, Seite neu laden. Weil der Mount schreibgeschützt ist, +greift dort die `.env`-Konfiguration **nicht** — es gilt die eingebackene `web/config.json`. +Zum Prüfen der `.env` das echte Image nehmen (`docker compose up -d --build`). + +``` +web/ + index.html Desktop + style.css alle Farben in den --p*-Variablen ganz oben + os.js Boot, beide Ansichten, Fenster, Einstellungen + config.json Standardkonfiguration (wird zur Laufzeit aus der .env ersetzt) + honeybar/ die einbettbare Leiste + Vorschauseite + looks/ zwei verworfene Entwürfe (System 7, Win95) — nur Ansicht +``` + +Neue Symbole sind 12×12-Pixelraster in `os.js` (`GLYPHS`). + +## Bauen und schieben + +```bash +./build.sh # → gitea.honeyy.duckdns.org/docker/honeyos:latest +./build.sh 0.2 # mit Tag +``` + +## Grenzen, die man kennen sollte + +- **localStorage ist pro Domain.** Betriebsart und Röhrenfarbe auf `os.…` kennt die Leiste + auf `notes.…` nicht. Der Umschalter ist ein Schalter, kein geteilter Zustand. +- **Die Lampe ist kein Health-Check.** Bei einer fremden Domain darf der Browser nur sehen, + *dass* geantwortet wurde, nicht *was*. Ein 500er sähe aus wie „online". +- **`config.json` und `honeybar.js` gehen mit `Access-Control-Allow-Origin: *` raus**, weil + die anderen Domains sie holen. Öffentliche Konfiguration — nichts Vertrauliches hinein. + +## Was als Nächstes ansteht + +1. **CORS in Aether** für `os.…`, damit der Desktop den Anmeldestand sehen kann. InkNote + hat mit `ALLOWED_ORIGINS` schon den Haken dafür. +2. **Überall abmelden** in der Leiste. Braucht pro App den CSRF-Weg zum Logout — bei Aether + ein `POST` mit Token, das geht nicht nebenbei über die Domaingrenze. +3. **Fenster mit Inhalt** statt nur Verweisen: „letzte Dateien", „letzte Notizen". Fenster + zeigen dann Daten aus den APIs — die Apps selbst bleiben in ihrem eigenen Tab. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..f3d7d3c --- /dev/null +++ b/build.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +REGISTRY="gitea.honeyy.duckdns.org" +OWNER="${GITEA_OWNER:-docker}" +TAG="${1:-latest}" + +IMAGE="${REGISTRY}/${OWNER}/honeyos:${TAG}" + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " HoneyOS — Build & Push to Gitea" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo " Image: ${IMAGE}" +echo "" + +echo "→ Logging in to ${REGISTRY}..." +docker login "${REGISTRY}" + +echo "" +echo "→ Building..." +docker build -t "${IMAGE}" . + +echo "" +echo "→ Pushing..." +docker push "${IMAGE}" + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " ✅ Done!" +echo " ${IMAGE}" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..68a54e7 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,11 @@ +# Entwicklung: web/ wird hineingemountet, ein Reload im Browser reicht. +services: + honeyos: + build: . + image: honeyos:dev + container_name: honeyos-dev + ports: + - "${HONEYOS_PORT:-8088}:80" + volumes: + - ./web:/usr/share/nginx/html:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..98ea010 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +# ───────────────────────────────────────────── +# HoneyOS — Betrieb aus der Gitea-Registry +# +# cp .env.example .env && anpassen +# docker login gitea.honeyy.duckdns.org +# docker compose pull && docker compose up -d +# +# Es gibt nichts zu sichern: HoneyOS hat keine Datenbank und kein Volume. +# Der ganze Zustand ist die .env — und was der Browser sich pro Gerät in +# localStorage merkt (Betriebsart, Röhre). +# ───────────────────────────────────────────── + +services: + honeyos: + image: gitea.honeyy.duckdns.org/docker/honeyos:latest + container_name: honeyos + restart: unless-stopped + ports: + - "${HONEYOS_PORT:-8088}:80" + environment: + - HONEYOS_USER=${HONEYOS_USER:-} + - HONEYOS_HOSTNAME=${HONEYOS_HOSTNAME:-honeyos} + - HONEYOS_VERSION=${HONEYOS_VERSION:-0.1} + + - HONEYOS_APP_1_ID=${HONEYOS_APP_1_ID:-} + - HONEYOS_APP_1_NAME=${HONEYOS_APP_1_NAME:-} + - HONEYOS_APP_1_SUBTITLE=${HONEYOS_APP_1_SUBTITLE:-} + - HONEYOS_APP_1_URL=${HONEYOS_APP_1_URL:-} + - HONEYOS_APP_1_GLYPH=${HONEYOS_APP_1_GLYPH:-} + - HONEYOS_APP_1_KEY=${HONEYOS_APP_1_KEY:-} + - HONEYOS_APP_1_ME=${HONEYOS_APP_1_ME:-} + - HONEYOS_APP_1_TOKEN_KEY=${HONEYOS_APP_1_TOKEN_KEY:-} + + - HONEYOS_APP_2_ID=${HONEYOS_APP_2_ID:-} + - HONEYOS_APP_2_NAME=${HONEYOS_APP_2_NAME:-} + - HONEYOS_APP_2_SUBTITLE=${HONEYOS_APP_2_SUBTITLE:-} + - HONEYOS_APP_2_URL=${HONEYOS_APP_2_URL:-} + - HONEYOS_APP_2_GLYPH=${HONEYOS_APP_2_GLYPH:-} + - HONEYOS_APP_2_KEY=${HONEYOS_APP_2_KEY:-} + - HONEYOS_APP_2_ME=${HONEYOS_APP_2_ME:-} + - HONEYOS_APP_2_TOKEN_KEY=${HONEYOS_APP_2_TOKEN_KEY:-} + + - HONEYOS_APP_3_ID=${HONEYOS_APP_3_ID:-} + - HONEYOS_APP_3_NAME=${HONEYOS_APP_3_NAME:-} + - HONEYOS_APP_3_SUBTITLE=${HONEYOS_APP_3_SUBTITLE:-} + - HONEYOS_APP_3_URL=${HONEYOS_APP_3_URL:-} + - HONEYOS_APP_3_GLYPH=${HONEYOS_APP_3_GLYPH:-} + - HONEYOS_APP_3_KEY=${HONEYOS_APP_3_KEY:-} + - HONEYOS_APP_3_ME=${HONEYOS_APP_3_ME:-} + - HONEYOS_APP_3_TOKEN_KEY=${HONEYOS_APP_3_TOKEN_KEY:-} + + - HONEYOS_APP_4_ID=${HONEYOS_APP_4_ID:-} + - HONEYOS_APP_4_NAME=${HONEYOS_APP_4_NAME:-} + - HONEYOS_APP_4_SUBTITLE=${HONEYOS_APP_4_SUBTITLE:-} + - HONEYOS_APP_4_URL=${HONEYOS_APP_4_URL:-} + - HONEYOS_APP_4_GLYPH=${HONEYOS_APP_4_GLYPH:-} + - HONEYOS_APP_4_KEY=${HONEYOS_APP_4_KEY:-} + - HONEYOS_APP_4_ME=${HONEYOS_APP_4_ME:-} + - HONEYOS_APP_4_TOKEN_KEY=${HONEYOS_APP_4_TOKEN_KEY:-} diff --git a/docker-entrypoint.d/20-honeyos-config.sh b/docker-entrypoint.d/20-honeyos-config.sh new file mode 100755 index 0000000..76feadc --- /dev/null +++ b/docker-entrypoint.d/20-honeyos-config.sh @@ -0,0 +1,79 @@ +#!/bin/sh +# Schreibt config.json aus den Umgebungsvariablen — läuft vor nginx. +# +# Das nginx-Image führt alles in /docker-entrypoint.d/ beim Start aus. Damit +# braucht HoneyOS keinen eigenen Entrypoint und kein Backend: die Konfiguration +# entsteht einmal beim Hochfahren, danach ist es wieder reines Ausliefern. +# +# Ohne HONEYOS_APP_1_URL bleibt die eingebackene config.json unangetastet — +# ein `docker run` ohne .env zeigt also weiter die Standard-Apps. +set -eu + +TARGET=/usr/share/nginx/html/config.json +MAX_APPS=12 + +# Wert einer Variablen holen, deren Name erst zur Laufzeit feststeht. +get() { eval "printf '%s' \"\${$1:-}\""; } + +# Für JSON entschärfen: Backslash und Anführungszeichen. Zeilenumbrüche haben +# in diesen Feldern nichts zu suchen und fliegen raus. +esc() { printf '%s' "$1" | tr -d '\n\r' | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; } + +# Ein Feld nur schreiben, wenn es gesetzt ist — leere Schlüssel wären Lärm. +field() { + [ -n "$2" ] || return 0 + printf ',"%s":"%s"' "$1" "$(esc "$2")" +} + +if [ -z "$(get HONEYOS_APP_1_URL)" ]; then + echo "honeyos: keine HONEYOS_APP_*-Variablen gesetzt — eingebackene config.json bleibt" + exit 0 +fi + +if [ ! -w "$(dirname "$TARGET")" ]; then + echo "honeyos: $(dirname "$TARGET") ist nicht beschreibbar (read-only mount?) — config.json bleibt wie sie ist" + exit 0 +fi + +tmp="${TARGET}.tmp" +{ + printf '{' + printf '"user":"%s"' "$(esc "$(get HONEYOS_USER)")" + printf ',"hostname":"%s"' "$(esc "${HONEYOS_HOSTNAME:-honeyos}")" + printf ',"version":"%s"' "$(esc "${HONEYOS_VERSION:-0.1}")" + printf ',"apps":[' + + n=1 + first=1 + while [ "$n" -le "$MAX_APPS" ]; do + url=$(get "HONEYOS_APP_${n}_URL") + if [ -z "$url" ]; then + n=$((n + 1)) + continue + fi + + id=$(get "HONEYOS_APP_${n}_ID") + [ -n "$id" ] || id="app${n}" + name=$(get "HONEYOS_APP_${n}_NAME") + [ -n "$name" ] || name=$(printf '%s' "$id" | tr '[:lower:]' '[:upper:]') + + [ "$first" -eq 1 ] || printf ',' + first=0 + + printf '{"id":"%s","name":"%s","url":"%s"' "$(esc "$id")" "$(esc "$name")" "$(esc "$url")" + field subtitle "$(get "HONEYOS_APP_${n}_SUBTITLE")" + field glyph "$(get "HONEYOS_APP_${n}_GLYPH")" + field key "$(get "HONEYOS_APP_${n}_KEY")" + field me "$(get "HONEYOS_APP_${n}_ME")" + field tokenKey "$(get "HONEYOS_APP_${n}_TOKEN_KEY")" + printf '}' + + n=$((n + 1)) + done + + printf ']}' +} > "$tmp" + +mv "$tmp" "$TARGET" +count=$(tr -cd '{' < "$TARGET" | wc -c) +echo "honeyos: config.json geschrieben ($((count - 1)) App(s))" diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..06da419 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,30 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Für den Healthcheck im Dockerfile und für den Reverse Proxy. + location = /healthz { + access_log off; + add_header Content-Type text/plain; + return 200 "ok\n"; + } + + # Das statische Blech: alles kurz cachen, nichts endlos. + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } + + # config.json und honeybar.js werden von den anderen Domains geholt. + # Beides ist öffentliche Konfiguration ohne Geheimnisse — CORS offen ist + # hier die richtige Antwort und keine Bequemlichkeit. + location ~* \.(json|js)$ { + add_header Access-Control-Allow-Origin "*"; + add_header Cache-Control "no-cache"; + } + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; +} diff --git a/web/config.json b/web/config.json new file mode 100644 index 0000000..076caaf --- /dev/null +++ b/web/config.json @@ -0,0 +1,26 @@ +{ + "user": "marc", + "hostname": "honeyos", + "version": "0.1", + "apps": [ + { + "id": "aether", + "name": "AETHER", + "subtitle": "Cloud & Dateien", + "url": "https://cloud.honeyy.duckdns.org", + "glyph": "cloud", + "key": "1", + "me": "/api/v1/auth/me" + }, + { + "id": "inknote", + "name": "INKNOTE", + "subtitle": "Notizen & Zeichnen", + "url": "https://notes.honeyy.duckdns.org", + "glyph": "note", + "key": "2", + "me": "/api/auth/me", + "tokenKey": "inknote_token" + } + ] +} diff --git a/web/honeybar/demo.html b/web/honeybar/demo.html new file mode 100644 index 0000000..48a56c5 --- /dev/null +++ b/web/honeybar/demo.html @@ -0,0 +1,41 @@ + + + + + + HoneyBar — Vorschau in einer fremden App + + + +
Irgendeine App (dunkles Theme, eigene Schrift)
+
+

Die Leiste oben kommt aus honeybar.js und gehört nicht zu dieser Seite.

+

+ Sie liegt in einem Shadow DOM: nichts von hier färbt auf sie ab, nichts von ihr auf + diese Seite. data-app="inknote" markiert, wo man gerade ist. +

+

Eine App, die mit 100vh rechnet, nimmt --honeybar-h ab.

+
+ + + + diff --git a/web/honeybar/honeybar.js b/web/honeybar/honeybar.js new file mode 100644 index 0000000..5785551 --- /dev/null +++ b/web/honeybar/honeybar.js @@ -0,0 +1,290 @@ +/* + * HoneyBar — die HoneyOS-Leiste zum Einhängen in eine fremde App. + * + * + * + * Eine Zeile, kein Build, keine Abhängigkeit. Die Leiste rendert in einem + * Shadow DOM — die Stile der Gastgeber-App kommen nicht rein, unsere nicht + * raus. Das ist der ganze Grund für die Konstruktion: Aether ist React + + * Tailwind, InkNote React + eigene Palette, und beide sollen von der Leiste + * nichts merken außer den 32 Pixeln oben. + * + * data-Attribute am + + diff --git a/web/looks/system7.html b/web/looks/system7.html new file mode 100644 index 0000000..f8a2dd0 --- /dev/null +++ b/web/looks/system7.html @@ -0,0 +1,184 @@ + + + + + + HoneyOS — Look: System 7 + + + + + + +
+
HoneyOS
+
+

Willkommen zurück, marc.

+
+ Programme2 + erreichbar2 von 2 + Version0.1 +
+
+

Jedes Programm läuft auf seiner eigenen Domain. HoneyOS öffnet sie nur.

+
+
+ + +
+
Programme
+
+
+ + + + + + Aether +
+
+ + + + + + InkNote +
+
+ + + + hinzufügen +
+
+
3 Objektealles bereit
+
+ + +
+
so sähe die Leiste in InkNote aus
+
+ ◆ HoneyOS + Aether + InkNote + marc   02:47 +
+
… hier beginnt die App selbst, mit ihrem eigenen Aussehen.
+
+ +
+ + + + + + Papierkorb +
+ +
Look: System 7  ·  Amber-CRT  ·  Windows 95
+ + diff --git a/web/looks/win95.html b/web/looks/win95.html new file mode 100644 index 0000000..823dd78 --- /dev/null +++ b/web/looks/win95.html @@ -0,0 +1,225 @@ + + + + + + HoneyOS — Look: Windows 95 + + + + +
+
+ + + + + + Aether +
+
+ + + + + + + InkNote +
+
+ + +
+
+ HoneyOS + _□× +
+ +
+

Willkommen zurück, marc.

+
+ Programme2 + erreichbar2 von 2 + Version0.1 +
+
Jedes Programm läuft auf seiner eigenen Domain.
HoneyOS öffnet sie nur.
+
+ OK + Einstellungen +
+
+
+ + +
+
+ Programme + _□× +
+
+
+
+ + + + Aether Cloud & Dateien +
+
+ + + + InkNote Notizen +
+
+ + + + Programm hinzufügen… +
+
+
+
3 Objekte
+
alles bereit
+
+
+
+ + +
+
so sähe die Leiste in InkNote aus×
+
+
+ HoneyOS + + Aether + InkNote + marc02:47 +
+
… hier beginnt die App selbst, mit ihrem eigenen Aussehen.
+
+
+ +
+ Look: Windows 95  ·  Amber-CRT  ·  System 7 +
+ + +
+ + + + + + Start + + + HoneyOS + Programme + 02:47 +
+ + diff --git a/web/os.js b/web/os.js new file mode 100644 index 0000000..35172a9 --- /dev/null +++ b/web/os.js @@ -0,0 +1,1024 @@ +/* + * HoneyOS — Desktop-Logik. + * + * Kein Build, kein Framework: die Datei wird so ausgeliefert wie sie hier steht. + * Zustand lebt an genau zwei Stellen — `state.cfg` (kommt aus config.json, die + * beim Containerstart aus der .env entsteht) und `state.settings` (nur Browser). Beides landet in + * localStorage, damit das Gerät sich sein HoneyOS selbst einrichten kann, ohne + * dass etwas neu deployt werden muss. + * + * ZWEI BETRIEBSARTEN, EIN DATENSTAND: + * terminal — Kacheln, Tastatur, alles auf einer Fläche (der ursprüngliche Look) + * desktop — Symbole, verschiebbare Fenster, Taskleiste unten + * Beide zeichnen aus denselben `state.cfg.apps`. Was sich unterscheidet, ist + * ausschließlich die Darstellung — Menüleiste oben, Einstellungen, Palette und + * Erreichbarkeitsprüfung sind in beiden dieselben. Deshalb liegen die + * gemeinsamen Teile hier oben und die beiden Ansichten weiter unten getrennt. + */ + +const LS_SETTINGS = "honeyos.settings"; +const LS_CFG = "honeyos.config"; +const SS_BOOTED = "honeyos.booted"; + +const DEFAULT_SETTINGS = { + mode: "terminal", // terminal | desktop + phosphor: "amber", // amber | green | ice + scanlines: "on", + flicker: "on", + glow: "on", + openIn: "tab", // tab | same +}; + +const state = { + cfg: { user: "marc", hostname: "honeyos", version: "0.1", apps: [] }, + settings: { ...DEFAULT_SETTINGS }, + probes: {}, // appId -> "up" | "down" | "unknown" + auth: {}, // appId -> {status: "in"|"out"|"blind", name} + wins: {}, // id -> {el, task, min} + zTop: 10, +}; + +const $ = (id) => document.getElementById(id); +const isTouch = () => window.matchMedia("(pointer: coarse)").matches; + +/* ── Pixel-Glyphen ───────────────────────────────────────────────────────── + * 12×12-Raster als Zeichenketten. Ein `#` ist ein Pixel. So bleiben die Icons + * im Retro-Raster statt aus einer Icon-Bibliothek zu kommen. + */ +const GLYPHS = { + cloud: [ + "............", + "....####....", + "...#....#...", + "..#......##.", + ".##........#", + "##.........#", + "#..........#", + "#..........#", + ".##########.", + "............", + "............", + "............", + ], + note: [ + "..########..", + "..#......#..", + "..#.####.#..", + "..#......#..", + "..#.####.#..", + "..#......#..", + "..#.####.#..", + "..#......#..", + "..#.##...#..", + "..#......#..", + "..########..", + "............", + ], + box: [ + "............", + ".##########.", + ".#........#.", + ".#........#.", + ".#...##...#.", + ".#..####..#.", + ".#..####..#.", + ".#...##...#.", + ".#........#.", + ".#........#.", + ".##########.", + "............", + ], + chip: [ + "............", + "..#.#.#.#...", + ".##########.", + ".#........#.", + "##.######.##", + ".#.#....#.#.", + "##.#....#.##", + ".#.######.#.", + ".#........#.", + ".##########.", + "..#.#.#.#...", + "............", + ], + gear: [ + "....####....", + "..#.####.#..", + "..########..", + ".####..####.", + "###......###", + "##........##", + "##........##", + "###......###", + ".####..####.", + "..########..", + "..#.####.#..", + "....####....", + ], + folder: [ + "............", + ".####.......", + "#....#......", + "#.....######", + "#..........#", + "#..........#", + "#..........#", + "#..........#", + "#..........#", + "############", + "............", + "............", + ], +}; + +function glyphSvg(name) { + const grid = GLYPHS[name] || GLYPHS.box; + const rects = []; + grid.forEach((row, y) => { + // Waagerechte Läufe zusammenfassen: weniger Rechtecke, gleiches Bild. + let run = 0; + for (let x = 0; x <= row.length; x++) { + if (row[x] === "#") { + run++; + } else if (run) { + rects.push(``); + run = 0; + } + } + }); + return ``; +} + +/* ── Laden & Speichern ─────────────────────────────────────────────────── */ + +async function loadConfig() { + let base = null; + try { + const res = await fetch("./config.json", { cache: "no-store" }); + if (res.ok) base = await res.json(); + } catch { + /* offline oder file:// — dann zählt allein der lokale Stand */ + } + if (base) state.cfg = { ...state.cfg, ...base }; + + const saved = readJson(LS_CFG); + if (saved) { + // Gespeichert wird nur, was der Nutzer wirklich geändert hat: Name und URLs. + if (saved.user) state.cfg.user = saved.user; + if (saved.urls) { + state.cfg.apps = state.cfg.apps.map((a) => (saved.urls[a.id] ? { ...a, url: saved.urls[a.id] } : a)); + } + } + + state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}) }; +} + +function readJson(key) { + try { + return JSON.parse(localStorage.getItem(key) || "null"); + } catch { + return null; + } +} + +function saveSettings() { + try { + localStorage.setItem(LS_SETTINGS, JSON.stringify(state.settings)); + } catch { + /* Privatmodus: Einstellungen gelten dann nur für diese Sitzung */ + } + applySettings(); +} + +function saveConfig() { + const urls = {}; + state.cfg.apps.forEach((a) => (urls[a.id] = a.url)); + try { + localStorage.setItem(LS_CFG, JSON.stringify({ user: state.cfg.user, urls })); + } catch { + /* siehe oben */ + } +} + +function applySettings() { + const r = document.documentElement; + r.dataset.phosphor = state.settings.phosphor; + r.dataset.scanlines = state.settings.scanlines; + r.dataset.flicker = state.settings.flicker; + r.dataset.glow = state.settings.glow; + r.dataset.mode = state.settings.mode; +} + +/* ── Boot ──────────────────────────────────────────────────────────────── */ + +function bootLines() { + const apps = state.cfg.apps; + return [ + { t: `HoneyOS ${state.cfg.version} — ${state.cfg.hostname}`, d: 90 }, + { t: "", d: 40 }, + { t: "prüfe speicher".padEnd(28, ".") + " 640K ok", d: 120 }, + { t: "lade benutzerprofil".padEnd(28, ".") + " " + state.cfg.user, d: 120 }, + ...apps.map((a) => ({ t: `binde ${a.id} ein`.padEnd(28, ".") + " [ ok ]", d: 140 })), + { t: "", d: 60 }, + { t: `starte ${state.settings.mode}-modus`.padEnd(28, ".") + " bereit.", d: 200 }, + ]; +} + +function runBoot() { + return new Promise((resolve) => { + const log = $("bootLog"); + const el = $("boot"); + el.hidden = false; + const lines = bootLines(); + let i = 0; + let done = false; + let timer; + + const finish = () => { + if (done) return; + done = true; + clearTimeout(timer); + window.removeEventListener("keydown", finish); + el.removeEventListener("click", finish); + el.hidden = true; + try { + sessionStorage.setItem(SS_BOOTED, "1"); + } catch { + /* egal — dann bootet es halt jedes Mal */ + } + resolve(); + }; + + const step = () => { + if (i >= lines.length) { + timer = setTimeout(finish, 420); + return; + } + const line = lines[i++]; + log.textContent += line.t + "\n"; + log.classList.add("caret"); + timer = setTimeout(step, line.d); + }; + + window.addEventListener("keydown", finish); + el.addEventListener("click", finish); + step(); + }); +} + +/* ── Gemeinsames: Menüleiste, Uhr, Stand, Start ────────────────────────── */ + +function greeting() { + const h = new Date().getHours(); + if (h < 5 || h >= 23) return "Noch wach?"; + if (h < 11) return "Guten Morgen."; + if (h < 18) return "Willkommen zurück."; + return "Guten Abend."; +} + +function renderShell() { + $("desktop").hidden = false; + $("mbUser").textContent = state.cfg.user; + renderMenubarApps(); + renderMode(); + tickClock(); +} + +function renderMenubarApps() { + const nav = $("mbApps"); + nav.innerHTML = ""; + state.cfg.apps.forEach((app) => { + const b = document.createElement("button"); + b.className = "mb-app"; + b.textContent = app.name; + b.title = `${app.name} öffnen — ${app.url}`; + b.addEventListener("click", () => launch(app)); + nav.appendChild(b); + }); +} + +/** Schaltet zwischen den Betriebsarten um. Beide Ansichten werden bei jedem + * Wechsel frisch gezeichnet — offene Fenster überleben ihn bewusst nicht. */ +function renderMode() { + const desktopMode = state.settings.mode === "desktop"; + + $("stage").hidden = desktopMode; + $("statusbar").hidden = desktopMode; + $("deskspace").hidden = !desktopMode; + $("taskbar").hidden = !desktopMode; + + if (desktopMode) { + renderDeskIcons(); + // Ohne offenes Fenster wäre der Schreibtisch leer — das Systemfenster + // ist der sinnvollste erste Anblick. Am Handy nicht: dort deckt jedes + // Fenster sofort die halbe Symbolspalte zu. + if (!Object.keys(state.wins).length && window.innerWidth > 620) openSystemWindow(); + updateTaskbar(); + } else { + closeAllWindows(); + renderTiles(); + } + renderStats(); + paintProbes(); +} + +function setMode(mode) { + if (state.settings.mode === mode) return; + state.settings.mode = mode; + saveSettings(); + renderMode(); +} + +function launch(app) { + if (state.settings.openIn === "same") { + window.location.href = app.url; + } else { + window.open(app.url, "_blank", "noopener"); + } +} + +/** Die Kennzahlen stehen in beiden Ansichten — deshalb über die Klasse in + * alle vorhandenen Listen schreiben, nicht in eine bestimmte id. */ +function renderStats() { + const up = Object.values(state.probes).filter((s) => s === "up").length; + const rows = [ + ["apps", `${state.cfg.apps.length}`], + ["erreichbar", `${up}/${state.cfg.apps.length}`], + ["modus", state.settings.mode], + ["röhre", state.settings.phosphor], + ["version", `v${state.cfg.version}`], + ]; + const html = rows.map(([k, v]) => `
${k}
${escapeHtml(v)}
`).join(""); + document.querySelectorAll(".statlist").forEach((dl) => (dl.innerHTML = html)); + document.querySelectorAll(".greet").forEach( + (p) => (p.textContent = `${greeting()} ${state.cfg.user}@${state.cfg.hostname}`) + ); +} + +function tickClock() { + const now = new Date(); + const hm = now.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); + const date = now.toLocaleDateString("de-DE", { + weekday: "short", + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + $("clock").textContent = hm; + $("sbRight").textContent = date; + $("taskDate").textContent = date; +} + +/* ── Erreichbarkeit ──────────────────────────────────────────────────────── + * Fremde Domain heißt: die Antwort ist undurchsichtig, wir sehen keinen Status. + * Mehr als "die Kiste hat geantwortet" ist von hier aus nicht zu holen — für + * eine Lampe auf der Kachel reicht das, ein Health-Check ist es nicht. + */ +async function probeApps() { + await Promise.all( + state.cfg.apps.map(async (app) => { + state.probes[app.id] = await probe(app.url); + }) + ); + paintProbes(); + renderStats(); + checkAuth(); +} + +async function probe(url) { + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), 5000); + try { + await fetch(url, { mode: "no-cors", cache: "no-store", signal: ctl.signal, redirect: "follow" }); + return "up"; + } catch { + return "down"; + } finally { + clearTimeout(t); + } +} + +/* ── Anmeldestand ────────────────────────────────────────────────────────── + * Wenn eine App einen "me"-Endpunkt in der Konfiguration hat, fragen wir ihn + * mit den Keksen des Browsers. Das funktioniert nur, weil alle Adressen unter + * derselben Domain liegen — sonst schickt der Browser die Sitzung nicht mit. + * + * Antwortet die App nicht oder verbietet CORS, ist der Stand "blind": dann + * zeigt HoneyOS eben nur die Lampe wie bisher. Kein Fehler, keine Meldung — + * die Anzeige ist eine Zugabe, nichts, worauf sich jemand verlassen muss. + */ +async function checkAuth() { + await Promise.all( + state.cfg.apps + .filter((a) => a.me) + .map(async (app) => { + state.auth[app.id] = await askMe(app); + }) + ); + paintProbes(); + applyAuthName(); +} + +async function askMe(app) { + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), 5000); + try { + const headers = {}; + // Apps mit Bearer-Token statt Cookie: der Schlüssel steht in der + // Konfiguration, der Wert im localStorage — und der ist pro Domain. + // Von hier aus lesbar ist er nur, wenn HoneyOS dieselbe Domain ist. + if (app.tokenKey) { + try { + const tok = localStorage.getItem(app.tokenKey); + if (tok) headers.Authorization = `Bearer ${tok}`; + } catch { + /* kein Speicherzugriff — dann ohne */ + } + } + const res = await fetch(resolveUrl(app, app.me), { + credentials: "include", + cache: "no-store", + headers, + signal: ctl.signal, + }); + if (res.status === 401 || res.status === 403) return { status: "out" }; + if (!res.ok) return { status: "blind" }; + return { status: "in", name: extractName(await res.json()) }; + } catch { + return { status: "blind" }; + } finally { + clearTimeout(t); + } +} + +/** Relative Pfade beziehen sich auf die App-Adresse, absolute gelten wie sie sind. */ +function resolveUrl(app, path) { + if (!path) return app.url; + if (/^https?:\/\//i.test(path)) return path; + return app.url.replace(/\/+$/, "") + (path.startsWith("/") ? path : "/" + path); +} + +/** Jede App antwortet anders — hier wird der Anzeigename herausgefischt. */ +function extractName(data) { + const u = (data && (data.user || data.profile)) || data || {}; + return u.display_name || u.displayName || u.name || u.username || u.preferred_username || ""; +} + +/** Nennt eine App einen Namen, gilt der — er kommt vom Anbieter, die + * Einstellung ist nur der Platzhalter, solange niemand antwortet. */ +function applyAuthName() { + const named = Object.values(state.auth).find((a) => a.status === "in" && a.name); + if (named) { + $("mbUser").textContent = named.name; + renderStats(); + } +} + +const STATE_TEXT = { up: "online", down: "keine antwort", unknown: "prüfe …" }; +const AUTH_TEXT = { in: "angemeldet", out: "nicht angemeldet" }; + +function stateLine(appId) { + const s = state.probes[appId] || "unknown"; + const a = state.auth[appId]; + const extra = a && AUTH_TEXT[a.status] ? ` · ${a.status === "in" && a.name ? a.name : AUTH_TEXT[a.status]}` : ""; + return { s, text: STATE_TEXT[s] + (s === "up" ? extra : "") }; +} + +function paintProbes() { + document.querySelectorAll(".tile-state").forEach((el) => { + const { s, text } = stateLine(el.dataset.app); + el.dataset.state = s; + el.innerHTML = `${escapeHtml(text)}`; + }); + document.querySelectorAll(".deskicon[data-app]").forEach((el) => { + const { s, text } = stateLine(el.dataset.app); + el.dataset.state = s; + el.title = `${el.dataset.name} — ${text}`; + }); +} + +/* ══ Ansicht 1: Terminal-Modus ═══════════════════════════════════════════ */ + +function renderTiles() { + const wrap = $("tiles"); + wrap.innerHTML = ""; + + state.cfg.apps.forEach((app) => { + const a = document.createElement("a"); + a.className = "tile"; + a.href = app.url; + a.target = state.settings.openIn === "tab" ? "_blank" : "_self"; + a.rel = "noopener"; + a.innerHTML = ` + ${app.key ? `[${app.key}]` : ""} + ${glyphSvg(app.glyph)} + ${escapeHtml(app.name)} + ${escapeHtml(app.subtitle || "")} + prüfe …`; + a.addEventListener("click", (e) => { + e.preventDefault(); + launch(app); + }); + wrap.appendChild(a); + }); + + const add = document.createElement("button"); + add.className = "tile tile-add"; + add.innerHTML = "+ app hinzufügen"; + add.addEventListener("click", openSettings); + wrap.appendChild(add); +} + +/* ══ Ansicht 2: Desktop-Modus ════════════════════════════════════════════ */ + +function renderDeskIcons() { + const wrap = $("deskIcons"); + wrap.innerHTML = ""; + + const items = [ + ...state.cfg.apps.map((app) => ({ + id: app.id, + name: app.name, + glyph: app.glyph, + app, + run: () => launch(app), + })), + { id: "programme", name: "Programme", glyph: "folder", run: openProgramsWindow }, + { id: "system", name: "System", glyph: "chip", run: openSystemWindow }, + { id: "settings", name: "Einstellungen", glyph: "gear", run: openSettings }, + ]; + + items.forEach((item) => { + const b = document.createElement("button"); + b.className = "deskicon"; + b.dataset.name = item.name; + if (item.app) b.dataset.app = item.app.id; + b.innerHTML = `${glyphSvg(item.glyph)}${escapeHtml(item.name)}`; + + // Am Zeigegerät wie früher: einmal auswählen, zweimal öffnen. + // Am Finger wäre das eine Zumutung — dort öffnet die erste Berührung. + b.addEventListener("click", () => { + selectIcon(b); + if (isTouch()) item.run(); + }); + b.addEventListener("dblclick", item.run); + b.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + item.run(); + } + }); + wrap.appendChild(b); + }); + + wrap.addEventListener("pointerdown", (e) => { + if (e.target === wrap) selectIcon(null); + }); +} + +function selectIcon(el) { + document.querySelectorAll(".deskicon[data-sel]").forEach((i) => delete i.dataset.sel); + if (el) el.dataset.sel = "1"; +} + +/* ── Fenster ───────────────────────────────────────────────────────────── */ + +/** + * Legt ein Fenster an oder holt ein vorhandenes nach vorn. + * `body` ist fertiges HTML, `onMount` bekommt den Rumpf zum Verdrahten. + */ +function openWindow({ id, title, body, w = 340, x, y, onMount }) { + const existing = state.wins[id]; + if (existing) { + existing.min = false; + delete existing.el.dataset.min; + focusWindow(id); + updateTaskbar(); + return existing.el; + } + + const layer = $("windows"); + const bounds = layer.getBoundingClientRect(); + const width = Math.min(w, Math.max(200, bounds.width - 24)); + const count = Object.keys(state.wins).length; + + const el = document.createElement("section"); + el.className = "dwin"; + el.style.width = width + "px"; + // Links liegt die Symbolspalte — Fenster starten rechts davon, sonst deckt + // das erste Fenster gleich die App-Symbole zu. + const startX = bounds.width > 420 ? 124 : 8; + el.style.left = (x ?? Math.min(startX + count * 26, Math.max(8, bounds.width - width - 8))) + "px"; + el.style.top = (y ?? 18 + count * 24) + "px"; + el.innerHTML = ` +
+ ${escapeHtml(title)} + + + + +
+
${body}
`; + + layer.appendChild(el); + state.wins[id] = { el, min: false, title }; + + el.querySelector('[data-act="close"]').addEventListener("click", () => closeWindow(id)); + el.querySelector('[data-act="min"]').addEventListener("click", () => minimizeWindow(id)); + el.addEventListener("pointerdown", () => focusWindow(id)); + makeDraggable(el, el.querySelector(".win-bar"), layer); + + if (onMount) onMount(el.querySelector(".win-body")); + focusWindow(id); + updateTaskbar(); + return el; +} + +function focusWindow(id) { + Object.entries(state.wins).forEach(([wid, w]) => (w.el.dataset.focus = wid === id ? "1" : "0")); + const w = state.wins[id]; + if (w) w.el.style.zIndex = String(++state.zTop); + updateTaskbar(); +} + +function closeWindow(id) { + const w = state.wins[id]; + if (!w) return; + w.el.remove(); + delete state.wins[id]; + updateTaskbar(); +} + +function closeAllWindows() { + Object.keys(state.wins).forEach(closeWindow); +} + +function minimizeWindow(id) { + const w = state.wins[id]; + if (!w) return; + w.min = true; + w.el.dataset.min = "1"; + updateTaskbar(); +} + +/** Ziehen am Titelbalken. Pointer Events, damit Maus und Finger denselben + * Weg nehmen; die Position wird im sichtbaren Bereich gehalten. */ +function makeDraggable(win, bar, layer) { + bar.addEventListener("pointerdown", (e) => { + if (e.target.closest(".win-btn")) return; + const wr = win.getBoundingClientRect(); + const lr = layer.getBoundingClientRect(); + const offX = e.clientX - wr.left; + const offY = e.clientY - wr.top; + win.dataset.drag = "1"; + bar.setPointerCapture(e.pointerId); + + const move = (ev) => { + const x = Math.max(0, Math.min(ev.clientX - lr.left - offX, lr.width - wr.width)); + const y = Math.max(0, Math.min(ev.clientY - lr.top - offY, lr.height - 26)); + win.style.left = x + "px"; + win.style.top = y + "px"; + }; + const up = () => { + delete win.dataset.drag; + bar.removeEventListener("pointermove", move); + bar.removeEventListener("pointerup", up); + bar.removeEventListener("pointercancel", up); + }; + bar.addEventListener("pointermove", move); + bar.addEventListener("pointerup", up); + bar.addEventListener("pointercancel", up); + }); +} + +function updateTaskbar() { + const list = $("taskList"); + if (!list) return; + list.innerHTML = ""; + Object.entries(state.wins).forEach(([id, w]) => { + const b = document.createElement("button"); + b.className = "task-btn"; + b.textContent = w.title; + b.dataset.active = !w.min && w.el.dataset.focus === "1" ? "1" : "0"; + b.addEventListener("click", () => { + if (w.min) { + w.min = false; + delete w.el.dataset.min; + focusWindow(id); + } else if (w.el.dataset.focus === "1") { + minimizeWindow(id); + } else { + focusWindow(id); + } + }); + list.appendChild(b); + }); +} + +/* ── Fensterinhalte ────────────────────────────────────────────────────── */ + +function openSystemWindow() { + openWindow({ + id: "system", + title: "system", + w: 320, + body: `

`, + onMount: renderStats, + }); +} + +function openProgramsWindow() { + const rows = state.cfg.apps + .map( + (a) => `` + ) + .join(""); + + openWindow({ + id: "programme", + title: "programme", + w: 360, + body: `
${rows} + +
`, + onMount: (root) => { + root.querySelectorAll("[data-launch]").forEach((b) => + b.addEventListener("click", () => { + const app = state.cfg.apps.find((a) => a.id === b.dataset.launch); + if (app) launch(app); + }) + ); + root.querySelector("[data-settings]").addEventListener("click", openSettings); + }, + }); +} + +/* ── Dialoge: Modal im Terminal, Fenster auf dem Schreibtisch ──────────── */ + +function openPanel(id, title, html, onMount, width) { + if (state.settings.mode === "desktop") { + openWindow({ id, title, body: html, w: width || 380, onMount }); + return; + } + $("modalTitle").textContent = title; + $("modalBody").innerHTML = html; + $("modal").hidden = false; + if (onMount) onMount($("modalBody")); +} + +function closeModal() { + $("modal").hidden = true; +} + +function openSettings() { + const s = state.settings; + const seg = (name, opts, cur) => + `
` + + opts.map(([v, l]) => ``).join("") + + `
`; + + openPanel( + "settings", + "einstellungen", + ` +
+ Betriebsart + ${seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)} +
+ + + +
+ Röhre + ${seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)} +
+ +
+ Bildröhren-Effekte + ${seg("scanlines", [["on", "scanlines an"], ["off", "aus"]], s.scanlines)} +
+ ${seg("flicker", [["on", "flackern an"], ["off", "aus"]], s.flicker)} +
+ ${seg("glow", [["on", "glühen an"], ["off", "aus"]], s.glow)} +
+ +
+ Apps öffnen + ${seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)} +
+ +
+ Adressen + ${state.cfg.apps + .map( + (a) => + `
${escapeHtml(a.name)} +
` + ) + .join("")} +
+ + `, + (root) => { + root.querySelectorAll(".seg").forEach((segEl) => { + segEl.addEventListener("click", (e) => { + const btn = e.target.closest("button[data-v]"); + if (!btn) return; + const key = segEl.dataset.seg; + segEl.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === btn))); + + if (key === "mode") { + // Der Moduswechsel zeichnet alles neu — die Einstellungen sollen + // dabei offen bleiben, also anschließend erneut öffnen. + setMode(btn.dataset.v); + openSettings(); + return; + } + state.settings[key] = btn.dataset.v; + saveSettings(); + renderStats(); + if (key === "openIn" && state.settings.mode === "terminal") renderTiles(); + }); + }); + + root.querySelector("#btnSave").addEventListener("click", () => { + const user = root.querySelector("#setUser").value.trim(); + if (user) state.cfg.user = user; + root.querySelectorAll("input[data-url]").forEach((inp) => { + const app = state.cfg.apps.find((a) => a.id === inp.dataset.url); + const val = inp.value.trim(); + if (app && val) app.url = val; + }); + saveConfig(); + if (state.settings.mode === "desktop") closeWindow("settings"); + else closeModal(); + $("mbUser").textContent = state.cfg.user; + renderMenubarApps(); + renderMode(); + probeApps(); + }); + + root.querySelector("#btnReset").addEventListener("click", () => { + localStorage.removeItem(LS_SETTINGS); + localStorage.removeItem(LS_CFG); + location.reload(); + }); + }, + 420 + ); +} + +function openAbout() { + openPanel( + "about", + "über honeyos", + `
+

HoneyOS ${escapeHtml(state.cfg.version)} — eine Oberfläche für mehrere eigene Apps. + Jede App bleibt ihre eigene Seite auf ihrer eigenen Domain; HoneyOS ist der Schreibtisch + davor und die Leiste darin.

+

Zwei Betriebsarten, ein Datenstand: terminal zeigt Kacheln, + desktop Symbole und Fenster. Die Leiste oben ist in beiden dieselbe — + und dieselbe steckt per honeybar/honeybar.js auch in Aether und InkNote.

+
+ ${state.cfg.apps.map((a) => `
${a.key || "·"}
${escapeHtml(a.name)} starten
`).join("")} +
M
Betriebsart wechseln
+
S
Einstellungen
+
?
diese Hilfe
+
Esc
Fenster schließen
+
+
` + ); +} + +/* ── Hilfsmittel ───────────────────────────────────────────────────────── */ + +function escapeHtml(s) { + return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]); +} +function escapeAttr(s) { + return escapeHtml(s).replace(/"/g, """); +} + +/* ── Startmenü ─────────────────────────────────────────────────────────── */ + +function buildStartMenu() { + const menu = document.createElement("div"); + menu.className = "startmenu"; + menu.id = "startmenu"; + $("desktop").appendChild(menu); + + const btn = $("btnStart"); + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const open = menu.dataset.open === "1"; + if (open) return closeStartMenu(); + + menu.innerHTML = `
starten
` + + state.cfg.apps.map((a) => ``).join("") + + `
+ + + +
`; + + menu.querySelectorAll("[data-app]").forEach((b) => + b.addEventListener("click", () => { + const app = state.cfg.apps.find((a) => a.id === b.dataset.app); + closeStartMenu(); + if (app) launch(app); + }) + ); + menu.querySelectorAll("[data-win]").forEach((b) => + b.addEventListener("click", () => { + closeStartMenu(); + ({ programme: openProgramsWindow, system: openSystemWindow, settings: openSettings, about: openAbout })[ + b.dataset.win + ](); + }) + ); + menu.querySelector("[data-mode]").addEventListener("click", () => { + closeStartMenu(); + setMode("terminal"); + }); + + menu.dataset.open = "1"; + btn.setAttribute("aria-expanded", "true"); + }); + + document.addEventListener("click", closeStartMenu); +} + +function closeStartMenu() { + const menu = $("startmenu"); + if (!menu) return; + menu.dataset.open = "0"; + $("btnStart").setAttribute("aria-expanded", "false"); +} + +/* ── Start ─────────────────────────────────────────────────────────────── */ + +async function main() { + await loadConfig(); + applySettings(); + + let skipBoot = false; + try { + skipBoot = sessionStorage.getItem(SS_BOOTED) === "1"; + } catch { + /* kein sessionStorage — dann bootet es jedes Mal, auch schön */ + } + if (!skipBoot) await runBoot(); + + renderShell(); + buildStartMenu(); + probeApps(); + setInterval(tickClock, 15000); + setInterval(probeApps, 120000); + + $("btnSettings").addEventListener("click", openSettings); + $("btnAbout").addEventListener("click", openAbout); + $("btnClose").addEventListener("click", closeModal); + $("modal").addEventListener("click", (e) => { + if (e.target === $("modal")) closeModal(); + }); + + window.addEventListener("keydown", (e) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + const typing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName); + if (e.key === "Escape") { + // Bei offenem Startmenü schließt Esc nur das Menü — sonst nähme es + // einem gleichzeitig das oberste Fenster weg. + const menu = $("startmenu"); + if (menu && menu.dataset.open === "1") return closeStartMenu(); + return state.settings.mode === "desktop" ? closeTopWindow() : closeModal(); + } + if (typing) return; + + const app = state.cfg.apps.find((a) => a.key === e.key); + if (app) return launch(app); + if (e.key === "m" || e.key === "M") setMode(state.settings.mode === "desktop" ? "terminal" : "desktop"); + if (e.key === "s" || e.key === "S") openSettings(); + if (e.key === "?") openAbout(); + }); +} + +/** Esc schließt im Desktop-Modus das oberste Fenster. */ +function closeTopWindow() { + const entries = Object.entries(state.wins).filter(([, w]) => !w.min); + if (!entries.length) return; + entries.sort((a, b) => Number(b[1].el.style.zIndex || 0) - Number(a[1].el.style.zIndex || 0)); + closeWindow(entries[0][0]); +} + +main(); diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..4a1498a --- /dev/null +++ b/web/style.css @@ -0,0 +1,764 @@ +/* + * HoneyOS — Amber-CRT-Desktop. + * + * Farben laufen ausschließlich über die Phosphor-Variablen unten. Wer eine + * neue Röhre will, ergänzt einen [data-phosphor]-Block und fasst sonst nichts an. + */ + +/* ── Phosphor ──────────────────────────────────────────────────────────── */ +:root, +[data-phosphor="amber"] { + --p: #ffb641; /* Vollton — Text, Rahmen im Fokus */ + --p-mid: #c8842a; /* halb — Sekundärtext */ + --p-dim: #6b4718; /* schwach — Rahmen, Trennlinien */ + --p-faint: #2a1c0c; /* Andeutung — Füllungen */ + --bg: #0a0705; + --bg-raised: #140d07; + --glow: 0 0 6px rgba(255, 182, 65, 0.45); +} +[data-phosphor="green"] { + --p: #5cff8f; + --p-mid: #2fa85c; + --p-dim: #17512c; + --p-faint: #08200f; + --bg: #040803; + --bg-raised: #071206; + --glow: 0 0 6px rgba(92, 255, 143, 0.4); +} +[data-phosphor="ice"] { + --p: #cfe8ff; + --p-mid: #7fa6c4; + --p-dim: #395366; + --p-faint: #12202a; + --bg: #04070a; + --bg-raised: #0a1118; + --glow: 0 0 6px rgba(207, 232, 255, 0.35); +} + +* { + box-sizing: border-box; +} + +/* Boot- und Desktop-Ebene tragen `display:flex` — das schlägt das + hidden-Attribut des Browsers, beide lägen sonst gleichzeitig da. */ +[hidden] { + display: none !important; +} + +html, +body { + height: 100%; + margin: 0; +} + +body { + background: var(--bg); + color: var(--p); + font-family: ui-monospace, "SF Mono", "Cascadia Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + overflow: hidden; +} + +/* ── Bildschirm + Effekte ──────────────────────────────────────────────── */ +.crt { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + overflow: hidden; + /* Sanftes Glimmen aus der Mitte, wie eine aufgewärmte Röhre */ + background: + radial-gradient(120% 90% at 50% 45%, color-mix(in srgb, var(--p) 7%, transparent), transparent 70%), + var(--bg); + text-shadow: var(--glow); +} + +.scanlines, +.vignette, +.flicker { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 90; +} + +.scanlines { + background: repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0.28) 0 1px, transparent 1px 3px); + opacity: 0.7; +} + +.vignette { + background: radial-gradient(115% 105% at 50% 50%, transparent 55%, rgba(0, 0, 0, 0.75) 100%); +} + +.flicker { + background: var(--p); + opacity: 0; + mix-blend-mode: overlay; + animation: flicker 7s infinite steps(1); +} + +@keyframes flicker { + 0%, 96%, 100% { opacity: 0; } + 97% { opacity: 0.035; } + 98% { opacity: 0; } + 99% { opacity: 0.02; } +} + +/* Wer die Effekte abschaltet, schaltet sie wirklich ab. */ +[data-scanlines="off"] .scanlines, +[data-flicker="off"] .flicker { + display: none; +} +[data-glow="off"] .crt { + text-shadow: none; +} + +@media (prefers-reduced-motion: reduce) { + .flicker { animation: none; } +} + +/* ── Boot ──────────────────────────────────────────────────────────────── */ +.boot { + flex: 1; + padding: clamp(16px, 5vw, 56px); + display: flex; + flex-direction: column; + justify-content: flex-start; + overflow: hidden; +} + +.boot-log { + margin: 0; + font: inherit; + white-space: pre-wrap; + color: var(--p); +} + +.boot-log .ok { color: var(--p); } +.boot-log .warn { color: var(--p-mid); } +.boot-log b { font-weight: 700; } + +.boot-hint { + margin-top: auto; + color: var(--p-dim); + font-size: 12px; +} + +.caret::after { + content: "\2588"; + animation: blink 1s steps(1) infinite; +} + +@keyframes blink { + 50% { opacity: 0; } +} + +/* ── Desktop ───────────────────────────────────────────────────────────── */ +.desktop { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + animation: warmup 0.5s ease-out; +} + +@keyframes warmup { + from { opacity: 0; transform: scaleY(0.9); filter: brightness(2.5); } + to { opacity: 1; transform: none; filter: none; } +} + +/* Menüleiste — Zwilling von honeybar.js */ +.menubar { + display: flex; + align-items: center; + gap: 10px; + padding: 0 10px; + height: 34px; + flex: 0 0 34px; + border-bottom: 1px solid var(--p-dim); + background: var(--bg-raised); + padding-top: env(safe-area-inset-top); + height: calc(34px + env(safe-area-inset-top)); + flex-basis: calc(34px + env(safe-area-inset-top)); +} + +.mb-logo { + background: none; + border: 0; + color: var(--p); + font: inherit; + letter-spacing: 0.14em; + cursor: pointer; + padding: 2px 4px; + white-space: nowrap; +} +.mb-logo b { font-weight: 400; color: var(--p-mid); } +.mb-logo .hex { font-size: 10px; vertical-align: 2px; } + +.mb-apps { + display: flex; + gap: 4px; + overflow-x: auto; + scrollbar-width: none; + flex: 1; +} +.mb-apps::-webkit-scrollbar { display: none; } + +.mb-app { + background: none; + border: 1px solid transparent; + color: var(--p-mid); + font: inherit; + font-size: 12px; + letter-spacing: 0.08em; + padding: 2px 9px; + cursor: pointer; + white-space: nowrap; +} +.mb-app:hover { color: var(--p); border-color: var(--p-dim); } +.mb-app[aria-current="true"] { + color: var(--bg); + background: var(--p); + border-color: var(--p); + text-shadow: none; +} + +.mb-right { + display: flex; + align-items: center; + gap: 10px; + color: var(--p-mid); + font-size: 12px; +} +.mb-user { opacity: 0.9; } +.mb-clock { font-variant-numeric: tabular-nums; color: var(--p); } + +.mb-btn { + background: none; + border: 1px solid var(--p-dim); + color: var(--p); + font: inherit; + line-height: 1; + padding: 3px 7px; + cursor: pointer; +} +.mb-btn:hover { background: var(--p-faint); } + +/* Bühne */ +.stage { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: clamp(14px, 3vw, 32px); + display: grid; + gap: clamp(14px, 3vw, 28px); + grid-template-columns: minmax(0, 1fr); + align-content: start; + max-width: 1100px; + width: 100%; + margin: 0 auto; +} + +@media (min-width: 860px) { + .stage { + grid-template-columns: 320px minmax(0, 1fr); + align-items: start; + } +} + +/* Fenster */ +.win { + border: 1px solid var(--p-dim); + background: var(--bg-raised); +} + +.win-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 3px 8px; + border-bottom: 1px solid var(--p-dim); + background: var(--p-faint); + font-size: 11px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--p-mid); +} +.win-dots { letter-spacing: 2px; opacity: 0.5; } +.win-close { + background: none; + border: 0; + color: var(--p); + font: inherit; + font-size: 16px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} + +.win-body { padding: 14px; } + +.greet { + margin: 0 0 12px; + font-size: 15px; +} + +.statlist { + margin: 0; + display: grid; + grid-template-columns: auto 1fr; + gap: 3px 12px; + font-size: 12.5px; +} +.statlist dt { color: var(--p-dim); } +.statlist dd { margin: 0; color: var(--p-mid); text-align: right; font-variant-numeric: tabular-nums; } +.statlist dd.up { color: var(--p); } +.statlist dd.down { color: var(--p-mid); opacity: 0.6; } + +/* App-Kacheln */ +.tiles { + display: grid; + gap: clamp(12px, 2.5vw, 20px); + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + align-content: start; +} + +.tile { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + min-height: 168px; + border: 1px solid var(--p-dim); + background: var(--bg-raised); + color: var(--p); + font: inherit; + text-align: left; + text-decoration: none; + cursor: pointer; + position: relative; + transition: background 0.12s, border-color 0.12s, transform 0.12s; +} +.tile:hover, +.tile:focus-visible { + background: var(--p-faint); + border-color: var(--p); + transform: translateY(-2px); + outline: none; +} +.tile:active { transform: none; } + +.tile-key { + position: absolute; + top: 8px; + right: 10px; + font-size: 11px; + color: var(--p-dim); +} + +.tile-icon { + width: 46px; + height: 46px; + color: var(--p); +} +.tile-icon svg { display: block; width: 100%; height: 100%; } + +.tile-name { + font-size: 16px; + letter-spacing: 0.16em; +} +.tile-sub { + font-size: 12px; + color: var(--p-mid); + margin-top: -6px; +} + +.tile-state { + margin-top: auto; + font-size: 11px; + letter-spacing: 0.1em; + color: var(--p-dim); + display: flex; + align-items: center; + gap: 6px; +} +.tile-state .led { + width: 7px; + height: 7px; + background: var(--p-dim); + border-radius: 50%; +} +.tile-state[data-state="up"] { color: var(--p); } +.tile-state[data-state="up"] .led { background: var(--p); box-shadow: var(--glow); } +.tile-state[data-state="down"] { color: var(--p-mid); } +.tile-state[data-state="down"] .led { background: transparent; border: 1px solid var(--p-mid); } + +/* leerer Zustand */ +.tile-add { + align-items: center; + justify-content: center; + color: var(--p-dim); + border-style: dashed; + min-height: 168px; +} + +/* Statuszeile */ +.statusbar { + flex: 0 0 auto; + display: flex; + justify-content: space-between; + gap: 12px; + padding: 4px 10px; + padding-bottom: calc(4px + env(safe-area-inset-bottom)); + border-top: 1px solid var(--p-dim); + background: var(--bg-raised); + color: var(--p-dim); + font-size: 11px; + letter-spacing: 0.06em; +} +.sb-right { color: var(--p-mid); white-space: nowrap; } + +@media (max-width: 620px) { + .statusbar { display: none; } +} + +/* ── Modal ─────────────────────────────────────────────────────────────── */ +.modal { + position: absolute; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + padding: 16px; + background: rgba(0, 0, 0, 0.65); +} +.modal[hidden] { display: none; } + +.win-modal { + width: min(520px, 100%); + max-height: min(78vh, 640px); + display: flex; + flex-direction: column; + box-shadow: 0 0 0 1px var(--p-faint), 0 18px 60px rgba(0, 0, 0, 0.7); +} +.win-modal .win-body { overflow-y: auto; } + +.field { + display: block; + margin-bottom: 16px; +} +.field > .label { + display: block; + font-size: 11px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--p-dim); + margin-bottom: 6px; +} + +.seg { display: flex; flex-wrap: wrap; gap: 6px; } +.seg button { + background: none; + border: 1px solid var(--p-dim); + color: var(--p-mid); + font: inherit; + font-size: 12px; + padding: 4px 12px; + cursor: pointer; +} +.seg button:hover { border-color: var(--p); color: var(--p); } +.seg button[aria-pressed="true"] { + background: var(--p); + border-color: var(--p); + color: var(--bg); + text-shadow: none; +} + +.field input[type="text"], +.field input[type="url"] { + width: 100%; + background: var(--bg); + border: 1px solid var(--p-dim); + color: var(--p); + font: inherit; + font-size: 13px; + padding: 6px 8px; +} +.field input:focus { + outline: none; + border-color: var(--p); +} + +.url-row { + display: grid; + grid-template-columns: 88px 1fr; + gap: 8px; + align-items: center; + margin-bottom: 8px; +} +.url-row span { font-size: 12px; color: var(--p-mid); letter-spacing: 0.1em; } + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 18px; + padding-top: 12px; + border-top: 1px solid var(--p-faint); +} +.btn { + background: none; + border: 1px solid var(--p-dim); + color: var(--p); + font: inherit; + font-size: 12px; + letter-spacing: 0.1em; + padding: 5px 14px; + cursor: pointer; +} +.btn:hover { background: var(--p-faint); border-color: var(--p); } +.btn-primary { background: var(--p); border-color: var(--p); color: var(--bg); text-shadow: none; } +.btn-primary:hover { background: var(--p); filter: brightness(1.1); } + +.about p { margin: 0 0 10px; color: var(--p-mid); font-size: 13px; } +.about code { + color: var(--p); + background: var(--p-faint); + padding: 1px 4px; +} +.keys { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 14px; + font-size: 12.5px; + margin-top: 12px; +} +.keys dt { color: var(--p); } +.keys dd { margin: 0; color: var(--p-mid); } + +/* ── Desktop-Modus ───────────────────────────────────────────────────────── + * Dieselbe Röhre, andere Betriebsart: Symbole auf der Fläche, echte Fenster, + * Taskleiste unten. Die Menüleiste oben bleibt in beiden Modi dieselbe — sie + * ist die Klammer, die HoneyOS mit den Apps teilt. + */ +.deskspace { + flex: 1; + min-height: 0; + position: relative; + overflow: hidden; +} + +.desk-icons { + position: absolute; + inset: 0; + padding: 12px 8px; + display: flex; + flex-direction: column; + flex-wrap: wrap; + align-content: flex-start; + gap: 2px; +} + +.deskicon { + width: 94px; + padding: 8px 4px 6px; + text-align: center; + background: none; + border: 1px solid transparent; + color: var(--p); + font: inherit; + cursor: pointer; +} +.deskicon:hover { border-color: var(--p-dim); } +.deskicon:focus-visible { outline: 1px dotted var(--p); outline-offset: -3px; } + +.di-glyph { + /* inline-Elemente ignorieren Breite und Höhe — ohne block wächst das SVG + auf seine Eigengröße und sprengt das Symbol. */ + display: block; + width: 34px; + height: 34px; + margin: 0 auto 6px; + color: var(--p); +} +.di-glyph svg { display: block; width: 100%; height: 100%; } + +.di-label { + display: inline-block; + font-size: 11.5px; + padding: 1px 4px; + letter-spacing: 0.06em; +} + +.deskicon[data-sel="1"] { background: var(--p-faint); border-color: var(--p-dim); } +.deskicon[data-sel="1"] .di-label { background: var(--p); color: var(--bg); text-shadow: none; } + +/* Erreichbarkeitslampe am Symbol */ +.deskicon[data-app] .di-label::after { content: ""; } +.deskicon[data-state="down"] .di-glyph { opacity: 0.45; } + +/* Fenster im Desktop-Modus */ +.windows { + position: absolute; + inset: 0; + pointer-events: none; +} + +.dwin { + position: absolute; + pointer-events: auto; + border: 1px solid var(--p-dim); + background: var(--bg-raised); + box-shadow: 0 12px 34px rgba(0, 0, 0, 0.55); + display: flex; + flex-direction: column; + max-height: calc(100% - 16px); +} +.dwin[data-focus="1"] { border-color: var(--p); } +.dwin[data-min="1"] { display: none; } + +.dwin .win-bar { + cursor: grab; + touch-action: none; + user-select: none; +} +.dwin[data-drag="1"] .win-bar { cursor: grabbing; } +.dwin[data-focus="0"] .win-bar { color: var(--p-dim); } + +.dwin .win-body { + overflow: auto; + min-height: 0; +} + +.win-btns { display: flex; gap: 2px; } +.win-btn { + background: none; + border: 1px solid var(--p-dim); + color: var(--p); + font: inherit; + font-size: 10px; + line-height: 1; + padding: 1px 5px; + cursor: pointer; +} +.win-btn:hover { background: var(--p-faint); border-color: var(--p); } + +/* Programme-Fenster */ +.applist { display: flex; flex-direction: column; gap: 2px; } +.applist button { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + background: none; + border: 1px solid transparent; + color: var(--p); + font: inherit; + font-size: 13px; + text-align: left; + padding: 5px 6px; + cursor: pointer; +} +.applist button:hover { background: var(--p-faint); border-color: var(--p-dim); } +.applist .al-glyph { width: 18px; height: 18px; flex: none; } +.applist .al-glyph svg { display: block; width: 100%; height: 100%; } +.applist .al-sub { margin-left: auto; color: var(--p-dim); font-size: 11px; } + +/* Taskleiste */ +.taskbar { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 6px; + padding: 3px 6px; + padding-bottom: calc(3px + env(safe-area-inset-bottom)); + border-top: 1px solid var(--p-dim); + background: var(--bg-raised); +} + +.task-start { + background: var(--p-faint); + border: 1px solid var(--p-dim); + color: var(--p); + font: inherit; + font-size: 11px; + letter-spacing: 0.16em; + padding: 3px 10px; + cursor: pointer; + flex: none; +} +.task-start:hover, .task-start[aria-expanded="true"] { background: var(--p); color: var(--bg); text-shadow: none; } + +.task-list { + display: flex; + gap: 4px; + flex: 1; + overflow-x: auto; + scrollbar-width: none; +} +.task-list::-webkit-scrollbar { display: none; } + +.task-btn { + background: none; + border: 1px solid var(--p-dim); + color: var(--p-mid); + font: inherit; + font-size: 11px; + padding: 3px 10px; + min-width: 96px; + max-width: 160px; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} +.task-btn:hover { color: var(--p); } +.task-btn[data-active="1"] { background: var(--p-faint); color: var(--p); border-color: var(--p); } + +.task-date { color: var(--p-dim); font-size: 11px; white-space: nowrap; } + +/* Startmenü */ +.startmenu { + position: absolute; + left: 6px; + bottom: 4px; + min-width: 208px; + z-index: 70; + border: 1px solid var(--p); + background: var(--bg-raised); + padding: 4px; + display: none; + flex-direction: column; + gap: 1px; + box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.6); +} +.startmenu[data-open="1"] { display: flex; } +.startmenu .sm-head { + padding: 4px 8px; + font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--p-dim); +} +.startmenu button { + background: none; + border: 0; + color: var(--p-mid); + font: inherit; + font-size: 12.5px; + text-align: left; + padding: 6px 9px; + cursor: pointer; +} +.startmenu button:hover { background: var(--p); color: var(--bg); text-shadow: none; } +.startmenu hr { border: 0; border-top: 1px solid var(--p-faint); margin: 3px 0; } + +@media (max-width: 620px) { + .task-btn { min-width: 0; } + .task-date { display: none; } +}