Ship to Desktop and /Applications; restructure Settings; add reset

Version pinned to 0.0.1 and staying on 0.x while this is in beta.

`npm run ship` now exports twice from one build: /Applications/Work.app
to test here, and ~/Desktop/Work-<version>.zip to send someone. One
build, so a tester runs byte for byte what was verified rather than a
second build that drifted. Packed with ditto rather than zip - a plain
zip mangles the bundle's symlinks and it will not open on the far end.

Settings: groups move up under apps, and the hidden-element list moves
into its own window behind a one-line summary. It had grown to 21
selectors of framework class names and was most of the page, in front of
the things people actually open Settings for.

Reset puts apps, groups, hidden elements and zoom back to defaults, and
leaves sessions alone - those live in WebKit's own store, and throwing
them away would mean signing back into every tool to undo a change to
the nav.
This commit is contained in:
2026-09-01 16:04:54 +02:00
parent 07bb22eb2d
commit 2a0b934d04
11 changed files with 210 additions and 101 deletions
+136 -87
View File
@@ -6,6 +6,7 @@ import type { Config, Group, WorkApp } from "../types";
import { Trash } from "./icons";
import {
BTN,
BTN_QUIET,
BTN_PRIMARY,
Badge,
Dialog,
@@ -29,10 +30,12 @@ interface Props {
onConfig: (c: Config) => void;
onTheme: (t: Theme) => void;
onClose: () => void;
/** Settings were reset; the shell has to rebuild every app's view. */
onReset: () => void;
}
export default function Settings({
config, theme, activeId, focusHiddenFor, onConfig, onTheme, onClose,
config, theme, activeId, focusHiddenFor, onConfig, onTheme, onClose, onReset,
}: Props) {
const [name, setName] = useState("");
const [url, setUrl] = useState("");
@@ -40,6 +43,8 @@ export default function Settings({
const [groupName, setGroupName] = useState("");
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<WorkApp | null>(null);
const [hiddenOpen, setHiddenOpen] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [notifyStatus, setNotifyStatus] = useState<string | null>(null);
const [reports, setReports] = useState<[string, string][]>([]);
@@ -56,7 +61,7 @@ export default function Settings({
useEffect(() => {
if (!focusHiddenFor) return;
document.getElementById("hidden-elements")?.scrollIntoView({ behavior: "smooth" });
setHiddenOpen(true);
}, [focusHiddenFor]);
const run = async (fn: () => Promise<Config>) => {
@@ -86,6 +91,7 @@ export default function Settings({
const withHidden = orderedApps.filter((a) => a.hidden.length > 0);
const totalHidden = withHidden.reduce((n, a) => n + a.hidden.length, 0);
return (
<>
@@ -170,58 +176,59 @@ export default function Settings({
</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">
<input
value={g.name}
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">
{config.apps.filter((a) => a.groupId === g.id).length} apps
</span>
<button
onClick={() => run(() => api.deleteGroup(g.id))}
title={`Delete ${g.name} — its apps stay, ungrouped`}
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}>New group</span>
<input value={groupName} onChange={(e) => setGroupName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addGroup()}
placeholder="Finance" className={`${INPUT} w-full`} />
</label>
<button onClick={addGroup} className={BTN}>Add group</button>
</div>
</section>
{/* ------------------------------------------- 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 icon={app.icon} 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>
)}
<section className="space-y-2">
<div className="flex items-center justify-between">
<SectionHeading>Hidden elements</SectionHeading>
<button onClick={() => setHiddenOpen(true)} className={BTN}>
{totalHidden === 0 ? "None hidden" : `Manage ${totalHidden} hidden`}
</button>
</div>
<p className={HELP}>
Changes apply immediately, without a reload. A selector that stops matching
after the site changes simply hides nothing.
Right-click anything in an app and choose <em>Hide this element</em>. The
full list opens in its own window — there is usually a lot of it, and it is
not what you came to Settings for.
</p>
</section>
{/* ------------------------------------------------------ zoom */}
<section className="space-y-2">
<SectionHeading>Zoom</SectionHeading>
@@ -305,43 +312,6 @@ export default function Settings({
</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">
<input
value={g.name}
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">
{config.apps.filter((a) => a.groupId === g.id).length} apps
</span>
<button
onClick={() => run(() => api.deleteGroup(g.id))}
title={`Delete ${g.name} — its apps stay, ungrouped`}
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}>New group</span>
<input value={groupName} onChange={(e) => setGroupName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addGroup()}
placeholder="Finance" className={`${INPUT} w-full`} />
</label>
<button onClick={addGroup} className={BTN}>Add group</button>
</div>
</section>
{/* ----------------------------------------------- appearance */}
<section className="space-y-2">
<SectionHeading>Appearance</SectionHeading>
@@ -356,12 +326,91 @@ export default function Settings({
/>
</section>
<div className="flex justify-end border-t border-slate-200 pt-4 dark:border-slate-800">
<div className="flex items-center justify-between border-t border-slate-200 pt-4 dark:border-slate-800">
<button
onClick={() => setConfirmReset(true)}
title="Put apps, groups, hidden elements and zoom back to defaults"
className={BTN_QUIET}
>
Reset to defaults
</button>
<button onClick={onClose} className={BTN}>Done</button>
</div>
</div>
</Dialog>
{hiddenOpen && (
<Dialog title="Hidden elements" onCancel={() => setHiddenOpen(false)} wide>
<div className="space-y-3">
{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 icon={app.icon} 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>
</div>
</Dialog>
)}
{confirmReset && (
<Dialog
title="Reset to defaults?"
onCancel={() => setConfirmReset(false)}
onConfirm={async () => {
await run(() => api.resetConfig());
setConfirmReset(false);
onReset();
}}
confirmLabel="Reset"
destructive
>
Your apps, groups, hidden elements and zoom go back to the defaults. You stay
signed in to everything — sessions live in the browser engine's own store, not
in this app's settings, and are not touched.
</Dialog>
)}
{confirmDelete && (
<Dialog
title={`Remove ${confirmDelete.name}?`}