Unread counts, a window margin, blurred dialogs, softer group names

Each rise in an app's unread count while you are elsewhere adds to a
tally beside its name, and a dot on its icon in the rail. Looking at the
app is the only thing that clears it.

The window has no frame of its own, so the shell now keeps a 6px margin
in a normal window - the only part of it that is not a web page, and so
the only place left to grab. Full screen gives the room back.

Dialogs get the app blurred behind them. An app's webview paints above
the shell, so it has to be moved aside before a dialog can be seen at
all, and once moved there is nothing left to blur - a still taken on the
way out is the only way to keep the background there. It runs on a
blocking worker: its completion handler is on the main thread, and
waiting there deadlocks until the timeout and returns nothing.

Settings is much larger, the cog moved to the foot of the nav, group
names read as names rather than shouted headings, and "Refresh icons"
bumps a version every favicon URL carries, for the ones that cache wrong.
This commit is contained in:
2026-09-01 14:31:51 +02:00
parent 8f989fb8f6
commit 1b6dabab0a
13 changed files with 369 additions and 33 deletions
+70 -5
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import * as api from "./api";
import Nav from "./components/Nav";
@@ -14,6 +15,9 @@ export default function App() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [focusHidden, setFocusHidden] = useState<string | null>(null);
const [theme, setTheme] = useAppearance("system");
const [unread, setUnread] = useState<Record<string, number>>({});
const [fullscreen, setFullscreen] = useState(false);
const [backdrop, setBackdrop] = useState<string | null>(null);
const stageRef = useRef<HTMLDivElement>(null);
const booted = useRef(false);
@@ -24,6 +28,20 @@ export default function App() {
const collapsed = config?.settings.navCollapsed ?? false;
/* The window has no frame of its own, so in a normal window the shell keeps
a margin around itself: somewhere to grab that is not a web page, and the
only way to move or place the window by hand. Full screen has no use for
it and gives the room back. */
useEffect(() => {
const w = getCurrentWindow();
const check = () => void w.isFullscreen().then(setFullscreen);
check();
const un = w.onResized(check);
return () => {
void un.then((f) => f());
};
}, []);
useEffect(() => {
api.getConfig().then((c) => {
setConfig(c);
@@ -103,17 +121,37 @@ export default function App() {
listen<[string, number]>("zoom-changed", () => {
void api.getConfig().then(setConfig);
}),
listen<[string, number][]>("unread-changed", (e) => {
setUnread(Object.fromEntries(e.payload));
}),
];
return () => {
unlisten.forEach((p) => p.then((f) => f()));
};
}, []);
// A native view paints over anything the shell draws, so a dialog needs the
// stage out of the way rather than merely on a higher z-index.
/* A native view paints over anything the shell draws, so a dialog needs the
app moved out of the way rather than merely a higher z-index — and once it
is moved there is nothing left behind the dialog to look at. A still taken
on the way out, blurred, puts the background back. */
useEffect(() => {
if (!config) return;
void (settingsOpen ? api.hideStage() : api.showStage());
let cancelled = false;
if (settingsOpen) {
void (async () => {
const shot = await api.stageSnapshot().catch(() => null);
if (cancelled) return;
setBackdrop(shot);
await api.hideStage();
})();
} else {
setBackdrop(null);
void api.showStage();
}
return () => {
cancelled = true;
};
}, [settingsOpen, config]);
const toggleCollapse = () => {
@@ -137,8 +175,20 @@ export default function App() {
if (!config) return null;
const frame = fullscreen ? 0 : 6;
return (
<div className="flex h-screen">
<div
data-tauri-drag-region
className="flex h-screen bg-slate-200 dark:bg-black"
style={{ padding: frame }}
>
<div
className={
"flex min-w-0 flex-1 overflow-hidden " +
(fullscreen ? "" : "rounded-xl border border-slate-300 dark:border-slate-800")
}
>
<Nav
config={config}
activeId={activeId}
@@ -150,11 +200,24 @@ export default function App() {
onBack={() => activeId && api.historyGo(activeId, -1)}
onForward={() => activeId && api.historyGo(activeId, 1)}
onReload={() => activeId && api.historyGo(activeId, 0)}
unread={unread}
/>
{/* The hole an app's native webview is positioned into. It stays empty
on purpose — anything drawn here would be painted over. */}
<div ref={stageRef} className="min-w-0 flex-1 bg-slate-100 dark:bg-slate-950">
<div
ref={stageRef}
className="relative min-w-0 flex-1 overflow-hidden bg-slate-100 dark:bg-slate-950"
>
{backdrop && (
<img
src={backdrop}
alt=""
aria-hidden
/* Scaled up so the blur has no soft edge to give itself away. */
className="absolute inset-0 h-full w-full scale-110 object-cover blur-[16px]"
/>
)}
{config.apps.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<p className="text-[13px] text-slate-500 dark:text-slate-400">
@@ -167,6 +230,8 @@ export default function App() {
)}
</div>
</div>
{settingsOpen && (
<Settings
config={config}
+4
View File
@@ -50,3 +50,7 @@ export const notificationClick = (appId: string, notificationId: string) =>
export const focusWindow = () => invoke<void>("focus_window");
export const probeApps = () => invoke<void>("probe_apps");
export const appReports = () => invoke<[string, string][]>("app_reports");
export const unreadCounts = () => invoke<[string, number][]>("unread_counts");
export const refreshFavicons = () => invoke<Config>("refresh_favicons");
export const stageSnapshot = () => invoke<string | null>("stage_snapshot");
+56 -17
View File
@@ -1,11 +1,13 @@
import type { Config, Group, WorkApp } from "../types";
import { Back, Cog, Collapse, Forward, Reload } from "./icons";
import { Favicon, HEADING, ICON_CHROME } from "./ui";
import { Favicon, GROUP_LABEL, ICON_CHROME } from "./ui";
interface Props {
config: Config;
activeId: string | null;
collapsed: boolean;
/** How much has arrived in each app since it was last looked at. */
unread: Record<string, number>;
onSelect: (id: string) => void;
onToggleCollapse: () => void;
onOpenSettings: () => void;
@@ -27,10 +29,11 @@ const TITLEBAR = 36;
export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL);
export default function Nav({
config, activeId, collapsed,
config, activeId, collapsed, unread,
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
onBack, onForward, onReload,
}: Props) {
const iconV = config.settings.faviconVersion ?? 0;
const groups = [...config.groups].sort((a, b) => a.order - b.order);
const inGroup = (id: string | null) =>
config.apps.filter((a) => a.groupId === id).sort((a, b) => a.order - b.order);
@@ -55,12 +58,36 @@ export default function Nav({
<button onClick={onReload} disabled={disabled} title="Reload" className={ICON_CHROME}>
<Reload />
</button>
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
<Cog />
</button>
</>
);
/* Settings lives at the foot of both layouts: it is the thing you reach for
least, and putting it there leaves the top for what you use constantly. */
const settingsButton = (
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
<Cog />
</button>
);
/* A count of what arrived while you were elsewhere. Absent, rather than
zero, when there is nothing — a row of noughts is just noise. */
const badge = (id: string, active: boolean) => {
const n = unread[id] ?? 0;
if (n === 0) return null;
return (
<span
title={`${n} new since you last looked`}
className={
"ml-auto min-w-[18px] shrink-0 rounded-full px-1.5 py-0.5 text-center " +
"font-mono text-[10px] font-semibold tabular-nums " +
(active ? "bg-white/25 text-white dark:bg-slate-900/20 dark:text-slate-900" : "bg-sky-500 text-white")
}
>
{n > 99 ? "99+" : n}
</span>
);
};
// ------------------------------------------------------------ icon rail
if (collapsed) {
const railBtn = (app: WorkApp) => {
@@ -79,7 +106,16 @@ export default function Nav({
}
>
{active && <span className="absolute left-0 h-5 w-[3px] rounded-full bg-sky-500" />}
<Favicon url={app.url} name={app.name} size={20} />
<Favicon url={app.url} name={app.name} size={20} version={iconV} />
{(unread[app.id] ?? 0) > 0 && (
<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"
>
{unread[app.id] > 99 ? "99+" : unread[app.id]}
</span>
)}
</button>
);
};
@@ -87,13 +123,10 @@ export default function Nav({
return (
<aside className={`${shell} items-center`} style={{ width: RAIL }}>
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="w-full shrink-0" />
{/* Only settings and the expander survive the rail. Back and forward
are a two-finger swipe, reload is ⌘R, and five buttons across 72px
is clutter standing in for a toolbar nobody asked for. */}
{/* Only the expander survives the rail's header. Back and forward are
a two-finger swipe and reload is ⌘R, so a toolbar here would be
clutter standing in for something nobody asked for. */}
<div className="flex w-full flex-col items-center border-b border-slate-200 pb-2 dark:border-slate-800">
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
<Cog />
</button>
<button onClick={onToggleCollapse} title="Expand" className={ICON_CHROME}>
<Collapse open={false} />
</button>
@@ -114,6 +147,9 @@ export default function Nav({
);
})}
</nav>
<div className="flex w-full flex-col items-center border-t border-slate-200 py-2 dark:border-slate-800">
{settingsButton}
</div>
</aside>
);
}
@@ -132,8 +168,9 @@ export default function Nav({
title={app.url}
className={`${row} ${app.id === activeId ? active : inactive}`}
>
<Favicon url={app.url} name={app.name} />
<Favicon url={app.url} name={app.name} version={iconV} />
<span className="truncate">{app.name}</span>
{badge(app.id, app.id === activeId)}
</button>
</li>
);
@@ -161,7 +198,7 @@ export default function Nav({
<section key={g.id} className="pt-3 first:pt-0">
<button
onClick={() => onToggleGroup(g)}
className={`${HEADING} flex w-full cursor-pointer items-center gap-1 px-2 pb-1
className={`${GROUP_LABEL} flex w-full cursor-pointer items-center gap-1 px-2 pb-1
hover:text-slate-700 dark:hover:text-slate-200`}
>
<svg
@@ -172,9 +209,7 @@ export default function Nav({
<path strokeLinecap="round" strokeLinejoin="round" d="M9 6l6 6-6 6" />
</svg>
<span className="truncate">{g.name}</span>
<span className="ml-auto font-mono text-[10px] tracking-normal opacity-60">
{apps.length}
</span>
<span className="ml-auto font-mono text-[10px] opacity-60">{apps.length}</span>
</button>
{!g.collapsed && <ul className="space-y-0.5">{apps.map(appRow)}</ul>}
</section>
@@ -187,6 +222,10 @@ export default function Nav({
</p>
)}
</nav>
<footer className="flex items-center gap-1 border-t border-slate-200 px-1.5 py-2 dark:border-slate-800">
{settingsButton}
</footer>
</aside>
);
}
+13 -4
View File
@@ -99,7 +99,16 @@ export default function Settings({
{/* ----------------------------------------------------- apps */}
<section className="space-y-2">
<SectionHeading>Apps</SectionHeading>
<div className="flex items-center justify-between">
<SectionHeading>Apps</SectionHeading>
<button
onClick={() => run(() => api.refreshFavicons())}
title="Fetch every icon again — one that cached wrong will not fix itself"
className={BTN}
>
Refresh icons
</button>
</div>
<div className="space-y-1.5">
{config.apps.length === 0 && <p className={HELP}>Nothing yet. Add the first one below.</p>}
{orderedApps.map((app) => (
@@ -107,7 +116,7 @@ export default function Settings({
key={app.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
>
<Favicon url={app.url} name={app.name} />
<Favicon url={app.url} name={app.name} version={config.settings.faviconVersion ?? 0} />
<input
value={app.name}
onChange={(e) => patch(app, { name: e.target.value })}
@@ -183,7 +192,7 @@ export default function Settings({
{withHidden.map((app) => (
<div key={app.id} className={SUBPANEL}>
<div className="mb-2 flex items-center gap-2">
<Favicon url={app.url} name={app.name} />
<Favicon url={app.url} name={app.name} version={config.settings.faviconVersion ?? 0} />
<span className="text-[12px] font-medium">{app.name}</span>
<Badge tone="accent">{app.hidden.length}</Badge>
</div>
@@ -231,7 +240,7 @@ export default function Settings({
key={app.id}
className="flex items-center gap-3 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
>
<Favicon url={app.url} name={app.name} />
<Favicon url={app.url} name={app.name} version={config.settings.faviconVersion ?? 0} />
<span className="w-28 shrink-0 truncate text-[12px]">{app.name}</span>
<input
type="range"
+16 -5
View File
@@ -16,6 +16,9 @@ export const ICON_BTN =
export const HEADING =
"text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-slate-400";
/** A group's own name, so it reads as a label rather than a shouted heading. */
export const GROUP_LABEL =
"text-[11px] font-normal text-slate-500 dark:text-slate-400";
export const LABEL = "text-[12px] text-slate-500 dark:text-slate-400";
export const HELP = "text-[11px] leading-snug text-slate-500 dark:text-slate-400";
export const SECTION = "border-b border-slate-200 px-4 py-4 dark:border-slate-800";
@@ -157,16 +160,21 @@ export function Dialog({
}) {
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-slate-950/70 p-5"
/* Light enough that the blurred still behind it still reads as the app
you were in, dark enough that the dialog is unambiguously in front. */
className="fixed inset-0 z-[70] flex items-center justify-center bg-slate-950/45 p-5"
onClick={onCancel}
>
<div
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className={`w-full ${wide ? "max-w-2xl max-h-[82vh] overflow-y-auto" : "max-w-sm"}
rounded-2xl border border-slate-300 bg-white p-5 shadow-2xl
dark:border-slate-700 dark:bg-slate-900`}
className={`w-full ${
wide
? "max-w-[min(1120px,92vw)] max-h-[90vh] overflow-y-auto"
: "max-w-sm"
} rounded-2xl border border-slate-300 bg-white p-5 shadow-2xl
dark:border-slate-700 dark:bg-slate-900`}
>
<h2 className="mb-1.5 text-[15px] font-semibold tracking-tight">{title}</h2>
<div className="mb-4 text-[13px] leading-relaxed text-slate-600 dark:text-slate-300">
@@ -201,10 +209,13 @@ export function Favicon({
url,
name,
size = 16,
version = 0,
}: {
url: string;
name: string;
size?: number;
/** Bumped by "Refresh icons" to get past a wrongly cached one. */
version?: number;
}) {
let host = "";
try {
@@ -228,7 +239,7 @@ export function Favicon({
</span>
{host && (
<img
src={`https://www.google.com/s2/favicons?sz=64&domain=${host}`}
src={`https://www.google.com/s2/favicons?sz=64&domain=${host}&v=${version}`}
alt=""
width={size}
height={size}
+1
View File
@@ -22,6 +22,7 @@ export interface Group {
export interface Settings {
navCollapsed: boolean;
theme: "system" | "light" | "dark";
faviconVersion: number;
}
export interface Config {