Compare commits

..
13 Commits
Author SHA1 Message Date
marcandClaude Opus 5 bf5b7f8e63 Kategorien sind Zeilen in der Liste, kein Feld am Formular
Marcs Vorschlag, und er ist der bessere: "+ kategorie" setzt eine
Ueberschriftenzeile, und was darunter steht, gehoert dazu, bis die naechste
kommt. Die Ueberschrift hat dieselben Pfeile wie eine App, laesst sich
umbenennen und mit x aufloesen.

Damit faellt die Sonderregel von gestern weg -- "am Rand der Gruppe
kategorisiert der Pfeil um, statt zu tauschen". Sie war die Beschreibung
eines Modells; jetzt ist es das Modell: Schiebt man eine App ueber eine
Ueberschrift, ist sie in der anderen Gruppe, und schiebt man eine
Ueberschrift, wandert die Grenze. Nichts davon braucht Code.

Vor allem loest es den echten Mangel der ersten Fassung: Eine Kategorie
umbenennen war ein Feld je App, jetzt ist es eine Zeile.

Gespeichert wird weiter als Feld an der App. Waere die Ueberschrift ein
Eintrag in der App-Liste, enthielte diese Zeilen ohne ID und ohne Adresse,
und jeder Leser muesste das wissen -- Kacheln, Schublade, Pruefung auf
Erreichbarkeit und vor allem die HoneyBar in fremden Apps. Der Preis: Eine
Ueberschrift ohne Apps darunter ueberlebt das Speichern nicht. Eine
Kategorie besteht durch ihre Apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:35:08 +02:00
marcandClaude Opus 5 501c3b8930 Apps lassen sich in Kategorien gruppieren
Ein Feld an der App, kein zweites Datenmodell: Eine Kategorie besteht genau
so lange, wie eine App sie traegt. Damit gibt es nichts anzulegen, nichts zu
loeschen und keine zweite Reihenfolge, die mit der bestehenden aus dem Tritt
geraten koennte.

Die Reihenfolge der Ueberschriften ist die ihres ersten Auftretens in der
Liste -- also ordnen dieselben Pfeile wie bisher beides. Am Rand einer Gruppe
haengt der Pfeil die App in die Nachbargruppe um, statt zu tauschen: derselbe
Knopf zum Sortieren und zum Umkategorisieren, und keine App kann zwischen
zwei Gruppen liegen bleiben.

Eingesammelt wird im Server, beim Schreiben wie beim Lesen. Das war nicht der
erste Entwurf: Zuerst sortierte die Oberflaeche und die HoneyBar setzte ihre
Ueberschriften beim Durchlaufen -- prompt stand eine App ohne Kategorie in der
HoneyBar unter der letzten Ueberschrift und auf dem Schreibtisch ganz oben.
Bei zwei Lesern derselben Liste gehoert die Reihenfolge dorthin, wo die Liste
liegt.

Ueberschriften erscheinen, wo man sucht: Kacheln, Schublade beider Leisten,
Programme-Fenster. Nicht im App-Streifen (32 Pixel) und nicht bei den
Schreibtischsymbolen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:12:03 +02:00
marcandClaude Opus 5 3b47003492 Die HoneyBar braucht kein 'unsafe-inline' mehr
Ihre Stile hingen als <style> im Shadow DOM, und Markup faellt unter die
CSP der Gastgeber-App: Jede fremde App musste deshalb style-src
'unsafe-inline' erlauben, sonst stand die HoneyBar schwarz auf schwarz da.

Jetzt kommen sie als konstruiertes Stylesheet ueber die CSSOM herein, die
keine CSP kennt. Damit braucht die App gar keine style-src-Erlaubnis mehr
-- auch keine fuer die HoneyOS-Domain, was der geplante Weg ueber eine
zweite Datei und <link> gekostet haette, samt zweiter Anfrage.

Der Strich zum Wiederausklappen hat seinen eigenen Shadow-Root und geht
denselben Weg. Der Rueckfall fuer alte Browser laeuft nach innerHTML,
sonst raeumt das Markup ihn gleich wieder weg.

Gemessen an einer nachgebauten App mit style-src 'self': Leiste bernstein,
32 px, Strich mit Verlauf, keine CSP-Meldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:50:06 +02:00
marc 8ca16aec84 Anmeldung zeigt dasselbe Wappen, und der Rahmen sitzt eng
Auf dem Anmeldebildschirm steht jetzt dieselbe Biene wie in der Leiste und im
Tab, nur gross — ein Geraet, ein Zeichen. Die 3/4-Ansicht wird nirgends mehr
ausgeliefert; sie bleibt als schraeg() im Werkzeug liegen.

Dazu sitzt der SVG-Rahmen jetzt auf dem, was wirklich gefuellt ist. Das Raster
ist quadratisch, die Biene nicht — unten blieb ein Viertel Luft, und die kostet
ueberall Groesse, wo das Bild in einen Kasten gelegt wird.

tools/biene.py schreibt die Zeile in honeybar.js jetzt selbst mit: Drei Kopien,
die von Hand gepflegt werden, laufen genau einmal auseinander.
2026-09-02 10:41:19 +02:00
marc ff413864a9 Die Anmeldung bekommt die Biene in Schraegsicht
Die 3/4-Ansicht ist unter 64 px unbrauchbar und deshalb genau hier richtig:
Auf dem Anmeldebildschirm ist Platz, und man wartet ohnehin auf den Anbieter.
Sie schwebt sechs Sekunden lang zwei Pixel weit — eher ein Atmen als eine
Bewegung, und bei prefers-reduced-motion steht sie still.

Gerechnet wird sie in tools/biene.py aus demselben Raster wie die anderen
Fassungen.
2026-09-02 10:27:03 +02:00
marc 3f8b98b869 Eine Biene als Wappen, an allen vier Stellen
Aus dem Quadrat links in beiden Leisten wird eine Pixelbiene von der Seite:
braune Streifen, schwarze Beine, Fluegel mit Kerbe. Sie steht in genau einem
Raster (tools/biene.py, 16x16 Zeichen); daraus entstehen SVG, PNG und eine
Fassung aus Schriftzeichen.

SVG statt PNG in den Leisten: Die HoneyBar laeuft in fremden Apps, und ob ein
<img> dort laedt, entscheidet deren CSP. Markup kann niemand blockieren.

Im System-Fenster und ueber dem Startlauf steht die Biene aus Schriftzeichen —
beides sind Textflaechen, ein Bild darin waere ein Fremdkoerper.

Das Wappen liegt an drei Stellen (Datei, Modul, HoneyBar-Zeile), weil die
Leiste nichts nachladen kann. TestWappenIstUeberallDasselbe haelt sie gleich.
2026-09-02 10:21:03 +02:00
marc aec042a8c9 HoneyBar: kein zweiter Punkt, der auf dieselbe Seite fuehrt 2026-09-01 01:12:02 +02:00
marc 75df4e0833 Bild einstellen gehoert in die Einstellungen, nicht ins Widget 2026-08-31 20:12:21 +02:00
marc e2988f7ee9 Die Werkzeugleiste der Notiz erscheint erst beim Schreiben 2026-08-31 20:09:39 +02:00
marc 3807ddca24 Notiz zeigt sich sofort gesetzt, Widgets nach Wunsch des Nutzers
Aus dem Zettel wird die Notiz: Der Text steht immer formatiert da, roh ist nur
die Zeile mit der Schreibmarke. Dazu eine kleine Werkzeugleiste (Aufgabe,
Aufzaehlung, Nummer, Ueberschrift, fett, Linie), Enter fuehrt Listen fort,
Backspace am Zeilenanfang nimmt erst den Vorsatz. Fuer groessere Umbauten
bleibt der Quelltext-Knopf.

Kein contenteditable: Der Quelltext bleibt die Wahrheit und wird nie aus dem
HTML zurueckgerechnet, das der Browser beim Tippen erzeugt.

Reihenfolge und An/Aus der Widgets liegen jetzt je Nutzer auf dem Server, in
einer Einstellung ("benutzer,notiz,-system,wetter,bild"). Der Schalter fuer das
Wetter auf dem Schreibtisch faellt damit weg — es ist ein Widget wie jedes
andere.

Die Einstellungen sind in Abschnitte sortiert (Darstellung, HoneyBar, Widgets,
Wetter, Programme, Konto), Beschriftung links, Schalter rechts.
2026-08-31 01:24:50 +02:00
marc c0abfbb491 Zwei Widgets: Zettel mit kleinem Markdown, Bild je Nutzer
Zettel: ein Textfeld, mehr nicht — InkNote gibt es schon. Gelesen wird
gesetzt (Ueberschriften, Haken, Aufzaehlungen, fett/kursiv/code, Strich),
geschrieben wird roh. Haken lassen sich ohne Bearbeiten setzen und
aendern die Quelle. Gespeichert wird 800 ms nach dem letzten Anschlag,
sofort beim Verlassen und beim Schliessen des Tabs (keepalive statt
sendBeacon: der kann nur POST und traegt keine Kopfzeilen). Grenze 4000
Zeichen, vom Server durchgesetzt.

Bild: genau eines je Nutzer, hochgeladen oder verlinkt. Die erste Datei,
die HoneyOS ablegt — in bilder/ neben honeyos.json, benannt nach einem
Fingerabdruck der Nutzerkennung. Ein neues Bild ersetzt das alte samt
Datei, die Adresse traegt einen Versionsanteil gegen den Zwischen-
speicher. SVG kommt nicht herein (Text, kann Skripte tragen), und der
Typ wird an den Bytes erkannt, nicht an der Angabe des Browsers.

Der Rahmen kann jetzt Widgets mit eigenem DOM: 'stabil' haelt den Inhalt,
'montiert' verdrahtet ihn. Ohne das haette das Anklopfen bei den Apps
alle zwei Minuten den Text unter den Fingern ausgetauscht.

Nebenbei: Der Aufrufhelfer setzte bei jedem Schreibzugriff
Content-Type: application/json — auch fuer Bildbytes.
2026-08-31 00:44:20 +02:00
marc b7cc06b7c1 Widget-Rahmen: Widgets sind Beschreibungen, keine Kaesten in der Seite
widgetAnmelden({ id, titel, wo, sichtbar, laden, leer, inhalt, takt }) —
der Rahmen macht daraus ein Fenster in der Spalte (Terminal) oder ein
Schild (Desktop), kennt lädt/fertig/nichts da/Fehler und haelt den
Nachladetakt. Benutzer, Wetter und Systemzustand sind umgestellt; aus
index.html sind vier feste Kaesten verschwunden und aus dem Raster die
Einzelplaetze.

Zwei Dinge, die erst dabei sichtbar wurden:

- Der Server meldete 'kein Ort eingestellt' und 'Dienst nicht
  erreichbar' beide als 204. Ein Ausfall sah damit aus wie ein
  abgeschaltetes Widget. Ausfall ist jetzt 502, mit Test.
- Die Reihenfolge haengt nicht mehr an der Modulauswertung: Seit
  glyphs.js seine Raster mit await auf oberster Ebene laedt, laufen
  Module ohne Symbole vorher durch — der Systemzustand stand deshalb
  ueber dem Benutzer. widgetsOrdnen() macht die Reihenfolge zur Angabe,
  und genau dort haengt spaeter die im Server gespeicherte ein.
2026-08-30 03:12:00 +02:00
marc 981a2331df Schublade trug die Ausblendmaske des App-Streifens
Das Menue hiess class='menu apps' — und .apps ist der Streifen in der
Leiste, der seine rechten 18 px per mask-image ausblendet, damit man
sieht, dass es seitwaerts weitergeht. Die Schublade erbte die Maske und
loeste sich rechts auf. In der Geometrie war nichts zu sehen, deshalb
hatte ich es zweimal uebersehen: nichts ragte hinaus, der Rahmen war da.

Heisst jetzt .menu.schublade, mit Test dagegen: class='apps' darf genau
einmal vorkommen.
2026-08-30 00:24:02 +02:00
23 changed files with 3054 additions and 340 deletions

No files matched your search

+175 -62
View File
@@ -43,8 +43,11 @@ Eine Annahme von damals hat sich nicht gehalten: „ohne sie zu verlassen"
schreibt, und die HoneyBar darf in einer fremden App nicht schreiben — die schreibt, und die HoneyBar darf in einer fremden App nicht schreiben — die
Sitzung kommt über die Site-Grenze nicht mit, und selbst unter einer Domain Sitzung kommt über die Site-Grenze nicht mit, und selbst unter einer Domain
liegt das CSRF-Prüfwort in einem Cookie, das nur HoneyOS' eigene Seiten lesen. liegt das CSRF-Prüfwort in einem Cookie, das nur HoneyOS' eigene Seiten lesen.
Also reicht die HoneyBar die Frage als `?einstellungen=1` an den Schreibtisch Die HoneyBar hat die Frage deshalb als `?einstellungen=1` an den Schreibtisch
weiter. Ein Klick, ein Seitenwechsel — dort, wo man angemeldet ist. weitergereicht. **Auch das ist seit dem 01.09.2026 weg** (Marcs Einwand): Wenn
der Punkt ohnehin nur weiterleitet, ist er neben „Schreibtisch" keine zweite
Möglichkeit, sondern dieselbe mit einem Zwischenschritt weniger. Das Bar-Menü
trägt jetzt Schreibtisch und Einklappen, sonst nichts.
**Abmelden ist ganz aus den Leisten verschwunden** und steht unten in den **Abmelden ist ganz aus den Leisten verschwunden** und steht unten in den
Einstellungen (Marcs Entscheidung, 29.08.2026). Aus fremden Apps war es ohnehin Einstellungen (Marcs Entscheidung, 29.08.2026). Aus fremden Apps war es ohnehin
@@ -165,51 +168,36 @@ ist am 29.08.2026 weggefallen: Aether und InkNote liegen längst unter
erst nach der Sitzung. **Die HoneyBar weiß dort jetzt, wer schaut.** Was bleibt, erst nach der Sitzung. **Die HoneyBar weiß dort jetzt, wer schaut.** Was bleibt,
ist die Frage, ob es sich bei einem Nutzer lohnt. ist die Frage, ob es sich bei einem Nutzer lohnt.
### 1.4 HoneyOS selbst als PWA ### 1.4 Widget-Rahmen — ✅ gebaut (30.08.2026)
Heute ist HoneyOS eine Website: Es hat `theme-color`, aber kein Manifest, kein Ein Widget ist jetzt eine **Beschreibung**, kein Stück HTML im Dokument:
Symbol und keinen Service Worker — installierbar ist es damit nicht. Am Handy
landet man also im Browser mit Adressleiste, während Aether und InkNote als
eigene Apps danebenliegen. Das ist genau der Bruch, den das Ganze eigentlich
verhindern soll: Der Schreibtisch sollte sich am wenigsten nach Website
anfühlen.
Was dazugehört, klein bis groß: ```js
widgetAnmelden({
id, titel,
wo: ["terminal", "desktop"], // wo es erscheinen darf
sichtbar: () => …, // Schalter aus → gar nicht erst da
laden: async () => false|void, // false = es gibt nichts zu zeigen
leer: "verbergen" | "Text", // was dann passiert
inhalt: () => "<html>",
takt: 600000, // Nachladen, optional
})
```
1. **Manifest** — Name, Startadresse, `display: standalone`, Hintergrund- und Der Rahmen (`web/js/widgets/rahmen.js`) macht daraus im Terminal-Modus ein
Themenfarbe. Die Farben sollten der eingestellten Röhre folgen; sie liegen Fenster in der Widgetspalte und im Desktop-Modus ein Schild, kennt die vier
im Server, das Manifest kann also erzeugt statt fest hinterlegt werden Zustände (lädt · fertig · nichts da · Fehler) und hält den Nachladetakt. Ein
(Aether macht das beim Symbol schon so). Widget, das seinen Server nicht erreicht, sagt das **in seinem eigenen Rahmen**
2. **Symbol** — ein Pixelglyph in 192 und 512 px, im Amber-Look. Passt zum und reißt weder den Schreibtisch noch die anderen Widgets mit.
Rest und ist ohne fremde Werkzeuge zu machen.
3. **Service Worker** — nötig für die Installierbarkeit. Hier ist zu
entscheiden, was er überhaupt soll (siehe unten).
**Die eigentliche Frage ist der Offline-Fall.** HoneyOS zeigt fremde Apps und Umgestellt sind Benutzer, Wetter und der Systemzustand. Aus der `index.html`
später deren Daten — ohne Netz gibt es davon nichts. Ein Schreibtisch, der sind damit vier fest verdrahtete Kästen verschwunden, und aus dem Raster die
offline mit leeren Kacheln dasteht, ist schlimmer als ein ehrlicher Hinweis. Einzelplätze — vorher hätte das dritte Widget beides wieder angefasst.
Sinnvoll wäre: Oberfläche und App-Liste im Cache, damit man die Symbole sieht
und antippen kann, und ein deutlicher Vermerk „ohne Verbindung". Widgets
bleiben leer, statt Altes von gestern als aktuell auszugeben.
**Aus dem Aether-Debakel gelernt und hier von Anfang an zu beachten:** Ein **Was dabei offen gelassen wurde:** die dritte Form. Widgets sollen später auch
Eintrag im Cache ist eine ganze Antwort **samt Kopfzeilen**. Wird die Startseite als Aufklappfeld in der HoneyBar leben können, deshalb erzeugt `inhalt()` nur
vorab abgelegt, friert damit auch ihre CSP ein — und eine Änderung an der den Kern; die Verpackung entsteht im Rahmen. Und die Reihenfolge ist eine
Konfiguration erreicht den Browser nie wieder. Also Navigationen Netz zuerst, Liste — genau das, was „Widgets verschieben" braucht.
Cache nur als Rückfall.
**Reihenfolge:** Erst umziehen (siehe unten), dann installierbar machen. Ein
Ursprungswechsel macht aus einer installierten PWA eine fremde App — wer
vorher installiert, installiert zweimal.
### 1.5 Widget-Rahmen
Ein Fenstertyp, der einen Inhalt holt, ihn anzeigt und sich benimmt, wenn es
schiefgeht: lädt gerade, nichts da, nicht angemeldet, Fehler. Einmal richtig
gebaut, kostet jedes weitere Widget danach fast nichts.
Muss in **beiden Betriebsarten** funktionieren — im Desktop als Fenster, im
Terminal als Kachel.
## 2 · Erste Widgets ## 2 · Erste Widgets
@@ -221,8 +209,10 @@ nicht geplant"). Was bleibt, ist alles, was für sich allein steht: Wetter
### Wetter in der HoneyBar, neben der Uhr — ✅ gebaut (25.08.2026) ### Wetter in der HoneyBar, neben der Uhr — ✅ gebaut (25.08.2026)
**Steht.** Zwei Schalter in den Einstellungen, unabhängig voneinander: auf dem **Steht.** Zwei Orte, unabhängig voneinander: auf dem Schreibtisch, in der
Schreibtisch, in der HoneyBar, beides oder keins. Die Stadt wird einmal gesucht HoneyBar, beides oder keins. Seit dem 31.08.2026 an zwei Stellen — auf dem
Schreibtisch ist das Wetter ein Widget wie jedes andere und steht in der
Widget-Liste, für die Leiste gibt es den eigenen Schalter. Die Stadt wird einmal gesucht
und mit ihren Koordinaten gespeichert; der Server holt das Wetter und legt es und mit ihren Koordinaten gespeichert; der Server holt das Wetter und legt es
zehn Minuten beiseite, die Oberflächen zeigen nur, was er hergibt. zehn Minuten beiseite, die Oberflächen zeigen nur, was er hergibt.
@@ -298,23 +288,28 @@ Zwei Auswege:
→ Ersteres ist das Richtige, sobald die HoneyBar mehr kann als heute (Widgets, → Ersteres ist das Richtige, sobald die HoneyBar mehr kann als heute (Widgets,
Systemmenü). Vorher lohnt der Umbau nicht. Systemmenü). Vorher lohnt der Umbau nicht.
### Die HoneyBar ohne `unsafe-inline` ### Die HoneyBar ohne `unsafe-inline` — ✅ gebaut (11.09.2026)
Fremde Apps bekommen die HoneyBar über den Reverse Proxy (siehe README). Dabei Fremde Apps mussten für die HoneyBar `style-src 'unsafe-inline'` erlauben, weil
muss deren CSP heute `style-src 'unsafe-inline'` erlauben, weil die HoneyBar ihre sie ihre Stile im Shadow DOM als `<style>` mitbrachte — ohne das erschien sie
Stile im Shadow DOM mitbringt — ohne das erscheint sie schwarz auf schwarz. schwarz auf schwarz (gemessen am 28.08.2026).
Gemessen am 28.08.2026 an einer nachgebauten fremden App.
**Auflösbar:** Die Stile als eigene Datei neben `honeybar.js` ausliefern und im **Anders gelöst als hier geplant.** Der Plan war, die Stile als eigene Datei
Shadow DOM per `<link>` einbinden. Dann genügt `style-src 'self' https://honeyos…`, auszuliefern und per `<link>` einzubinden. Das hätte `'unsafe-inline'` gegen
und die CSP der fremden App bleibt streng. Kostet eine zweite Anfrage, die `style-src … https://honeyos…` getauscht — eine CSP-Änderung gegen eine andere,
zwischengespeichert wird. plus eine zweite Anfrage. Stattdessen hängt die HoneyBar ihre Stile jetzt als
konstruiertes Stylesheet (`adoptedStyleSheets`) ein: Die CSP greift nur auf
Markup, nicht auf die CSSOM. Damit braucht die Gastgeber-App **gar keine
`style-src`-Erlaubnis** mehr, und es bleibt bei einer Datei ohne zweite Anfrage.
Zu prüfen ist dabei, was sonst noch inline gesetzt wird: die Lage der HoneyBar Der Strich zum Wiederausklappen hat seinen eigenen Shadow-Root und wäre sonst
selbst und der Innenabstand, den sie der Seite verpasst. Über die CSSOM gesetzte das einzige Stück ohne Farbe geblieben — er geht denselben Weg.
Stile (`el.style.x = …`) fallen nicht unter die CSP — was im Markup steht, schon.
### Bild-Widget — Marcs Idee (29.08.2026) Zwei Fallen dabei: Der Rückfall für alte Browser hängt ein `<style>` in den
Root, also muss er **nach** `innerHTML` laufen, sonst räumt das Markup ihn
gleich wieder weg. Und `el.style.…` war nie das Problem — das ist schon CSSOM.
### Bild-Widget — ✅ gebaut (30.08.2026)
Ein Widget, das ein Bild zeigt: hochgeladen oder als Adresse eingetragen. Genau Ein Widget, das ein Bild zeigt: hochgeladen oder als Adresse eingetragen. Genau
**eines je Nutzer**; ein neues ersetzt das alte, ohne Verwaltung und ohne Liste. **eines je Nutzer**; ein neues ersetzt das alte, ohne Verwaltung und ohne Liste.
@@ -339,11 +334,25 @@ Zu klären, wenn es soweit ist:
- Passt zum Aufnehmen eigener App-Logos (1.3): dieselbe Ablage, dasselbe - Passt zum Aufnehmen eigener App-Logos (1.3): dieselbe Ablage, dasselbe
Ausliefern. Zusammen bauen spart die Hälfte. Ausliefern. Zusammen bauen spart die Hälfte.
### Notizzettel-Widget — Marcs Idee (29.08.2026) **Nachtrag 31.08.2026.** Hochladen, Verlinken und Entfernen standen erst im
Widget selbst und sind in die Einstellungen gewandert (Marcs Einwand): Das
Widget zeigt, die Einstellungen stellen ein — wie beim Wetter. Damit gilt für
alle Widgets dieselbe Regel, und der Schreibtisch bleibt eine Anzeige statt
einer Bedienoberfläche.
### Notiz-Widget — ✅ gebaut (30.08.2026), Sofort-Ansicht (31.08.2026)
Ein Zettel auf dem Schreibtisch: hineintippen, fertig. Serverseitig je Nutzer, Ein Zettel auf dem Schreibtisch: hineintippen, fertig. Serverseitig je Nutzer,
also auf jedem Gerät derselbe Text. also auf jedem Gerät derselbe Text.
**Nachtrag 31.08.2026 — was daraus wurde.** Aus dem „Zettel" ist die *Notiz*
geworden, mit kleinem Markdown, einer Werkzeugleiste und **Sofort-Ansicht**: Der
Text ist immer gesetzt zu sehen, roh ist nur die Zeile, in der die Schreibmarke
steht. Der Weg über `contenteditable` wurde verworfen — man müsste aus dem HTML,
das der Browser beim Tippen erzeugt, den Quelltext zurückraten, und ein Fehler
dabei kostet Text. Die Grenze zu InkNote steht trotz Formatierung: ein Text,
keine Liste von Notizen, keine Ordner, keine Suche.
**Die Abgrenzung ist hier wichtiger als die Technik:** InkNote gibt es schon. **Die Abgrenzung ist hier wichtiger als die Technik:** InkNote gibt es schon.
Der Zettel darf **keine zweite Notiz-App** werden — kein Titel, keine Liste, Der Zettel darf **keine zweite Notiz-App** werden — kein Titel, keine Liste,
keine Ordner, keine Suche, keine Formatierung. Genau *ein* Textfeld, das man keine Ordner, keine Suche, keine Formatierung. Genau *ein* Textfeld, das man
@@ -368,6 +377,54 @@ Zu klären, wenn es soweit ist:
- **In der HoneyBar?** Nur lesend, und auch das erst, wenn alles unter einer - **In der HoneyBar?** Nur lesend, und auch das erst, wenn alles unter einer
Domain liegt — schreiben kann sie von einer fremden Site aus nicht. Domain liegt — schreiben kann sie von einer fremden Site aus nicht.
### Das Wappen — ✅ gebaut (02.09.2026)
Aus dem `■` ist eine Pixelbiene geworden (Marcs Idee, 01.09.2026). Vier Anläufe:
einfarbig 12 × 12 (wirkte durch den angedeuteten Streifen wie ein Grinsen),
farbig 16 × 16 von vorn, als Kasten mit geraden Kanten, und schließlich die
**Seitenansicht** nach einem gedruckten Vorbild — braune Streifen, schwarze
Beine, Flügel mit Kerbe.
Was dabei hängen blieb, steht im [README](README.md): SVG statt PNG wegen der
fremden CSPs, gelb statt mitgefärbt, und die Zeichenfassung fürs System-Fenster.
Eine 3/4-Ansicht (gerechneter Würfel, 2:1) liegt in der Schublade — sie ist
unter 64 px unbrauchbar, taugt aber für ein großes Startbild.
### Kategorien für Apps — ✅ gebaut (12.09.2026)
Überschriften über den Apps, damit aus einer Liste eine Ordnung wird.
**In zwei Anläufen gebaut, und der zweite kam von Marc.** Zuerst war die Kategorie
ein Feld im App-Formular, und die Pfeile bekamen eine Sonderregel: Am Rand einer
Gruppe kategorisieren statt tauschen. Das funktionierte, war aber die Beschreibung
eines Modells und nicht das Modell selbst. Seine Frage — warum nicht einfach eine
Überschriftenzeile in der Liste, und was darunter steht, gehört dazu? — ist die
bessere Bedienung: Man sieht, was man sortiert, das Umhängen ergibt sich von
selbst, und **Umbenennen ist eine Zeile statt eines Feldes je App**. Genau das war
der Mangel der ersten Fassung.
**Gespeichert wird trotzdem als Feld an der App.** Die Überschrift gibt es nur im
Editor. Wäre sie ein Eintrag in der App-Liste, enthielte diese Zeilen ohne ID und
ohne Adresse, und jeder Leser müsste das wissen — Kacheln, Schublade,
Erreichbarkeitsprüfung, und vor allem die HoneyBar in fremden Apps, die gerade
erst auf das Nötigste getrimmt wurde. Der Preis: Eine Überschrift ohne Apps
darunter überlebt das Speichern nicht. Das ist vertretbar — eine Kategorie besteht
durch ihre Apps.
Eingesammelt wird **im Server**, beim Schreiben wie beim Lesen. Auch das war nicht
der erste Entwurf: Zuerst sortierte die Oberfläche und die HoneyBar setzte ihre
Überschriften beim Durchlaufen — prompt stand eine App ohne Kategorie in der
HoneyBar unter der letzten Überschrift und auf dem Schreibtisch ganz oben. Bei
zwei Lesern derselben Liste gehört die Reihenfolge dorthin, wo die Liste liegt.
Offen gelassen: Schreibtischsymbole bleiben flach (Ordner wären ein eigenes
Thema), und der App-Streifen der Leiste bekommt keine Überschriften — dort ist
kein Platz.
**Falls eine leere Kategorie doch bleiben soll**, ist das das Argument für eine
echte Kategorienliste im Server — dann aber als eigenes Feld neben `apps`, nicht
als Eintrag in ihr.
### Eigene App-Logos — verschoben aus 1.3 (29.08.2026) ### Eigene App-Logos — verschoben aus 1.3 (29.08.2026)
**Beschlossen als Planung (25.08.2026), aber nicht als Nächstes.** Je App ein **Beschlossen als Planung (25.08.2026), aber nicht als Nächstes.** Je App ein
@@ -402,7 +459,7 @@ wenige Farben reduziert, hart gerastert) oder bunt bleiben? Ein Original-Logo
mit Farbverlauf neben Pixelglyphen sieht schnell nach Fremdkörper aus. Ein mit Farbverlauf neben Pixelglyphen sieht schnell nach Fremdkörper aus. Ein
Filter in der HoneyBar wäre billig zu haben und ließe sich abschalten. Filter in der HoneyBar wäre billig zu haben und ließe sich abschalten.
### Widgets verschieben, Reihenfolge im Server — Marcs Idee (29.08.2026) ### Widgets verschieben, Reihenfolge im Server — ✅ gebaut (31.08.2026)
Widgets per Ziehen umsortieren, die Reihenfolge liegt beim Nutzer auf dem Widgets per Ziehen umsortieren, die Reihenfolge liegt beim Nutzer auf dem
Server — also auf jedem Gerät gleich. Server — also auf jedem Gerät gleich.
@@ -424,9 +481,23 @@ schon gebaut) oder am Handy Pfeiltasten statt Ziehen anbieten. Nicht vergessen:
Ohne das ist die Funktion am Handy schlicht nicht da. Ohne das ist die Funktion am Handy schlicht nicht da.
Voraussetzung ist ein Widget-Verzeichnis — heute stehen Benutzer und Wetter Voraussetzung ist ein Widget-Verzeichnis — heute stehen Benutzer und Wetter
fest im HTML. Sinnvoll zusammen mit dem Widget-Rahmen (1.5) zu bauen, nicht fest im HTML. Sinnvoll zusammen mit dem Widget-Rahmen (1.4) zu bauen, nicht
davor. davor.
**Gebaut (31.08.2026), wie vorgeschlagen: eine Liste, beide Ansichten folgen
ihr.** Dazu ein Schalter je Widget — wer den Systemzustand nicht sehen will,
soll ihn abschalten können, ohne ihn nach unten zu schieben.
Beides steht in **einer** Einstellung: `widgets = "benutzer,notiz,-system,…"`,
das Minus heißt aus. Ein Feld statt zwei, weil beides zusammengehört: Ein
ausgeschaltetes Widget behält seinen Platz. Damit brauchte es weder einen neuen
Endpunkt noch eine neue Tabelle — die Einstellungen liegen ohnehin je Nutzer auf
dem Server.
Ziehen ist es nicht geworden, sondern **Pfeile** — aus demselben Grund wie bei
den Apps (1.3b): Am Handy löst HTML5-Drag per Berührung nicht aus, und die Liste
ist kurz. Freie Plätze auf dem Schreibtisch bleiben offen, falls sie je fehlen.
### Widgets in der HoneyBar selbst ### Widgets in der HoneyBar selbst
Bisher sind Widgets als Fenster auf dem Schreibtisch gedacht. Der Schreibtisch Bisher sind Widgets als Fenster auf dem Schreibtisch gedacht. Der Schreibtisch
@@ -434,9 +505,9 @@ ist aber die Seite, die man am seltensten offen hat — die HoneyBar dagegen ist
überall. Naheliegend also: ein Knopf in der HoneyBar klappt dasselbe Widget als überall. Naheliegend also: ein Knopf in der HoneyBar klappt dasselbe Widget als
kleines Feld auf, ohne die App zu verlassen. kleines Feld auf, ohne die App zu verlassen.
Technisch ist das dieselbe Sache wie 1.5, nur ein zweites Zuhause für denselben Technisch ist das dieselbe Sache wie 1.4, nur ein zweites Zuhause für denselben
Inhalt: Der Rahmen muss dann drei Formen können — Fenster, Kachel, Aufklappfeld. Inhalt: Der Rahmen muss dann drei Formen können — Fenster, Kachel, Aufklappfeld.
Das ist beim **Bauen** von 1.5 zu bedenken, hinterher wird es teuer. Das ist beim **Bauen** von 1.4 zu bedenken, hinterher wird es teuer.
Für Widgets, die nur sich selbst brauchen (Wetter, Bild, Uhr), ist das Für Widgets, die nur sich selbst brauchen (Wetter, Bild, Uhr), ist das
geschenkt — sie fragen HoneyOS, nicht die App. geschenkt — sie fragen HoneyOS, nicht die App.
@@ -445,6 +516,48 @@ geschenkt — sie fragen HoneyOS, nicht die App.
## 3 · Ausbau ## 3 · Ausbau
### 3.x HoneyOS selbst als PWA — zurückgestellt (30.08.2026)
Heute ist HoneyOS eine Website: Es hat `theme-color`, aber kein Manifest, kein
Symbol und keinen Service Worker — installierbar ist es damit nicht. Am Handy
landet man also im Browser mit Adressleiste, während Aether und InkNote als
eigene Apps danebenliegen. Das ist genau der Bruch, den das Ganze eigentlich
verhindern soll: Der Schreibtisch sollte sich am wenigsten nach Website
anfühlen.
Was dazugehört, klein bis groß:
1. **Manifest** — Name, Startadresse, `display: standalone`, Hintergrund- und
Themenfarbe. Die Farben sollten der eingestellten Röhre folgen; sie liegen
im Server, das Manifest kann also erzeugt statt fest hinterlegt werden
(Aether macht das beim Symbol schon so).
2. **Symbol** — ein Pixelglyph in 192 und 512 px, im Amber-Look. Passt zum
Rest und ist ohne fremde Werkzeuge zu machen.
3. **Service Worker** — nötig für die Installierbarkeit. Hier ist zu
entscheiden, was er überhaupt soll (siehe unten).
**Die eigentliche Frage ist der Offline-Fall.** HoneyOS zeigt fremde Apps und
später deren Daten — ohne Netz gibt es davon nichts. Ein Schreibtisch, der
offline mit leeren Kacheln dasteht, ist schlimmer als ein ehrlicher Hinweis.
Sinnvoll wäre: Oberfläche und App-Liste im Cache, damit man die Symbole sieht
und antippen kann, und ein deutlicher Vermerk „ohne Verbindung". Widgets
bleiben leer, statt Altes von gestern als aktuell auszugeben.
**Aus dem Aether-Debakel gelernt und hier von Anfang an zu beachten:** Ein
Eintrag im Cache ist eine ganze Antwort **samt Kopfzeilen**. Wird die Startseite
vorab abgelegt, friert damit auch ihre CSP ein — und eine Änderung an der
Konfiguration erreicht den Browser nie wieder. Also Navigationen Netz zuerst,
Cache nur als Rückfall.
**Zurückgestellt (Marc, 30.08.2026): erst die Hauptfunktionen, dann das
Gehäuse.** Die PWA ist mehr Gefühl als Funktion — sie macht nichts möglich, was
heute nicht ginge, während der Widget-Rahmen darunter alles Weitere trägt.
**Reihenfolge:** Erst umziehen (siehe unten), dann installierbar machen. Ein
Ursprungswechsel macht aus einer installierten PWA eine fremde App — wer
vorher installiert, installiert zweimal.
- Welche Widgets wo liegen, **pro Nutzer im Server** merken — die Einstellungen - Welche Widgets wo liegen, **pro Nutzer im Server** merken — die Einstellungen
liegen ohnehin schon dort, das ist eine Erweiterung, kein Neubau liegen ohnehin schon dort, das ist eine Erweiterung, kein Neubau
- Widgets im Terminal-Modus - Widgets im Terminal-Modus
+236 -27
View File
@@ -126,9 +126,46 @@ fragt über die Domaingrenze und bleibt dort leer.
überall gleich: Streifen, Schublade, Kacheln, Schreibtischsymbole, Startmenü. Neue Apps überall gleich: Streifen, Schublade, Kacheln, Schreibtischsymbole, Startmenü. Neue Apps
hängen sich hinten an. hängen sich hinten an.
Gespeichert wird die Liste als Ganzes; der Server prüft sie noch einmal und antwortet mit ## Kategorien
dem, was er wirklich behalten hat. Eine Adresse ohne `https://` kommt als Fehlermeldung
zurück, nicht als kaputter Eintrag. Apps lassen sich gruppieren — *Apps*, *Dienste*, *Werkzeuge*, was du willst:
```
APPS
AETHER INKNOTE BENTOPDF
DIENSTE
GITEA POCKETID
```
**Angelegt wird eine Kategorie als Zeile in der Liste**, mit „+ kategorie". Was darunter
steht, gehört dazu, bis die nächste Überschrift kommt — wie in einem Dokument. Die
Überschrift ist eine Zeile wie jede andere: Sie hat dieselben Pfeile ▲▼, lässt sich
umbenennen und mit × auflösen. **Ein Sonderfall zum Umhängen existiert nicht:** Schiebst du
eine App über eine Überschrift, ist sie in der anderen Gruppe, und schiebst du eine
Überschrift, wandert die Grenze.
Was **oberhalb der ersten** Überschrift steht, hat keine Kategorie und erscheint ohne
Überschrift ganz oben. Wer keine anlegt, sieht alles wie zuvor.
**Eine Kategorie besteht nur durch ihre Apps.** Gespeichert wird sie als Feld an jeder App,
nicht als eigener Eintrag — sonst enthielte die App-Liste Zeilen ohne ID und ohne Adresse,
und jeder Leser müsste das wissen: Kacheln, Schublade, Erreichbarkeitsprüfung und vor allem
die HoneyBar in fremden Apps. Zwei Folgen davon, die man kennen sollte:
- Eine Überschrift **ohne Apps darunter überlebt das Speichern nicht.** Beim Bearbeiten
bleibt sie stehen, danach ist sie weg.
- Eine Kategorie **umbenennen ist trotzdem eine Zeile**, nicht ein Feld je App — der Editor
schreibt das Feld beim Speichern an allen Apps der Gruppe.
Überschriften erscheinen dort, wo man **sucht** — Kacheln, Schublade beider Leisten,
Programme-Fenster. Nicht im App-Streifen der Leiste (32 Pixel, kein Platz) und nicht bei
den Schreibtischsymbolen (ein Schreibtisch ist flach; Ordner wären ein eigenes Thema).
**Die Reihenfolge legt der Server fest**, nicht die Oberfläche: `SetApps` sammelt Apps
derselben Kategorie zusammen, beim Schreiben wie beim Lesen. Sortierte jeder Leser für
sich, sähe dieselbe Liste an zwei Stellen verschieden aus — genau das passierte, als die
HoneyBar ihre Überschriften beim Durchlaufen setzte und eine App ohne Kategorie am Ende
unter der letzten Überschrift landete.
## Die Schublade ## Die Schublade
@@ -184,15 +221,177 @@ in beiden: Menüleiste, Palette, Einstellungen, App-Liste.
**Tasten:** `M` Betriebsart · `S` Einstellungen · `?` Hilfe · **Tasten:** `M` Betriebsart · `S` Einstellungen · `?` Hilfe ·
`Esc` schließt Startmenü, oberstes Fenster oder Dialog — in dieser Reihenfolge. `Esc` schließt Startmenü, oberstes Fenster oder Dialog — in dieser Reihenfolge.
## Das Wappen
Links in beiden Leisten sitzt eine Pixelbiene — seitlich, mit braunen Streifen,
schwarzen Beinen und Flügeln mit Kerbe. Sie ersetzt das `■`, das dort seit dem
ersten Tag stand.
**Sie steht an genau einer Stelle:** `tools/biene.py`, als 16 × 16 Zeichen, ein
Zeichen je Pixel. Daraus entstehen alle Fassungen:
```
python3 tools/biene.py
```
| Ausgabe | wohin |
|---|---|
| `web/img/biene.svg` | Favicon und Anmeldebildschirm |
| `web/js/biene.js` | Menüleiste des Schreibtischs (`BIENE_SVG`), System-Fenster und Startlauf (`BIENE_ASCII`) |
| `web/img/biene-256.png`, `-32.png` | Doku und Vorschau |
In die HoneyBar ist dieselbe SVG-Zeile **einkopiert** — sie ist eine einzelne
Datei ohne zweite Anfrage. Ein Test (`TestWappenIstUeberallDasselbe`) hält die
drei Kopien zusammen; wer das Raster ändert, lässt das Skript laufen und
kopiert die Zeile neu.
**Warum SVG und nicht PNG?** Die HoneyBar läuft in *fremden* Apps. Ein `<img>`
holte dort eine Datei von HoneyOS, und ob das erlaubt ist, entscheidet die CSP
der fremden App (`img-src`). Markup kann niemand blockieren, es braucht keine
zweite Anfrage, und es ist mit 2,6 kB kleiner als das PNG.
**Warum bleibt sie gelb?** In der grünen und der eisblauen Röhre wechselt alles
die Farbe mit — die Biene nicht. Ein Zeichen, das die Farbe wechselt, ist ein
Symbol wie *wolke*; eines, das seine Farbe behält, ist ein Wappen. Der eine
warme Fleck liest sich als „das hier ist das System selbst".
**Überall dasselbe Zeichen:** Leiste, Tab und Anmeldebildschirm zeigen dieselbe
Datei, nur in anderer Größe. Eine 3/4-Ansicht (ein gerechneter Würfel in
2:1-Schrägsicht) steckt als `schraeg()` in `tools/biene.py`, wird aber nirgends
ausgeliefert — sie ist unter 64 px unbrauchbar, und ein zweites Wappen für
große Flächen wäre ein zweites Wappen.
### Und aus Schriftzeichen
Im System-Fenster und über dem Startlauf steht dieselbe Biene aus `#`, `:` und
`-`. Beides sind Textflächen — ein Bild darin wäre ein Fremdkörper, wie ein
Foto in einer Tabelle. So wird aus dem System-Fenster ein **neofetch**.
Zwei Dinge stecken in der Umsetzung, die man nur durch Ausprobieren findet: Ein
Zeichen ist etwa halb so breit wie hoch, ein Pixel muss also **zwei Zeichen**
breit sein. Und Blockzeichen (`█ ▓ ▒`) sehen im Editor perfekt aus, hinterlassen
im Browser aber feine Fugen zwischen den Zellen — der Körper wird zur Waffel.
Gewöhnliche Schriftzeichen behaupten gar nicht erst, eine Fläche zu sein.
Dass ausgerechnet die **Seitenansicht** als Text funktioniert, ist kein Zufall:
Ihre Streifen laufen senkrecht, also bleibt jede Textzeile ein
zusammenhängendes Stück Körper. Von vorn zerfällt die Biene in Streifen und
liest sich wie ein Strichcode.
## Widgets
Ein Widget ist eine **Beschreibung**, kein Kasten in der `index.html`:
```js
widgetAnmelden({
id: "wetter", titel: "wetter",
wo: ["terminal", "desktop"], // wo es erscheinen darf
sichtbar: () => true, // eigene Bedingung, optional
laden: async () => …, // false = es gibt nichts zu zeigen
leer: "verbergen", // oder ein Satz, der dann dasteht
inhalt: () => "<html>",
takt: 10 * 60 * 1000, // Nachladen, optional
})
```
Den Rest macht `web/js/widgets/rahmen.js`: Im Terminal-Modus wird daraus ein
Fenster in der linken Spalte, im Desktop-Modus ein Schild oben rechts. Er kennt
vier Zustände — **lädt · fertig · nichts da · Fehler** — und hält den
Nachladetakt.
**Ein Widget, das seinen Server nicht erreicht, sagt das in seinem eigenen
Rahmen.** Es verschwindet nicht (das sähe aus wie abgeschaltet) und reißt weder
den Schreibtisch noch die anderen Widgets mit. Wo „nichts da" schlicht heißt,
dass es nichts zu zeigen gibt, kann ein Widget mit `leer: "verbergen"` ganz
wegbleiben — so macht es das Wetter ohne eingestellten Ort.
Eine neue Anzeige ist damit eine Datei in `web/js/widgets/` und ein Import in
`os.js`. Weder das Raster noch die Seite werden dafür angefasst.
### Reihenfolge und An/Aus liegen beim Nutzer
Unter *Einstellungen → Widgets* steht jedes Widget mit einem Schalter und zwei
Pfeilen. Beides zusammen steht in **einer** Einstellung, und die liegt wie alle
anderen beim Konto auf dem Server — also auf jedem Gerät gleich:
```
widgets = "benutzer,notiz,-system,wetter,bild"
```
Das Minus heißt aus. Ein Feld statt zwei, weil beides zusammengehört: Wer ein
Widget ausschaltet, soll seinen Platz behalten, wenn er es wieder einschaltet.
Die Liste ist ein Wunsch, kein Gesetz — unbekannte Namen fallen weg, neue
Widgets hängen sich hinten an und sind an. Ein Widget, das erst später
eingeschaltet wird, holt seine Daten dann nach.
Pfeile statt Ziehen: Ziehen wäre hübscher, löst am Handy per Berührung aber
nicht aus (dieselbe Entscheidung wie bei der Reihenfolge der Apps).
## Notiz und Bild
Zwei Widgets, die nichts abfragen, sondern etwas *sind*. Beides liegt **je
Nutzer auf dem Server** — die Notiz als Text in `honeyos.json`, das Bild als
Datei in `bilder/` daneben (oder als fremde Adresse, nie beides).
### Die Notiz zeigt sich sofort gesetzt
Der Text ist **immer** formatiert zu sehen; roh ist nur die eine Zeile, in der
die Schreibmarke steht. Man tippt also in einer Aufzählung und sieht ringsum
schon Überschriften, Häkchen und Punkte — statt `- [ ] Milch` zu schreiben und
sich vorzustellen, wie das gleich aussieht.
Die Werkzeugleiste (Aufgabe · Aufzählung · Nummer · Überschrift · fett · Linie)
erscheint **erst beim Schreiben** und wirkt auf genau diese Zeile — ohne offene
Zeile hätten ihre Knöpfe nichts anzufassen, und eine Notiz, die man nur liest,
soll wie ein Text aussehen und nicht wie ein Formular. Enter führt eine Liste fort und nummeriert weiter;
Enter auf einer leeren Aufzählungszeile beendet sie. Backspace am Zeilenanfang
nimmt erst den Aufzählungspunkt, dann erst die Zeile. Häkchen lassen sich
anklicken, ohne in den Text zu springen — das ist der häufigste Griff an einer
Liste. Für größere Umbauten gibt es den Knopf **quelltext**: dann doch das ganze
Feld, roh.
**Warum nicht `contenteditable`?** Weil man aus dem, was der Browser beim Tippen
und Einfügen an HTML erzeugt, den Quelltext *zurückraten* müsste — und ein
Fehler dabei kostet Text. Hier bleibt der Quelltext die Wahrheit und wird nie
zurückgerechnet: Die Anzeige entsteht aus ihm, geändert wird immer nur eine
Zeile. Der Preis ist, dass man nicht über mehrere Zeilen hinweg markieren kann;
dafür ist der Quelltext-Knopf da.
Das kleine Markdown ist von Hand geschrieben und absichtlich klein:
Überschriften (`#`, `##`, `###`), Häkchen (`- [ ]`, `- [x]`), Aufzählungen,
nummerierte Listen, `---`, `**fett**`, `*kursiv*`, `` `code` ``. Keine
Bibliothek — HoneyOS hat keinen Bauschritt, und für sechs Regeln lohnt kein
Nachladen. Escapt wird **vor** dem Formatieren: Eine Notiz, die `<script>`
ausführt, wäre eine Falle für den Tag, an dem man etwas hineinkopiert.
**Die Notiz ist keine zweite Notiz-App.** InkNote gibt es schon: kein Titel,
keine Liste von Notizen, keine Ordner, keine Suche. Genau ein Text für das, was
nicht länger als einen Tag lebt. Gespeichert wird verzögert (und beim Verlassen
der Seite sofort); zwei offene Geräte überschreiben einander, der letzte Stand
gewinnt.
### Das Bild
Genau eines je Nutzer, hochgeladen oder verlinkt; ein neues ersetzt das alte
samt Datei. **Eingestellt wird es unter *Einstellungen → Bild*, nicht im Widget**
— wie beim Wetter, dessen Ort auch nicht in der Wetterkachel gesucht wird. Ein
Schild auf dem Schreibtisch, das zu drei Vierteln aus Knöpfen besteht, zeigt vor
allem sich selbst. Erlaubt sind PNG, JPEG, WebP und GIF — der Typ wird aus den
**Bytes** bestimmt, nicht aus dem, was der Browser behauptet. SVG ist bewusst
nicht dabei: Es kann Skripte enthalten. Ausgeliefert wird mit einem
Versionsanteil in der Adresse, sonst bliebe beim Austausch das alte Bild im
Zwischenspeicher hängen.
## Wetter ## Wetter
Ein Pixelsymbol mit der Temperatur — wahlweise auf dem Schreibtisch, in der Ein Pixelsymbol mit der Temperatur — wahlweise auf dem Schreibtisch, in der
HoneyBar, in beidem oder nirgends. Zwei getrennte Schalter unter HoneyBar, in beidem oder nirgends. Zwei Orte, zwei Schalter an zwei Stellen:
*Einstellungen → Wetter*, weil das zwei verschiedene Orte sind: Wer es beim Auf dem Schreibtisch ist das Wetter ein Widget wie jedes andere und steht in
Arbeiten in einer App sehen will, will es nicht zwangsläufig auch auf einer der Widget-Liste; für die Leiste gibt es einen eigenen Schalter unter
Seite, die er selten aufruft. *Einstellungen → HoneyBar*. Wer es beim Arbeiten in einer App sehen will, will
es nicht zwangsläufig auch auf einer Seite, die er selten aufruft.
Die Stadt wird dort einmal gesucht und **mit ihren Koordinaten** gespeichert; Die Stadt wird unter *Einstellungen → Wetter* einmal gesucht und **mit ihren Koordinaten** gespeichert;
sonst müsste jede Abfrage den Ort erneut suchen — zwei Anfragen nach draußen sonst müsste jede Abfrage den Ort erneut suchen — zwei Anfragen nach draußen
statt einer. Ohne Stadt bleibt das Wetter aus. statt einer. Ohne Stadt bleibt das Wetter aus.
@@ -286,7 +485,7 @@ location / {
# 2. Die CSP der App ersetzen, nicht ergänzen. Zwei CSP-Kopfzeilen bedeuten # 2. Die CSP der App ersetzen, nicht ergänzen. Zwei CSP-Kopfzeilen bedeuten
# nicht „mehr erlaubt", sondern „beide müssen zustimmen". # nicht „mehr erlaubt", sondern „beide müssen zustimmen".
proxy_hide_header Content-Security-Policy; proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://honeyos.example.com; connect-src 'self' https://honeyos.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://honeyos.example.com; connect-src 'self' https://honeyos.example.com; style-src 'self'; img-src 'self' data:" always;
# 3. Nur HTML anfassen; JSON, Bilder und Downloads bleiben unberührt. # 3. Nur HTML anfassen; JSON, Bilder und Downloads bleiben unberührt.
sub_filter_types text/html; sub_filter_types text/html;
@@ -301,12 +500,18 @@ Liste ist zugleich die CORS-Freigabe. **Auf den Ursprung genau**: `localhost` un
oder ein abweichender Port. Passt er nicht, erscheint die HoneyBar mit leerer oder ein abweichender Port. Passt er nicht, erscheint die HoneyBar mit leerer
App-Liste und in der Konsole steht ein CORS-Fehler. App-Liste und in der Konsole steht ein CORS-Fehler.
**Warum `style-src 'unsafe-inline'`:** Die HoneyBar bringt ihre Stile mit, im **Kein `style-src 'unsafe-inline'` nötig** (seit 11.09.2026). Die HoneyBar
Shadow DOM. Eine strenge CSP verbietet die — gemessen: die HoneyBar erscheint, bringt ihre Stile zwar mit, hängt sie aber als konstruiertes Stylesheet
aber schwarz auf schwarz, ohne Röhre. Das ist eine echte Abschwächung der (`adoptedStyleSheets`) in ihren Shadow DOM statt als `<style>`-Element. Nur
CSP dieser App; viele fertige Werkzeuge brauchen `unsafe-inline` ohnehin für Markup fällt unter die CSP, über die CSSOM gesetzte Stile nicht — die App
sich selbst, aber wissen sollte man es. (Auflösbar, indem die HoneyBar ihre Stile braucht für die HoneyBar also weder `'unsafe-inline'` noch eine Freigabe für
als eigene Datei ausliefert — steht in [IDEAS.md](IDEAS.md).) die HoneyOS-Domain. Vorher stand die HoneyBar unter einer strengen CSP schwarz
auf schwarz da. Gemessen an einer nachgebauten App mit `style-src 'self'`:
Leiste bernstein, 32 px, keine CSP-Meldung.
Browser ohne konstruierbare Stylesheets (vor Safari 16.4, Firefox 101) fallen
auf das `<style>`-Element zurück — dort gilt der alte Stand, also `unsafe-inline`
oder eine Leiste ohne Farbe.
**Apps mit COEP** (alles mit WASM oder SharedArrayBuffer — BentoPDF etwa) **Apps mit COEP** (alles mit WASM oder SharedArrayBuffer — BentoPDF etwa)
lehnen fremde Dateien ab, die sich nicht zur Einbettung bekennen. HoneyOS lehnen fremde Dateien ab, die sich nicht zur Einbettung bekennen. HoneyOS
@@ -449,21 +654,25 @@ Das `■ HONEYOS` links ist ein Menü — auf dem Schreibtisch wie in jeder App:
| in der HoneyBar (fremde App) | auf dem Schreibtisch | | in der HoneyBar (fremde App) | auf dem Schreibtisch |
|---|---| |---|---|
| Schreibtisch | Über HoneyOS | | Schreibtisch | Über HoneyOS |
| Einstellungen … | Einstellungen | | HoneyBar einklappen | Einstellungen |
| HoneyBar einklappen | Terminal-/Desktop-Modus | | — | Terminal-/Desktop-Modus |
Rechts daneben klappt ein Pfeil die App-Liste auf — in beiden Leisten gleich. Rechts daneben klappt ein Pfeil die App-Liste auf — in beiden Leisten gleich.
Das Zahnrad, das dort früher stand, ist weg: Einstellungen stehen jetzt links Das Zahnrad, das dort früher stand, ist weg: Auf dem Schreibtisch stehen die
im Systemmenü, wo sie in jeder App auch stehen. Einstellungen links im Systemmenü; in einer fremden App gibt es sie nicht,
sondern nur den Weg dorthin.
**In den Apps sind die Punkte Links auf den Schreibtisch, keine Handlungen** — **In den Apps ist „Schreibtisch" ein Link, keine Handlung** — und das ist eine
und das ist eine Grenze, keine Bequemlichkeit. Einstellungen schreiben, und Grenze, keine Bequemlichkeit. Einstellungen schreiben, und dafür braucht der
dafür braucht der Server die HoneyOS-Sitzung: Aus einer fremden Site schickt der Server die HoneyOS-Sitzung: Aus einer fremden Site schickt der Browser sie nicht
Browser sie nicht mit, und selbst unter einer Domain liegt das CSRF-Prüfwort in mit, und selbst unter einer Domain liegt das CSRF-Prüfwort in einem Cookie, das
einem Cookie, das nur HoneyOS' eigene Seiten lesen dürfen. Also stellt die nur HoneyOS' eigene Seiten lesen dürfen.
HoneyBar die Frage in der Adresse (`?einstellungen=1`), und der Schreibtisch
beantwortet sie: Er macht dasselbe Feld auf, das auch `[S]` öffnet, und nimmt Deshalb stand dort lange ein zweiter Punkt „Einstellungen …", der auf dieselbe
den Anhang danach wieder aus der Adresse. Seite führte, nur mit geöffnetem Feld. **Er ist weg** (01.09.2026): Zwei Türen
in denselben Raum sind keine zwei Wege. Wer etwas einstellen will, ist damit
ohnehin auf dem Schreibtisch, und dort steht das Feld einen Klick — oder `[S]` —
entfernt.
**Abmelden steht in den Einstellungen, ganz unten — und sonst nirgends.** Nicht **Abmelden steht in den Einstellungen, ganz unten — und sonst nirgends.** Nicht
in der HoneyBar fremder Apps, nicht im Systemmenü, nicht im Startmenü. Es ist in der HoneyBar fremder Apps, nicht im Systemmenü, nicht im Startmenü. Es ist
+174 -2
View File
@@ -3,14 +3,18 @@
package store package store
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"time"
) )
// Der ganze Zustand von HoneyOS passt in eine JSON-Datei: eine Handvoll Apps // Der ganze Zustand von HoneyOS passt in eine JSON-Datei: eine Handvoll Apps
@@ -29,12 +33,30 @@ type App struct {
Subtitle string `json:"subtitle,omitempty"` Subtitle string `json:"subtitle,omitempty"`
URL string `json:"url"` URL string `json:"url"`
Glyph string `json:"glyph,omitempty"` Glyph string `json:"glyph,omitempty"`
// Die Kategorie ist freier Text, kein Verweis auf eine Liste von
// Kategorien — es gibt keine. Eine Kategorie besteht genau so lange, wie
// eine App sie trägt; damit gibt es nichts anzulegen, nichts zu löschen
// und vor allem keine zweite Reihenfolge, die mit dieser aus dem Tritt
// geraten könnte. Die Reihenfolge der Überschriften ergibt sich aus dem
// ersten Auftreten in dieser Liste.
Kategorie string `json:"kategorie,omitempty"`
} }
type User struct { type User struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Picture string `json:"picture,omitempty"` Picture string `json:"picture,omitempty"`
Settings map[string]string `json:"settings,omitempty"` Settings map[string]string `json:"settings,omitempty"`
// Die Notiz. Nicht in den Einstellungen, obwohl sie dorthin passen würde:
// Die sind auf kurze Werte ausgelegt (200 Zeichen je Feld), und eine Notiz
// ist Text, kein Schalter.
Note string `json:"note,omitempty"`
// Das Bild — entweder eine Datei neben honeyos.json oder eine fremde
// Adresse, nie beides. `Bildstand` ist der Versionsanteil in der Adresse:
// Ohne ihn bliebe beim Austausch das alte Bild im Zwischenspeicher hängen.
Bild string `json:"bild,omitempty"` // Dateiname in bilder/
BildURL string `json:"bildUrl,omitempty"` // oder eine fremde Adresse
Bildstand string `json:"bildstand,omitempty"` // gegen den Zwischenspeicher
} }
type data struct { type data struct {
@@ -52,6 +74,9 @@ const (
maxApps = 24 maxApps = 24
maxSettings = 40 maxSettings = 40
maxFieldLen = 200 maxFieldLen = 200
// Eine Notiz, kein Dokument. Wer mehr braucht, hat InkNote — und der Server
// muss die Grenze durchsetzen, nicht das Formular.
maxNoteLen = 4000
) )
var ( var (
@@ -76,6 +101,10 @@ func Open(path string) (*Store, error) {
if s.d.Apps == nil { if s.d.Apps == nil {
s.d.Apps = []App{} s.d.Apps = []App{}
} }
// Auch beim Lesen einsammeln: honeyos.json darf von Hand bearbeitet werden
// (das ist der halbe Grund für eine Datei statt einer Datenbank), und dann
// stehen Kategorien schnell durcheinander.
s.d.Apps = sortiereNachKategorie(s.d.Apps)
if s.d.Users == nil { if s.d.Users == nil {
s.d.Users = map[string]*User{} s.d.Users = map[string]*User{}
} }
@@ -119,6 +148,7 @@ func (s *Store) SetApps(apps []App) error {
a.Subtitle = strings.TrimSpace(a.Subtitle) a.Subtitle = strings.TrimSpace(a.Subtitle)
a.URL = strings.TrimSpace(a.URL) a.URL = strings.TrimSpace(a.URL)
a.Glyph = strings.TrimSpace(a.Glyph) a.Glyph = strings.TrimSpace(a.Glyph)
a.Kategorie = strings.TrimSpace(a.Kategorie)
if !idPattern.MatchString(a.ID) { if !idPattern.MatchString(a.ID) {
return fmt.Errorf("App %d: ID muss klein geschrieben sein und mit Buchstabe oder Ziffer beginnen", i+1) return fmt.Errorf("App %d: ID muss klein geschrieben sein und mit Buchstabe oder Ziffer beginnen", i+1)
@@ -138,7 +168,7 @@ func (s *Store) SetApps(apps []App) error {
if a.Glyph != "" && !knownGlyphs[a.Glyph] { if a.Glyph != "" && !knownGlyphs[a.Glyph] {
a.Glyph = "" a.Glyph = ""
} }
for _, f := range []string{a.Name, a.Subtitle, a.URL} { for _, f := range []string{a.Name, a.Subtitle, a.URL, a.Kategorie} {
if len(f) > maxFieldLen { if len(f) > maxFieldLen {
return fmt.Errorf("App %q: ein Feld ist länger als %d Zeichen", a.ID, maxFieldLen) return fmt.Errorf("App %q: ein Feld ist länger als %d Zeichen", a.ID, maxFieldLen)
} }
@@ -148,10 +178,44 @@ func (s *Store) SetApps(apps []App) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.d.Apps = cleaned s.d.Apps = sortiereNachKategorie(cleaned)
return s.save() return s.save()
} }
// sortiereNachKategorie sammelt Apps derselben Kategorie zusammen, ohne die
// Reihenfolge innerhalb einer Gruppe oder die der Gruppen zu ändern: Die
// Gruppen stehen in der Reihenfolge ihres ersten Auftretens, die namenlose
// immer zuerst.
//
// Das gehört hierher und nicht in die Oberfläche, weil es mehrere Leser gibt:
// der Schreibtisch, die HoneyBar in fremden Apps, irgendwann ein weiterer.
// Sortierte jeder für sich, sähe dieselbe Liste an zwei Stellen verschieden
// aus — genau das ist passiert, als die HoneyBar die Überschriften einfach
// beim Durchlaufen setzte und eine App ohne Kategorie am Ende unter der
// letzten Überschrift landete.
func sortiereNachKategorie(apps []App) []App {
ohne := make([]App, 0, len(apps))
reihenfolge := []string{}
nach := map[string][]App{}
for _, a := range apps {
if a.Kategorie == "" {
ohne = append(ohne, a)
continue
}
if _, da := nach[a.Kategorie]; !da {
reihenfolge = append(reihenfolge, a.Kategorie)
}
nach[a.Kategorie] = append(nach[a.Kategorie], a)
}
out := ohne
for _, k := range reihenfolge {
out = append(out, nach[k]...)
}
return out
}
func (s *Store) User(sub string) User { func (s *Store) User(sub string) User {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -182,6 +246,27 @@ func (s *Store) SeenUser(sub, name, picture string) error {
return s.save() return s.save()
} }
// Note gibt die Notiz des Nutzers. Kein Rückfall auf den Standardnutzer wie
// bei den Einstellungen: Eine fremde Notiz ist kein Ersatz für die eigene.
func (s *Store) Note(sub string) string {
return s.User(sub).Note
}
// SetNote schreibt die Notiz. Zwei offene Geräte überschreiben einander — der
// letzte Stand gewinnt. Für eine Notiz ist das vertretbar; alles darüber
// bräuchte Versionen und eine Zusammenführung, und dann wäre es keine
// Kleinigkeit mehr, sondern eine Notiz-App.
func (s *Store) SetNote(sub, text string) error {
if len([]rune(text)) > maxNoteLen {
return fmt.Errorf("die Notiz fasst %d Zeichen", maxNoteLen)
}
s.mu.Lock()
defer s.mu.Unlock()
s.userLocked(sub).Note = text
return s.save()
}
func (s *Store) SetSettings(sub string, in map[string]string) error { func (s *Store) SetSettings(sub string, in map[string]string) error {
if len(in) > maxSettings { if len(in) > maxSettings {
return fmt.Errorf("zu viele Einstellungen") return fmt.Errorf("zu viele Einstellungen")
@@ -254,5 +339,92 @@ func (s *Store) AppOrigins() map[string]bool {
return out return out
} }
/* ── Bilder ────────────────────────────────────────────────────────────────
*
* Das Erste, was HoneyOS als Datei ablegt. Bis hierher war alles in einer
* einzigen JSON-Datei, und das soll auch so bleiben: Die Bilder liegen daneben
* in `bilder/`, die JSON kennt nur ihre Namen. Ein Nutzer hat genau **ein**
* Bild — ein neues ersetzt das alte, samt Datei.
*
* Der Dateiname ist ein Fingerabdruck der Nutzerkennung, keine Kennung im
* Klartext: In einem Ordnernamen hätte eine OIDC-`sub` nichts zu suchen, und
* manche taugen gar nicht als Dateiname.
*/
func (s *Store) bilderDir() string { return filepath.Join(filepath.Dir(s.path), "bilder") }
func bildName(sub, endung string) string {
sum := sha256.Sum256([]byte(sub))
return hex.EncodeToString(sum[:8]) + endung
}
// Bild sagt, was der Nutzer hinterlegt hat: eine Datei, eine Adresse oder
// nichts. `stand` gehört an die Adresse der Datei.
func (s *Store) Bild(sub string) (datei, url, stand string) {
u := s.User(sub)
return u.Bild, u.BildURL, u.Bildstand
}
func (s *Store) BildPfad(sub string) (string, bool) {
datei, _, _ := s.Bild(sub)
if datei == "" {
return "", false
}
return filepath.Join(s.bilderDir(), datei), true
}
// SetBildDatei legt die Bytes ab und macht sie zum Bild des Nutzers. Ein
// vorhandenes Bild wird ersetzt — auch die alte Datei, sonst wächst der Ordner
// mit jedem Austausch.
func (s *Store) SetBildDatei(sub, endung string, daten []byte) error {
if err := os.MkdirAll(s.bilderDir(), 0o755); err != nil {
return fmt.Errorf("bilder-ordner: %w", err)
}
name := bildName(sub, endung)
if err := os.WriteFile(filepath.Join(s.bilderDir(), name), daten, 0o644); err != nil {
return fmt.Errorf("bild schreiben: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
u := s.userLocked(sub)
if u.Bild != "" && u.Bild != name {
os.Remove(filepath.Join(s.bilderDir(), u.Bild))
}
u.Bild, u.BildURL = name, ""
u.Bildstand = strconv.FormatInt(time.Now().Unix(), 36)
return s.save()
}
// SetBildURL nimmt stattdessen eine fremde Adresse. Eine leere löscht alles.
func (s *Store) SetBildURL(sub, url string) error {
if len(url) > maxFieldLen*4 {
return fmt.Errorf("die Adresse ist zu lang")
}
if url != "" && !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
return fmt.Errorf("die Adresse muss mit http:// oder https:// beginnen")
}
s.mu.Lock()
defer s.mu.Unlock()
u := s.userLocked(sub)
if u.Bild != "" {
os.Remove(filepath.Join(s.bilderDir(), u.Bild))
}
u.Bild, u.BildURL = "", url
u.Bildstand = strconv.FormatInt(time.Now().Unix(), 36)
return s.save()
}
// userLocked holt (oder legt an) den Nutzer. Nur mit gehaltenem Schloss.
func (s *Store) userLocked(sub string) *User {
u, ok := s.d.Users[sub]
if !ok {
u = &User{}
s.d.Users[sub] = u
}
return u
}
// DataPath nennt die eine Datei, in der alles steht. // DataPath nennt die eine Datei, in der alles steht.
func DataPath(dir string) string { return filepath.Join(dir, "honeyos.json") } func DataPath(dir string) string { return filepath.Join(dir, "honeyos.json") }
+54
View File
@@ -0,0 +1,54 @@
package store
import (
"path/filepath"
"testing"
)
// Die Reihenfolge der Apps ist die Anzeige-Reihenfolge — für den Schreibtisch
// wie für die HoneyBar in fremden Apps. Deshalb legt der Server sie fest und
// nicht jeder Leser für sich: Als die HoneyBar ihre Überschriften noch selbst
// beim Durchlaufen setzte, landete eine App ohne Kategorie am Ende unter der
// letzten Überschrift, während der Schreibtisch sie oben zeigte.
func TestKategorienWerdenEingesammelt(t *testing.T) {
s, err := Open(filepath.Join(t.TempDir(), "honeyos.json"))
if err != nil {
t.Fatal(err)
}
// Absichtlich durcheinander: zwei Gruppen verschränkt, die App ohne
// Kategorie zuletzt.
err = s.SetApps([]App{
{ID: "a", URL: "https://e.test/a", Kategorie: "Apps"},
{ID: "g", URL: "https://e.test/g", Kategorie: "Dienste"},
{ID: "i", URL: "https://e.test/i", Kategorie: "Apps"},
{ID: "p", URL: "https://e.test/p", Kategorie: "Dienste"},
{ID: "l", URL: "https://e.test/l"},
})
if err != nil {
t.Fatal(err)
}
will := []string{"l", "a", "i", "g", "p"}
hat := []string{}
for _, a := range s.Apps() {
hat = append(hat, a.ID)
}
for i := range will {
if i >= len(hat) || hat[i] != will[i] {
t.Fatalf("Reihenfolge %v, erwartet %v — ohne Kategorie zuerst, dann die Gruppen in der Reihenfolge ihres ersten Auftretens", hat, will)
}
}
// Und nach dem Neuladen dasselbe: Wer honeyos.json von Hand bearbeitet,
// bekommt seine Kategorien ebenfalls eingesammelt.
s2, err := Open(s.path)
if err != nil {
t.Fatal(err)
}
for i, a := range s2.Apps() {
if a.ID != will[i] {
t.Fatalf("nach dem Neuladen %q an Stelle %d, erwartet %q", a.ID, i, will[i])
}
}
}
+14
View File
@@ -15,8 +15,22 @@ import (
// Open-Meteo an — das wäre langsam, unzuverlässig und benutzte einen fremden // Open-Meteo an — das wäre langsam, unzuverlässig und benutzte einen fremden
// Dienst für unsere Testläufe. // Dienst für unsere Testläufe.
func FakeFuerTest(t *testing.T, code int, grad float64) *Dienst { func FakeFuerTest(t *testing.T, code int, grad float64) *Dienst {
return fake(t, code, grad, false)
}
// KaputtFuerTest stellt einen Dienst, der nicht antwortet — für den Fall, den
// die Oberfläche seit dem Widget-Rahmen anders behandelt als „nichts da".
func KaputtFuerTest(t *testing.T) *Dienst {
return fake(t, 0, 0, true)
}
func fake(t *testing.T, code int, grad float64, kaputt bool) *Dienst {
t.Helper() t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if kaputt {
w.WriteHeader(http.StatusInternalServerError)
return
}
if strings.Contains(r.URL.Path, "search") { if strings.Contains(r.URL.Path, "search") {
json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{ json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{
{"name": "Köln", "country": "Deutschland", "admin1": "Nordrhein-Westfalen", {"name": "Köln", "country": "Deutschland", "admin1": "Nordrhein-Westfalen",
+129 -2
View File
@@ -16,6 +16,7 @@ import (
"crypto/subtle" "crypto/subtle"
"embed" "embed"
"encoding/json" "encoding/json"
"io"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
@@ -51,7 +52,7 @@ func ladeGlyphen() map[string][]string {
// Version ist die einzige Stelle, an der sie steht. build.sh liest sie hier aus // Version ist die einzige Stelle, an der sie steht. build.sh liest sie hier aus
// und vergibt denselben Docker-Tag; die Oberfläche holt sie über /api/public. // und vergibt denselben Docker-Tag; die Oberfläche holt sie über /api/public.
// Vorher stand sie zusätzlich in os.js und lief mit den Image-Tags auseinander. // Vorher stand sie zusätzlich in os.js und lief mit den Image-Tags auseinander.
const Version = "1.1" const Version = "1.3"
type Server struct { type Server struct {
cfg config.Config cfg config.Config
@@ -146,6 +147,19 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /api/me", s.requireAuth(s.handleMe)) mux.HandleFunc("GET /api/me", s.requireAuth(s.handleMe))
mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings)) mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings))
// Die Notiz — nur für Angemeldete, in beide Richtungen. Sie steht bewusst
// nicht in `appsReadable`: Die HoneyBar in einer fremden App weist sich mit
// dem Schlüssel aus, und der kennt keinen Nutzer. Fremde Notizen gehen
// niemanden etwas an.
mux.HandleFunc("GET /api/note", s.requireAuth(s.handleNote))
mux.HandleFunc("PUT /api/note", s.requireAuth(s.handleSetNote))
// Das Bild: nachsehen, hochladen, verlinken, ausliefern. Vier Wege, weil
// „hochgeladene Bytes" und „fremde Adresse" verschiedene Dinge sind und
// eine Abzweigung nach Inhaltstyp im selben Endpunkt nur Rätsel aufgäbe.
mux.HandleFunc("GET /api/image/info", s.requireAuth(s.handleBildInfo))
mux.HandleFunc("POST /api/image", s.requireAuth(s.handleBildHoch))
mux.HandleFunc("PUT /api/image", s.requireAuth(s.handleBildLink))
mux.HandleFunc("GET /api/image", s.requireAuth(s.handleBild))
mux.HandleFunc("GET /api/apps", s.appsReadable(s.handleApps)) mux.HandleFunc("GET /api/apps", s.appsReadable(s.handleApps))
mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps)) mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps))
// Dieselbe Tür wie die App-Liste: Die HoneyBar steht in einer fremden App und // Dieselbe Tür wie die App-Liste: Die HoneyBar steht in einer fremden App und
@@ -368,6 +382,114 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request, sub string)
// Normalfall, solange niemand einen gewählt hat, und die HoneyBar soll dafür // Normalfall, solange niemand einen gewählt hat, und die HoneyBar soll dafür
// nicht rot blinken, sondern das Symbol einfach weglassen. Dasselbe gilt, wenn // nicht rot blinken, sondern das Symbol einfach weglassen. Dasselbe gilt, wenn
// der Dienst nicht erreichbar ist: lieber kein Wetter als eine Zahl von gestern. // der Dienst nicht erreichbar ist: lieber kein Wetter als eine Zahl von gestern.
func (s *Server) handleNote(w http.ResponseWriter, r *http.Request, sub string) {
writeJSON(w, http.StatusOK, map[string]string{"text": s.store.Note(sub)})
}
func (s *Server) handleSetNote(w http.ResponseWriter, r *http.Request, sub string) {
var in struct {
Text string `json:"text"`
}
if err := decode(r, &in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := s.store.SetNote(sub, in.Text); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
/* ── Das Bild ──────────────────────────────────────────────────────────────
*
* Genau eines je Nutzer, entweder hochgeladen oder verlinkt. Der Ausweis ist
* immer die Sitzung: Bilder gehen weder über den App-Schlüssel noch an fremde
* Ursprünge — es ist das persönlichste, was HoneyOS speichert.
*/
// bildTypen: was hereindarf, und mit welcher Endung es abgelegt wird.
//
// **SVG steht bewusst nicht dabei.** Es ist Text, kann Skripte enthalten und
// würde, aus derselben Adresse ausgeliefert, im Namen der Seite laufen. Ein
// Bildwidget ist diesen Weg nicht wert.
var bildTypen = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
}
const maxBild = 3 << 20 // 3 MB
func (s *Server) handleBildInfo(w http.ResponseWriter, r *http.Request, sub string) {
datei, url, stand := s.store.Bild(sub)
switch {
case datei != "":
writeJSON(w, http.StatusOK, map[string]string{"art": "datei", "url": "/api/image?v=" + stand})
case url != "":
writeJSON(w, http.StatusOK, map[string]string{"art": "link", "url": url})
default:
writeJSON(w, http.StatusOK, map[string]string{"art": "", "url": ""})
}
}
func (s *Server) handleBildHoch(w http.ResponseWriter, r *http.Request, sub string) {
defer r.Body.Close()
daten, err := io.ReadAll(io.LimitReader(r.Body, maxBild+1))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Bild nicht lesbar"})
return
}
if len(daten) > maxBild {
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "Das Bild ist größer als 3 MB"})
return
}
// Der eigene Blick auf die Bytes, nicht die Angabe des Browsers: Wer
// hochlädt, bestimmt sonst den Typ — und damit, was ausgeliefert wird.
typ := strings.SplitN(http.DetectContentType(daten), ";", 2)[0]
endung, ok := bildTypen[typ]
if !ok {
writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{
"error": "Nur PNG, JPEG, WebP oder GIF — erkannt wurde " + typ,
})
return
}
if err := s.store.SetBildDatei(sub, endung, daten); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleBildLink(w http.ResponseWriter, r *http.Request, sub string) {
var in struct {
URL string `json:"url"`
}
if err := decode(r, &in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := s.store.SetBildURL(sub, strings.TrimSpace(in.URL)); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleBild(w http.ResponseWriter, r *http.Request, sub string) {
pfad, ok := s.store.BildPfad(sub)
if !ok {
http.NotFound(w, r)
return
}
// Der Versionsanteil steht in der Adresse, also darf der Browser es lange
// behalten: Ein neues Bild bekommt eine neue Adresse.
w.Header().Set("Cache-Control", "private, max-age=604800")
http.ServeFile(w, r, pfad)
}
func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request, sub string) { func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request, sub string) {
set := s.store.SettingsFor(sub) set := s.store.SettingsFor(sub)
lat, err1 := strconv.ParseFloat(set["weatherLat"], 64) lat, err1 := strconv.ParseFloat(set["weatherLat"], 64)
@@ -380,7 +502,12 @@ func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request, sub strin
wetter, err := s.wetter.Jetzt(r.Context(), set["weatherCity"], lat, lon) wetter, err := s.wetter.Jetzt(r.Context(), set["weatherCity"], lat, lon)
if err != nil { if err != nil {
log.Printf("wetter: %v", err) log.Printf("wetter: %v", err)
w.WriteHeader(http.StatusNoContent) // 502, nicht 204: Ein Ort ist eingestellt, es ist also etwas zu zeigen —
// der Dienst ist nur gerade nicht da. Die Oberflaeche unterscheidet das
// seit dem Widget-Rahmen: „nichts eingestellt" verschwindet still,
// „nicht erreichbar" sagt es. Beides als 204 zu melden hiess, dass ein
// Ausfall aussah wie ein abgeschaltetes Widget.
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "Wetterdienst nicht erreichbar"})
return return
} }
writeJSON(w, http.StatusOK, wetter) writeJSON(w, http.StatusOK, wetter)
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Erzeugt das HoneyOS-Wappen aus einem Textraster.
Die Biene steht **hier** und nirgends sonst: als 16 × 16 Zeichen, ein Zeichen
je Pixel. Daraus entstehen alle Fassungen, die HoneyOS braucht — SVG für die
beiden Leisten, PNG für Dokumentation und Vorschau, und eine Fassung aus
Schriftzeichen fürs System-Fenster und den Startlauf.
python3 tools/biene.py
Kein Pillow, keine Abhängigkeit: Ein PNG ist ein Kopfteil, ein zlib-Strom aus
Bildzeilen (jede mit einem Filterbyte davor) und eine Prüfsumme — das kann die
Standardbibliothek. Der Vorteil gegenüber einem Bildprogramm ist nicht die
Bequemlichkeit, sondern dass die **Quelle Text bleibt**: Ein Pixel ändern heißt
einen Buchstaben ändern, danach stimmen wieder alle Fassungen überein.
"""
import json
import pathlib
import re
import struct
import zlib
WURZEL = pathlib.Path(__file__).resolve().parent.parent
# Die Farben nach dem Vorbild: Die Streifen sind **braun**. Schwarz sind nur
# Augen, Beine und Fühler — das ist der Unterschied zwischen warm und hart.
FARBEN = {
"Y": (242, 179, 61), # Honiggelb, der Körper
"L": (255, 208, 96), # Oberkante, eine Spur heller
"y": (210, 149, 42), # Unterseite im Schatten
"S": (92, 58, 28), # Streifen
"s": (70, 43, 20), # Streifen an der Unterkante
"E": (24, 20, 16), # Augenfeld und Beine
"B": (99, 199, 192), # der Lichtpunkt im Auge
"D": (59, 36, 18), # Fühler
"W": (245, 242, 231), # Flügel
"w": (214, 209, 190), # Flügel, untere Lage
}
# Von der Seite: Stachel hinten, Kopf in Laufrichtung. Diese Ansicht ist es
# geworden, weil ihre Streifen **senkrecht** laufen — deshalb bleibt sie auch
# als Text lesbar, wo die Vorderansicht in Streifen zerfällt.
BIENE = [
"....WW...WW.....",
"..WWWWW.WWWWW...",
"..WWWWWWWWWWW.D.",
"...WWWWWWWWW.DD.",
"...LLLLLLLLLLL..",
"..YYSSYYSSYYYYY.",
".YYYSSYYSSYYEBY.",
"SYYYSSYYSSYYEEY.",
".YYYSSYYSSYYYYY.",
"..yssyyssyyyyyy.",
"...EE..EE..EE...",
"...EE..EE..EE...",
"................",
"................",
"................",
"................",
]
# ── Die 3/4-Ansicht ──────────────────────────────────────────────────────
#
# Liegt in der Schublade: HoneyOS zeigt überall dasselbe Wappen von der Seite,
# also wird sie zurzeit nirgends ausgeliefert. Wer sie doch braucht (ein großes
# Startbild, ein Aufkleber), ruft `schraeg()` und reicht das Ergebnis an `svg()`
# oder `png()`. Unter 64 px zerfällt sie — zu viele Kanten auf zu wenig Pixeln.
#
# Ein Würfel hat in Pixelkunst die Steigung 2:1: zwei Pixel nach rechts, einer
# nach unten. Von Hand wird das krumm, also werden die drei sichtbaren Flächen
# gerechnet — Deckel als Raute, darunter zwei Wangen. Wichtig ist, **zeilenweise**
# zu rechnen: Wer die Raute Punkt für Punkt setzt, lässt Löcher, weil zwei
# Schritte nach rechts nur einen nach unten kosten.
def wuerfel(breite, wand):
hoehe = breite // 2
mitte = breite / 2
deckel = {
(x, y)
for y in range(hoehe)
for x in range(breite)
if abs(x + 0.5 - mitte) / 2 + abs(y + 0.5 - hoehe / 2) <= hoehe / 2
}
unterkante = {}
for (x, y) in deckel:
unterkante[x] = max(unterkante.get(x, -1), y)
flaechen = {q: "T" for q in deckel}
for x, yb in unterkante.items():
for t in range(1, wand + 1):
flaechen[(x, yb + t)] = "V" if x < mitte else "R" # vorn / rechts
return flaechen, unterkante, mitte
def schraeg(breite=22, wand=10, dx=5, dy=9, n=32, band=5):
flaechen, unterkante, mitte = wuerfel(breite, wand)
g = [["." for _ in range(n)] for _ in range(n)]
def setz(x, y, c):
if 0 <= x + dx < n and 0 <= y + dy < n:
g[y + dy][x + dx] = c
# Die Streifen laufen parallel zur linken Deckelkante (u = x - 2y) und
# setzen sich auf der rechten Wange als senkrechte Bänder fort: dieselbe
# Rechnung, ausgewertet an der Kante, an der die Wange beginnt. Deshalb
# passen sie über den Knick hinweg zusammen.
streifig = lambda x, y: ((x - 2 * y) // band) % 2 == 0
for (x, y), art in sorted(flaechen.items()):
if art == "T":
setz(x, y, "S" if streifig(x, y) else "L")
elif art == "R":
setz(x, y, "s" if streifig(x, unterkante[x]) else "y")
else:
setz(x, y, "Y")
# Augen: Rechtecke in Flächenkoordinaten — auf dem Schirm werden daraus
# Parallelogramme, genau wie beim Vorbild.
for ax in (2, 7):
for x in range(ax, ax + 3):
if x in unterkante:
for t in (3, 4, 5):
setz(x, unterkante[x] + t, "E")
if ax + 1 in unterkante:
setz(ax + 1, unterkante[ax + 1] + 4, "B")
for k in range(3): # zwei Flügel in derselben Schräge
for b in range(6):
setz(3 + 2 * k + b, -2 + k, "W")
setz(10 + 2 * k + b, -4 + k, "w")
for k in range(2): # kurze Fühler am Deckelrand
setz(int(mitte) - 5 - k, -3 - k, "D")
setz(int(mitte) + 6 + k, -5 - k, "D")
return ["".join(z) for z in g]
def svg(raster):
"""Waagerechte Läufe gleicher Farbe werden ein Rechteck — dieselbe Idee wie
bei den Pixelsymbolen in web/js/glyphs.js, nur mit Farbe statt currentColor.
Der Rahmen wird auf das gesetzt, was wirklich gefüllt ist: Das Raster ist
quadratisch, die Biene ist es nicht. Mit dem vollen Quadrat als viewBox
bliebe unten ein Viertel Luft — und die kostet überall Größe, wo das Bild in
einen Kasten gelegt wird: in der Leiste, im Tab, auf dem Anmeldebildschirm.
Warum SVG und nicht PNG für die Leisten: Die HoneyBar läuft in *fremden*
Apps. Ein <img> holt dort eine Datei von HoneyOS, und ob das erlaubt ist,
entscheidet die CSP der fremden App. Markup kann niemand blockieren."""
teile = []
for y, zeile in enumerate(raster):
x = 0
while x < len(zeile):
z = zeile[x]
if z not in FARBEN:
x += 1
continue
lauf = 1
while x + lauf < len(zeile) and zeile[x + lauf] == z:
lauf += 1
r, g, b = FARBEN[z]
teile.append(f'<rect x="{x}" y="{y}" width="{lauf}" height="1" fill="#{r:02x}{g:02x}{b:02x}"/>')
x += lauf
voll = [(x, y) for y, z in enumerate(raster) for x, c in enumerate(z) if c in FARBEN]
x0 = min(x for x, _ in voll)
y0 = min(y for _, y in voll)
breite = max(x for x, _ in voll) - x0 + 1
hoehe = max(y for _, y in voll) - y0 + 1
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{x0} {y0} {breite} {hoehe}" '
'shape-rendering="crispEdges" aria-hidden="true">' + "".join(teile) + "</svg>")
def png(raster, faktor, ziel):
"""Vergrößert wird um ganze Faktoren, damit jedes Pixel ein Quadrat bleibt."""
n = len(raster)
roh = bytearray()
for zeile in raster:
pixel = bytearray()
for zeichen in zeile:
r, g, b = FARBEN.get(zeichen, (0, 0, 0))
pixel += bytes((r, g, b, 255 if zeichen in FARBEN else 0)) * faktor
for _ in range(faktor):
roh += b"\x00" + pixel # Filterbyte 0: keine Vorhersage
def block(art, daten):
return (struct.pack(">I", len(daten)) + art + daten
+ struct.pack(">I", zlib.crc32(art + daten) & 0xFFFFFFFF))
kopf = struct.pack(">IIBBBBB", n * faktor, n * faktor, 8, 6, 0, 0, 0)
ziel.write_bytes(b"\x89PNG\r\n\x1a\n" + block(b"IHDR", kopf)
+ block(b"IDAT", zlib.compress(bytes(roh), 9)) + block(b"IEND", b""))
# Ein Zeichen ist etwa halb so breit wie hoch — **ein Pixel sind zwei Zeichen**,
# sonst steht die Biene hochkant. Blockzeichen (█ ▓ ▒) wären naheliegend,
# kacheln aber nicht: Zwischen den Zellen bleiben Fugen, und der Körper wird zur
# Waffel. Gewöhnliche Schriftzeichen behaupten gar nicht erst, eine Fläche zu
# sein — deshalb sehen sie besser aus.
ZEICHEN = {"Y": "#", "L": "#", "y": "#", "S": ":", "s": ":", "E": ":", "D": ":", "W": "-", "B": "o"}
def text(raster):
zeilen = ["".join(ZEICHEN.get(c, " ") * 2 for c in z).rstrip() for z in raster]
while zeilen and not zeilen[0]:
zeilen.pop(0)
while zeilen and not zeilen[-1]:
zeilen.pop()
return "\n".join(zeilen)
def main():
bild = svg(BIENE)
zeichen = text(BIENE)
(WURZEL / "web/img/biene.svg").write_text(bild + "\n")
png(BIENE, 16, WURZEL / "web/img/biene-256.png")
png(BIENE, 2, WURZEL / "web/img/biene-32.png")
# Für die Oberfläche als Modul: Das Wappen im Markup (die Leiste holt kein
# Bild nach) und die Zeichenfassung fürs System-Fenster.
(WURZEL / "web/js/biene.js").write_text(
"/* Erzeugt von tools/biene.py — nicht von Hand ändern.\n"
" *\n"
" * Das Wappen als Markup statt als Bilddatei: So kommt es ohne zweite\n"
" * Anfrage aus und lässt sich mit dem Text daneben ausrichten.\n"
" */\n"
# Einfache Anführungszeichen: So steht das Markup wörtlich da und ist
# mit web/img/biene.svg vergleichbar — ein Test hält beides gleich.
f"export const BIENE_SVG = '{bild}';\n\n"
f"export const BIENE_ASCII = {json.dumps(zeichen)};\n"
)
bar = WURZEL / "web/honeybar/honeybar.js"
alt = bar.read_text()
neu = re.sub(r" const WAPPEN = '[^\n]*';", f" const WAPPEN = '{bild}';", alt)
if neu != alt:
bar.write_text(neu)
print("web/honeybar/honeybar.js: Wappen ersetzt")
print(text(BIENE))
print(f"\nweb/img/biene.svg ({len(bild)} Zeichen), biene-256.png, biene-32.png, web/js/biene.js")
if __name__ == "__main__":
main()
+376 -26
View File
@@ -202,7 +202,24 @@ body {
white-space: nowrap; white-space: nowrap;
} }
.mb-logo b { font-weight: 400; color: var(--p-mid); } .mb-logo b { font-weight: 400; color: var(--p-mid); }
.mb-logo .hex { font-size: 10px; vertical-align: 2px; } /* Das Wappen bleibt gelb, während der Rest die Röhre wechselt: Ein Zeichen,
das die Farbe mitwechselt, ist ein Symbol wie „wolke" — eines, das seine
Farbe behält, ist ein Wappen. */
.mb-logo .hex { display: inline-block; width: 20px; height: 20px; vertical-align: -6px; }
.mb-logo .hex svg { display: block; width: 100%; height: 100%; }
/* Die Biene aus Schriftzeichen — im System-Fenster und über dem Startlauf.
Ein Zeichen ist halb so breit wie hoch, deshalb ist im Raster jeder Pixel
zwei Zeichen breit (siehe tools/biene.py). */
.biene-text {
margin: 0 0 8px;
color: var(--p);
font-family: inherit;
font-size: 9px;
line-height: 1.1;
overflow-x: auto;
}
.boot-biene { font-size: 11px; margin-bottom: 14px; }
.mb-apps { .mb-apps {
display: flex; display: flex;
@@ -284,14 +301,11 @@ body {
grid-template-columns: 320px minmax(0, 1fr); grid-template-columns: 320px minmax(0, 1fr);
align-items: start; align-items: start;
} }
/* Ohne feste Plätze verteilt das Raster die drei Bereiche der Reihe nach — /* Zwei Bereiche, keine Einzelplätze mehr: Links die Widgetspalte, rechts
das Wetter landete dann neben „system" und schob die Kacheln in die die Apps. Vorher hatte jedes Widget seinen festen Platz im Raster — beim
schmale linke Spalte. Links stehen die Anzeigen untereinander, rechts dritten hätte man das Raster wieder anfassen müssen. */
gehören die Apps, über die volle Höhe. */ .widgetspalte { grid-column: 1; }
.win-user { grid-column: 1; grid-row: 1; } .tiles { grid-column: 2; }
.win-status { grid-column: 1; grid-row: 2; }
.win-weather { grid-column: 1; grid-row: 3; }
.tiles { grid-column: 2; grid-row: 1 / span 3; }
} }
/* Fenster */ /* Fenster */
@@ -493,6 +507,62 @@ body {
margin-bottom: 6px; margin-bottom: 6px;
} }
/* ── Einstellungen: Abschnitte statt einer langen Spalte ───────────────────
Beschriftung links, Schalter rechts. Bei zwölf Einstellungen untereinander
sucht man sonst; mit Überschriften weiß man, wo man ist. */
.sek { margin-bottom: 18px; }
.sek + .sek { border-top: 1px solid var(--p-faint); padding-top: 14px; }
.sek-titel {
margin: 0 0 10px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--p);
}
.sek-hinweis { margin: 8px 0 0; color: var(--p-dim); font-size: 11px; line-height: 1.4; }
.reihe {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 7px;
}
.reihe .r-label { font-size: 12.5px; color: var(--p-mid); }
.reihe .seg { justify-content: flex-end; }
@media (max-width: 460px) {
.reihe { flex-direction: column; align-items: stretch; gap: 4px; }
.reihe .seg { justify-content: flex-start; }
}
/* Die Widget-Liste: an/aus, Name, Pfeile — dieselben Pfeile wie bei den Apps. */
.wl { list-style: none; margin: 0; padding: 0; }
.wl-zeile {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--p-faint);
padding: 5px 6px;
margin-bottom: 4px;
}
.wl-zeile.wl-aus .wl-name { color: var(--p-dim); }
.wl-an {
background: var(--p);
border: 1px solid var(--p);
color: var(--bg);
font: inherit;
font-size: 11px;
text-shadow: none;
padding: 2px 8px;
cursor: pointer;
flex: none;
width: 44px;
}
.wl-an[aria-pressed="false"] { background: none; border-color: var(--p-dim); color: var(--p-dim); text-shadow: inherit; }
.wl-name { flex: 1; min-width: 0; font-size: 13px; color: var(--p-mid); }
.wl-name em { font-style: normal; color: var(--p-dim); font-size: 11px; margin-left: 6px; }
.wl .ae-move { margin-left: 0; }
.seg { display: flex; flex-wrap: wrap; gap: 6px; } .seg { display: flex; flex-wrap: wrap; gap: 6px; }
.seg button { .seg button {
background: none; background: none;
@@ -788,15 +858,9 @@ body {
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
} }
/* Abmelden steht unten in den Einstellungen, abgesetzt vom Rest: Es ist die /* Abmelden steht unten in den Einstellungen, im letzten Abschnitt: Es ist die
einzige Schaltfläche dort, die nicht etwas einstellt, sondern etwas beendet. */ einzige Schaltfläche dort, die nicht etwas einstellt, sondern etwas beendet. */
.field-out { .btn-out { width: 100%; color: var(--p-mid); margin-top: 10px; }
border-top: 1px solid var(--p-faint);
padding-top: 12px;
margin-top: 4px;
margin-bottom: 0;
}
.btn-out { width: 100%; color: var(--p-mid); }
.btn-out:hover { color: var(--bg); background: var(--p); } .btn-out:hover { color: var(--bg); background: var(--p); }
.startmenu .sm-head { .startmenu .sm-head {
padding: 4px 8px; padding: 4px 8px;
@@ -880,8 +944,26 @@ body {
overflow: hidden; overflow: hidden;
} }
/* Dasselbe Wappen wie in der Leiste, nur groß. Es schwebt ein wenig — sechs
Sekunden für zwei Pixel, also eher ein Atmen als eine Bewegung. Wer Bewegung
abgestellt hat, bekommt es ruhig. */
.login-biene {
display: block;
width: clamp(128px, 28vw, 176px);
height: auto;
margin: 22px 0 0;
image-rendering: pixelated;
animation: schweben 6s ease-in-out infinite;
}
@keyframes schweben {
50% { transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.login-biene { animation: none; }
}
.login-box { .login-box {
margin-top: 26px; margin-top: 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -1028,9 +1110,7 @@ body {
white-space: nowrap; white-space: nowrap;
} }
/* Terminal-Modus: ein Fenster wie die anderen. Schmal gehalten — über die
volle Breite gezogen sähe eine Temperatur nach Bekanntmachung aus. */
.win-weather { max-width: 320px; align-self: start; }
/* Schreibtisch-Modus: schwebendes Schild, rechts unter der Menüleiste. Nicht /* Schreibtisch-Modus: schwebendes Schild, rechts unter der Menüleiste. Nicht
@@ -1101,16 +1181,205 @@ body {
.uw-name { color: var(--p); font-size: 15px; } .uw-name { color: var(--p); font-size: 15px; }
.uw-gruss { color: var(--p-mid); } .uw-gruss { color: var(--p-mid); }
.uw-host { color: var(--p-dim); font-size: 12px; } .uw-host { color: var(--p-dim); font-size: 12px; }
.win-user { max-width: 320px; align-self: start; } /* Die Spalte selbst hält die Breite — vorher trug sie jedes Widget einzeln,
und ein neues ohne diese Zeile stand quer. */
.widgetspalte {
display: flex;
flex-direction: column;
gap: clamp(14px, 3vw, 28px);
align-self: start;
min-width: 0;
}
@media (min-width: 860px) {
.widgetspalte { max-width: 320px; }
}
/* Die vier Zustände, die der Rahmen kennt. „lädt" und „nicht erreichbar"
stehen im selben Kasten wie der Inhalt — ein Widget, das bei einem Fehler
verschwindet, sieht aus wie eines, das man nie eingeschaltet hat. */
.w-zustand { color: var(--p-dim); margin: 0; font-size: 12.5px; }
.w-fehler { color: var(--p-mid); }
/* ── Notiz ─────────────────────────────────────────────────────────────────
* Gesetzt ist der Normalfall — roh ist nur die Zeile, in der man steht (und im
* Quelltext-Modus das ganze Feld). Beide sind gleich hoch, damit der Kasten
* beim Umschalten nicht springt. */
.notiz-text,
.notiz-feld {
min-height: 112px;
max-height: 320px;
overflow-y: auto;
}
.notiz-feld {
display: block;
width: 100%;
background: var(--bg);
border: 1px solid var(--p-dim);
color: var(--p);
font: inherit;
font-size: 13px;
line-height: 1.45;
padding: 8px;
resize: vertical;
}
.notiz-feld:focus { outline: none; border-color: var(--p); }
.notiz-text { font-size: 13px; line-height: 1.45; color: var(--p-mid); }
.notiz-text h3,
.notiz-text h4,
.notiz-text h5 {
margin: 10px 0 4px;
color: var(--p);
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.notiz-text h3 { font-size: 13px; }
.notiz-text h4 { font-size: 12px; }
.notiz-text h5 { font-size: 11px; color: var(--p-mid); }
.notiz-text > :first-child { margin-top: 0; }
.notiz-text p { margin: 0 0 6px; }
.notiz-text ul,
.notiz-text ol { margin: 0 0 6px; padding-left: 18px; }
.notiz-text li { margin: 1px 0; }
.notiz-text code {
background: var(--p-faint);
padding: 0 3px;
color: var(--p);
}
.notiz-text b { color: var(--p); font-weight: 400; }
.notiz-text hr { border: 0; border-top: 1px solid var(--p-faint); margin: 8px 0; }
/* Häkchen: anklickbar, ohne in den Text zu springen. Die Aufzählungspunkte
fallen weg — das Kästchen ist der Punkt. */
.notiz-text .notiz-hak { list-style: none; margin-left: -18px; }
.notiz-text .notiz-hak label {
display: flex;
align-items: baseline;
gap: 7px;
cursor: pointer;
}
.notiz-text .notiz-hak input {
appearance: none;
flex: none;
width: 11px;
height: 11px;
border: 1px solid var(--p-dim);
background: var(--bg);
cursor: pointer;
position: relative;
top: 1px;
}
.notiz-text .notiz-hak input:checked { background: var(--p); border-color: var(--p); }
.notiz-text .notiz-hak.ab span { color: var(--p-dim); text-decoration: line-through; }
.notiz-text i { color: var(--p-mid); font-style: italic; }
/* Die Zeile, in der die Schreibmarke steht: als Feld, aber ohne wie eines
auszusehen. Sie soll an der Stelle sitzen, an der eben noch der gesetzte
Text stand — gleiche Schrift, gleiche Höhe, nur ein Streifen links zeigt,
dass hier gerade der Quelltext steht. */
.notiz-zeile {
display: block;
width: 100%;
box-sizing: border-box;
background: var(--p-faint);
border: 0;
border-left: 2px solid var(--p);
color: var(--p);
font: inherit;
font-size: 13px;
line-height: 1.45;
padding: 1px 4px 1px 6px;
margin: 1px 0;
resize: none;
overflow: hidden;
}
.notiz-zeile:focus { outline: none; }
.notiz-rohli { list-style: none; margin-left: -18px; }
.notiz-leer { cursor: text; }
/* Werkzeugleiste: klein, einreihig, immer da. Sie wirkt auf die Zeile, in der
man steht — deshalb steht sie oben und nicht neben dem Knopf zum Umschalten. */
.notiz-werkzeuge { display: flex; gap: 4px; margin-bottom: 6px; }
.notiz-wz {
background: none;
border: 1px solid var(--p-faint);
color: var(--p-mid);
font: inherit;
font-size: 12px;
line-height: 1;
width: 24px;
height: 22px;
cursor: pointer;
padding: 0;
}
.notiz-wz:hover:not(:disabled) { border-color: var(--p); color: var(--p); }
.notiz-wz:disabled { opacity: 0.35; cursor: default; }
.nw-fett { font-weight: 700; }
.w-fuss {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
}
.w-stand { color: var(--p-dim); font-size: 11px; }
.w-fuss .btn { font-size: 12px; padding: 3px 9px; }
/* ── Bild ──────────────────────────────────────────────────────────────────
* Fester Rahmen, das Bild füllt ihn. Verzerren wäre die Alternative — und
* Zuschneiden von Hand ein eigenes Stück Arbeit. */
.bildw {
border: 1px solid var(--p-faint);
background: var(--bg);
aspect-ratio: 16 / 10;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.bildw-bild { width: 100%; height: 100%; object-fit: cover; display: block; }
.bildw .w-zustand { padding: 0 12px; text-align: center; }
/* Die Knöpfe stehen in den Einstellungen, nicht im Widget: links eine kleine
Vorschau, rechts was man damit tun kann. */
.bildset { display: flex; gap: 10px; align-items: flex-start; }
.bildset-vorschau {
flex: none;
width: 84px;
height: 56px;
border: 1px solid var(--p-faint);
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.bildset-bild { width: 100%; height: 100%; object-fit: cover; display: block; }
.bildset-leer { color: var(--p-dim); font-size: 11px; }
.bildset-rechts { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.bildset-knoepfe { display: flex; gap: 4px; flex-wrap: wrap; }
.bildset-knoepfe .btn { font-size: 12px; padding: 3px 9px; }
.bildw-linkzeile { display: flex; gap: 6px; margin-top: 8px; }
.bildw-linkzeile input {
flex: 1;
min-width: 0;
background: var(--bg);
border: 1px solid var(--p-dim);
color: var(--p);
font: inherit;
font-size: 12px;
padding: 4px 6px;
}
.bildw-linkzeile input:focus { outline: none; border-color: var(--p); }
/* Einspaltig (Handy, schmales Fenster) sollen die Widgets so breit sein wie das /* Einspaltig (Handy, schmales Fenster) sollen die Widgets so breit sein wie das
Systemfenster. Drei verschieden breite Kästen untereinander sehen aus wie ein Systemfenster. Drei verschieden breite Kästen untereinander sehen aus wie ein
Fehler, nicht wie Absicht. Muss hinter den Regeln stehen, die es aufhebt — Fehler, nicht wie Absicht. Muss hinter den Regeln stehen, die es aufhebt —
eine Medienabfrage erhöht die Spezifität nicht. */ eine Medienabfrage erhöht die Spezifität nicht. */
@media (max-width: 859px) {
.win-weather,
.win-user { max-width: none; }
}
/* Die Schilder auf dem Schreibtisch stapeln sich oben rechts. */ /* Die Schilder auf dem Schreibtisch stapeln sich oben rechts. */
.deskwidgets { .deskwidgets {
@@ -1127,3 +1396,84 @@ body {
@media (max-width: 620px) { @media (max-width: 620px) {
.deskwidgets { position: static; align-items: stretch; margin: 10px; } .deskwidgets { position: static; align-items: stretch; margin: 10px; }
} }
/* ── Kategorien ────────────────────────────────────────────────────────────
*
* Eine Überschrift je Gruppe, in derselben Sprache wie die übrigen Etiketten:
* klein, gesperrt, gedimmt. Sie soll die Kacheln gliedern, nicht mit ihnen um
* Aufmerksamkeit ringen.
*/
.tiles-kopf {
/* Bleibt ein Kind desselben Rasters und spannt über alle Spalten — ein
eigenes Raster je Gruppe hätte die Kacheln zweier Gruppen gegeneinander
verschoben. */
grid-column: 1 / -1;
margin: 6px 0 -4px;
font-size: 11px;
font-weight: normal;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--p-dim);
}
.tiles-kopf:first-child { margin-top: 0; }
.applist .al-kopf {
padding: 8px 8px 3px;
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--p-dim);
}
.applist .al-kopf:first-child { padding-top: 2px; }
/* Im Editor ist die Überschrift eine Zeile wie jede andere: Sie lässt sich
umbenennen, verschieben und auflösen. Deshalb sitzt hier ein Eingabefeld und
kein Text — Umbenennen ist eine Zeile statt eines Feldes je App. */
.ae-gruppe {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 4px 3px;
}
.ae-gruppe:first-child { padding-top: 2px; }
.ae-kat {
flex: 1;
min-width: 0;
background: none;
border: 1px solid transparent;
border-bottom-color: var(--p-faint);
color: var(--p-mid);
font: inherit;
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
padding: 3px 4px;
}
.ae-kat::placeholder { color: var(--p-dim); text-transform: none; letter-spacing: normal; }
.ae-kat:focus { outline: none; border-color: var(--p-dim); background: var(--p-faint); color: var(--p); }
.ae-kat-weg {
flex: none;
background: none;
border: 1px solid transparent;
color: var(--p-dim);
font: inherit;
line-height: 1;
padding: 2px 6px;
cursor: pointer;
}
.ae-kat-weg:hover,
.ae-kat-weg:focus-visible { color: var(--p); border-color: var(--p-dim); }
/* Die Schublade ist ein Raster mit drei Spalten — die Überschrift spannt
darüber, sonst stünde sie als vierte Kachel neben den Apps. */
.drawer .d-kopf {
grid-column: 1 / -1;
padding: 7px 4px 1px;
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--p-dim);
}
.drawer .d-kopf:first-child { padding-top: 0; }
+102 -29
View File
@@ -98,10 +98,44 @@
"display:block", "display:block",
"color-scheme:dark", "color-scheme:dark",
].join(";"); ].join(";");
/* Das Wappen. Es steht hier als Markup, weil die HoneyBar in fremden
* Apps läuft: Ein <img> holte eine Datei von HoneyOS, und ob das geht,
* entscheidet die CSP der fremden App. Erzeugt von tools/biene.py —
* dieselbe Zeile steht in web/img/biene.svg (ein Test hält sie gleich).
*/
const WAPPEN = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 12" shape-rendering="crispEdges" aria-hidden="true"><rect x="4" y="0" width="2" height="1" fill="#f5f2e7"/><rect x="9" y="0" width="2" height="1" fill="#f5f2e7"/><rect x="2" y="1" width="5" height="1" fill="#f5f2e7"/><rect x="8" y="1" width="5" height="1" fill="#f5f2e7"/><rect x="2" y="2" width="11" height="1" fill="#f5f2e7"/><rect x="14" y="2" width="1" height="1" fill="#3b2412"/><rect x="3" y="3" width="9" height="1" fill="#f5f2e7"/><rect x="13" y="3" width="2" height="1" fill="#3b2412"/><rect x="3" y="4" width="11" height="1" fill="#ffd060"/><rect x="2" y="5" width="2" height="1" fill="#f2b33d"/><rect x="4" y="5" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="5" width="2" height="1" fill="#f2b33d"/><rect x="8" y="5" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="5" width="5" height="1" fill="#f2b33d"/><rect x="1" y="6" width="3" height="1" fill="#f2b33d"/><rect x="4" y="6" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="6" width="2" height="1" fill="#f2b33d"/><rect x="8" y="6" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="6" width="2" height="1" fill="#f2b33d"/><rect x="12" y="6" width="1" height="1" fill="#181410"/><rect x="13" y="6" width="1" height="1" fill="#63c7c0"/><rect x="14" y="6" width="1" height="1" fill="#f2b33d"/><rect x="0" y="7" width="1" height="1" fill="#5c3a1c"/><rect x="1" y="7" width="3" height="1" fill="#f2b33d"/><rect x="4" y="7" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="7" width="2" height="1" fill="#f2b33d"/><rect x="8" y="7" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="7" width="2" height="1" fill="#f2b33d"/><rect x="12" y="7" width="2" height="1" fill="#181410"/><rect x="14" y="7" width="1" height="1" fill="#f2b33d"/><rect x="1" y="8" width="3" height="1" fill="#f2b33d"/><rect x="4" y="8" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="8" width="2" height="1" fill="#f2b33d"/><rect x="8" y="8" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="8" width="5" height="1" fill="#f2b33d"/><rect x="2" y="9" width="1" height="1" fill="#d2952a"/><rect x="3" y="9" width="2" height="1" fill="#462b14"/><rect x="5" y="9" width="2" height="1" fill="#d2952a"/><rect x="7" y="9" width="2" height="1" fill="#462b14"/><rect x="9" y="9" width="6" height="1" fill="#d2952a"/><rect x="3" y="10" width="2" height="1" fill="#181410"/><rect x="7" y="10" width="2" height="1" fill="#181410"/><rect x="11" y="10" width="2" height="1" fill="#181410"/><rect x="3" y="11" width="2" height="1" fill="#181410"/><rect x="7" y="11" width="2" height="1" fill="#181410"/><rect x="11" y="11" width="2" height="1" fill="#181410"/></svg>';
/* ── Stile ohne CSP-Ausnahme ──────────────────────────────────────────────
* Die Stile kommen als konstruiertes Stylesheet in den Shadow-Root, nicht
* als style-Element. Der Unterschied ist nicht Geschmack, sondern der
* ganze Punkt: Ein style-Element ist Markup und faellt damit unter die CSP der
* fremden App — die musste deshalb bis hierher style-src 'unsafe-inline'
* erlauben, sonst stand die HoneyBar schwarz auf schwarz da. Ueber die
* CSSOM gesetzte Stile fallen nicht darunter. So braucht die Gastgeber-App
* fuer die HoneyBar gar keine style-src-Erlaubnis mehr, und es bleibt
* trotzdem bei einer Datei ohne zweite Anfrage.
*
* Der Rueckfall ist fuer Browser ohne konstruierbare Stylesheets (vor
* Safari 16.4, Firefox 101): Dort steht es wie bisher, also nicht
* schlechter — nur eben weiter mit 'unsafe-inline'.
*/
function stileAnlegen(wurzel, css) {
try {
const blatt = new CSSStyleSheet();
blatt.replaceSync(css);
wurzel.adoptedStyleSheets = [blatt];
return;
} catch {
/* alter Browser: unten der Weg ueber das Markup */
}
const el = document.createElement("style");
el.textContent = css;
wurzel.appendChild(el);
}
const root = host.attachShadow({ mode: "open" }); const root = host.attachShadow({ mode: "open" });
root.innerHTML = ` const STIL = `
<style>
:host, * { box-sizing: border-box; } :host, * { box-sizing: border-box; }
/* Dieselben Röhren wie auf dem Schreibtisch. Welche gilt, sagt der /* Dieselben Röhren wie auf dem Schreibtisch. Welche gilt, sagt der
Server; bis seine Antwort da ist, leuchtet Bernstein. */ Server; bis seine Antwort da ist, leuchtet Bernstein. */
@@ -136,16 +170,20 @@
} }
.home { letter-spacing: .14em; color: var(--p); padding-left: 2px; } .home { letter-spacing: .14em; color: var(--p); padding-left: 2px; }
.home b { color: var(--p-mid); font-weight: 400; } .home b { color: var(--p-mid); font-weight: 400; }
/* Dasselbe Quadrat wie in der Menüleiste des Schreibtischs: dort ist es /* Dasselbe Wappen wie in der Menüleiste des Schreibtischs, in derselben
klein und leicht angehoben. Ein Zeichen in voller Größe sah daneben Größe. Es bleibt gelb, während der Rest die Röhre wechselt: Ein
wie ein anderes Logo aus. */ Zeichen, das die Farbe mitwechselt, ist ein Symbol — eines, das seine
.home .hex { font-size: 10px; vertical-align: 2px; } Farbe behält, ist ein Wappen. */
.home .hex { display: inline-block; width: 20px; height: 20px; vertical-align: -6px; }
.home .hex svg { display: block; width: 100%; height: 100%; }
.home:hover { border-color: var(--p-dim); } .home:hover { border-color: var(--p-dim); }
.home[aria-expanded="true"] { border-color: var(--p-dim); background: var(--p-faint); } .home[aria-expanded="true"] { border-color: var(--p-dim); background: var(--p-faint); }
/* Bei vielen Apps schiebt man den Streifen seitwärts. Ohne /* Bei vielen Apps schiebt man den Streifen seitwärts. Ohne
flex-shrink:0 an den Knöpfen quetscht das Raster sie stattdessen flex-shrink:0 an den Knöpfen quetscht das Raster sie stattdessen
zusammen, bis nur noch Buchstabenreste dastehen — am Handy sofort. zusammen, bis nur noch Buchstabenreste dastehen — am Handy sofort.
Der Schatten rechts zeigt an, dass es weitergeht. */ Der Schatten rechts zeigt an, dass es weitergeht. */
/* Nur der Streifen in der Leiste. Wer eine weitere Klasse "apps"
vergibt, erbt hier Maske und Bildlauf mit. */
.apps { .apps {
display: flex; gap: 4px; flex: 1 1 auto; min-width: 0; display: flex; gap: 4px; flex: 1 1 auto; min-width: 0;
overflow-x: auto; scrollbar-width: none; overflow-x: auto; scrollbar-width: none;
@@ -198,7 +236,11 @@
aufgemacht, weil man etwas sucht — also wird gelesen, nicht nur aufgemacht, weil man etwas sucht — also wird gelesen, nicht nur
wiedererkannt. Deshalb stehen hier immer Namen, anders als im wiedererkannt. Deshalb stehen hier immer Namen, anders als im
Streifen oben, wo ein Blick reichen muss. */ Streifen oben, wo ein Blick reichen muss. */
.menu.apps { min-width: 318px; max-width: min(360px, calc(100vw - 12px)); } /* NICHT .menu.apps nennen: Der Streifen oben heisst .apps und blendet
seine rechten 18 px mit einer Maske aus, damit man sieht, dass es
seitwaerts weitergeht. Eine zweite Klasse "apps" am Menue holt diese
Maske mit — die Schublade sah rechts aus, als loese sie sich auf. */
.menu.schublade { min-width: 318px; max-width: min(360px, calc(100vw - 12px)); }
/* minmax(0, 1fr): Ohne das darf eine lange Beschreibung ihre Spalte /* minmax(0, 1fr): Ohne das darf eine lange Beschreibung ihre Spalte
auseinanderdruecken, und die Schublade wird breiter als der Schirm. */ auseinanderdruecken, und die Schublade wird breiter als der Schirm. */
.drawer { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2px; } .drawer { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2px; }
@@ -234,14 +276,24 @@
padding: 4px 8px; font-size: 10px; letter-spacing: .18em; padding: 4px 8px; font-size: 10px; letter-spacing: .18em;
text-transform: uppercase; color: var(--p-dim); text-transform: uppercase; color: var(--p-dim);
} }
/* Ueberschrift einer Kategorie in der Schublade. Wie .head, nur mit
etwas Luft darueber, und sie spannt ueber alle drei Spalten der
Schublade — sonst stuende sie als vierte Kachel neben den Apps. */
.d-kopf {
grid-column: 1 / -1;
padding: 6px 4px 1px; font-size: 10px; letter-spacing: .18em;
text-transform: uppercase; color: var(--p-dim);
}
.d-kopf:first-child { padding-top: 0; }
/* Dasselbe Menue, nur an der anderen Seite: das Systemmenue haengt am /* Dasselbe Menue, nur an der anderen Seite: das Systemmenue haengt am
Logo links, das App-Menue am Pfeil rechts. */ Logo links, das App-Menue am Pfeil rechts. */
.menu.sys { left: 6px; right: auto; } .menu.sys { left: 6px; right: auto; }
@media (max-width: 560px) { .clock { display: none; } } @media (max-width: 560px) { .clock { display: none; } }
</style> `;
root.innerHTML = `
<div class="bar"> <div class="bar">
<button class="home" id="home" title="HoneyOS-Menü" aria-haspopup="true" aria-expanded="false"><span class="hex">&#9632;</span> HONEY<b>OS</b></button> <button class="home" id="home" title="HoneyOS-Menü" aria-haspopup="true" aria-expanded="false"><span class="hex">${WAPPEN}</span> HONEY<b>OS</b></button>
<nav class="apps" id="apps"></nav> <nav class="apps" id="apps"></nav>
<div class="right"> <div class="right">
<span class="wx" id="wx" hidden></span> <span class="wx" id="wx" hidden></span>
@@ -253,16 +305,17 @@
<div class="menu sys" id="sysmenu" role="menu"> <div class="menu sys" id="sysmenu" role="menu">
<div class="head">honeyos</div> <div class="head">honeyos</div>
<a id="mHome" role="menuitem">Schreibtisch</a> <a id="mHome" role="menuitem">Schreibtisch</a>
<a id="mSettings" role="menuitem">Einstellungen &hellip;</a>
<hr /> <hr />
<button id="hide" role="menuitem">HoneyBar einklappen</button> <button id="hide" role="menuitem">HoneyBar einklappen</button>
</div> </div>
<div class="menu apps" id="menu" role="menu"> <div class="menu schublade" id="menu" role="menu">
<div class="head">wechseln zu</div> <div class="head">wechseln zu</div>
<div class="drawer" id="menuApps"></div> <div class="drawer" id="menuApps"></div>
</div>`; </div>`;
stileAnlegen(root, STIL);
/* ── Aussehen ───────────────────────────────────────────────────────────── /* ── Aussehen ─────────────────────────────────────────────────────────────
* Dieselben drei Röhren wie in style.css. Sie hier zu wiederholen ist die * Dieselben drei Röhren wie in style.css. Sie hier zu wiederholen ist die
* bewusste Entscheidung: die HoneyBar soll ohne zweite Datei auskommen, damit * bewusste Entscheidung: die HoneyBar soll ohne zweite Datei auskommen, damit
@@ -311,6 +364,13 @@
return span; return span;
}; };
// Die Schublade bekommt Ueberschriften, der Streifen nicht: Dort sucht man
// eine App, hier erkennt man sie im Vorbeigehen wieder — und fuer eine
// Ueberschrift ist in 32 Pixeln Hoehe ohnehin kein Platz. Die Reihenfolge
// der Gruppen ist die ihres ersten Auftretens in der Liste; HoneyOS
// sortiert sie schon so, hier wird nur eingesammelt.
let letzteKategorie = null;
apps.forEach((app) => { apps.forEach((app) => {
if (app.id === "honeyos") return; // steckt schon links im Logo if (app.id === "honeyos") return; // steckt schon links im Logo
@@ -325,6 +385,17 @@
if (app.id === current) a.setAttribute("aria-current", "page"); if (app.id === current) a.setAttribute("aria-current", "page");
nav.appendChild(a); nav.appendChild(a);
const kat = (app.kategorie || "").trim();
if (kat !== letzteKategorie) {
letzteKategorie = kat;
if (kat) {
const kopf = document.createElement("div");
kopf.className = "d-kopf";
kopf.textContent = kat;
menuApps.appendChild(kopf);
}
}
const m = document.createElement("a"); const m = document.createElement("a");
m.className = "d-app"; m.className = "d-app";
m.href = app.url; m.href = app.url;
@@ -463,25 +534,25 @@
} }
/** /**
* Die zwei Wege ins System. Beides sind Links auf den Schreibtisch, keine * Der eine Weg ins System: ein Link auf den Schreibtisch, keine Aktion hier.
* Aktionen hier — und das ist keine Bequemlichkeit, sondern die Grenze:
* *
* Einstellungen **schreiben**. Dafuer braucht der Server die HoneyOS-Sitzung, * Das ist keine Bequemlichkeit, sondern die Grenze: Einstellungen
* und die schickt der Browser aus einer fremden App nicht mit; liegt alles * **schreiben**. Dafuer braucht der Server die HoneyOS-Sitzung, und die
* unter einer Domain, kommt sie zwar mit, aber das CSRF-Prüfwort steht in * schickt der Browser aus einer fremden App nicht mit; liegt alles unter
* einem Cookie der HoneyOS-Adresse, das dieses Skript von hier aus nicht * einer Domain, kommt sie zwar mit, aber das CSRF-Prüfwort steht in einem
* lesen darf. * Cookie der HoneyOS-Adresse, das dieses Skript von hier aus nicht lesen darf.
*
* Hier stand deshalb lange ein zweiter Punkt „Einstellungen …", der auf
* dieselbe Seite fuehrte, nur mit geoeffnetem Feld. **Weg damit** (Marcs
* Einwand, 01.09.2026): Zwei Tueren in denselben Raum sind keine zwei Wege.
* Wer eine Einstellung aendern will, ist ohnehin auf dem Schreibtisch, und
* dort steht sie einen Klick oder ein [S] entfernt.
* *
* Abmelden steht hier bewusst gar nicht: Es gehoert dorthin, wo man sich * Abmelden steht hier bewusst gar nicht: Es gehoert dorthin, wo man sich
* angemeldet hat. Wer es aus einer App heraus braucht, geht ueber * angemeldet hat — unten in den Einstellungen.
* „Einstellungen …" — dort unten steht der Knopf.
*/ */
function systemZiele() { function systemZiele() {
const heim = new URL(homeUrl, location.href); root.getElementById("mHome").href = new URL(homeUrl, location.href).href;
root.getElementById("mHome").href = heim.href;
const einst = new URL(homeUrl, location.href);
einst.search = "einstellungen=1";
root.getElementById("mSettings").href = einst.href;
} }
/** /**
@@ -506,8 +577,11 @@
// auch wenn in HoneyOS Grün eingestellt war. Er gehört zur HoneyBar, also // auch wenn in HoneyOS Grün eingestellt war. Er gehört zur HoneyBar, also
// trägt er dieselbe Röhre. // trägt er dieselbe Röhre.
const t = ROEHREN[(settings && settings.phosphor) || "amber"] || ROEHREN.amber; const t = ROEHREN[(settings && settings.phosphor) || "amber"] || ROEHREN.amber;
r.innerHTML = ` // Auch hier ueber die CSSOM statt als style-Element: Der Strich hat seinen
<style> // eigenen Shadow-Root und waere sonst das einzige Stueck geblieben, das
// unter einer strengen CSP unsichtbar bleibt.
r.innerHTML = `<div class="strich"></div>`;
stileAnlegen(r, `
.strich { .strich {
height: 3px; height: 3px;
background: linear-gradient(90deg, ${t.dim}, ${t.p} 50%, ${t.dim}); background: linear-gradient(90deg, ${t.dim}, ${t.p} 50%, ${t.dim});
@@ -515,8 +589,7 @@
transition: opacity .15s, height .15s; transition: opacity .15s, height .15s;
} }
:host(:hover) .strich { opacity: 1; height: 3px; } :host(:hover) .strich { opacity: 1; height: 3px; }
</style> `);
<div class="strich"></div>`;
nub.addEventListener("click", () => { nub.addEventListener("click", () => {
forget(); forget();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 12" shape-rendering="crispEdges" aria-hidden="true"><rect x="4" y="0" width="2" height="1" fill="#f5f2e7"/><rect x="9" y="0" width="2" height="1" fill="#f5f2e7"/><rect x="2" y="1" width="5" height="1" fill="#f5f2e7"/><rect x="8" y="1" width="5" height="1" fill="#f5f2e7"/><rect x="2" y="2" width="11" height="1" fill="#f5f2e7"/><rect x="14" y="2" width="1" height="1" fill="#3b2412"/><rect x="3" y="3" width="9" height="1" fill="#f5f2e7"/><rect x="13" y="3" width="2" height="1" fill="#3b2412"/><rect x="3" y="4" width="11" height="1" fill="#ffd060"/><rect x="2" y="5" width="2" height="1" fill="#f2b33d"/><rect x="4" y="5" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="5" width="2" height="1" fill="#f2b33d"/><rect x="8" y="5" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="5" width="5" height="1" fill="#f2b33d"/><rect x="1" y="6" width="3" height="1" fill="#f2b33d"/><rect x="4" y="6" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="6" width="2" height="1" fill="#f2b33d"/><rect x="8" y="6" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="6" width="2" height="1" fill="#f2b33d"/><rect x="12" y="6" width="1" height="1" fill="#181410"/><rect x="13" y="6" width="1" height="1" fill="#63c7c0"/><rect x="14" y="6" width="1" height="1" fill="#f2b33d"/><rect x="0" y="7" width="1" height="1" fill="#5c3a1c"/><rect x="1" y="7" width="3" height="1" fill="#f2b33d"/><rect x="4" y="7" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="7" width="2" height="1" fill="#f2b33d"/><rect x="8" y="7" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="7" width="2" height="1" fill="#f2b33d"/><rect x="12" y="7" width="2" height="1" fill="#181410"/><rect x="14" y="7" width="1" height="1" fill="#f2b33d"/><rect x="1" y="8" width="3" height="1" fill="#f2b33d"/><rect x="4" y="8" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="8" width="2" height="1" fill="#f2b33d"/><rect x="8" y="8" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="8" width="5" height="1" fill="#f2b33d"/><rect x="2" y="9" width="1" height="1" fill="#d2952a"/><rect x="3" y="9" width="2" height="1" fill="#462b14"/><rect x="5" y="9" width="2" height="1" fill="#d2952a"/><rect x="7" y="9" width="2" height="1" fill="#462b14"/><rect x="9" y="9" width="6" height="1" fill="#d2952a"/><rect x="3" y="10" width="2" height="1" fill="#181410"/><rect x="7" y="10" width="2" height="1" fill="#181410"/><rect x="11" y="10" width="2" height="1" fill="#181410"/><rect x="3" y="11" width="2" height="1" fill="#181410"/><rect x="7" y="11" width="2" height="1" fill="#181410"/><rect x="11" y="11" width="2" height="1" fill="#181410"/></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+12 -31
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0a0705" /> <meta name="theme-color" content="#0a0705" />
<title>HoneyOS</title> <title>HoneyOS</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%230a0705'/%3E%3Cpath d='M4 4h2v3h4V4h2v8h-2V9H6v3H4z' fill='%23ffb641'/%3E%3C/svg%3E" /> <link rel="icon" href="./img/biene.svg" />
<link rel="stylesheet" href="./css/style.css" /> <link rel="stylesheet" href="./css/style.css" />
</head> </head>
<body> <body>
@@ -13,6 +13,7 @@
<div class="crt" id="screen"> <div class="crt" id="screen">
<!-- ── Boot ───────────────────────────────────────────────────────── --> <!-- ── Boot ───────────────────────────────────────────────────────── -->
<div class="boot" id="boot" hidden> <div class="boot" id="boot" hidden>
<pre class="biene-text boot-biene" id="bootBiene" aria-hidden="true"></pre>
<pre class="boot-log" id="bootLog" aria-live="polite"></pre> <pre class="boot-log" id="bootLog" aria-live="polite"></pre>
<p class="boot-hint">[ beliebige Taste = überspringen ]</p> <p class="boot-hint">[ beliebige Taste = überspringen ]</p>
</div> </div>
@@ -20,6 +21,9 @@
<!-- ── Anmeldung ──────────────────────────────────────────────────── --> <!-- ── Anmeldung ──────────────────────────────────────────────────── -->
<div class="login" id="login" hidden> <div class="login" id="login" hidden>
<pre class="boot-log" id="loginLog"></pre> <pre class="boot-log" id="loginLog"></pre>
<!-- Dasselbe Wappen wie oben links in der Leiste und im Tab, nur groß:
ein Gerät, ein Zeichen. -->
<img class="login-biene" src="./img/biene.svg" alt="" width="160" height="160" />
<div class="login-box"> <div class="login-box">
<button class="login-btn" id="btnLogin">&#9656; <span id="loginLabel">Anmelden</span></button> <button class="login-btn" id="btnLogin">&#9656; <span id="loginLabel">Anmelden</span></button>
<p class="login-note" id="loginNote"></p> <p class="login-note" id="loginNote"></p>
@@ -33,7 +37,7 @@
<!-- Das Logo ist das Systemmenü — dasselbe wie in der HoneyBar <!-- Das Logo ist das Systemmenü — dasselbe wie in der HoneyBar
fremder Apps, damit HoneyOS von überall gleich erreichbar ist. --> fremder Apps, damit HoneyOS von überall gleich erreichbar ist. -->
<button class="mb-logo" id="btnSystem" title="HoneyOS-Menü" aria-haspopup="true" aria-expanded="false"> <button class="mb-logo" id="btnSystem" title="HoneyOS-Menü" aria-haspopup="true" aria-expanded="false">
<span class="hex">&#9632;</span> HONEY<b>OS</b> <span class="hex" id="logoBiene"></span> HONEY<b>OS</b>
</button> </button>
<nav class="mb-apps" id="mbApps" aria-label="Anwendungen"></nav> <nav class="mb-apps" id="mbApps" aria-label="Anwendungen"></nav>
<div class="mb-right"> <div class="mb-right">
@@ -47,30 +51,10 @@
</header> </header>
<main class="stage" id="stage"> <main class="stage" id="stage">
<!-- Statusfenster --> <!-- Links die Widgets untereinander, rechts die Apps. Was hier steht,
<section class="win win-status"> entscheidet der Widget-Rahmen (js/widgets/rahmen.js) — im
<div class="win-bar"><span class="win-title">system</span><span class="win-dots">&#9679;&#9679;&#9679;</span></div> Dokument steht nur der Platz dafür. -->
<div class="win-body"> <div class="widgetspalte" id="widgetSpalte"></div>
<dl class="statlist" id="statList"></dl>
</div>
</section>
<!-- Wer angemeldet ist. Stand früher in der Menüleiste — dort ist
es aber der einzige Unterschied zur HoneyBar in den Apps
gewesen, und der Schreibtisch hat für so etwas Platz. -->
<section class="win win-user" id="userTile" hidden>
<div class="win-bar"><span class="win-title">angemeldet</span><span class="win-dots">&#9679;&#9679;&#9679;</span></div>
<div class="win-body user-body"></div>
</section>
<!-- Wetter. Steht in beiden Betriebsarten zur Verfügung, wird aber
nur gezeigt, wenn ein Ort eingestellt und das Widget an ist. -->
<section class="win win-weather" id="wxTile" hidden>
<div class="win-bar"><span class="win-title">wetter</span><span class="win-dots">&#9679;&#9679;&#9679;</span></div>
<div class="win-body wx-body"></div>
</section>
<!-- App-Kacheln -->
<section class="tiles" id="tiles" aria-label="Apps starten"></section> <section class="tiles" id="tiles" aria-label="Apps starten"></section>
</main> </main>
@@ -78,11 +62,8 @@
<div class="deskspace" id="deskspace" hidden> <div class="deskspace" id="deskspace" hidden>
<div class="desk-icons" id="deskIcons"></div> <div class="desk-icons" id="deskIcons"></div>
<div class="windows" id="windows"></div> <div class="windows" id="windows"></div>
<!-- Dasselbe Wetter, im Schreibtisch-Modus als kleines Feld oben rechts --> <!-- Dieselben Widgets, im Schreibtisch-Modus als Schilder oben rechts -->
<div class="deskwidgets"> <div class="deskwidgets" id="deskwidgets"></div>
<div class="deskwidget" id="userDesk" hidden></div>
<div class="deskwidget" id="wxDesk" hidden></div>
</div>
</div> </div>
<footer class="taskbar" id="taskbar" hidden> <footer class="taskbar" id="taskbar" hidden>
+8
View File
@@ -0,0 +1,8 @@
/* Erzeugt von tools/biene.py — nicht von Hand ändern.
*
* Das Wappen als Markup statt als Bilddatei: So kommt es ohne zweite
* Anfrage aus und lässt sich mit dem Text daneben ausrichten.
*/
export const BIENE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 12" shape-rendering="crispEdges" aria-hidden="true"><rect x="4" y="0" width="2" height="1" fill="#f5f2e7"/><rect x="9" y="0" width="2" height="1" fill="#f5f2e7"/><rect x="2" y="1" width="5" height="1" fill="#f5f2e7"/><rect x="8" y="1" width="5" height="1" fill="#f5f2e7"/><rect x="2" y="2" width="11" height="1" fill="#f5f2e7"/><rect x="14" y="2" width="1" height="1" fill="#3b2412"/><rect x="3" y="3" width="9" height="1" fill="#f5f2e7"/><rect x="13" y="3" width="2" height="1" fill="#3b2412"/><rect x="3" y="4" width="11" height="1" fill="#ffd060"/><rect x="2" y="5" width="2" height="1" fill="#f2b33d"/><rect x="4" y="5" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="5" width="2" height="1" fill="#f2b33d"/><rect x="8" y="5" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="5" width="5" height="1" fill="#f2b33d"/><rect x="1" y="6" width="3" height="1" fill="#f2b33d"/><rect x="4" y="6" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="6" width="2" height="1" fill="#f2b33d"/><rect x="8" y="6" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="6" width="2" height="1" fill="#f2b33d"/><rect x="12" y="6" width="1" height="1" fill="#181410"/><rect x="13" y="6" width="1" height="1" fill="#63c7c0"/><rect x="14" y="6" width="1" height="1" fill="#f2b33d"/><rect x="0" y="7" width="1" height="1" fill="#5c3a1c"/><rect x="1" y="7" width="3" height="1" fill="#f2b33d"/><rect x="4" y="7" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="7" width="2" height="1" fill="#f2b33d"/><rect x="8" y="7" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="7" width="2" height="1" fill="#f2b33d"/><rect x="12" y="7" width="2" height="1" fill="#181410"/><rect x="14" y="7" width="1" height="1" fill="#f2b33d"/><rect x="1" y="8" width="3" height="1" fill="#f2b33d"/><rect x="4" y="8" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="8" width="2" height="1" fill="#f2b33d"/><rect x="8" y="8" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="8" width="5" height="1" fill="#f2b33d"/><rect x="2" y="9" width="1" height="1" fill="#d2952a"/><rect x="3" y="9" width="2" height="1" fill="#462b14"/><rect x="5" y="9" width="2" height="1" fill="#d2952a"/><rect x="7" y="9" width="2" height="1" fill="#462b14"/><rect x="9" y="9" width="6" height="1" fill="#d2952a"/><rect x="3" y="10" width="2" height="1" fill="#181410"/><rect x="7" y="10" width="2" height="1" fill="#181410"/><rect x="11" y="10" width="2" height="1" fill="#181410"/><rect x="3" y="11" width="2" height="1" fill="#181410"/><rect x="7" y="11" width="2" height="1" fill="#181410"/><rect x="11" y="11" width="2" height="1" fill="#181410"/></svg>';
export const BIENE_ASCII = " ---- ----\n ---------- ----------\n ---------------------- ::\n ------------------ ::::\n ######################\n ####::::####::::##########\n ######::::####::::####::oo##\n::######::::####::::####::::##\n ######::::####::::##########\n ##::::####::::############\n :::: :::: ::::\n :::: :::: ::::";
+38 -5
View File
@@ -16,14 +16,15 @@ export const DEFAULT_SETTINGS = {
openIn: "tab", // tab | same openIn: "tab", // tab | same
// Symbole im App-Streifen beider Leisten. Im Aufklappmenü stehen sie immer. // Symbole im App-Streifen beider Leisten. Im Aufklappmenü stehen sie immer.
barGlyphs: "on", barGlyphs: "on",
// Wetter. Zwei Schalter statt einem: Der Schreibtisch und die HoneyBar sind // Wetter in der HoneyBar. Auf dem Schreibtisch entscheidet das die Widget-
// zwei verschiedene Orte, und wer das Wetter beim Arbeiten in einer App will, // Liste — zwei Schalter für dasselbe Widget wären einer zu viel.
// will es nicht zwingend auch auf einer Seite, die er selten aufruft.
weatherBar: "off", weatherBar: "off",
weatherDesk: "off",
weatherCity: "", weatherCity: "",
weatherLat: "", weatherLat: "",
weatherLon: "", weatherLon: "",
// Reihenfolge und An/Aus der Widgets, als eine Liste: "benutzer,-wetter,notiz".
// Leer heißt: Grundstellung (siehe widgets/rahmen.js).
widgets: "",
}; };
export const state = { export const state = {
@@ -53,7 +54,9 @@ export async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) }; const headers = { ...(opts.headers || {}) };
if (APPS_KEY) headers["X-HoneyOS-Key"] = APPS_KEY; if (APPS_KEY) headers["X-HoneyOS-Key"] = APPS_KEY;
if (opts.method && opts.method !== "GET") { if (opts.method && opts.method !== "GET") {
headers["Content-Type"] = "application/json"; // Nur voreinstellen, nicht überschreiben: Das Bild-Widget schickt Bytes und
// nennt deren Typ selbst. Vorher log der Aufrufhelfer den Server an.
if (!headers["Content-Type"]) headers["Content-Type"] = "application/json";
const tok = cookie("honeyos_csrf"); const tok = cookie("honeyos_csrf");
if (tok) headers["X-CSRF-Token"] = tok; if (tok) headers["X-CSRF-Token"] = tok;
} }
@@ -120,3 +123,33 @@ export function applySettings() {
r.dataset.glow = state.settings.glow; r.dataset.glow = state.settings.glow;
r.dataset.mode = state.settings.mode; r.dataset.mode = state.settings.mode;
} }
/* ── Kategorien ────────────────────────────────────────────────────────────
*
* Apps tragen eine Kategorie als freien Text. Eine Liste von Kategorien gibt
* es nicht: Eine Kategorie besteht, solange eine App sie trägt. Deshalb ist
* dies auch keine Sortierung, sondern nur ein Einsammeln — die Reihenfolge
* der Gruppen ist die ihres ersten Auftretens, die Reihenfolge innerhalb
* einer Gruppe die der App-Liste. Damit ordnen dieselben Pfeile wie bisher
* beides zugleich, und es gibt nichts, was auseinanderlaufen könnte.
*
* Apps ohne Kategorie bilden die erste Gruppe, und die trägt keine
* Überschrift: Wer nie eine Kategorie vergibt, sieht alles wie zuvor.
*/
export function gruppiere(apps) {
const gruppen = [];
const nach = new Map();
for (const app of apps) {
const k = (app.kategorie || "").trim();
if (!nach.has(k)) {
const g = { kategorie: k, apps: [] };
nach.set(k, g);
gruppen.push(g);
}
nach.get(k).apps.push(app);
}
// Ohne Kategorie zuerst — das ist der Stand von vorher, und der gehört nach
// oben, nicht dahin, wo zufällig die erste unbenannte App steht.
gruppen.sort((a, b) => (a.kategorie === "" ? -1 : b.kategorie === "" ? 1 : 0));
return gruppen;
}
+284 -112
View File
@@ -1,11 +1,24 @@
import { import {
$, api, APPS_KEY, cookie, DEFAULT_SETTINGS, escapeAttr, escapeHtml, isTouch, $, api, APPS_KEY, cookie, DEFAULT_SETTINGS, escapeAttr, escapeHtml, gruppiere, isTouch,
applySettings, loadLocalSettings, LS_SETTINGS, readJson, saveSettings, applySettings, loadLocalSettings, LS_SETTINGS, readJson, saveSettings,
SS_BOOTED, state, SS_BOOTED, state,
} from "./core.js"; } from "./core.js";
import { GLYPHS, glyphSvg } from "./glyphs.js"; import { GLYPHS, glyphSvg } from "./glyphs.js";
import { renderUser } from "./widgets/user.js"; import { BIENE_ASCII, BIENE_SVG } from "./biene.js";
import { loadWeather, renderWeather } from "./widgets/weather.js"; import {
widgetPlan, widgetPlanSetzen, widgetsLaden, widgetsOrdnen, widgetsStarten, widgetsZeichnen,
} from "./widgets/rahmen.js";
import "./widgets/user.js";
import { statsHtml } from "./widgets/system.js";
import { loadWeather, renderWeatherBar } from "./widgets/weather.js";
import "./widgets/notiz.js";
import { bildFormularHtml, bildFormularMontieren } from "./widgets/bild.js";
// Die Reihenfolge steht hier und nicht in den Importzeilen darüber: Module
// werden nicht in deren Reihenfolge ausgewertet, sobald eines auf oberster
// Ebene wartet (glyphs.js holt seine Raster mit await). Bis die Reihenfolge
// aus dem Server kommt, ist das ihre Stelle.
widgetsOrdnen(["benutzer", "system", "wetter", "notiz", "bild"]);
/* /*
* HoneyOS — Desktop-Logik. * HoneyOS — Desktop-Logik.
@@ -67,6 +80,9 @@ function runBoot() {
return new Promise((resolve) => { return new Promise((resolve) => {
const log = $("bootLog"); const log = $("bootLog");
const el = $("boot"); const el = $("boot");
// Über dem Protokoll die Biene — auch hier aus Schriftzeichen, denn der
// Startlauf ist Text und nichts anderes.
$("bootBiene").textContent = BIENE_ASCII;
el.hidden = false; el.hidden = false;
const lines = bootLines(); const lines = bootLines();
let i = 0; let i = 0;
@@ -110,14 +126,20 @@ function runBoot() {
function renderShell() { function renderShell() {
$("desktop").hidden = false; $("desktop").hidden = false;
// Das Wappen steht im Markup, nicht als Bilddatei — dieselbe Zeile wie in
// der HoneyBar fremder Apps, damit beide Leisten wirklich dasselbe zeigen.
$("logoBiene").innerHTML = BIENE_SVG;
renderMenubarApps(); renderMenubarApps();
renderMode(); renderMode();
tickClock(); tickClock();
// Nicht abgewartet: Das Wetter kommt von außen und darf den Aufbau des // Nicht abgewartet: Das Wetter kommt von außen und darf den Aufbau des
// Schreibtischs nicht verzögern. Danach alle 10 Minuten, im selben Takt, // Schreibtischs nicht verzögern. Danach alle 10 Minuten, im selben Takt,
// in dem der Server seinen Zwischenspeicher erneuert. // in dem der Server seinen Zwischenspeicher erneuert.
loadWeather(); // Nicht abgewartet: Die Widgets holen ihre Daten von außen und dürfen den
setInterval(loadWeather, 10 * 60 * 1000); // Aufbau des Schreibtischs nicht verzögern. Den Takt fürs Nachladen kennt
// jedes Widget selbst.
widgetsLaden().then(renderWeatherBar);
widgetsStarten();
} }
function renderMenubarApps() { function renderMenubarApps() {
@@ -163,9 +185,7 @@ function renderMode() {
closeAllWindows(); closeAllWindows();
renderTiles(); renderTiles();
} }
renderStats(); widgetsZeichnen();
renderUser();
renderWeather();
paintProbes(); paintProbes();
} }
@@ -188,18 +208,13 @@ function launch(app) {
/** Die Kennzahlen stehen in beiden Ansichten — deshalb über die Klasse in /** Die Kennzahlen stehen in beiden Ansichten — deshalb über die Klasse in
* alle vorhandenen Listen schreiben, nicht in eine bestimmte id. */ * alle vorhandenen Listen schreiben, nicht in eine bestimmte id. */
/** Zeichnet den Systemzustand überall neu, wo er gerade steht: als Widget in
* der Spalte und, im Desktop-Modus, im Fenster aus dem Startmenü. */
function renderStats() { function renderStats() {
const up = Object.values(state.probes).filter((s) => s === "up").length; widgetsZeichnen();
const rows = [ document.querySelectorAll("#windows .statlist").forEach((dl) => {
["apps", `${state.cfg.apps.length}`], dl.outerHTML = statsHtml();
["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]) => `<dt>${k}</dt><dd>${escapeHtml(v)}</dd>`).join("");
document.querySelectorAll(".statlist").forEach((dl) => (dl.innerHTML = html));
} }
function tickClock() { function tickClock() {
@@ -270,7 +285,7 @@ function renderTiles() {
const wrap = $("tiles"); const wrap = $("tiles");
wrap.innerHTML = ""; wrap.innerHTML = "";
state.cfg.apps.forEach((app) => { const kachel = (app) => {
const a = document.createElement("a"); const a = document.createElement("a");
a.className = "tile"; a.className = "tile";
a.href = app.url; a.href = app.url;
@@ -285,7 +300,25 @@ function renderTiles() {
e.preventDefault(); e.preventDefault();
launch(app); launch(app);
}); });
wrap.appendChild(a); return a;
};
// Gruppen bekommen eine Überschrift, die namenlose erste nicht: Ohne
// Kategorien sieht die Fläche aus wie immer, nur mit ihnen entsteht Struktur.
//
// Die Überschrift bleibt ein Kind desselben Rasters und spannt über alle
// Spalten (CSS: .tiles-kopf). Ein eigenes Raster je Gruppe haette jede
// Gruppe ihre Spalten selbst bestimmen lassen — dann stehen die Kacheln
// zweier Gruppen nicht mehr untereinander.
const gruppen = gruppiere(state.cfg.apps);
gruppen.forEach((g) => {
if (g.kategorie) {
const h = document.createElement("h2");
h.className = "tiles-kopf";
h.textContent = g.kategorie;
wrap.appendChild(h);
}
g.apps.forEach((app) => wrap.appendChild(kachel(app)));
}); });
const add = document.createElement("button"); const add = document.createElement("button");
@@ -490,13 +523,18 @@ function openSystemWindow() {
id: "system", id: "system",
title: "system", title: "system",
w: 320, w: 320,
body: `<p class="greet"></p><dl class="statlist"></dl>`, // Derselbe Inhalt wie das Widget im Terminal-Modus — eine Quelle, damit
onMount: renderStats, // nicht zwei Listen mit denselben Zahlen auseinanderlaufen.
body: statsHtml(),
}); });
} }
function openProgramsWindow() { function openProgramsWindow() {
const rows = state.cfg.apps const rows = gruppiere(state.cfg.apps)
.map(
(g) =>
(g.kategorie ? `<div class="al-kopf">${escapeHtml(g.kategorie)}</div>` : "") +
g.apps
.map( .map(
(a) => `<button data-launch="${a.id}"> (a) => `<button data-launch="${a.id}">
<span class="al-glyph">${glyphSvg(a.glyph)}</span> <span class="al-glyph">${glyphSvg(a.glyph)}</span>
@@ -504,6 +542,8 @@ function openProgramsWindow() {
<span class="al-sub">${escapeHtml(a.subtitle || "")}</span> <span class="al-sub">${escapeHtml(a.subtitle || "")}</span>
</button>` </button>`
) )
.join("")
)
.join(""); .join("");
openWindow({ openWindow({
@@ -544,75 +584,72 @@ function closeModal() {
function openSettings() { function openSettings() {
const s = state.settings; const s = state.settings;
/* Ein Feld je Zeile, Beschriftung links, Schalter rechts — und die Zeilen in
* Abschnitten. Vorher stand alles untereinander in einer einzigen Spalte,
* mit Abstandshaltern zwischen den Schaltern; bei zwölf Einstellungen sieht
* das aus wie eine Liste, in der man suchen muss. */
const seg = (name, opts, cur) => const seg = (name, opts, cur) =>
`<div class="seg" data-seg="${name}">` + `<div class="seg" data-seg="${name}">` +
opts.map(([v, l]) => `<button type="button" data-v="${v}" aria-pressed="${cur === v}">${l}</button>`).join("") + opts.map(([v, l]) => `<button type="button" data-v="${v}" aria-pressed="${cur === v}">${l}</button>`).join("") +
`</div>`; `</div>`;
const zeile = (label, rechts) => `<div class="reihe"><span class="r-label">${label}</span>${rechts}</div>`;
const schalter = (name, label, anLabel = "an") =>
zeile(label, seg(name, [["on", anLabel], ["off", "aus"]], s[name]));
const sektion = (titel, inhalt) => `<section class="sek"><h4 class="sek-titel">${titel}</h4>${inhalt}</section>`;
openPanel( openPanel(
"settings", "settings",
"einstellungen", "einstellungen",
` sektion(
<div class="field"> "Darstellung",
<span class="label">Betriebsart</span> zeile("Betriebsart", seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)) +
${seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)} zeile("Röhre", seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)) +
</div> schalter("scanlines", "Scanlines") +
schalter("flicker", "Flackern") +
<div class="field"> schalter("glow", "Glühen")
<span class="label">Röhre</span> ) +
${seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)} sektion(
</div> "HoneyBar",
zeile("Symbole im App-Streifen", seg("barGlyphs", [["on", "an"], ["off", "nur namen"]], s.barGlyphs)) +
<div class="field"> zeile("Apps öffnen", seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)) +
<span class="label">Bildröhren-Effekte</span> schalter("weatherBar", "Wetter in der Leiste") +
${seg("scanlines", [["on", "scanlines an"], ["off", "aus"]], s.scanlines)} `<p class="sek-hinweis">Gilt auch für die HoneyBar in deinen Apps — es ist dieselbe Leiste.</p>`
<div style="height:6px"></div> ) +
${seg("flicker", [["on", "flackern an"], ["off", "aus"]], s.flicker)} sektion(
<div style="height:6px"></div> "Widgets",
${seg("glow", [["on", "glühen an"], ["off", "aus"]], s.glow)} `<ul class="wl" id="wlListe"></ul>
</div> <p class="sek-hinweis">Reihenfolge und An/Aus liegen bei deinem Konto, nicht am Gerät.</p>`
) +
<div class="field"> sektion(
<span class="label">HoneyBar</span> "Bild",
${seg("barGlyphs", [["on", "symbole an"], ["off", "nur namen"]], s.barGlyphs)} bildFormularHtml() +
<p class="ae-hint" style="margin-left:0">gilt für den App-Streifen hier und in jeder App</p> `<p class="sek-hinweis">Genau ein Bild je Konto; ein neues ersetzt das alte.
</div> Zu sehen ist es im Widget „bild".</p>`
) +
<div class="field"> sektion(
<span class="label">Apps öffnen</span> "Wetter",
${seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)} `<div class="wx-city-row">
</div>
<div class="field">
<span class="label">Wetter</span>
${seg("weatherDesk", [["on", "auf dem schreibtisch"], ["off", "aus"]], s.weatherDesk)}
<div style="height:6px"></div>
${seg("weatherBar", [["on", "in der honeybar"], ["off", "aus"]], s.weatherBar)}
<div class="wx-city-row">
<input id="wxQuery" type="text" placeholder="Stadt suchen …" autocomplete="off" /> <input id="wxQuery" type="text" placeholder="Stadt suchen …" autocomplete="off" />
<button class="btn" id="btnWxSearch">suchen</button> <button class="btn" id="btnWxSearch">suchen</button>
</div> </div>
<div id="wxResults" class="wx-results"></div> <div id="wxResults" class="wx-results"></div>
<p class="ae-hint" id="wxCurrent" style="margin-left:0"></p> <p class="sek-hinweis" id="wxCurrent"></p>`
</div> ) +
sektion(
<div class="field"> "Programme",
<span class="label">Programme</span> zeile("Eingetragene Apps", `<button class="btn" id="btnApps">${state.cfg.apps.length} — verwalten …</button>`)
<button class="btn" id="btnApps">${state.cfg.apps.length} eingetragen — verwalten …</button> ) +
</div> sektion(
"Konto",
<p class="login-note" style="margin-top:4px"> `<p class="sek-hinweis">Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem
Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.</p>` +
Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus. // Abmelden steht hier und nur hier: Es ist das Einzige, was man aus
</p>` + // einer fremden App heraus nicht anklicken können soll (siehe
// Abmelden steht hier und nur hier: Es ist das Einzige, was man aus einer // honeybar.js), und von HoneyOS aus findet man es dort, wo alles
// fremden App heraus nicht anklicken können soll (siehe honeybar.js), und // andere auch steht.
// von HoneyOS aus findet man es dort, wo alles andere auch steht. (state.oidc ? `<button class="btn btn-out" id="btnLogout">⏻ Abmelden</button>` : "")
(state.oidc ),
? `<div class="field field-out">
<button class="btn btn-out" id="btnLogout">⏻ Abmelden</button>
</div>`
: ""),
(root) => { (root) => {
root.querySelectorAll(".seg").forEach((segEl) => { root.querySelectorAll(".seg").forEach((segEl) => {
segEl.addEventListener("click", (e) => { segEl.addEventListener("click", (e) => {
@@ -637,6 +674,9 @@ function openSettings() {
}); });
}); });
widgetListeZeichnen(root.querySelector("#wlListe"));
bildFormularMontieren(root);
const zeigeOrt = () => { const zeigeOrt = () => {
root.querySelector("#wxCurrent").textContent = state.settings.weatherCity root.querySelector("#wxCurrent").textContent = state.settings.weatherCity
? `eingestellt: ${state.settings.weatherCity}` ? `eingestellt: ${state.settings.weatherCity}`
@@ -691,13 +731,108 @@ function openSettings() {
); );
} }
/* ── Die Widget-Liste in den Einstellungen ─────────────────────────────────
*
* Dieselbe Bedienung wie bei den Apps: Pfeile statt Ziehen. Ziehen wäre hübsch,
* aber am Handy ist es fummelig, und die Liste ist kurz.
*
* Der Rahmen kennt die Widgets, nicht diese Datei — hier wird nur gezeigt, was
* er hergibt, und zurückgegeben, was der Nutzer daraus gemacht hat.
*/
function widgetListeZeichnen(ul) {
if (!ul) return;
const plan = widgetPlan();
ul.innerHTML = plan
.map(
(p, i) => `
<li class="wl-zeile${p.an ? "" : " wl-aus"}">
<button type="button" class="wl-an" data-an="${i}" aria-pressed="${p.an}">${p.an ? "an" : "aus"}</button>
<span class="wl-name">${escapeHtml(p.titel)}${
p.wo && p.wo.length === 1 ? `<em>nur ${p.wo[0]}</em>` : ""
}</span>
<span class="ae-move">
<button type="button" class="ae-arrow" data-hoch="${i}" ${i === 0 ? "disabled" : ""}>&#9650;</button>
<button type="button" class="ae-arrow" data-runter="${i}" ${
i === plan.length - 1 ? "disabled" : ""
}>&#9660;</button>
</span>
</li>`
)
.join("");
const uebernehmen = () => {
widgetPlanSetzen(plan);
widgetListeZeichnen(ul);
};
ul.querySelectorAll("[data-an]").forEach((b) =>
b.addEventListener("click", () => {
const p = plan[Number(b.dataset.an)];
p.an = !p.an;
uebernehmen();
})
);
const tausche = (i, j) => {
[plan[i], plan[j]] = [plan[j], plan[i]];
uebernehmen();
};
ul.querySelectorAll("[data-hoch]").forEach((b) =>
b.addEventListener("click", () => tausche(Number(b.dataset.hoch), Number(b.dataset.hoch) - 1))
);
ul.querySelectorAll("[data-runter]").forEach((b) =>
b.addEventListener("click", () => tausche(Number(b.dataset.runter), Number(b.dataset.runter) + 1))
);
}
/* ── App-Verwaltung ──────────────────────────────────────────────────────── /* ── App-Verwaltung ────────────────────────────────────────────────────────
* Bearbeitet wird eine Kopie der Liste; erst „speichern" schickt sie als * Bearbeitet wird eine Kopie der Liste; erst „speichern" schickt sie als
* Ganzes zum Server. Der prüft sie noch einmal und antwortet mit dem, was er * Ganzes zum Server. Der prüft sie noch einmal und antwortet mit dem, was er
* wirklich behalten hat — das ist danach der Stand, nicht das Formular. * wirklich behalten hat — das ist danach der Stand, nicht das Formular.
*//* Bearbeitet wird eine Liste aus zweierlei Zeilen: Apps und Überschriften.
*
* Das ist bewusst **nicht** das, was gespeichert wird — gespeichert ist eine
* Kategorie als Feld an jeder App. Die Überschrift gibt es nur hier, weil sie
* hier das ehrlichere Bild ist: Man sieht, was man sortiert, und das Umhängen
* einer App braucht keine Sonderregel, es ergibt sich daraus, dass sie unter
* einer anderen Überschrift landet.
*
* In der App-Liste selbst hätten Überschriften nichts zu suchen: Dann enthielte
* sie Einträge ohne ID und ohne Adresse, und jeder Leser müsste das wissen —
* die Kachelfläche, die Schublade, die Erreichbarkeitsprüfung und vor allem
* die HoneyBar in fremden Apps, die mit so wenig wie möglich auskommen soll.
*
* Was oberhalb der ersten Überschrift steht, hat keine Kategorie.
*/ */
function openApps() { function openApps() {
let draft = state.cfg.apps.map((a) => ({ ...a })); const KOPF = "kopf";
// Aus der gespeicherten Liste die Zeilen bauen: je Gruppe eine Überschrift,
// darunter ihre Apps. Die Gruppe ohne Kategorie steht oben und bekommt keine.
const zeilenAus = (apps) =>
gruppiere(apps).flatMap((g) => [
...(g.kategorie ? [{ typ: KOPF, name: g.kategorie }] : []),
...g.apps.map((a) => ({ ...a })),
]);
// Und zurück: durchlaufen, die zuletzt gesehene Überschrift mitschreiben.
// Eine Überschrift ohne Apps darunter verschwindet dabei — eine Kategorie
// besteht nur durch die Apps, die sie tragen.
const appsAus = (zeilen) => {
let kategorie = "";
const out = [];
for (const z of zeilen) {
if (z.typ === KOPF) {
kategorie = z.name.trim();
continue;
}
const { __offen, ...rest } = z;
out.push({ ...rest, kategorie });
}
return out;
};
let zeilen = zeilenAus(state.cfg.apps);
// Die Auswahl kommt aus glyphs.json selbst — wer dort ein Symbol ergänzt, // Die Auswahl kommt aus glyphs.json selbst — wer dort ein Symbol ergänzt,
// kann es sofort wählen. Die Wettersymbole liegen in derselben Datei (die // kann es sofort wählen. Die Wettersymbole liegen in derselben Datei (die
@@ -705,18 +840,30 @@ function openApps() {
const WETTER_GLYPHEN = ["sonne", "heiter", "wolke", "nebel", "regen", "schnee", "gewitter"]; const WETTER_GLYPHEN = ["sonne", "heiter", "wolke", "nebel", "regen", "schnee", "gewitter"];
const GLYPHS_LIST = Object.keys(GLYPHS).filter((g) => !WETTER_GLYPHEN.includes(g)); const GLYPHS_LIST = Object.keys(GLYPHS).filter((g) => !WETTER_GLYPHEN.includes(g));
const pfeile = (i, was) => `
<span class="ae-move">
<button class="ae-arrow" data-up="${i}" ${i === 0 ? "disabled" : ""}
title="nach oben" aria-label="${escapeAttr(was)} nach oben">&#9650;</button>
<button class="ae-arrow" data-down="${i}" ${i === zeilen.length - 1 ? "disabled" : ""}
title="nach unten" aria-label="${escapeAttr(was)} nach unten">&#9660;</button>
</span>`;
const kopfHtml = (z, i) => `
<div class="ae-gruppe">
<input class="ae-kat" data-k="${i}" value="${escapeAttr(z.name)}"
placeholder="kategorie" aria-label="Name der Kategorie" />
<button class="ae-kat-weg" data-kdel="${i}" title="Kategorie auflösen"
aria-label="Kategorie ${escapeAttr(z.name)} auflösen">&times;</button>
${pfeile(i, z.name || "kategorie")}
</div>`;
const rowHtml = (a, i) => ` const rowHtml = (a, i) => `
<details class="approw" ${a.__offen ? "open" : ""}> <details class="approw" ${a.__offen ? "open" : ""}>
<summary> <summary>
<span class="ae-glyph">${glyphSvg(a.glyph)}</span> <span class="ae-glyph">${glyphSvg(a.glyph)}</span>
<span>${escapeHtml(a.name || a.id || "neue app")}</span> <span>${escapeHtml(a.name || a.id || "neue app")}</span>
<span class="ae-url">${escapeHtml(a.url || "")}</span> <span class="ae-url">${escapeHtml(a.url || "")}</span>
<span class="ae-move"> ${pfeile(i, a.name || a.id)}
<button class="ae-arrow" data-up="${i}" ${i === 0 ? "disabled" : ""}
title="nach oben" aria-label="${escapeAttr(a.name || a.id)} nach oben">&#9650;</button>
<button class="ae-arrow" data-down="${i}" ${i === draft.length - 1 ? "disabled" : ""}
title="nach unten" aria-label="${escapeAttr(a.name || a.id)} nach unten">&#9660;</button>
</span>
</summary> </summary>
<div class="ae-body"> <div class="ae-body">
<label class="ae-field"><span>ID</span> <label class="ae-field"><span>ID</span>
@@ -740,35 +887,55 @@ function openApps() {
const render = (root) => { const render = (root) => {
const list = root.querySelector("#appList"); const list = root.querySelector("#appList");
list.innerHTML = draft.length list.innerHTML = zeilen.length
? draft.map(rowHtml).join("") ? zeilen.map((z, i) => (z.typ === KOPF ? kopfHtml(z, i) : rowHtml(z, i))).join("")
: `<div class="apped-empty">Noch keine App eingetragen.<br />Mit „+ neue app" die erste anlegen.</div>`; : `<div class="apped-empty">Noch keine App eingetragen.<br />Mit „+ neue app" die erste anlegen.</div>`;
list.querySelectorAll("input[data-f], select[data-f]").forEach((inp) => { list.querySelectorAll("input[data-f], select[data-f]").forEach((inp) => {
inp.addEventListener("input", () => { inp.addEventListener("input", () => {
draft[Number(inp.dataset.i)][inp.dataset.f] = inp.value; zeilen[Number(inp.dataset.i)][inp.dataset.f] = inp.value;
}); });
}); });
// Umbenennen ist hier eine Zeile — das ist der Grund für diese Bauart.
// Neu gezeichnet wird dabei nicht, sonst verlöre das Feld den Cursor.
list.querySelectorAll("input[data-k]").forEach((inp) => {
inp.addEventListener("input", () => {
zeilen[Number(inp.dataset.k)].name = inp.value;
});
});
list.querySelectorAll("[data-del]").forEach((b) => list.querySelectorAll("[data-del]").forEach((b) =>
b.addEventListener("click", () => { b.addEventListener("click", () => {
draft.splice(Number(b.dataset.del), 1); zeilen.splice(Number(b.dataset.del), 1);
render(root);
})
);
// Eine Überschrift löschen heißt: Ihre Apps rutschen in die Gruppe darüber.
// Die Apps selbst anzufassen wäre falsch — aufgelöst wird eine Kategorie,
// nicht das, was in ihr steht.
list.querySelectorAll("[data-kdel]").forEach((b) =>
b.addEventListener("click", () => {
zeilen.splice(Number(b.dataset.kdel), 1);
render(root); render(root);
}) })
); );
// Merken, was offen ist — das Umsortieren zeichnet die Liste neu, und ohne // Merken, was offen ist — das Umsortieren zeichnet die Liste neu, und ohne
// das klappte dabei alles zu, was man gerade bearbeitet. // das klappte dabei alles zu, was man gerade bearbeitet.
list.querySelectorAll(".approw").forEach((d, i) => { list.querySelectorAll(".approw").forEach((d) => {
const i = Number(d.querySelector("[data-up]").dataset.up);
d.addEventListener("toggle", () => { d.addEventListener("toggle", () => {
if (draft[i]) draft[i].__offen = d.open; if (zeilen[i]) zeilen[i].__offen = d.open;
}); });
}); });
// Die Pfeile sitzen im <summary>: Ohne preventDefault klappt jeder // Die Pfeile sitzen im <summary>: Ohne preventDefault klappt jeder
// Sortierklick die Zeile auf oder zu. // Sortierklick die Zeile auf oder zu. Getauscht wird stumpf Nachbar gegen
// Nachbar — dass eine App dabei die Gruppe wechselt oder eine ganze Gruppe
// wandert, ergibt sich von selbst und braucht keine eigene Regel.
const tausche = (i, j) => { const tausche = (i, j) => {
if (j < 0 || j >= draft.length) return; if (j < 0 || j >= zeilen.length) return;
[draft[i], draft[j]] = [draft[j], draft[i]]; [zeilen[i], zeilen[j]] = [zeilen[j], zeilen[i]];
render(root); render(root);
// Zurück auf denselben Knopf, damit man mehrmals hintereinander drücken // Zurück auf denselben Knopf, damit man mehrmals hintereinander drücken
// kann, ohne die Maus nachzuführen. // kann, ohne die Maus nachzuführen.
@@ -792,6 +959,7 @@ function openApps() {
`<div class="apped"><div id="appList"></div> `<div class="apped"><div id="appList"></div>
<div class="modal-actions"> <div class="modal-actions">
<span class="saveline" id="appMsg"></span> <span class="saveline" id="appMsg"></span>
<button class="btn" id="btnKat">+ kategorie</button>
<button class="btn" id="btnAdd">+ neue app</button> <button class="btn" id="btnAdd">+ neue app</button>
<button class="btn btn-primary" id="btnSaveApps">speichern</button> <button class="btn btn-primary" id="btnSaveApps">speichern</button>
</div> </div>
@@ -801,19 +969,26 @@ function openApps() {
root.querySelector("#btnAdd").addEventListener("click", () => { root.querySelector("#btnAdd").addEventListener("click", () => {
// Immer ans Ende — sortiert wird mit den Pfeilen, nicht beim Anlegen. // Immer ans Ende — sortiert wird mit den Pfeilen, nicht beim Anlegen.
draft.push({ id: "", name: "", url: "", glyph: "box", __offen: true }); zeilen.push({ id: "", name: "", url: "", glyph: "box", __offen: true });
render(root); render(root);
}); });
root.querySelector("#btnKat").addEventListener("click", () => {
zeilen.push({ typ: KOPF, name: "" });
render(root);
[...root.querySelectorAll(".ae-kat")].pop()?.focus();
});
root.querySelector("#btnSaveApps").addEventListener("click", async () => { root.querySelector("#btnSaveApps").addEventListener("click", async () => {
const msg = root.querySelector("#appMsg"); const msg = root.querySelector("#appMsg");
msg.dataset.kind = ""; msg.dataset.kind = "";
msg.textContent = "speichere …"; msg.textContent = "speichere …";
try { try {
const clean = draft.map(({ __offen, ...rest }) => rest); state.cfg.apps = (await api("/api/apps", { method: "PUT", body: JSON.stringify(appsAus(zeilen)) })).apps;
state.cfg.apps = (await api("/api/apps", { method: "PUT", body: JSON.stringify(clean) })).apps; const offen = new Set(zeilen.filter((z) => z.__offen).map((z) => z.id));
const offen = new Set(draft.filter((a) => a.__offen).map((a) => a.id)); zeilen = zeilenAus(state.cfg.apps).map((z) =>
draft = state.cfg.apps.map((a) => ({ ...a, __offen: offen.has(a.id) })); z.typ === KOPF ? z : { ...z, __offen: offen.has(z.id) }
);
msg.textContent = `${state.cfg.apps.length} gespeichert.`; msg.textContent = `${state.cfg.apps.length} gespeichert.`;
render(root); render(root);
renderMenubarApps(); renderMenubarApps();
@@ -881,7 +1056,11 @@ function buildAppMenu() {
`<div class="sm-head">wechseln zu</div>` + `<div class="sm-head">wechseln zu</div>` +
(state.cfg.apps.length (state.cfg.apps.length
? `<div class="drawer">` + ? `<div class="drawer">` +
state.cfg.apps gruppiere(state.cfg.apps)
.map(
(g) =>
(g.kategorie ? `<div class="d-kopf">${escapeHtml(g.kategorie)}</div>` : "") +
g.apps
.map( .map(
(a) => (a) =>
`<button class="d-app" data-app="${escapeAttr(a.id)}"> `<button class="d-app" data-app="${escapeAttr(a.id)}">
@@ -890,6 +1069,8 @@ function buildAppMenu() {
${a.subtitle ? `<span class="d-sub">${escapeHtml(a.subtitle)}</span>` : ""} ${a.subtitle ? `<span class="d-sub">${escapeHtml(a.subtitle)}</span>` : ""}
</button>` </button>`
) )
.join("")
)
.join("") + .join("") +
`</div>` `</div>`
: `<button data-settings="1">keine app eingetragen …</button>`); : `<button data-settings="1">keine app eingetragen …</button>`);
@@ -1145,15 +1326,6 @@ async function main() {
if (e.target === $("modal")) closeModal(); if (e.target === $("modal")) closeModal();
}); });
// Was die HoneyBar aus einer fremden App herüberreicht: Sie darf selbst
// nichts schreiben (siehe honeybar.js), also stellt sie die Frage in der
// Adresse und der Schreibtisch beantwortet sie. Danach kommt die Adresse
// weg — ein Neuladen soll die Einstellungen nicht wieder aufreißen.
if (new URLSearchParams(location.search).get("einstellungen") === "1") {
history.replaceState(null, "", location.pathname);
openSettings();
}
window.addEventListener("keydown", (e) => { window.addEventListener("keydown", (e) => {
if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.metaKey || e.ctrlKey || e.altKey) return;
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName); const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
+185
View File
@@ -0,0 +1,185 @@
import { api, escapeAttr, escapeHtml } from "../core.js";
import { widgetAnmelden, widgetsLaden, widgetsZeichnen } from "./rahmen.js";
/* ── Das Bild ──────────────────────────────────────────────────────────────
*
* Genau **ein** Bild je Nutzer, hochgeladen oder verlinkt. Ein neues ersetzt
* das alte, ohne Verwaltung und ohne Liste — das ist der ganze Reiz: Es ist
* das erste Widget, das nichts abfragt, sondern etwas *ist*.
*
* Hochgeladen wird zum eigenen Server; verlinkt wird auf eine fremde Adresse.
* Der Unterschied ist keiner der Bequemlichkeit: Ein Link holt bei jedem
* Aufbau des Schreibtischs etwas von woanders, ein Upload nie.
*
* DAS WIDGET ZEIGT, DIE EINSTELLUNGEN STELLEN EIN.
*
* Hochladen, Verlinken und Entfernen standen erst im Widget selbst. Das ist die
* Ausnahme gewesen, nicht die Regel: Der Ort des Wetters wird auch nicht in der
* Wetterkachel gesucht. Ein Schild auf dem Schreibtisch, das zu drei Vierteln
* aus Knöpfen besteht, zeigt vor allem sich selbst.
*
* Also: Das Widget zeigt nur noch das Bild. Die Knöpfe stehen unter
* *Einstellungen → Bild* — dieselbe Datei, aber ein zweiter Ausgang
* (`bildFormularHtml` / `bildFormularMontieren`), den os.js einhängt.
*/
const LEER = "Kein Bild — unter Einstellungen → Bild hochladen oder verlinken.";
const FORM_ID = "bildForm";
let art = ""; // "datei" | "link" | ""
let quelle = "";
let stand = ""; // Meldung im Einstellungsfeld
let linkAuf = false;
let kaputt = false; // die Adresse liefert kein Bild
widgetAnmelden({
id: "bild",
titel: "bild",
// Das Bild soll beim Neuzeichnen nicht neu geladen werden — ein Bild, das
// alle zwei Minuten kurz weiß aufblitzt, wäre schlechter als keines.
stabil: true,
async laden() {
const d = await api("/api/image/info");
art = (d && d.art) || "";
quelle = (d && d.url) || "";
kaputt = false;
},
inhalt() {
if (kaputt) {
return `<div class="bildw"><p class="w-zustand">${
art === "link" ? "Adresse liefert kein Bild" : "Bild nicht lesbar"
}</p></div>`;
}
return `<div class="bildw">${
quelle
? `<img class="bildw-bild" src="${escapeAttr(quelle)}" alt="" />`
: `<p class="w-zustand">${LEER}</p>`
}</div>`;
},
montiert(el) {
// Ein Bild, das nicht kommt, soll es sagen — bei einem Link ist das der
// Normalfall des Tages, an dem die fremde Seite umzieht.
el.querySelector(".bildw-bild")?.addEventListener("error", () => {
kaputt = true;
melden(art === "link" ? "Adresse liefert kein Bild" : "Bild nicht lesbar");
neu();
});
},
});
/* ── Der Ausgang in die Einstellungen ──────────────────────────────────── */
/** Das Feld für *Einstellungen → Bild*. os.js hängt es ein, kennt es aber nicht. */
export function bildFormularHtml() {
return `<div id="${FORM_ID}">${formInhalt()}</div>`;
}
/** Nach dem Einhängen verdrahten. */
export function bildFormularMontieren(root) {
const el = root.querySelector(`#${FORM_ID}`);
if (el) verdrahte(el);
}
function formInhalt() {
const vorschau = quelle
? `<img class="bildset-bild" src="${escapeAttr(quelle)}" alt="" />`
: `<span class="bildset-leer">kein bild</span>`;
return `
<div class="bildset">
<span class="bildset-vorschau">${vorschau}</span>
<div class="bildset-rechts">
<div class="bildset-knoepfe">
<input class="bildw-datei" type="file" accept="image/png,image/jpeg,image/webp,image/gif" hidden />
<button type="button" class="btn bildw-waehlen">${quelle ? "ersetzen" : "hochladen"}</button>
<button type="button" class="btn bildw-link">link</button>
${quelle ? `<button type="button" class="btn bildw-weg">entfernen</button>` : ""}
</div>
<span class="w-stand">${escapeHtml(stand)}</span>
</div>
</div>
${
linkAuf
? `<div class="bildw-linkzeile">
<input class="bildw-url" type="url" spellcheck="false"
placeholder="https://…" value="${escapeAttr(art === "link" ? quelle : "")}" />
<button type="button" class="btn bildw-uebernehmen">setzen</button>
</div>`
: ""
}`;
}
function verdrahte(el) {
const datei = el.querySelector(".bildw-datei");
el.querySelector(".bildw-waehlen").addEventListener("click", () => datei.click());
datei.addEventListener("change", () => datei.files[0] && hochladen(datei.files[0]));
el.querySelector(".bildw-link").addEventListener("click", () => {
linkAuf = !linkAuf;
formNeu();
});
const url = el.querySelector(".bildw-url");
if (url) {
url.focus();
const setzen = () => linkSetzen(url.value.trim());
el.querySelector(".bildw-uebernehmen").addEventListener("click", setzen);
url.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
setzen();
}
});
}
el.querySelector(".bildw-weg")?.addEventListener("click", () => linkSetzen(""));
}
/** Das Einstellungsfeld neu zeichnen — es steht nicht unter dem Widget-Rahmen. */
function formNeu() {
const el = document.getElementById(FORM_ID);
if (!el) return;
el.innerHTML = formInhalt();
verdrahte(el);
}
async function hochladen(file) {
melden("lädt hoch …");
try {
await api("/api/image", { method: "POST", body: file, headers: { "Content-Type": file.type || "application/octet-stream" } });
linkAuf = false;
await widgetsLaden("bild");
melden("");
} catch (e) {
melden(e.message);
}
formNeu();
}
async function linkSetzen(adresse) {
melden(adresse ? "übernehme …" : "entferne …");
try {
await api("/api/image", { method: "PUT", body: JSON.stringify({ url: adresse }) });
linkAuf = false;
await widgetsLaden("bild");
melden("");
} catch (e) {
melden(e.message);
}
formNeu();
}
function melden(s) {
stand = s;
const el = document.querySelector(`#${FORM_ID} .w-stand`);
if (el) el.textContent = s;
}
/** Neu zeichnen — stabile Widgets lässt der Rahmen sonst in Ruhe. */
function neu() {
document.querySelectorAll('[data-widget="bild"]').forEach((el) => delete el.dataset.zustand);
widgetsZeichnen();
}
+546
View File
@@ -0,0 +1,546 @@
import { api, escapeHtml } from "../core.js";
import { widgetAnmelden, widgetsZeichnen } from "./rahmen.js";
/* ── Die Notiz ─────────────────────────────────────────────────────────────
*
* Ein Text, mehr nicht. **InkNote gibt es schon** — die Notiz darf keine zweite
* Notiz-App werden: kein Titel, keine Liste von Notizen, keine Ordner, keine
* Suche. Genau ein Text, den man im Vorbeigehen benutzt: Einkaufszettel, eine
* Nummer, ein Gedanke. Alles, was länger als einen Tag lebt, gehört nach
* InkNote.
*
* SOFORT-ANSICHT — warum zeilenweise?
*
* Vorher gab es zwei Ansichten: gelesen gesetzt, geschrieben roh. Wer schrieb,
* sah `- [ ] Milch` und musste sich merken, wie das gleich aussehen wird. Das
* ist die Verwirrung, die hier weg soll.
*
* Der Text ist deshalb **immer** gesetzt zu sehen; roh ist nur die eine Zeile,
* in der die Schreibmarke steht. Man tippt also in einer Aufzählung, sieht aber
* ringsum schon Überschriften, Häkchen und Punkte.
*
* Der Weg dorthin wäre auch anders gegangen — ein `contenteditable`, das
* fertiges HTML entgegennimmt und daraus wieder Markdown macht. Das ist die
* Bauform, die in jedem Editor Ärger macht: Der Browser erzeugt beim Tippen
* eigenes HTML (`<div>`, `<span style>`, beim Einfügen ganze Fremdkörper), und
* aus dem muss man den Quelltext *zurückraten*. Ein Fehler dabei kostet Text.
*
* Hier bleibt der Quelltext die Wahrheit und wird nie zurückgerechnet: Die
* Anzeige entsteht aus ihm, und geändert wird immer nur genau eine Zeile.
* Deshalb kann hier nichts verlorengehen, was man nicht selbst gelöscht hat.
*
* Für größere Umbauten (alles markieren, Absätze verschieben) gibt es den
* Knopf „quelltext": dann doch das ganze Feld, roh.
*/
const LEER = "Noch nichts notiert.";
const VERZUG = 800; // ms bis zum Speichern beim Tippen
let text = "";
let zeile = -1; // welche Zeile gerade roh bearbeitet wird, -1 = keine
let marke = 0; // wohin die Schreibmarke nach dem Neuzeichnen gehört
let roh = false; // ganzes Feld roh (der Umweg für größere Umbauten)
let stand = ""; // "speichert …" | "gespeichert" | Fehlertext
let uhr = null;
const zeilen = () => text.split("\n");
const setzen = (arr) => {
text = arr.join("\n");
};
widgetAnmelden({
id: "notiz",
titel: "notiz",
// Die Notiz hält ein Textfeld: Ihr Inhalt darf nicht bei jedem Neuzeichnen
// ersetzt werden, sonst verliert man beim Tippen Text und Schreibmarke.
stabil: true,
async laden() {
const d = await api("/api/note");
text = (d && d.text) || "";
},
inhalt() {
if (roh) {
return `
<textarea class="notiz-feld" spellcheck="false"
placeholder="# Einkauf&#10;- [ ] Milch&#10;- [x] Brot">${escapeHtml(text)}</textarea>
${fuss("fertig")}`;
}
const koerper = text.trim() || zeile >= 0
? `<div class="notiz-text">${markdown(text, zeile)}</div>`
: `<div class="notiz-text notiz-leer"><p class="w-zustand">${LEER}</p></div>`;
// Die Werkzeuge nur beim Schreiben: Sie wirken auf die Zeile, in der die
// Schreibmarke steht — ohne eine solche Zeile stünden sechs Knöpfe da, die
// nichts anfassen können. Und eine Notiz, die man nur liest, soll wie ein
// Text aussehen und nicht wie ein Formular.
return `${zeile >= 0 ? werkzeuge() : ""}${koerper}${fuss("quelltext")}`;
},
montiert(el) {
el.querySelectorAll("[data-tat]").forEach((b) => {
// Nicht auf „click": Ein Mausklick nimmt dem Textfeld vorher den Fokus,
// und mit ihm wüsste man nicht mehr, für welche Zeile der Knopf gilt.
b.addEventListener("mousedown", (e) => {
e.preventDefault();
tat(b.dataset.tat);
});
});
if (roh) {
const feld = el.querySelector(".notiz-feld");
feld.focus();
feld.setSelectionRange(feld.value.length, feld.value.length);
feld.addEventListener("input", () => {
text = feld.value;
spaeterSpeichern();
});
return;
}
const eingabe = el.querySelector(".notiz-zeile");
if (eingabe) {
eingabe.focus();
eingabe.setSelectionRange(marke, marke);
wachsen(eingabe);
eingabe.addEventListener("input", () => {
const z = zeilen();
z[zeile] = eingabe.value;
setzen(z);
wachsen(eingabe);
spaeterSpeichern();
});
eingabe.addEventListener("keydown", (e) => taste(e, eingabe));
}
// Anklicken heißt: hier weiterschreiben.
el.querySelectorAll("[data-z]").forEach((b) =>
b.addEventListener("mousedown", (e) => {
if (e.target.closest(".notiz-zeile, .notiz-hakbox")) return;
e.preventDefault();
const i = Number(b.dataset.z);
springen(i, zeilen()[i].length);
})
);
el.querySelector(".notiz-leer")?.addEventListener("mousedown", (e) => {
e.preventDefault();
springen(0, 0);
});
// Häkchen lassen sich setzen, ohne in den Text zu springen — das ist der
// häufigste Griff an einer Liste.
el.querySelectorAll(".notiz-hakbox").forEach((box) =>
box.addEventListener("change", () => {
text = hakenUmlegen(text, Number(box.dataset.hak));
neu();
jetztSpeichern();
})
);
},
});
/* ── Die kleine Werkzeugleiste ─────────────────────────────────────────────
* Sechs Knöpfe, keine Auswahlfelder. Alles, was das kleine Markdown kann, und
* nichts darüber hinaus — eine Leiste mit Knöpfen für Dinge, die die Notiz gar
* nicht kennt, wäre schlimmer als keine.
*
* Sie erscheint erst, wenn man in einer Zeile steht, und verschwindet wieder,
* sobald man fertig ist.
*/
const KNOEPFE = [
["haken", "☐", "Aufgabe"],
["punkt", "•", "Aufzählung"],
["nummer", "1.", "Nummerierte Liste"],
["ueber", "H", "Überschrift"],
["fett", "B", "Fett"],
["linie", "—", "Trennlinie"],
];
function werkzeuge() {
return (
`<div class="notiz-werkzeuge">` +
KNOEPFE.map(
([tat, zeichen, titel]) =>
`<button type="button" class="notiz-wz nw-${tat}" data-tat="${tat}" title="${titel}">${zeichen}</button>`
).join("") +
`</div>`
);
}
function fuss(knopf) {
return `
<div class="notiz-fuss">
<span class="notiz-stand">${escapeHtml(stand)}</span>
<button type="button" class="btn notiz-modus" data-tat="modus">${knopf}</button>
</div>`;
}
/** Was die Knöpfe tun. Alle arbeiten auf der Zeile, in der man gerade steht. */
function tat(name) {
if (name === "modus") {
roh = !roh;
zeile = -1;
jetztSpeichern();
neu();
return;
}
// Ohne Schreibmarke gibt es keine „aktuelle Zeile" — dann eine neue am Ende.
if (zeile < 0) {
const z = zeilen();
if (z[z.length - 1].trim()) z.push("");
setzen(z);
zeile = z.length - 1;
marke = 0;
}
const z = zeilen();
if (name === "linie") {
z.splice(zeile + (z[zeile].trim() ? 1 : 0), 0, "---", "");
setzen(z);
zeile += z[zeile].trim() ? 2 : 1;
marke = 0;
neu();
jetztSpeichern();
return;
}
if (name === "fett") {
const feld = document.querySelector(".notiz-zeile");
const a = feld ? feld.selectionStart : z[zeile].length;
const b = feld ? feld.selectionEnd : a;
const wort = z[zeile].slice(a, b);
z[zeile] = z[zeile].slice(0, a) + "**" + wort + "**" + z[zeile].slice(b);
marke = wort ? b + 4 : a + 2; // ohne Auswahl: zwischen die Sterne
setzen(z);
neu();
jetztSpeichern();
return;
}
const VORSATZ = { haken: "- [ ] ", punkt: "- ", nummer: "1. ", ueber: "# " };
const alt = vorsatz(z[zeile]);
const rest = z[zeile].slice(alt.length);
const neuerVorsatz = alt === VORSATZ[name] ? "" : VORSATZ[name];
z[zeile] = neuerVorsatz + rest;
marke = Math.max(0, marke - alt.length) + neuerVorsatz.length;
setzen(z);
neu();
jetztSpeichern();
}
/** Der Listen- oder Überschriften-Vorsatz einer Zeile, samt Leerzeichen. */
function vorsatz(z) {
const m = /^(\s*(?:[-*]\s+\[[ xX]\]\s+|[-*]\s+|\d+[.)]\s+|#{1,3}\s+))/.exec(z);
return m ? m[1] : "";
}
/* ── Tastatur in der einen rohen Zeile ─────────────────────────────────── */
function taste(e, feld) {
const i = zeile;
const z = zeilen();
const anfang = feld.selectionStart === 0 && feld.selectionEnd === 0;
const ende = feld.selectionStart === feld.value.length && feld.selectionEnd === feld.value.length;
if (e.key === "Enter") {
e.preventDefault();
const links = feld.value.slice(0, feld.selectionStart);
const rechts = feld.value.slice(feld.selectionEnd);
const v = vorsatz(links);
// Enter auf einer leeren Aufzählungszeile beendet die Liste, statt einen
// weiteren leeren Punkt anzuhängen.
if (v && !links.slice(v.length).trim() && !rechts) {
z[i] = "";
setzen(z);
springen(i, 0);
return;
}
z.splice(i, 1, links, weiterfuehren(v) + rechts);
setzen(z);
springen(i + 1, weiterfuehren(v).length);
return;
}
if (e.key === "Backspace" && anfang) {
const v = vorsatz(feld.value);
if (v) {
// Erst den Vorsatz weg, dann erst die Zeile: „Backspace am Zeilenanfang"
// heißt meistens „das ist doch kein Aufzählungspunkt".
e.preventDefault();
z[i] = feld.value.slice(v.length);
setzen(z);
springen(i, 0);
return;
}
if (i > 0) {
e.preventDefault();
const stelle = z[i - 1].length;
z.splice(i - 1, 2, z[i - 1] + z[i]);
setzen(z);
springen(i - 1, stelle);
}
return;
}
if (e.key === "Delete" && ende && i < z.length - 1) {
e.preventDefault();
const stelle = z[i].length;
z.splice(i, 2, z[i] + z[i + 1]);
setzen(z);
springen(i, stelle);
return;
}
if (e.key === "ArrowUp" && anfang && i > 0) {
e.preventDefault();
springen(i - 1, z[i - 1].length);
return;
}
if (e.key === "ArrowDown" && ende && i < z.length - 1) {
e.preventDefault();
springen(i + 1, 0);
return;
}
if (e.key === "Escape") {
e.preventDefault();
beenden();
}
}
/** Was nach Enter am Anfang der neuen Zeile steht. */
function weiterfuehren(v) {
if (!v || v.trim().startsWith("#")) return "";
const haken = /^(\s*[-*]\s+)\[[ xX]\]\s+$/.exec(v);
if (haken) return haken[1] + "[ ] "; // eine neue Aufgabe ist offen
const nummer = /^(\s*)(\d+)([.)]\s+)$/.exec(v);
if (nummer) return nummer[1] + (Number(nummer[2]) + 1) + nummer[3];
return v;
}
function springen(i, stelle) {
zeile = i;
marke = stelle;
neu();
}
function beenden() {
zeile = -1;
neu();
jetztSpeichern();
}
// Ein Klick irgendwo anders hin heißt: fertig mit dieser Zeile. Nicht über
// „blur" — den löst auch jeder Knopf in der Werkzeugleiste aus.
//
// In der **Erfassungsphase**, und das ist hier keine Feinheit: Ein Klick in die
// Notiz zeichnet sie neu, und danach hängt das angeklickte Element nicht mehr
// im Dokument. `closest()` liefe dann ins Leere und dieser Zuhörer hielte einen
// Klick auf die Notiz für einen Klick nach draußen — er würde also genau das
// beenden, was der Klick gerade geöffnet hat.
document.addEventListener(
"mousedown",
(e) => {
if (zeile < 0 || e.target.closest('[data-widget="notiz"]')) return;
beenden();
},
true
);
/** Neu zeichnen — der Rahmen lässt stabile Widgets sonst in Ruhe. */
function neu() {
document.querySelectorAll('[data-widget="notiz"]').forEach((el) => delete el.dataset.zustand);
widgetsZeichnen();
}
/** Ein Zeilenfeld ist so hoch wie sein Text — es soll aussehen wie die Zeile,
* die es ersetzt, nicht wie ein Formular. */
function wachsen(feld) {
feld.style.height = "auto";
feld.style.height = feld.scrollHeight + "px";
}
/* ── Speichern ─────────────────────────────────────────────────────────── */
function spaeterSpeichern() {
stand = "";
clearTimeout(uhr);
uhr = setTimeout(jetztSpeichern, VERZUG);
}
/**
* Speichert, wenn sich etwas geändert hat.
*
* Zwei offene Geräte überschreiben einander; der letzte Stand gewinnt. Für eine
* Notiz ist das vertretbar — man sollte es nur wissen und nicht später
* „Datenverlust" nennen.
*/
let gespeichert = null;
async function jetztSpeichern() {
clearTimeout(uhr);
if (text === gespeichert) return;
const wollte = text;
standSetzen("speichert …");
try {
await api("/api/note", { method: "PUT", body: JSON.stringify({ text: wollte }) });
gespeichert = wollte;
standSetzen("gespeichert");
} catch (e) {
standSetzen(e.message || "nicht gespeichert");
}
}
function standSetzen(s) {
stand = s;
document.querySelectorAll('[data-widget="notiz"] .notiz-stand').forEach((el) => {
el.textContent = s;
});
}
/* ── Das kleine Markdown ───────────────────────────────────────────────────
*
* Absichtlich von Hand und absichtlich klein: Überschriften, Häkchen, Striche,
* Aufzählungen, fett, kursiv, Code. Keine Bibliothek — HoneyOS hat keinen
* Bauschritt, und für sechs Regeln lohnt kein Nachladen.
*
* **Zuerst wird escapt, dann gesetzt.** Der Text ist zwar der eigene, aber eine
* Notiz, die `<script>` ausführt, wäre eine Falle für den Tag, an dem man etwas
* hineinkopiert.
*
* `bearbeitet` ist die Zeile, die roh gezeigt wird. Sie behält ihren Platz in
* der Liste, in der sie steht — sonst spränge der Punkt beim Hineinklicken aus
* der Aufzählung heraus.
*/
export function markdown(quelle, bearbeitet = -1) {
const zln = quelle.split("\n");
let html = "";
let liste = null; // "ul" | "ol" | null
// Die Häkchen werden über den **Quelltext** gezählt, nicht über das, was man
// sieht: Die gerade bearbeitete Zeile zeigt keines, ihre Nummer muss aber
// vergeben bleiben, sonst schaltet ein Klick darunter das falsche um.
const hakNr = {};
let n = -1;
zln.forEach((z, i) => {
if (HAKEN.test(z)) hakNr[i] = ++n;
});
const listeZu = () => {
if (liste) html += `</${liste}>`;
liste = null;
};
const listeAuf = (art) => {
if (liste !== art) {
listeZu();
html += `<${art}>`;
liste = art;
}
};
zln.forEach((rohzeile, i) => {
const z = rohzeile.trimEnd();
if (i === bearbeitet) {
const feld =
`<textarea class="notiz-zeile" rows="1" spellcheck="false" ` +
`placeholder="schreiben …">${escapeHtml(rohzeile)}</textarea>`;
const art = HAKEN.test(z) || PUNKT.test(z) ? "ul" : ZAHL.test(z) ? "ol" : null;
if (art) {
listeAuf(art);
html += `<li class="notiz-rohli">${feld}</li>`;
} else {
listeZu();
html += feld;
}
return;
}
if (!z.trim()) {
listeZu();
return;
}
const ueber = /^(#{1,3})\s+(.*)$/.exec(z);
if (ueber) {
listeZu();
const g = ueber[1].length + 2; // # → h3, ## → h4, ### → h5
html += `<h${g} data-z="${i}">${inline(ueber[2])}</h${g}>`;
return;
}
if (/^(-{3,}|\*{3,})$/.test(z.trim())) {
listeZu();
html += `<hr data-z="${i}" />`;
return;
}
const hak = HAKEN.exec(z);
if (hak) {
listeAuf("ul");
const an = hak[1].toLowerCase() === "x";
html +=
`<li class="notiz-hak${an ? " ab" : ""}" data-z="${i}">` +
`<label><input class="notiz-hakbox" type="checkbox" data-hak="${hakNr[i]}"${an ? " checked" : ""} />` +
`<span>${inline(hak[2])}</span></label></li>`;
return;
}
const punkt = PUNKT.exec(z);
if (punkt) {
listeAuf("ul");
html += `<li data-z="${i}">${inline(punkt[1])}</li>`;
return;
}
const zahl = ZAHL.exec(z);
if (zahl) {
listeAuf("ol");
html += `<li data-z="${i}">${inline(zahl[1])}</li>`;
return;
}
listeZu();
html += `<p data-z="${i}">${inline(z)}</p>`;
});
listeZu();
return html;
}
const HAKEN = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/;
const PUNKT = /^\s*[-*]\s+(.*)$/;
const ZAHL = /^\s*\d+[.)]\s+(.*)$/;
function inline(s) {
return escapeHtml(s)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, "$1<i>$2</i>");
}
/** Legt das n-te Häkchen im Quelltext um — gezählt wie beim Setzen. */
export function hakenUmlegen(quelle, n) {
let i = -1;
return quelle
.split("\n")
.map((z) => {
const hak = /^(\s*[-*]\s+\[)([ xX])(\]\s+.*)$/.exec(z);
if (!hak) return z;
i++;
if (i !== n) return z;
return hak[1] + (hak[2].toLowerCase() === "x" ? " " : "x") + hak[3];
})
.join("\n");
}
// Beim Verlassen der Seite noch schnell sichern — der Verzug beim Tippen darf
// keinen Satz kosten, nur weil der Tab zugeht.
//
// `keepalive` statt sendBeacon: Der Beacon kann nur POST und trägt keine
// eigenen Kopfzeilen — unser Weg ist ein PUT mit Prüfwort. Ein Beacon wäre
// hier also nicht schneller, sondern schlicht abgewiesen worden.
window.addEventListener("pagehide", () => {
if (text === gespeichert) return;
api("/api/note", { method: "PUT", body: JSON.stringify({ text }), keepalive: true }).catch(() => {});
});
+249
View File
@@ -0,0 +1,249 @@
/* ── Der Widget-Rahmen ─────────────────────────────────────────────────────
*
* Ein Widget ist hier eine **Beschreibung**, kein Stück HTML im Dokument.
* Vorher standen Benutzer und Wetter fest in index.html, jedes mit eigener
* Zeigen-und-Verstecken-Logik in seiner Datei — beim dritten Widget hätte man
* dieselben zwanzig Zeilen ein drittes Mal geschrieben.
*
* Der Rahmen kümmert sich um das, was jedes Widget braucht und keines für sich
* lösen sollte:
*
* · beide Betriebsarten — Fenster im Terminal, Schild auf dem Schreibtisch
* · die vier Zustände: lädt · fertig · nichts da · Fehler
* · Reihenfolge und Nachladen im Takt
*
* Ein Widget beschreibt sich so:
*
* {
* id, titel,
* wo: ["terminal", "desktop"] // wo es erscheinen darf
* sichtbar: () => true // Schalter aus → gar nicht erst da
* laden: async () => false|void // false = es gibt nichts zu zeigen
* leer: "verbergen" | "Text" // was dann passiert
* inhalt: () => "<html>"
* takt: 600000 // ms, optional
* }
*
* Absichtlich offen gelassen: eine dritte Form. Die Widgets sollen später auch
* als Aufklappfeld in der HoneyBar leben können — deshalb erzeugt `inhalt()`
* nur den Kern, und die Verpackung entsteht hier.
*/
import { $, saveSettings, state } from "../core.js";
const REGISTER = [];
/** id → "laedt" | "fertig" | "leer" | "fehler" */
const zustand = {};
const uhren = {};
/** Was schon einmal geladen wurde — ein Widget, das erst später eingeschaltet
* wird, holt seine Daten dann nach. */
const geladen = new Set();
export function widgetAnmelden(def) {
REGISTER.push(def);
zustand[def.id] = def.laden ? "laedt" : "fertig";
}
/** Nur zum Nachsehen von außen — die Liste selbst gehört dem Rahmen. */
export function widgetListe() {
return REGISTER.map((w) => w.id);
}
/**
* Legt die Reihenfolge fest. Was nicht genannt ist, hängt sich hinten an.
*
* Warum überhaupt? Weil die Reihenfolge sonst davon abhinge, in welcher
* Reihenfolge die Module ausgewertet werden — und das ist **nicht** die
* Reihenfolge der Importzeilen: `glyphs.js` lädt seine Raster mit `await` auf
* oberster Ebene, also warten alle Module, die es brauchen, während die
* anderen schon durchlaufen. Der Systemzustand braucht keine Symbole und stand
* deshalb vor dem Benutzer, obwohl er darunter importiert wird.
*
* Hier ist die Reihenfolge damit eine Angabe statt einer Nebenwirkung. Sie ist
* die **Grundstellung**: Was der Nutzer selbst sortiert hat, steht in seinen
* Einstellungen und schlägt sie (siehe widgetPlan).
*/
export function widgetsOrdnen(ids) {
const platz = (w) => {
const i = ids.indexOf(w.id);
return i < 0 ? ids.length : i;
};
REGISTER.sort((a, b) => platz(a) - platz(b));
}
/* ── Was der Nutzer eingestellt hat ────────────────────────────────────────
*
* Reihenfolge und An/Aus stehen in **einer** Einstellung, als Liste von IDs mit
* einem Minus vor dem, was aus ist:
*
* widgets = "benutzer,system,-wetter,notiz,bild"
*
* Ein Feld statt zwei, weil beides zusammen gehört: Wer ein Widget ausschaltet,
* soll seinen Platz behalten, wenn er es wieder einschaltet. Und weil die
* Einstellungen ohnehin schon je Nutzer auf dem Server liegen, braucht es dafür
* weder eine neue Tabelle noch einen neuen Endpunkt.
*
* Die gespeicherte Liste ist ein Wunsch, kein Gesetz: Unbekannte IDs fallen
* weg (ein Widget kann verschwinden), neue hängen sich hinten an und sind an.
*/
const AUS = "-";
/** Die Widgets in der Reihenfolge des Nutzers: [{id, titel, wo, an}]. */
export function widgetPlan() {
const aus = new Set();
const reihe = [];
String(state.settings.widgets || "")
.split(",")
.map((e) => e.trim())
.filter(Boolean)
.forEach((e) => {
const id = e.startsWith(AUS) ? e.slice(1) : e;
if (e.startsWith(AUS)) aus.add(id);
if (!reihe.includes(id) && REGISTER.some((w) => w.id === id)) reihe.push(id);
});
REGISTER.forEach((w) => reihe.includes(w.id) || reihe.push(w.id));
return reihe.map((id) => {
const w = REGISTER.find((x) => x.id === id);
return { id, titel: w.titel, wo: w.wo, an: !aus.has(id) };
});
}
/** Übernimmt einen geänderten Plan: speichern, nachladen, neu zeichnen. */
export function widgetPlanSetzen(plan) {
state.settings.widgets = plan.map((p) => (p.an ? "" : AUS) + p.id).join(",");
saveSettings();
widgetsZeichnen();
// Ein gerade erst eingeschaltetes Widget hat seine Daten noch nie geholt.
widgetsLaden();
}
/**
* Lädt ein Widget (oder alle) und zeichnet neu.
*
* Fehler bleiben hier: Ein Widget, das seinen Server nicht erreicht, sagt das
* in seinem eigenen Rahmen — es darf weder den Schreibtisch aufhalten noch die
* anderen Widgets mitreißen.
*/
export async function widgetsLaden(nurId) {
const an = new Set(widgetPlan().filter((p) => p.an).map((p) => p.id));
const dran = REGISTER.filter((w) => {
if (!w.laden) return false;
if (nurId) return w.id === nurId;
return an.has(w.id) && !geladen.has(w.id);
});
await Promise.all(
dran.map(async (w) => {
geladen.add(w.id);
zustand[w.id] = "laedt";
widgetsZeichnen();
try {
zustand[w.id] = (await w.laden()) === false ? "leer" : "fertig";
} catch {
zustand[w.id] = "fehler";
}
})
);
widgetsZeichnen();
}
/** Startet die Nachlade-Takte. Einmal beim Aufbau des Schreibtischs. */
export function widgetsStarten() {
REGISTER.forEach((w) => {
if (!w.takt || uhren[w.id]) return;
uhren[w.id] = setInterval(() => {
if (widgetPlan().find((p) => p.id === w.id)?.an) widgetsLaden(w.id);
}, w.takt);
});
}
function zustandHtml(w) {
switch (zustand[w.id]) {
case "laedt":
return `<p class="w-zustand">lädt …</p>`;
case "fehler":
return `<p class="w-zustand w-fehler">nicht erreichbar</p>`;
case "leer":
return `<p class="w-zustand">${w.leer}</p>`;
default:
return w.inhalt();
}
}
/**
* Zeichnet alle Widgets in die Ansicht, die gerade gilt.
*
* Jedes Widget bekommt seinen eigenen Knoten, der stehen bleibt, solange es
* gebraucht wird. Der erste Entwurf hat beide Behälter bei jedem Durchgang neu
* gefüllt — kürzer, aber falsch, sobald ein Widget etwas im DOM hält: Der
* Zettel wird alle zwei Minuten mitgezeichnet (die Apps werden dann angeklopft),
* und wer gerade tippte, verlor Text und Schreibmarke.
*
* Deshalb zwei Zusagen an die Widgets:
* · `stabil: true` — dein Inhalt wird nicht neu gesetzt, solange sich dein
* Zustand nicht ändert. Du führst ihn selbst nach.
* · `montiert(el)` — wird nach jedem Neusetzen gerufen, zum Verdrahten.
*/
function baue(behaelter, dran, alsFenster) {
const alt = new Map([...behaelter.children].map((el) => [el.dataset.widget, el]));
dran.forEach((w) => {
let el = alt.get(w.id);
alt.delete(w.id);
if (!el) {
el = document.createElement(alsFenster ? "section" : "div");
el.dataset.widget = w.id;
el.className = alsFenster ? "win win-widget" : "deskwidget";
if (alsFenster) {
el.innerHTML =
`<div class="win-bar"><span class="win-title">${w.titel}</span>` +
`<span class="win-dots">&#9679;&#9679;&#9679;</span></div><div class="win-body"></div>`;
}
behaelter.appendChild(el);
}
const koerper = alsFenster ? el.querySelector(".win-body") : el;
const frisch = el.dataset.zustand !== zustand[w.id];
if (frisch || !w.stabil) {
koerper.innerHTML = zustandHtml(w);
el.dataset.zustand = zustand[w.id];
if (w.montiert && zustand[w.id] === "fertig") w.montiert(koerper);
}
});
alt.forEach((el) => el.remove());
// Reihenfolge nur anfassen, wenn sie nicht stimmt: Einen Knoten zu verschieben
// heißt, ihn kurz aus dem Dokument zu nehmen — ein Textfeld verliert dabei
// die Schreibmarke.
const soll = dran.map((w) => w.id).join(",");
const ist = [...behaelter.children].map((el) => el.dataset.widget).join(",");
if (soll !== ist) {
dran.forEach((w) => behaelter.appendChild(behaelter.querySelector(`[data-widget="${w.id}"]`)));
}
}
/**
* Wählt aus, was gerade dran ist, und lässt es zeichnen.
*/
export function widgetsZeichnen() {
const desktop = state.settings.mode === "desktop";
const modus = desktop ? "desktop" : "terminal";
const spalte = $("widgetSpalte");
const schilder = $("deskwidgets");
if (!spalte || !schilder) return;
const dran = widgetPlan()
.filter((p) => p.an)
.map((p) => REGISTER.find((w) => w.id === p.id))
.filter((w) => {
if (w.wo && !w.wo.includes(modus)) return false;
if (w.sichtbar && !w.sichtbar()) return false;
// „nichts da" heißt bei manchen Widgets: gar nicht erst hinstellen. Eine
// leere Wetterkachel wäre schlechter als keine.
return !(zustand[w.id] === "leer" && w.leer === "verbergen");
});
baue(spalte, desktop ? [] : dran, true);
baue(schilder, desktop ? dran : [], false);
}
+35
View File
@@ -0,0 +1,35 @@
import { escapeHtml, state } from "../core.js";
import { BIENE_ASCII } from "../biene.js";
import { widgetAnmelden } from "./rahmen.js";
/* ── Systemzustand ─────────────────────────────────────────────────────────
*
* Nur im Terminal-Modus ein Widget. Auf dem Schreibtisch gibt es dieselben
* Zahlen als Fenster über das Startmenü — ein Schild, das dauerhaft „modus:
* desktop" behauptet, während man im Desktop-Modus steht, sagt niemandem etwas.
*
* Die Biene steht hier **aus Schriftzeichen**, nicht als Bild: Das Fenster
* besteht aus lauter Textzeilen, und ein Bild darin wäre ein Fremdkörper — wie
* ein Foto in einer Tabelle. Als Text gehört sie dazu, und das Ganze wird zu
* dem, was man bei so einem Fenster ohnehin im Kopf hat: einem neofetch.
*/
export function statsHtml() {
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}`],
];
return `<dl class="statlist">${rows
.map(([k, v]) => `<dt>${k}</dt><dd>${escapeHtml(v)}</dd>`)
.join("")}</dl>`;
}
widgetAnmelden({
id: "system",
titel: "system",
wo: ["terminal"],
inhalt: () => `<pre class="biene-text">${BIENE_ASCII}</pre>${statsHtml()}`,
});
+12 -17
View File
@@ -1,5 +1,6 @@
import { $, escapeAttr, state } from "../core.js"; import { escapeAttr, escapeHtml, state } from "../core.js";
import { glyphSvg } from "../glyphs.js"; import { glyphSvg } from "../glyphs.js";
import { widgetAnmelden } from "./rahmen.js";
function greeting() { function greeting() {
const h = new Date().getHours(); const h = new Date().getHours();
@@ -15,8 +16,13 @@ function greeting() {
* die HoneyBar in den Apps nicht hat — beide sollen aber gleich aussehen, sonst * die HoneyBar in den Apps nicht hat — beide sollen aber gleich aussehen, sonst
* merkt man beim Wechsel, dass es zwei verschiedene HoneyBars sind. Auf dem * merkt man beim Wechsel, dass es zwei verschiedene HoneyBars sind. Auf dem
* Schreibtisch ist ohnehin Platz dafür, und die Begrüßung gehört dazu. * Schreibtisch ist ohnehin Platz dafür, und die Begrüßung gehört dazu.
*
* Kein `laden`: Der Name steht schon im Zustand, seit die Seite ihn geholt hat.
*/ */
function userHtml() { widgetAnmelden({
id: "benutzer",
titel: "angemeldet",
inhalt() {
const bild = state.me && state.me.picture const bild = state.me && state.me.picture
? `<img class="uw-bild" src="${escapeAttr(state.me.picture)}" alt="" />` ? `<img class="uw-bild" src="${escapeAttr(state.me.picture)}" alt="" />`
: `<span class="uw-bild uw-leer">${glyphSvg("chip")}</span>`; : `<span class="uw-bild uw-leer">${glyphSvg("chip")}</span>`;
@@ -24,21 +30,10 @@ function userHtml() {
<div class="uw"> <div class="uw">
${bild} ${bild}
<div class="uw-text"> <div class="uw-text">
<b class="uw-name">${state.cfg.user}</b> <b class="uw-name">${escapeHtml(state.cfg.user)}</b>
<span class="uw-gruss">${greeting()}</span> <span class="uw-gruss">${greeting()}</span>
<span class="uw-host">${state.cfg.user}@${state.cfg.hostname}</span> <span class="uw-host">${escapeHtml(state.cfg.user)}@${escapeHtml(state.cfg.hostname)}</span>
</div> </div>
</div>`; </div>`;
} },
});
export function renderUser() {
const tile = $("userTile");
const desk = $("userDesk");
const desktopMode = state.settings.mode === "desktop";
const html = userHtml();
tile.hidden = desktopMode;
desk.hidden = !desktopMode;
tile.querySelector(".user-body").innerHTML = html;
desk.innerHTML = html;
}
+43 -27
View File
@@ -1,5 +1,6 @@
import { $, api, state } from "../core.js"; import { api, $, state } from "../core.js";
import { glyphSvg } from "../glyphs.js"; import { glyphSvg } from "../glyphs.js";
import { widgetAnmelden, widgetsLaden } from "./rahmen.js";
/* ── Wetter ──────────────────────────────────────────────────────────────── /* ── Wetter ────────────────────────────────────────────────────────────────
* *
@@ -7,21 +8,36 @@ import { glyphSvg } from "../glyphs.js";
* was er hergibt. Kein Ort eingestellt oder Dienst nicht erreichbar: 204, und * was er hergibt. Kein Ort eingestellt oder Dienst nicht erreichbar: 204, und
* das Widget bleibt weg. Eine Zahl von gestern wäre schlechter als keine. * das Widget bleibt weg. Eine Zahl von gestern wäre schlechter als keine.
*/ */
export async function loadWeather() { widgetAnmelden({
id: "wetter",
titel: "wetter",
leer: "verbergen",
takt: 10 * 60 * 1000, // derselbe Takt, in dem der Server seinen Speicher erneuert
async laden() {
if (!state.settings.weatherCity) { if (!state.settings.weatherCity) {
state.weather = null; state.weather = null;
renderWeather(); return false;
return;
} }
try { try {
state.weather = await api("/api/weather"); state.weather = await api("/api/weather");
} catch { } catch (e) {
// Auch im Fehlerfall den alten Stand wegwerfen: Das Widget zeigt dann
// „nicht erreichbar", und in der Leiste verschwindet das Symbol. Eine
// Temperatur von vorhin, die aussieht wie von jetzt, wäre schlechter.
state.weather = null; state.weather = null;
throw e;
} }
renderWeather(); // 204: kein Ort, kein Dienst — der Aufrufhelfer gibt dann nichts zurück.
if (!state.weather || !state.weather.glyph) {
state.weather = null;
return false;
} }
return true;
},
function weatherHtml(w) { inhalt() {
const w = state.weather;
return ` return `
<div class="wx"> <div class="wx">
<span class="wx-glyph">${glyphSvg(w.glyph)}</span> <span class="wx-glyph">${glyphSvg(w.glyph)}</span>
@@ -31,28 +47,22 @@ function weatherHtml(w) {
<span class="wx-city">${w.ort}</span> <span class="wx-city">${w.ort}</span>
</div> </div>
</div>`; </div>`;
} },
});
export function renderWeather() { /**
const w = state.weather; * Und in der HoneyBar. Die Menüleiste hier ist zwar anderer Code als
const tile = $("wxTile"); * honeybar.js in den Apps, aber für den, der davorsitzt, dieselbe HoneyBar —
const desk = $("wxDesk"); * ein Schalter, der nur woanders wirkt, wäre nicht zu erklären.
*
* Sie hängt am selben geladenen Stand, hat aber ihren eigenen Schalter und
* gehört nicht in den Widget-Rahmen: Sie ist kein Widget, sondern ein Teil der
* Leiste.
*/
export function renderWeatherBar() {
const bar = $("mbWx"); const bar = $("mbWx");
const desktopMode = state.settings.mode === "desktop"; if (!bar) return;
const w = state.weather;
// Das Widget: Fenster im Terminal-Modus, Schild auf dem Schreibtisch.
const alsWidget = state.settings.weatherDesk === "on" && w;
tile.hidden = !alsWidget || desktopMode;
desk.hidden = !alsWidget || !desktopMode;
if (alsWidget) {
const html = weatherHtml(w);
tile.querySelector(".wx-body").innerHTML = html;
desk.innerHTML = html;
}
// Und in der HoneyBar. Die Menüleiste hier ist zwar anderer Code als
// honeybar.js in den Apps, aber für den, der davorsitzt, dieselbe HoneyBar —
// ein Schalter, der nur woanders wirkt, wäre nicht zu erklären.
const inBar = state.settings.weatherBar === "on" && w; const inBar = state.settings.weatherBar === "on" && w;
bar.hidden = !inBar; bar.hidden = !inBar;
if (inBar) { if (inBar) {
@@ -60,3 +70,9 @@ export function renderWeather() {
bar.title = `${w.ort}: ${w.text}, ${Math.round(w.grad)}°`; bar.title = `${w.ort}: ${w.text}, ${Math.round(w.grad)}°`;
} }
} }
/** Beides zusammen: Widget und Leiste hängen am selben Abruf. */
export async function loadWeather() {
await widgetsLaden("wetter");
renderWeatherBar();
}
+140
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"testing" "testing"
"honeyos/internal/auth"
"honeyos/internal/store" "honeyos/internal/store"
"honeyos/internal/weather" "honeyos/internal/weather"
) )
@@ -46,6 +47,49 @@ func TestMitOrtKommtDasWetter(t *testing.T) {
} }
} }
// Ist ein Ort eingestellt und der Dienst fällt aus, ist das etwas anderes als
// „nichts eingestellt": Es gibt etwas zu zeigen, es kommt nur gerade nicht. Die
// Oberfläche unterscheidet das — 204 verschwindet still, 502 sagt „nicht
// erreichbar". Beides als 204 zu melden hiess: Ein Ausfall sah aus wie ein
// abgeschaltetes Widget.
func TestAusfallIstNichtDasselbeWieNichts(t *testing.T) {
s := newTestServer(t, "")
s.wetter = weather.KaputtFuerTest(t)
if err := s.store.SetSettings(store.LocalSub, map[string]string{
"weatherCity": "Köln", "weatherLat": "50.938", "weatherLon": "6.96",
}); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/api/weather", nil))
if rec.Code != http.StatusBadGateway {
t.Fatalf("bei Ausfall erwartet 502, bekam %d", rec.Code)
}
}
// Der Streifen in der Leiste heisst .apps und blendet seine rechten 18 Pixel
// mit einer Maske aus. Bekommt ein zweites Element dieselbe Klasse, erbt es die
// Maske — die Schublade sah rechts aus, als loese sie sich auf, und in der
// Geometrie war nichts zu sehen. Deshalb hier festgenagelt.
func TestNurDerStreifenHeisstApps(t *testing.T) {
b, err := os.ReadFile("web/honeybar/honeybar.js")
if err != nil {
t.Fatal(err)
}
src := string(b)
for _, verboten := range []string{`class="menu apps"`, `class="apps menu"`} {
if strings.Contains(src, verboten) {
t.Errorf("%s: Das Menue erbt damit die Ausblendmaske des App-Streifens", verboten)
}
}
// Genau ein Element darf die Klasse tragen: der Streifen selbst.
if n := strings.Count(src, `class="apps"`); n != 1 {
t.Errorf(`class="apps" kommt %dx vor, erwartet genau einmal (der Streifen)`, n)
}
}
// Die HoneyBar bringt ihr CSS und ihr Markup in Vorlagenzeichenketten mit. // Die HoneyBar bringt ihr CSS und ihr Markup in Vorlagenzeichenketten mit.
// Ein Backtick in einem Kommentar beendet die Zeichenkette mittendrin — die // Ein Backtick in einem Kommentar beendet die Zeichenkette mittendrin — die
// Datei ist dann kaputt, und zwar nur in der HoneyBar, nicht auf dem // Datei ist dann kaputt, und zwar nur in der HoneyBar, nicht auf dem
@@ -85,3 +129,99 @@ func TestHoneyBarOhneBacktickInKommentaren(t *testing.T) {
} }
} }
} }
// Die Notiz ist eine Notiz, kein Dokument: Die Grenze muss der Server ziehen,
// nicht das Formular — sonst schreibt sie jeder Aufruf aus, der das Formular
// umgeht.
func TestNotizHatEineGrenze(t *testing.T) {
s := newTestServer(t, "")
h := s.routes()
put := func(text string) int {
body, _ := json.Marshal(map[string]string{"text": text})
req := httptest.NewRequest("PUT", "/api/note", strings.NewReader(string(body)))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code
}
if code := put("# Einkauf\n- [ ] Milch"); code != http.StatusNoContent {
t.Fatalf("kurze Notiz: %d, erwartet 204", code)
}
if code := put(strings.Repeat("x", 4001)); code != http.StatusBadRequest {
t.Errorf("zu lange Notiz: %d, erwartet 400", code)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/note", nil))
var out map[string]string
json.Unmarshal(rec.Body.Bytes(), &out)
if out["text"] != "# Einkauf\n- [ ] Milch" {
t.Errorf("zurück kam %q — die zu lange Notiz darf die gute nicht überschrieben haben", out["text"])
}
}
// Fremde Notizen gehen niemanden etwas an: Der Schlüssel für die App-Liste darf
// sie nicht öffnen, auch nicht lesend.
func TestNotizNichtUeberDenSchluessel(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
s := newTestServer(t, idp.URL)
s.cfg.AppsKey = "geheim123"
s.auth = auth.New(s.cfg)
req := httptest.NewRequest("GET", "/api/note", nil)
req.Header.Set("X-HoneyOS-Key", "geheim123")
rec := httptest.NewRecorder()
s.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("mit Schlüssel: %d, erwartet 401", rec.Code)
}
}
// Das Wappen steht an drei Stellen: als Datei (Favicon), als Modul für den
// Schreibtisch und als Zeile in der HoneyBar — die kann keine Datei nachladen,
// weil sie in fremden Apps läuft und deren CSP das entscheidet.
//
// Drei Kopien laufen auseinander, sobald jemand eine davon von Hand anfasst.
// Erzeugt werden alle von tools/biene.py; dieser Test hält sie zusammen.
func TestWappenIstUeberallDasselbe(t *testing.T) {
roh, err := os.ReadFile("web/img/biene.svg")
if err != nil {
t.Fatalf("web/img/biene.svg fehlt: %v", err)
}
wappen := strings.TrimSpace(string(roh))
if !strings.HasPrefix(wappen, "<svg") || len(wappen) < 500 {
t.Fatalf("web/img/biene.svg sieht nicht nach dem Wappen aus (%d Zeichen)", len(wappen))
}
for _, datei := range []string{"web/honeybar/honeybar.js", "web/js/biene.js"} {
b, err := os.ReadFile(datei)
if err != nil {
t.Fatalf("%s: %v", datei, err)
}
if !strings.Contains(string(b), wappen) {
t.Errorf("%s trägt ein anderes Wappen als web/img/biene.svg — tools/biene.py laufen lassen", datei)
}
}
}
// Die HoneyBar darf ihre Stile nicht als <style>-Element mitbringen: Markup
// faellt unter die CSP der fremden App, ueber die CSSOM gesetzte Stile nicht.
// Mit einem <style> muesste jede Gastgeber-App wieder 'unsafe-inline' erlauben,
// und ohne das stuende die HoneyBar dort schwarz auf schwarz — genau so war es
// bis zum 11.09.2026. Der Rueckfall fuer alte Browser baut das Element per
// createElement, nicht im Markup; deshalb sucht der Test nach dem Tag.
func TestHoneyBarBringtKeinStyleElementMit(t *testing.T) {
b, err := os.ReadFile("web/honeybar/honeybar.js")
if err != nil {
t.Fatal(err)
}
src := string(b)
if strings.Contains(src, "<style>") || strings.Contains(src, "<style ") {
t.Error("honeybar.js enthaelt ein <style>-Element im Markup — das braucht 'unsafe-inline' in der fremden App")
}
if !strings.Contains(src, "adoptedStyleSheets") {
t.Error("honeybar.js setzt keine adoptedStyleSheets mehr — wie kommen die Stile in den Shadow DOM?")
}
}