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:
1 parent
2ea448f091
commit
00b1d4469f
12 files changed
+144
-5
No files matched your search
@@ -24,6 +24,15 @@ INKNOTE_ADMIN_PASSWORD=
|
|||||||
|
|
||||||
# ── Optional ── unset keeps the default shown
|
# ── Optional ── unset keeps the default shown
|
||||||
# INKNOTE_MAX_IMAGE_MB=25 # pasted images
|
# 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_MAX_PDF_MB=200 # imported PDFs, limited separately
|
||||||
# INKNOTE_UID=1000 # must own INKNOTE_DATA_DIR
|
# INKNOTE_UID=1000 # must own INKNOTE_DATA_DIR
|
||||||
# INKNOTE_GID=1000
|
# INKNOTE_GID=1000
|
||||||
|
|||||||
@@ -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
|
## 2026-08-22 — A bigger face, and the theme where the settings are
|
||||||
|
|
||||||
**Images** — web `sha256:fe35b0e7…`, server `sha256:a557fb4d…`
|
**Images** — web `sha256:fe35b0e7…`, server `sha256:a557fb4d…`
|
||||||
|
|||||||
@@ -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 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. */
|
* the app never has to know the issuer, the scopes or the redirect. */
|
||||||
app.get("/api/auth/oidc/start", async (req, res) => {
|
app.get("/api/auth/oidc/start", async (req, res) => {
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ services:
|
|||||||
# Optional tuning; unset means the server's own defaults apply.
|
# Optional tuning; unset means the server's own defaults apply.
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
||||||
- INKNOTE_MAX_IMAGE_MB=${INKNOTE_MAX_IMAGE_MB:-}
|
- 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:-}
|
- INKNOTE_MAX_PDF_MB=${INKNOTE_MAX_PDF_MB:-}
|
||||||
- MAX_VERSIONS_PER_NOTE=${MAX_VERSIONS_PER_NOTE:-}
|
- MAX_VERSIONS_PER_NOTE=${MAX_VERSIONS_PER_NOTE:-}
|
||||||
- VERSION_RETENTION_DAYS=${VERSION_RETENTION_DAYS:-}
|
- VERSION_RETENTION_DAYS=${VERSION_RETENTION_DAYS:-}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ services:
|
|||||||
# Optional tuning; unset means the server's own defaults apply.
|
# Optional tuning; unset means the server's own defaults apply.
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
||||||
- INKNOTE_MAX_IMAGE_MB=${INKNOTE_MAX_IMAGE_MB:-}
|
- 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:-}
|
- INKNOTE_MAX_PDF_MB=${INKNOTE_MAX_PDF_MB:-}
|
||||||
- MAX_VERSIONS_PER_NOTE=${MAX_VERSIONS_PER_NOTE:-}
|
- MAX_VERSIONS_PER_NOTE=${MAX_VERSIONS_PER_NOTE:-}
|
||||||
- VERSION_RETENTION_DAYS=${VERSION_RETENTION_DAYS:-}
|
- VERSION_RETENTION_DAYS=${VERSION_RETENTION_DAYS:-}
|
||||||
|
|||||||
+14
-1
@@ -38,7 +38,20 @@
|
|||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; padding: 0; overflow: hidden; overscroll-behavior: none; }
|
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 { 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.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; }
|
#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; }
|
||||||
|
|||||||
@@ -933,7 +933,7 @@ export default function App() {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
height: "100vh",
|
height: "calc(100vh - var(--honeybar-h, 0px))",
|
||||||
background: T.bg,
|
background: T.bg,
|
||||||
color: T.text,
|
color: T.text,
|
||||||
fontFamily: FONT_UI,
|
fontFamily: FONT_UI,
|
||||||
@@ -1000,7 +1000,7 @@ export default function App() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
height: "100vh",
|
height: "calc(100vh - var(--honeybar-h, 0px))",
|
||||||
width: "100vw",
|
width: "100vw",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
background: T.bg,
|
background: T.bg,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import NoteApp from "./App.jsx";
|
import NoteApp from "./App.jsx";
|
||||||
|
import { mountHoneybar } from "./honeybar.js";
|
||||||
|
|
||||||
// Hide splash screen
|
// Hide splash screen
|
||||||
const hideSplash = () => {
|
const hideSplash = () => {
|
||||||
@@ -19,6 +20,9 @@ ReactDOM.createRoot(document.getElementById("root")).render(
|
|||||||
|
|
||||||
setTimeout(hideSplash, 200);
|
setTimeout(hideSplash, 200);
|
||||||
|
|
||||||
|
// Not awaited: the bar is a decoration and must never hold up the app.
|
||||||
|
mountHoneybar();
|
||||||
|
|
||||||
// Register service worker
|
// Register service worker
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
window.addEventListener("load", async () => {
|
window.addEventListener("load", async () => {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function AuthScreen({ onLogin, dark, initialError = "" }) {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
minHeight: "100vh",
|
minHeight: "calc(100vh - var(--honeybar-h, 0px))",
|
||||||
background: T.bg,
|
background: T.bg,
|
||||||
fontFamily: "'DM Sans',sans-serif",
|
fontFamily: "'DM Sans',sans-serif",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function Sidebar({
|
|||||||
zIndex: isMobile ? 900 : 1,
|
zIndex: isMobile ? 900 : 1,
|
||||||
width: isMobile ? (open ? "100vw" : 0) : open ? 260 : 0,
|
width: isMobile ? (open ? "100vw" : 0) : open ? 260 : 0,
|
||||||
minWidth: isMobile ? 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,
|
background: T.sidebar,
|
||||||
borderRight: isMobile ? "none" : `1px solid ${T.border}`,
|
borderRight: isMobile ? "none" : `1px solid ${T.border}`,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|||||||
@@ -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"`),
|
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.
|
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
|
## API Reference
|
||||||
|
|||||||
Reference in new issue
Block a user