feat(honeybar): the HoneyOS bar, loaded at runtime

A strip along the top that leads to the other apps of the same system. It is
served by HoneyOS; InkNote only says where to find it, through
INKNOTE_HONEYBAR_ORIGIN. Empty means no bar and no change at all.

The origin is served by the API rather than built into the page, because an
image is pulled ready-made and a service worker keeps whole responses: anything
frozen into index.html would outlive the setting.

Layout follows the bar through --honeybar-h. The body has overflow:hidden, so
the surplus height would not have scrolled but been cut off at the bottom; and
#root padded itself around the notch that the bar now covers, which would have
counted that gap twice.

Unit 263/263, all fourteen browser suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-25 11:07:43 +02:00
1 parent 2ea448f091
commit 00b1d4469f
12 files changed
+144 -5

No files matched your search

+9
View File
@@ -24,6 +24,15 @@ INKNOTE_ADMIN_PASSWORD=
# ── Optional ── unset keeps the default shown
# INKNOTE_MAX_IMAGE_MB=25 # pasted images
# ── HoneyOS bar ───────────────────────────────────────────────────────────────
# The strip along the top that ties the apps together. Empty = no bar.
# Wirkt beim Neustart, nicht beim Bauen.
# INKNOTE_HONEYBAR_ORIGIN=https://honeyos.example.com
# Only needed when HoneyOS lives on a different site than InkNote — then the
# browser will not send its session and the bar needs this to fetch the app
# list. Same domain (notes.example.org + example.org): leave empty.
# INKNOTE_HONEYBAR_KEY=
# INKNOTE_MAX_PDF_MB=200 # imported PDFs, limited separately
# INKNOTE_UID=1000 # must own INKNOTE_DATA_DIR
# INKNOTE_GID=1000
+26
View File
@@ -14,6 +14,32 @@ a rebuild is not bit-identical.
---
## 2026-08-25 — The bar that ties the apps together
**Images** — web `sha256:7c802a96…`, server `sha256:a156fb92…`
**Added**
- **A strip along the top that leads to the other apps.** It is served by
HoneyOS, so what it looks like and which apps it lists are settled there —
InkNote only says where to find it. `INKNOTE_HONEYBAR_ORIGIN` in the `.env`
switches it on, empty leaves everything exactly as before.
- **`GET /api/public/config`** hands out that origin, and the key that goes with
it when HoneyOS lives on a different site. Runtime, not build time: an image
is pulled ready-made, so a value baked in at build time could never be changed
from the `.env` — and a service worker keeps whole responses, headers
included, so anything frozen into `index.html` outlives the setting in the
browser. Aether learned that one the hard way first.
**Changed**
- **The app now measures its height against the bar**, in `#root` and in the
four places that asked for the full window. With `overflow: hidden` on the
body the surplus would not have scrolled — it would have been cut off at the
bottom, silently. And `#root` padded itself around the phone's notch, which
the bar now covers, so that gap would have been counted twice. Without a bar
`--honeybar-h` is `0px` and every one of these is what it was.
---
## 2026-08-22 — A bigger face, and the theme where the settings are
**Images** — web `sha256:fe35b0e7…`, server `sha256:a557fb4d…`
+20
View File
@@ -878,6 +878,26 @@ app.get("/api/auth/methods", (req, res) => {
});
});
/* Where the HoneyOS bar lives, if it is wanted at all.
*
* Served at runtime rather than baked into the build for two reasons. The image
* is pulled ready-made, so a value fixed at build time could never be changed
* from the .env afterwards. And a service worker caches whole responses,
* headers included — anything frozen into index.html stays frozen in the
* browser long after the server has moved on.
*
* The key is only handed out together with an origin: without a bar it has
* nothing to unlock, and it should not sit in the page for no reason. It buys
* the list of apps and nothing else. */
app.get("/api/public/config", (req, res) => {
const origin = String(process.env.INKNOTE_HONEYBAR_ORIGIN || "").replace(/\/+$/, "");
res.json({
honeybar: origin
? { origin, key: String(process.env.INKNOTE_HONEYBAR_KEY || "") }
: null,
});
});
/* The browser is sent to the provider from here rather than from the app, so
* the app never has to know the issuer, the scopes or the redirect. */
app.get("/api/auth/oidc/start", async (req, res) => {
+5
View File
@@ -32,6 +32,11 @@ services:
# Optional tuning; unset means the server's own defaults apply.
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
- INKNOTE_MAX_IMAGE_MB=${INKNOTE_MAX_IMAGE_MB:-}
# HoneyOS bar. Empty origin means no bar at all; the key is only needed
# when HoneyOS sits on a different site, where the browser withholds its
# session. Takes effect on restart — nothing here is baked into the image.
- INKNOTE_HONEYBAR_ORIGIN=${INKNOTE_HONEYBAR_ORIGIN:-}
- INKNOTE_HONEYBAR_KEY=${INKNOTE_HONEYBAR_KEY:-}
- INKNOTE_MAX_PDF_MB=${INKNOTE_MAX_PDF_MB:-}
- MAX_VERSIONS_PER_NOTE=${MAX_VERSIONS_PER_NOTE:-}
- VERSION_RETENTION_DAYS=${VERSION_RETENTION_DAYS:-}
+5
View File
@@ -30,6 +30,11 @@ services:
# Optional tuning; unset means the server's own defaults apply.
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
- INKNOTE_MAX_IMAGE_MB=${INKNOTE_MAX_IMAGE_MB:-}
# HoneyOS bar. Empty origin means no bar at all; the key is only needed
# when HoneyOS sits on a different site, where the browser withholds its
# session. Takes effect on restart — nothing here is baked into the image.
- INKNOTE_HONEYBAR_ORIGIN=${INKNOTE_HONEYBAR_ORIGIN:-}
- INKNOTE_HONEYBAR_KEY=${INKNOTE_HONEYBAR_KEY:-}
- INKNOTE_MAX_PDF_MB=${INKNOTE_MAX_PDF_MB:-}
- MAX_VERSIONS_PER_NOTE=${MAX_VERSIONS_PER_NOTE:-}
- VERSION_RETENTION_DAYS=${VERSION_RETENTION_DAYS:-}
+14 -1
View File
@@ -38,7 +38,20 @@
</script>
<style>
body { margin: 0; padding: 0; overflow: hidden; overscroll-behavior: none; }
#root { padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); height: 100vh; }
/* --honeybar-h is set by the HoneyOS bar while it is on the page, and is
0 when it is not. The bar sits above the app and pushes it down, so the
app has that much less room; body has overflow:hidden, which means the
difference would not scroll but simply be cut off at the bottom.
It also covers the notch itself, so the padding above must give way —
otherwise the gap is counted twice. max() keeps that from going
negative when there is no bar. */
#root {
box-sizing: border-box;
padding: max(0px, calc(env(safe-area-inset-top) - var(--honeybar-h, 0px)))
env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
height: calc(100vh - var(--honeybar-h, 0px));
}
#splash { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px; background: #0d1117; z-index: 9999; transition: opacity 0.3s; }
#splash.hide { opacity: 0; pointer-events: none; }
#splash .logo { width: 64px; height: 64px; border-radius: 18px; background: linear-gradient(135deg, #388bfd, #a855f7); display: flex; align-items: center; justify-content: center; font-size: 32px; animation: pulse 1.5s infinite; }
+2 -2
View File
@@ -933,7 +933,7 @@ export default function App() {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
height: "calc(100vh - var(--honeybar-h, 0px))",
background: T.bg,
color: T.text,
fontFamily: FONT_UI,
@@ -1000,7 +1000,7 @@ export default function App() {
<div
style={{
display: "flex",
height: "100vh",
height: "calc(100vh - var(--honeybar-h, 0px))",
width: "100vw",
overflow: "hidden",
background: T.bg,
+37
View File
@@ -0,0 +1,37 @@
/**
* Load the HoneyOS bar, if this instance is configured to have one.
*
* The bar is a strip along the top of every app that belongs to the same
* system: it says where you are and takes you to the others. It lives on the
* HoneyOS server, so it stays in step everywhere without InkNote being rebuilt.
*
* Deliberately fire-and-forget. If HoneyOS is slow, down, or simply not
* configured, nothing here throws and nothing here waits — InkNote is not the
* kind of app that may fail to start because a decoration is missing. Offline
* there is no bar at all, which is the right trade for an offline-first app.
*/
export async function mountHoneybar() {
// The bar mounts itself on load; a second script tag would mean two bars.
if (document.querySelector("script[data-honeybar]")) return;
let origin, key;
try {
const r = await fetch("/api/public/config");
if (!r.ok) return;
const cfg = (await r.json()).honeybar;
if (!cfg?.origin) return;
({ origin, key } = cfg);
} catch {
return; // offline, or no server — either way, no bar
}
const el = document.createElement("script");
el.dataset.honeybar = "";
el.defer = true;
el.src = origin.replace(/\/+$/, "") + "/honeybar/honeybar.js";
// honeybar.js reads its settings from document.currentScript, which is set
// for a script inserted this way just as it is for one written in the HTML.
el.dataset.app = "inknote";
if (key) el.dataset.key = key;
document.head.appendChild(el);
}
+4
View File
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import NoteApp from "./App.jsx";
import { mountHoneybar } from "./honeybar.js";
// Hide splash screen
const hideSplash = () => {
@@ -19,6 +20,9 @@ ReactDOM.createRoot(document.getElementById("root")).render(
setTimeout(hideSplash, 200);
// Not awaited: the bar is a decoration and must never hold up the app.
mountHoneybar();
// Register service worker
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
+1 -1
View File
@@ -81,7 +81,7 @@ export function AuthScreen({ onLogin, dark, initialError = "" }) {
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
minHeight: "calc(100vh - var(--honeybar-h, 0px))",
background: T.bg,
fontFamily: "'DM Sans',sans-serif",
}}
+1 -1
View File
@@ -157,7 +157,7 @@ export function Sidebar({
zIndex: isMobile ? 900 : 1,
width: isMobile ? (open ? "100vw" : 0) : open ? 260 : 0,
minWidth: isMobile ? 0 : open ? 260 : 0,
height: isMobile ? "100vh" : "auto",
height: isMobile ? "calc(100vh - var(--honeybar-h, 0px))" : "auto",
background: T.sidebar,
borderRight: isMobile ? "none" : `1px solid ${T.border}`,
display: "flex",
+20
View File
@@ -169,6 +169,26 @@ The web UI listens on port `3000`, the API only inside the compose network. If a
reverse proxy sits in front, bind the port to localhost (`"127.0.0.1:3000:80"`),
otherwise the app is reachable on the host's address without TLS.
#### The HoneyOS bar
`INKNOTE_HONEYBAR_ORIGIN` puts a strip along the top of every page that leads to
the other apps of the same system — it is served by HoneyOS, so what it looks
like and which apps it lists are settled there, not here. Empty means no bar at
all, and nothing about the app changes.
The value takes effect on restart: the page asks the server for it
(`GET /api/public/config`) and loads the script itself. It is deliberately not
built into the image — an image is pulled ready-made, and a service worker keeps
whole responses, so anything frozen into `index.html` would outlive the setting.
`INKNOTE_HONEYBAR_KEY` is only needed when HoneyOS sits on a *different site*
than InkNote (`honeyos.example.com` vs `notes.example.org`). The browser then
withholds the HoneyOS session, and the bar has no other way to ask for the list
of apps. Under a shared domain it can stay empty.
Offline there is no bar — it comes from another server. The app itself is
unaffected, which is the right way round for one that is built to work offline.
---
## API Reference