Browser pairing, notifications, element hiding; drop the top bar

Pairing decrypts a Chromium browser's cookie store (PBKDF2-HMAC-SHA1
against its Keychain key, then AES-128-CBC) and injects the result into
WKHTTPCookieStore. Only the configured apps' hosts and their sign-in
hosts survive the filter. Browsers are offered most-recently-used first,
since the first entry becomes the default and someone with four
Chromium browsers installed wants the one they actually browse in.

WKWebView defines window.Notification but it does nothing: constructing
one throws no error and shows no banner, so a page believes it notified
you. Measured on the machine as `api=function shim=no` before the shim
was made unconditional; `from page: Odoo - Test notification -> raised`
after.

Anything on a page can be right-clicked away. The rule is re-asserted on
every navigation, because the injected script only carries a snapshot
from when the view was built and a selector added since would otherwise
come back on reload.

The top bar is gone. Navigation lives beside the cog, the nav carries
the traffic lights, and two-finger swipe goes back and forward.

The seed is now the real app list, scoped to exact hosts so a Drive link
inside Gmail switches rather than being swallowed.
This commit is contained in:
2026-09-01 12:17:31 +02:00
parent e369c82774
commit ff4a0c6bc4
44 changed files with 2492 additions and 419 deletions
+73 -55
View File
@@ -1,4 +1,5 @@
import type { Config, Group, WorkApp } from "../types";
import { Back, Cog, Collapse, EyeOff, Forward, Reload } from "./icons";
import { Favicon, HEADING, ICON_CHROME } from "./ui";
interface Props {
@@ -9,51 +10,65 @@ interface Props {
onToggleCollapse: () => void;
onOpenSettings: () => void;
onToggleGroup: (g: Group) => void;
onBack: () => void;
onForward: () => void;
onReload: () => void;
onPick: () => void;
}
const RAIL = 52;
/**
* Wide enough that the macOS traffic lights fit inside the rail rather than
* spilling over the page. Everything else about the rail follows from that.
*/
const RAIL = 72;
const PANEL = 240;
/** The strip the traffic lights sit in. Draggable, since there is no title bar. */
const TITLEBAR = 36;
export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL);
function Chevron({ dir }: { dir: "left" | "right" }) {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path
strokeLinecap="round"
strokeLinejoin="round"
d={dir === "left" ? "M15 6l-6 6 6 6" : "M9 6l6 6-6 6"}
/>
</svg>
);
}
function Cog() {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="12" cy="12" r="3.2" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.14.34.4.62.73.79.24.13.51.2.78.21H21a2 2 0 110 4h-.09c-.7.01-1.33.43-1.51 1z" />
</svg>
);
}
export default function Nav({
config,
activeId,
collapsed,
onSelect,
onToggleCollapse,
onOpenSettings,
onToggleGroup,
config, activeId, collapsed,
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
onBack, onForward, onReload, onPick,
}: Props) {
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);
const ungrouped = inGroup(null);
const disabled = !activeId;
const shell =
"flex shrink-0 flex-col overflow-hidden border-r border-slate-300 bg-white " +
"dark:border-slate-800 dark:bg-slate-900";
/* Back, forward, reload, hide-an-element and settings, in that order —
navigation first because it is what gets reached for most. */
const controls = (
<>
<button onClick={onBack} disabled={disabled} title="Back (or swipe left)" className={ICON_CHROME}>
<Back />
</button>
<button onClick={onForward} disabled={disabled} title="Forward (or swipe right)" className={ICON_CHROME}>
<Forward />
</button>
<button onClick={onReload} disabled={disabled} title="Reload" className={ICON_CHROME}>
<Reload />
</button>
<button
onClick={onPick}
disabled={disabled}
title="Hide an element on this page — or right-click it"
className={ICON_CHROME}
>
<EyeOff />
</button>
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
<Cog />
</button>
</>
);
// ------------------------------------------------------------ icon rail
if (collapsed) {
const railBtn = (app: WorkApp) => {
@@ -65,41 +80,38 @@ export default function Nav({
title={app.name}
aria-label={app.name}
className={
"relative grid size-9 cursor-pointer place-items-center rounded-lg transition-colors " +
"relative grid size-10 cursor-pointer place-items-center rounded-lg transition-colors " +
(active
? "bg-slate-100 dark:bg-slate-800"
: "opacity-70 hover:bg-slate-100 hover:opacity-100 dark:hover:bg-slate-800")
}
>
{active && (
<span className="absolute -left-2 h-5 w-[3px] rounded-full bg-sky-500" />
)}
<Favicon url={app.url} name={app.name} size={18} />
{active && <span className="absolute left-0 h-5 w-[3px] rounded-full bg-sky-500" />}
<Favicon url={app.url} name={app.name} size={20} />
</button>
);
};
return (
<aside className={`${shell} w-[52px] items-center`} style={{ width: RAIL }}>
<div className="flex w-full flex-col items-center gap-1 border-b border-slate-200 py-2 dark:border-slate-800">
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
<Cog />
</button>
<aside className={`${shell} items-center`} style={{ width: RAIL }}>
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="w-full shrink-0" />
<div className="flex w-full flex-col items-center border-b border-slate-200 pb-2 dark:border-slate-800">
<div className="flex flex-wrap justify-center gap-0.5 px-1">{controls}</div>
<button onClick={onToggleCollapse} title="Expand" className={ICON_CHROME}>
<Chevron dir="right" />
<Collapse open={false} />
</button>
</div>
<nav className="flex min-h-0 flex-1 flex-col items-center gap-1 overflow-y-auto py-3">
{ungrouped.map(railBtn)}
{ungrouped.length > 0 && groups.length > 0 && (
<span className="my-1 h-px w-6 bg-slate-200 dark:bg-slate-800" />
<span className="my-1 h-px w-7 bg-slate-200 dark:bg-slate-800" />
)}
{groups.map((g, i) => {
const apps = inGroup(g.id);
if (apps.length === 0) return null;
return (
<div key={g.id} className="flex flex-col items-center gap-1">
{i > 0 && <span className="my-1 h-px w-6 bg-slate-200 dark:bg-slate-800" />}
{i > 0 && <span className="my-1 h-px w-7 bg-slate-200 dark:bg-slate-800" />}
{apps.map(railBtn)}
</div>
);
@@ -125,22 +137,30 @@ export default function Nav({
>
<Favicon url={app.url} name={app.name} />
<span className="truncate">{app.name}</span>
{app.hidden.length > 0 && (
<span
title={`${app.hidden.length} element${app.hidden.length === 1 ? "" : "s"} hidden here`}
className="ml-auto font-mono text-[10px] opacity-50"
>
{app.hidden.length}
</span>
)}
</button>
</li>
);
return (
<aside className={shell} style={{ width: PANEL }}>
<header className="flex items-center justify-between gap-2 border-b border-slate-200 px-3 py-2 dark:border-slate-800">
<span className="truncate text-[13px] font-semibold tracking-tight">Apps</span>
<div className="flex shrink-0 items-center gap-1">
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
<Cog />
</button>
<button onClick={onToggleCollapse} title="Collapse" className={ICON_CHROME}>
<Chevron dir="left" />
</button>
</div>
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="shrink-0" />
<header className="flex items-center gap-0.5 border-b border-slate-200 px-1.5 pb-2 dark:border-slate-800">
{controls}
<button
onClick={onToggleCollapse}
title="Collapse"
className={`${ICON_CHROME} ml-auto`}
>
<Collapse open />
</button>
</header>
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
@@ -158,9 +178,7 @@ export default function Nav({
<svg
viewBox="0 0 24 24"
className={`size-3 transition-transform ${g.collapsed ? "" : "rotate-90"}`}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
fill="none" stroke="currentColor" strokeWidth="2.5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 6l6 6-6 6" />
</svg>
+234 -99
View File
@@ -1,49 +1,53 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import * as api from "../api";
import type { Config, Group, WorkApp } from "../types";
import type { Theme } from "../hooks/useAppearance";
import type { Browser, Config, Group, PairResult, WorkApp } from "../types";
import { Trash } from "./icons";
import {
BTN,
BTN_PRIMARY,
Badge,
Dialog,
Favicon,
HELP,
ICON_CHROME,
INPUT,
LABEL,
SUBPANEL,
SectionHeading,
Segmented,
SUBPANEL,
Spinner,
} from "./ui";
interface Props {
config: Config;
/** The app a test notification is fired from. */
activeId: string | null;
theme: Theme;
/** Opens straight onto the hidden-elements section when set. */
focusHiddenFor?: string | null;
onConfig: (c: Config) => void;
onTheme: (t: Theme) => void;
onClose: () => void;
}
function TrashIcon() {
return (
<svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h16M10 11v6M14 11v6" />
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
);
}
export default function Settings({ config, theme, onConfig, onTheme, onClose }: Props) {
export default function Settings({
config, theme, activeId, focusHiddenFor, onConfig, onTheme, onClose,
}: Props) {
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [groupId, setGroupId] = useState<string>("");
const [groupId, setGroupId] = useState("");
const [groupName, setGroupName] = useState("");
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<WorkApp | null>(null);
const [browsers, setBrowsers] = useState<Browser[]>([]);
const [browser, setBrowser] = useState("");
const [pairing, setPairing] = useState(false);
const [notifyStatus, setNotifyStatus] = useState<string | null>(null);
const [paired, setPaired] = useState<PairResult | null>(null);
const groups = [...config.groups].sort((a, b) => a.order - b.order);
// Same order the nav shows, so the two lists never disagree about position.
@@ -53,6 +57,18 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
(a, b) => groupRank(a.groupId) - groupRank(b.groupId) || a.order - b.order,
);
useEffect(() => {
api.listBrowsers().then((b) => {
setBrowsers(b);
setBrowser(config.settings.pairedBrowser ?? b[0]?.id ?? "");
});
}, [config.settings.pairedBrowser]);
useEffect(() => {
if (!focusHiddenFor) return;
document.getElementById("hidden-elements")?.scrollIntoView({ behavior: "smooth" });
}, [focusHiddenFor]);
const run = async (fn: () => Promise<Config>) => {
try {
onConfig(await fn());
@@ -78,6 +94,23 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
const patch = (app: WorkApp, fields: Partial<WorkApp>) =>
run(() => api.updateApp({ ...app, ...fields }));
const pair = async () => {
if (!browser) return;
setPairing(true);
setPaired(null);
try {
setPaired(await api.pairBrowser(browser));
onConfig(await api.getConfig());
setError(null);
} catch (e) {
setError(String(e));
} finally {
setPairing(false);
}
};
const withHidden = orderedApps.filter((a) => a.hidden.length > 0);
return (
<>
<Dialog title="Settings" onCancel={onClose} wide footer={<div />}>
@@ -92,87 +125,68 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
<section className="space-y-2">
<SectionHeading>Apps</SectionHeading>
<div className="space-y-1.5">
{config.apps.length === 0 && (
<p className={HELP}>Nothing yet. Add the first one below.</p>
)}
{config.apps.length === 0 && <p className={HELP}>Nothing yet. Add the first one below.</p>}
{orderedApps.map((app) => (
<div
key={app.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
<div
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} />
<input
value={app.name}
onChange={(e) => patch(app, { name: e.target.value })}
className={`${INPUT} h-[26px] w-full flex-1`}
/>
<input
value={app.url}
onChange={(e) => patch(app, { url: e.target.value })}
title="Changing this rebuilds the app's view"
className={`${INPUT} h-[26px] w-full flex-[1.4] font-mono text-[11px]`}
/>
<select
value={app.groupId ?? ""}
onChange={(e) => patch(app, { groupId: e.target.value || null })}
className={`${INPUT} h-[26px] w-[110px] shrink-0 cursor-pointer`}
>
<Favicon url={app.url} name={app.name} />
<input
value={app.name}
onChange={(e) => patch(app, { name: e.target.value })}
className={`${INPUT} h-[26px] w-full flex-1`}
/>
<input
value={app.url}
onChange={(e) => patch(app, { url: e.target.value })}
title="Changing this rebuilds the app's view"
className={`${INPUT} h-[26px] w-full flex-[1.4] font-mono text-[11px]`}
/>
<select
value={app.groupId ?? ""}
onChange={(e) => patch(app, { groupId: e.target.value || null })}
className={`${INPUT} h-[26px] w-[110px] shrink-0 cursor-pointer`}
>
<option value="">No group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<button
onClick={() => setConfirmDelete(app)}
title={`Remove ${app.name}`}
className={`${ICON_CHROME} hover:text-red-500!`}
>
<TrashIcon />
</button>
</div>
<option value="">No group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<button
onClick={() => setConfirmDelete(app)}
title={`Remove ${app.name}`}
className={`${ICON_CHROME} hover:text-red-500!`}
>
<Trash />
</button>
</div>
))}
</div>
<div className={`${SUBPANEL} flex items-end gap-2`}>
<label className="flex-1 space-y-1">
<span className={LABEL}>Name</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Odoo"
className={`${INPUT} w-full`}
/>
<input value={name} onChange={(e) => setName(e.target.value)}
placeholder="Odoo" className={`${INPUT} w-full`} />
</label>
<label className="flex-[1.4] space-y-1">
<span className={LABEL}>URL</span>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
<input value={url} onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addApp()}
placeholder="example.com"
className={`${INPUT} w-full font-mono text-[11px]`}
/>
placeholder="example.com" className={`${INPUT} w-full font-mono text-[11px]`} />
</label>
<label className="w-[110px] space-y-1">
<span className={LABEL}>Group</span>
<select
value={groupId}
onChange={(e) => setGroupId(e.target.value)}
className={`${INPUT} w-full cursor-pointer`}
>
<select value={groupId} onChange={(e) => setGroupId(e.target.value)}
className={`${INPUT} w-full cursor-pointer`}>
<option value="">No group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</label>
<button onClick={addApp} className={BTN_PRIMARY}>
Add
</button>
<button onClick={addApp} className={BTN_PRIMARY}>Add</button>
</div>
<p className={HELP}>
An app owns its URL's host. Links to any other app on this list switch to it;
@@ -180,21 +194,150 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
</p>
</section>
{/* --------------------------------------------------- groups */}
{/* ------------------------------------------- hidden elements */}
<section id="hidden-elements" className="space-y-2">
<SectionHeading>Hidden elements</SectionHeading>
{withHidden.length === 0 ? (
<p className={HELP}>
Nothing hidden. Right-click anything in an app and choose <em>Hide this
element</em> or use the eye button in the nav to pick one.
</p>
) : (
<div className="space-y-3">
{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} />
<span className="text-[12px] font-medium">{app.name}</span>
<Badge tone="accent">{app.hidden.length}</Badge>
</div>
<div className="space-y-1">
{app.hidden.map((sel, i) => (
<div key={i} className="flex items-center gap-2">
<input
value={sel}
onChange={(e) => {
const next = [...app.hidden];
next[i] = e.target.value;
run(() => api.setHidden(app.id, next));
}}
title="Edit the selector to widen or narrow what it hides"
className={`${INPUT} h-[26px] w-full flex-1 font-mono text-[11px]`}
/>
<button
onClick={() =>
run(() => api.setHidden(app.id, app.hidden.filter((_, j) => j !== i)))
}
title="Show this again"
className={`${ICON_CHROME} hover:text-red-500!`}
>
<Trash />
</button>
</div>
))}
</div>
</div>
))}
</div>
)}
<p className={HELP}>
Changes apply immediately, without a reload. A selector that stops matching
after the site changes simply hides nothing.
</p>
</section>
{/* -------------------------------------------------- pairing */}
<section className="space-y-2">
<SectionHeading>Browser pairing</SectionHeading>
{browsers.length === 0 ? (
<p className={HELP}>No browser with a readable cookie store was found.</p>
) : (
<>
<div className={`${SUBPANEL} flex items-end gap-2`}>
<label className="flex-1 space-y-1">
<span className={LABEL}>Import cookies from</span>
<select value={browser} onChange={(e) => setBrowser(e.target.value)}
className={`${INPUT} w-full cursor-pointer`}>
{browsers.map((b) => (
<option key={b.id} value={b.id}>{b.label}</option>
))}
</select>
</label>
<button onClick={pair} disabled={pairing || !browser} className={BTN_PRIMARY}>
{pairing ? <Spinner /> : config.settings.lastPairedAt ? "Pair again" : "Pair"}
</button>
</div>
{config.settings.lastPairedAt && !paired && (
<p className={HELP}>Last paired {config.settings.lastPairedAt}.</p>
)}
{paired && (
<div className="space-y-1 rounded-lg bg-sky-500/10 px-3 py-2">
<p className="text-[12px] text-sky-700 dark:text-sky-300">
Imported {paired.imported} cookie{paired.imported === 1 ? "" : "s"} across{" "}
{paired.domains} domain{paired.domains === 1 ? "" : "s"}.
</p>
{paired.domainNames.length > 0 && (
<p className="font-mono text-[11px] leading-relaxed text-slate-600 dark:text-slate-300">
{paired.domainNames.join(" ")}
</p>
)}
{paired.warnings.map((w, i) => (
<p key={i} className="text-[11px] text-slate-600 dark:text-slate-300">{w}</p>
))}
</div>
)}
<p className={HELP}>
Only cookies for the apps above and their sign-in hosts are read nothing
else leaves the browser. macOS asks for Keychain permission the first time.
Google and Microsoft tie sessions to the browser that made them, so those two
may still ask you to sign in once; after that it sticks.
</p>
</>
)}
</section>
{/* --------------------------------------------- notifications */}
<section className="space-y-2">
<SectionHeading>Notifications</SectionHeading>
<div className="flex items-center gap-2">
<button
onClick={() => activeId && api.testNotification(activeId)}
disabled={!activeId}
className={BTN}
>
Send a test notification
</button>
<button
onClick={async () => setNotifyStatus(await api.notificationStatus())}
className={BTN}
>
Check permission
</button>
</div>
{notifyStatus && (
<p className={`${HELP} font-mono`}>{notifyStatus}</p>
)}
<p className={HELP}>
WKWebView defines a notification API that silently does nothing, so it is
replaced with one that forwards to macOS. Notifications raised by a service
worker in the background are not covered only those a page raises while open.
</p>
</section>
{/* ---------------------------------------------------- groups */}
<section className="space-y-2">
<SectionHeading>Groups</SectionHeading>
<div className="space-y-1.5">
{groups.length === 0 && <p className={HELP}>No groups yet.</p>}
{groups.map((g: Group) => (
<div
key={g.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
>
<div key={g.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800">
<input
value={g.name}
onChange={(e) =>
run(() => api.updateGroup({ ...g, name: e.target.value }))
}
onChange={(e) => run(() => api.updateGroup({ ...g, name: e.target.value }))}
className={`${INPUT} h-[26px] w-full flex-1`}
/>
<span className="shrink-0 font-mono text-[10px] text-slate-400">
@@ -205,7 +348,7 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
title={`Delete ${g.name} — its apps stay, ungrouped`}
className={`${ICON_CHROME} hover:text-red-500!`}
>
<TrashIcon />
<Trash />
</button>
</div>
))}
@@ -213,17 +356,11 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
<div className={`${SUBPANEL} flex items-end gap-2`}>
<label className="flex-1 space-y-1">
<span className={LABEL}>New group</span>
<input
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
<input value={groupName} onChange={(e) => setGroupName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addGroup()}
placeholder="Finance"
className={`${INPUT} w-full`}
/>
placeholder="Finance" className={`${INPUT} w-full`} />
</label>
<button onClick={addGroup} className={BTN}>
Add group
</button>
<button onClick={addGroup} className={BTN}>Add group</button>
</div>
</section>
@@ -242,9 +379,7 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
</section>
<div className="flex justify-end border-t border-slate-200 pt-4 dark:border-slate-800">
<button onClick={onClose} className={BTN}>
Done
</button>
<button onClick={onClose} className={BTN}>Done</button>
</div>
</div>
</Dialog>
@@ -262,7 +397,7 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
>
It disappears from the nav and its view is closed. The session it holds for{" "}
<span className="font-mono text-[12px]">{confirmDelete.url}</span> is kept, so
adding it back does not mean logging in again.
adding it back does not mean signing in again.
</Dialog>
)}
</>
-75
View File
@@ -1,75 +0,0 @@
import { ICON_CHROME } from "./ui";
interface Props {
url: string | null;
appName: string | null;
onBack: () => void;
onForward: () => void;
onReload: () => void;
onOpenExternal: () => void;
}
/** Reserves the strip the macOS traffic lights sit in. */
const TRAFFIC_LIGHTS = 78;
function Icon({ d }: { d: string }) {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d={d} />
</svg>
);
}
/**
* Browser chrome, kept to the four things a tabless app still needs. It spans
* the whole window rather than sitting inside the stage, so the traffic lights
* have somewhere to live no matter how narrow the nav gets.
*/
export default function TopBar({
url,
appName,
onBack,
onForward,
onReload,
onOpenExternal,
}: Props) {
return (
<header
data-tauri-drag-region
className="flex h-[38px] shrink-0 items-center gap-1 border-b border-slate-300
bg-white px-2 dark:border-slate-800 dark:bg-slate-900"
style={{ paddingLeft: TRAFFIC_LIGHTS }}
>
<button onClick={onBack} title="Back" className={ICON_CHROME}>
<Icon d="M15 6l-6 6 6 6" />
</button>
<button onClick={onForward} title="Forward" className={ICON_CHROME}>
<Icon d="M9 6l6 6-6 6" />
</button>
<button onClick={onReload} title="Reload" className={ICON_CHROME}>
<Icon d="M20 11a8 8 0 10-2.3 5.7M20 5v6h-6" />
</button>
<div
data-tauri-drag-region
className="mx-2 flex min-w-0 flex-1 items-center gap-2 text-[11px]"
>
{appName && (
<span className="shrink-0 font-medium text-slate-600 dark:text-slate-300">
{appName}
</span>
)}
<span className="truncate text-slate-400 dark:text-slate-500">{url ?? ""}</span>
</div>
<button
onClick={onOpenExternal}
title="Open this page in your browser"
className={ICON_CHROME}
disabled={!url}
>
<Icon d="M14 5h5v5M19 5l-8 8M18 14v4a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2h4" />
</button>
</header>
);
}
+36
View File
@@ -0,0 +1,36 @@
/** One stroke weight, one size, one place to find them. */
type Props = { className?: string };
const S = ({ d, className = "size-4" }: { d: string } & Props) => (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
);
export const Back = (p: Props) => <S d="M15 6l-6 6 6 6" {...p} />;
export const Forward = (p: Props) => <S d="M9 6l6 6-6 6" {...p} />;
export const Reload = (p: Props) => <S d="M20 11a8 8 0 10-2.3 5.7M20 5v6h-6" {...p} />;
export const External = (p: Props) => (
<S d="M14 5h5v5M19 5l-8 8M18 14v4a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2h4" {...p} />
);
export const EyeOff = (p: Props) => (
<S d="M3 3l18 18M10.6 10.6a2 2 0 002.8 2.8M9.4 5.4A9.5 9.5 0 0112 5c5 0 9 4.5 9 7a11 11 0 01-2.4 3.5M6.2 6.9C4 8.3 3 10.4 3 12c0 2.5 4 7 9 7a9.6 9.6 0 004-.85" {...p} />
);
export const Trash = ({ className = "size-3.5" }: Props) => (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h16M10 11v6M14 11v6" />
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
);
export const Cog = (p: Props) => (
<svg viewBox="0 0 24 24" className={p.className ?? "size-4"} fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="12" cy="12" r="3.2" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.14.34.4.62.73.79.24.13.51.2.78.21H21a2 2 0 110 4h-.09c-.7.01-1.33.43-1.51 1z" />
</svg>
);
export const Collapse = ({ open, className = "size-4" }: Props & { open: boolean }) => (
<S d={open ? "M15 6l-6 6 6 6" : "M9 6l6 6-6 6"} className={className} />
);
+10
View File
@@ -56,6 +56,16 @@ export const ICON_CHROME =
`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900 ` +
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white";
/** The only spinning thing in the app; used where a wait has no known length. */
export function Spinner({ className = "size-4" }: { className?: string }) {
return (
<svg className={`${className} animate-spin`} viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" className="opacity-25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
);
}
export function SectionHeading({ children }: { children: ReactNode }) {
return <h2 className={`flex items-center gap-2 ${HEADING}`}>{children}</h2>;
}