Remember window and tab; fill saved passwords; match the title bar

The window's position and size survive a restart, and so does the app
you were last in.

Saving a password was only half of it - a saved password you cannot get
back out is not saved in any useful sense. Right-click now offers "Fill
saved password" wherever a page has a password field. Only on an explicit
click, only for the host on screen, and only into that page's own fields,
never on load. The value is set through the native setter and followed by
input and change events, because assigning .value is invisible to React
and Angular and the site would submit an empty field.

The title bar is painted the nav's colour rather than its own grey, so
the two read as one surface in either theme. Doing that revealed that the
window's content had always run the full height underneath it - the
opaque bar was hiding it - so the shell is now inset by the measured
title bar height, or the nav lands on the traffic lights.

The rail's unread badge is a fixed circle inside the button's bounds.
Padding around a number made a pill at two digits, and hanging it off the
corner put it under the rail's clip; past nine it reads "9+", with the
figure itself in the expanded nav.
This commit is contained in:
2026-09-01 19:17:38 +02:00
parent cccc6caa1e
commit 899f0b41c4
19 changed files with 350 additions and 20 deletions
+27 -3
View File
@@ -20,9 +20,13 @@ export default function App() {
const [activeId, setActiveId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [focusHidden, setFocusHidden] = useState<string | null>(null);
const [theme, setTheme] = useAppearance("system");
const [theme, setTheme, dark] = useAppearance("system");
const [unread, setUnread] = useState<Record<string, number>>({});
const [backdrop, setBackdrop] = useState<string | null>(null);
/* The window's content runs the full height, underneath the title bar — the
opaque bar was only hiding that. With the bar painted the nav's colour, the
shell has to keep clear of it or the nav lands on the traffic lights. */
const [chrome, setChrome] = useState(0);
const [offer, setOffer] = useState<PasswordOffer | null>(null);
const [saved, setSaved] = useState<string | null>(null);
@@ -36,12 +40,18 @@ export default function App() {
const collapsed = config?.settings.navCollapsed ?? false;
useEffect(() => {
void api.chromeHeight().then(setChrome);
}, []);
useEffect(() => {
api.getConfig().then((c) => {
setConfig(c);
setTheme(c.settings.theme);
// Back where you left off, if that app still exists.
const remembered = c.apps.find((a) => a.id === c.settings.lastApp);
const first = [...c.apps].sort((a, b) => a.order - b.order)[0];
setActiveId(first?.id ?? null);
setActiveId(remembered?.id ?? first?.id ?? null);
});
}, [setTheme]);
@@ -92,6 +102,12 @@ export default function App() {
if (activeId) void api.setActive(activeId);
}, [activeId]);
// The title bar is painted the nav's colour, so it has to follow whatever
// "system" resolved to — not just an explicit Light or Dark choice.
useEffect(() => {
void api.setWindowChrome(dark);
}, [dark]);
useEffect(() => {
const unlisten = [
// A link in one app that points at another: Rust decided, the shell moves.
@@ -179,7 +195,13 @@ export default function App() {
if (!config) return null;
return (
<div className="flex h-screen">
/* The inset is the nav's own colour, so the title bar above it and the
sidebar below read as one surface. */
<div
className="flex h-screen flex-col bg-white dark:bg-slate-900"
style={{ paddingTop: chrome }}
>
<div className="flex min-h-0 flex-1">
<Nav
config={config}
activeId={activeId}
@@ -221,6 +243,8 @@ export default function App() {
)}
</div>
</div>
{offer && (
<Dialog
title="Save this password?"
+3
View File
@@ -61,3 +61,6 @@ export const stageSnapshot = () => invoke<string | null>("stage_snapshot");
export const savePassword = () => invoke<string>("save_password");
export const discardPassword = () => invoke<void>("discard_password");
export const resetConfig = () => invoke<Config>("reset_config");
export const setWindowChrome = (dark: boolean) =>
invoke<void>("set_window_chrome", { dark });
export const chromeHeight = () => invoke<number>("chrome_height");
+8 -3
View File
@@ -105,12 +105,17 @@ export default function Nav({
{active && <span className="absolute left-0 h-5 w-[3px] rounded-full bg-sky-500" />}
<Favicon icon={app.icon} name={app.name} size={20} />
{(unread[app.id] ?? 0) > 0 && (
/* A fixed circle, inside the button's own bounds. Padding around a
number makes a pill as soon as there are two digits, and hanging
it off the corner puts it under the rail's clip. Anything past
nine reads as "9+" — the exact figure is in the expanded nav. */
<span
title={`${unread[app.id]} new since you last looked`}
className="absolute -right-0.5 -top-0.5 min-w-[15px] rounded-full bg-sky-500
px-1 text-center font-mono text-[9px] font-semibold text-white"
className="absolute right-0 top-0 grid size-[15px] place-items-center
rounded-full bg-sky-500 font-mono text-[9px] font-semibold
leading-none text-white"
>
{unread[app.id] > 99 ? "99+" : unread[app.id]}
{unread[app.id] > 9 ? "9+" : unread[app.id]}
</span>
)}
</button>
+6 -3
View File
@@ -10,14 +10,17 @@ export type Theme = "system" | "light" | "dark";
*/
export function useAppearance(initial: Theme) {
const [theme, setTheme] = useState<Theme>(initial);
/** What "system" actually resolved to — the title bar has to follow it too. */
const [dark, setDark] = useState(false);
useEffect(() => setTheme(initial), [initial]);
useEffect(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const apply = () => {
const dark = theme === "dark" || (theme === "system" && media.matches);
document.documentElement.classList.toggle("dark", dark);
const isDark = theme === "dark" || (theme === "system" && media.matches);
document.documentElement.classList.toggle("dark", isDark);
setDark(isDark);
};
apply();
if (theme !== "system") return;
@@ -25,5 +28,5 @@ export function useAppearance(initial: Theme) {
return () => media.removeEventListener("change", apply);
}, [theme]);
return [theme, setTheme] as const;
return [theme, setTheme, dark] as const;
}
+1
View File
@@ -24,6 +24,7 @@ export interface Group {
export interface Settings {
navCollapsed: boolean;
theme: "system" | "light" | "dark";
lastApp: string | null;
}
export interface Config {