39 Commits
Author SHA1 Message Date
Vincent 2065d82d27 Quieten the settings lists; gather the nav buttons onto one bar
Three things, all about how much line-work the eye has to read past.

A group's app count is gone from the nav and from its row in Settings.
It was decoration: the apps are right there to be counted, and in the
nav it sat where the eye lands looking for a name.

The Settings lists framed every row and then framed all three fields
inside it — four outlines a row, forty down the Apps list, and in light
mode they carried nearly the same weight as the text. Each list is now
framed once with hairline rules between rows, and the fields inside draw
no border until you hover or focus one. Reads as a table rather than a
stack of boxes.

Back, forward, reload and collapse now share a single tinted bar instead
of floating loose in the header, and the one under the pointer lifts out
of it. They are one instrument and now look like one.

The focus tell on a bare field is the border alone. A filled background
would have been the same white — slate-900 in dark — as the panel behind
it, which says nothing.
2026-09-03 22:27:12 +02:00
Vincent 9aac28c0cf Drop the title bar; drag the window by the nav
The page now runs to the very top of the window: titleBarStyle Overlay
gives the full size content view, so there is no reserved band above the
apps and the nav reaches the top edge with the traffic lights floating
over it.

That costs the old drag strip. A native webview swallows mouse events,
so the shell cannot offer a drag region anywhere over a page — once the
page starts at y=0 the only surface left is the nav.

So the nav becomes the handle, all of it, at either width. Every
container carries the attribute and only the buttons do not, which keeps
them clickable: a drag region is decided by the element under the
cursor. The scrolling app list is included, and loses nothing by it —
mousedown is all a drag region takes, never the wheel or a trackpad
swipe.

Verified by dragging from bare points in both the collapsed rail and the
expanded panel: the window follows the delta exactly, and clicking an
app still switches to it rather than moving the window.
2026-09-03 22:19:17 +02:00
Vincent 9d69038cac Let the login test page drive itself
Verifying the password flow needs a login to actually happen, and
driving one by hand means a human at the keyboard. ?auto=1 fills the
fields and logs in on load, with user, pass, succeed, rmdom and push as
query parameters so each trigger and the failure case can be exercised
without touching a real account.

Values are written with the native setter followed by a real 'input'
event, which is what a keystroke produces and what inject.js listens
for. Worth being clear about the limit: this proves the trigger fires
for a field that reached its value the way a browser reports it, not
that physical typing works.
2026-09-03 20:31:06 +02:00
Vincent 56e161911e Make the save-password dialog actually visible
Nothing had ever been saved: every app carried savedAccount=None and the
Keychain held no Work entries at all. So right-clicking "Fill saved
password" had nothing to recall — it was never a fill bug. The offer was
firing correctly on login; its dialog just could not be seen.

Two things hid it.

The stage was parked only for settingsOpen, so the password offer left
the apps where they were — and a native webview paints over anything the
shell draws. Every shell dialog needs the park, not just Settings.

Parking alone was still not enough. show_only and set_stage each move
every webview back to the stage, and the staggered bootstrap calls
show_only once per app as it builds them, so the park was undone within
milliseconds — the dialog appeared and vanished in a flash. Nothing
recorded that the apps were *meant* to be away, so every layout pass
raced to put them back.

AppState now carries that intent and placement() applies it, so a pass
that runs while a dialog is up leaves the apps parked instead of
returning them to the stage.

Verified end to end against a local login form: the offer appears and
stays on an XHR login that fires no submit event, the credential reaches
the Keychain, right-click fill puts it back, and a failed login is
correctly not offered for saving.
2026-09-03 20:30:58 +02:00
Vincent 229dd557cc Remove the M1 marker — this project runs locally again 2026-09-03 19:56:30 +02:00
Vincent 554d4c2c19 Add test-login page (preserved from M1 before removing the M1 setup) 2026-09-03 19:55:06 +02:00
Vincent 556201288a Merge M1's work before handoff 2026-09-03 19:44:20 +02:00
Vincent 72916d3c79 Tell the M1 agent how to browse: m1-browse, not the Chrome extension
The Claude browser extension cannot work on M1 — it links to a single
persistent interactive session, and the relay runs one-shot processes. It
drives a real visible Chrome over CDP instead, so manual intervention over
screen sharing still works and logins persist.
2026-09-03 19:44:19 +02:00
Vincent d26b1f7b29 Make the M1 marker side-aware: M1 is the worker, M5 relays
The marker is committed, so M1's own agent reads it too. The first version
told whichever side read it to run m1-say — on M1 that meant relaying to
itself. Now each side is told its role by a one-line check for the M1-only
token file.
2026-09-03 13:11:37 +02:00
Vincent b254d3c06e Mark this project as living on M1 (every chat relays there) 2026-09-03 13:10:14 +02:00
Vincent b47f9c3bef Merge M1's work: Rust toolchain pin + settings.local.json ignore
Reconciles the M5/M1 divergence from the rsync-based handoff. Both sides
had independently added the same .gitignore line; M1 also pinned the Rust
toolchain. Nothing dropped. M1 is a git remote from here on, so this
cannot recur.
2026-09-03 13:05:10 +02:00
Vincent c3fe0df10b Keep the M1 agent's own allowlist out of the repo
.claude/settings.local.json holds the permissions the headless agent on
M1 runs under. It belongs to that machine, not to this project, and
should neither be committed nor pushed back by the handoff sync.
2026-09-03 12:31:34 +02:00
Vincent 8cc34c48ed Add a handover note for forking
The useful thing to hand someone is not what the code does - the README
and the spec cover that - but the constraints that cost time to find:
native view layering, why an inactive webview must not be hidden, why a
background page must not be told it is hidden, and the several APIs that
deadlock or silently return a fallback when called from the wrong thread.
2026-09-02 20:00:44 +02:00
Vincent 36506718c2 Make the download card visible, and the events arrive at all
Two separate faults, both hiding the same feature.

The events never reached the shell. They were emitted from inside
WebKit's download delegate, which runs on the main thread, and delivering
an event means running JavaScript in a webview - which cannot happen from
in there. They are now emitted from a spawned task, like every other
event in the app that works.

And the card was drawn over the page, where it could never be seen: an
app's webview is a native view painted above everything the shell draws,
so a card over the page is a card behind the page. It now lives in the
nav, which is the shell's own - full detail when the nav is open, just
the state icon on the rail.

Verified end to end on a Gmail attachment: requested, named, saved, and
shown as "Downloaded" with a Show button that reveals it.
2026-09-02 11:07:22 +02:00
Vincent 04b731cf37 Say when something is downloading, and where it goes
The webview saves the file perfectly well and mentions it to nobody,
which makes a download indistinguishable from a click that did nothing.
Each one is now announced in the corner as it saves, and offers to show
the finished file in the Finder. The folder is settable and defaults to
the system's Downloads.

Two gaps in the API shape this. There is no progress - DownloadEvent
reports a start and a finish and nothing between - so the destination
file is polled as it grows and the bytes written are shown; the indicator
spins rather than fills, because there is no total to divide by. And on
macOS the finish always reports no path at all, so the destination
assigned at request time is remembered against the URL and read back at
the end.

A name already taken gets "(2)" appended. Silently overwriting is the
last thing anyone wants from a download they were not told about.
2026-09-02 10:42:45 +02:00
Vincent e1ac3d7509 Size the rail to the traffic lights instead of guessing
The collapsed rail was a fixed 72px, which left the cluster with more
room on its left than its right. The width now comes from the buttons
themselves - the close button's own inset plus the zoom button's right
edge - so the margin matches on both sides. That inset is macOS's to
choose and has changed between releases, so it is asked for rather than
assumed.

Measured at startup rather than on demand. The answer arrives on the main
thread, and a command waiting for it there deadlocks until the timeout
and silently returns the fallback - which is what the first attempt did.
2026-09-02 10:07:32 +02:00
Vincent 0ae5746a30 Give the title bar strip back its drag, and the rule its full height
The window's content runs the full height underneath the title bar, so
the shell covers the bar - and a webview covering the bar swallows the
gesture that moves the window. The strip standing in for it now says it
is a drag region.

That strip also moves inside each column instead of spanning both, so the
rule between the nav and the app runs the whole height of the window
rather than starting below the bar.
2026-09-02 09:44:51 +02:00
Vincent aa19da48de Stop announcing the same email twice
Both notification paths feed one channel, which is how a single arrival
came to be announced twice: Gmail names the sender, then the unread count
says "1 new" behind it seconds later.

A count is now skipped when the app has spoken for itself in the last
twenty seconds - generous, because a count is only noticed on a
four-second tick, well after the app raised its own. Settings can also
turn count notifications off outright, for hearing only what an app says
in its own words, at the cost of the tools that never say anything.

`npm run ship` now updates only /Applications. The Desktop installer
moved behind `--dmg`, for when a build is going to someone else.
2026-09-02 09:36:57 +02:00
Vincent c989fef6fe Ship a drag-to-Applications installer instead of a zip
The Desktop artefact is now Work-<version>.dmg rather than a zip: an
installer is what someone expects to be handed, rather than a bare bundle
to file away themselves. Same single build behind both destinations.
2026-09-02 06:36:28 +02:00
Vincent c96154ca33 Stop insetting apps by the title bar twice
Painting the title bar showed that the window's content view runs the
full height including the bar, so the shell was given a top inset to keep
clear of it. Child webviews were still being offset by the same amount on
top of that, which left a title bar's worth of empty space above every
page.

The shell and a child webview share an origin, so no correction is needed
between them: the shell pads itself, and the rect it reports is already
right. The measurement stays, for that padding only.
2026-09-01 19:23:11 +02:00
Vincent 899f0b41c4 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.
2026-09-01 19:17:38 +02:00
Vincent cccc6caa1e Generalise a measurement in the spec that named an inbox
The end-to-end note for background liveness quoted a real unread count
from a real mailbox. The point it makes is that a background app's count
rises untouched, which it makes just as well without the number.
2026-09-01 16:42:04 +02:00
Vincent cc0b370c4d Stop the first-run seed carrying one company's tools
A build gets sent to testers, and this one arrived with the apps it was
developed against baked in as defaults - including an internal Odoo
hostname and a per-person Google account index in every path. Nothing
personal ships inside the bundle, but the seed is compiled into it.

The seed is now three generic Google apps that link to each other, which
is enough for a tester to see app switching work without learning where
anyone works.

A test asserts it stays that way: every seeded URL must be a plain
google.com host with no account index. It would otherwise drift back the
next time the seed is edited for convenience.
2026-09-01 16:07:59 +02:00
Vincent 2a0b934d04 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.
2026-09-01 16:04:54 +02:00
Vincent 07bb22eb2d One border weight, and no sliver beside the nav
The nav's right edge read as two lines because it was two things: its own
border, and a sliver of the stage's background showing through a
sub-pixel gap before the app's webview began. The stage rect is now
rounded to whole pixels and to the same edges each time, so the app
covers the stage exactly.

Borders are one weight throughout and the lightest that still separates -
slate-200 in light, slate-800 in dark. Nothing drawn heavier than it
needs to be to read as an edge.
2026-09-01 15:32:21 +02:00
Vincent 0567a4b7c7 Offer to save a password when a login is submitted
Submitting a form containing a password now offers to remember it.

It goes into the macOS Keychain, through the Security framework rather
than the `security` binary - a password passed as a command-line argument
is visible in `ps` to anyone on the machine, however briefly. Never
apps.json, never a log.

The value travels as little as it can: the injected script hands it
straight to Rust, which holds it in memory and tells the shell only which
host and which username, since that is all the shell needs to ask the
question. It is written on Save and dropped on anything else.

Autofill is deliberately not built. Reading a password back out and
injecting it into a page is a materially larger surface than offering to
store one, and deserves its own decision.
2026-09-01 15:26:53 +02:00
Vincent 3aa9d9c3f7 Empty-cache menu item; catch two more ways an app can notify
Right-click now offers "Empty cache and reload". Caches only - never
cookies or local storage: emptying a cache is what you do when a page is
serving something stale, and signing you out while doing it would be a
different and much less welcome feature. The reload waits for the removal
to finish, or it would refill from what was being thrown away.

Google Chat could play its own alert sound and still reach none of this,
because some apps never construct a Notification - they hand it to their
service worker registration instead. ServiceWorkerRegistration.prototype
.showNotification now routes to the same place. A worker calling it from
its own context is still out of reach; a page calling it is not.

Also shims the badge API, which is the precise form of an unread count -
a number an app states outright rather than one scraped from its title.
WKWebView does not implement it, so an app calling it was talking to
nobody. Chat's title carries no count at all, which is why no counter
ever appeared for it.

The background-apps probe now reports all three signals per app, so which
one an app actually uses is a reading rather than a guess.
2026-09-01 15:23:46 +02:00
Vincent 5eb9157b7e Place apps below the title bar, not behind it
A child webview is positioned against the window frame while the shell
measures the hole it left from inside the content view. Borderless, those
origins coincided and it never showed. With a title bar back they are a
title bar apart, so every app was drawn that much too high - painting
over the right-hand part of the bar, which read as the bar being tinted
by whichever site was open, and leaving a strip of the same height along
the bottom.

Tauri cannot report that height. Both inner_position/outer_position and
inner_size/outer_size come back identical on macOS - measured, both
differences were zero against a window whose content is plainly a title
bar shorter than its frame. NSWindow.contentLayoutRect knows, so it is
asked once and cached.

Also hides the window title, and centres the navigation row in its own
strip rather than letting it crowd the bar above it.
2026-09-01 15:14:56 +02:00
Vincent 17d13d2505 Show each site's own icon; restore the native title bar
Both previous attempts asked a question about a domain, and a domain does
not know which product it is serving. Google's favicon service returned a
marketing site's icon for anything behind a login and nothing for a
private host; Simple Icons returned one flat brand mark where the real
one is multicoloured and, for Gmail, carries the unread count.

The page already holds the answer - fetched, authenticated, current. The
injected script now reads link[rel~="icon"] and reports the best one:
largest declared sizes wins, an Apple touch icon counts as 180, and an
.ico is penalised as usually the 16px tab icon. It rechecks on the same
tick as the unread count, which is when a site like Gmail redraws its
icon with a badge. The URL is stored, so the nav is right at launch
rather than blank until every page has loaded.

The custom frame is gone with it: ordinary macOS title bar, traffic
lights where every other window puts them, and the bar following the
app's Light/Dark choice through set_theme. A window that behaves like a
window beats one that looks bespoke.
2026-09-01 15:03:09 +02:00
Vincent ffedb72b83 Brand colours on the marks; round the app's corners; move the lights in
Simple Icons publishes each brand's own hex next to its glyph, and the
build was throwing it away. Tiles now carry it - Gmail red, Drive blue,
Chat green, Gemini violet - and the glyph is drawn black or white by the
tile's perceived brightness, since brand colours are picked to look right
rather than to carry a white mark. GitHub and Notion are near-black,
Snapchat is pure yellow, and a fixed white glyph loses one end of that.

An app's right corners are now rounded on its own layer. The container's
rounded-xl could never have clipped them: an app is a native view sitting
on top, not something the shell lays out, so it kept square corners and
overhung the curve. Only the right pair - the left edge butts against the
nav, and rounding it would notch the middle of the window.

The traffic lights move to (26, 24) and the drag strip deepens to match,
so they sit inside the window's margin instead of against its corner.

Adds Gemini, Claude, ChatGPT, Linear, ClickUp, HubSpot, Shopify and
Cloudflare to the host table.
2026-09-01 14:51:20 +02:00
Vincent ada18d54ae Draw apps with Simple Icons instead of fetched favicons
The favicon service was wrong as often as it was right: a sign-in page's
icon for anything behind a login, nothing at all for a private host, and
both answers cached past any way of asking again. Refreshing could not
fix it, because the staleness was not local. Now every mark is.

Each app is its brand glyph in white on a round tile, coloured from
Tailwind's 500s by hashing the host - you find things by their colour, so
one that moved every launch would be worse than none. A host with no
glyph gets its initial in the same tile.

Matching tries the registrable name first, since a self-hosted tool is
nearly always on a subdomain of its vendor - aputure.odoo.com is Odoo,
not Aputure. A short table covers what a domain cannot answer, which is
most of Google.

The build reduces Simple Icons' 15MB of SVGs to one 4.5MB map in public/,
fetched once rather than parsed into the bundle at every start; the
bundle stays at 233KB. It is generated on every build, so never committed
and never stale.

Traffic lights are offset to sit inside the window margin rather than
crowding its edge.
2026-09-01 14:45:41 +02:00
Vincent 1b6dabab0a 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.
2026-09-01 14:31:51 +02:00
Vincent 8f989fb8f6 Keep background apps alive, and notify from their unread count
The report was that a backgrounded site dies slowly and stops fetching.
It did, for three separate reasons, and the first two fixes each traded
one failure for another:

Hiding inactive views lets WebKit suspend them, so nothing is hidden any
more - every view keeps its size and stays in the window, and the active
one is ordered on top.

Telling a background page it was hidden then made Gmail throttle its own
syncing; its unread count sat unchanged for two and a half minutes. That
spoof is gone.

Neither was enough on its own: a background page keeps its timers but
loses the connection its updates arrive on, and WKWebView has no
equivalent of Electron's backgroundThrottling. Background apps are now
poked every 45s with the events a page uses to catch up after you return
to a tab - it fetches without losing a half-written reply.

Notifications now come from the unread count in the title rather than
the site's notification code, which Gmail will not run while it believes
you are looking at it. Measured: Gmail behind Odoo went 155 -> 157
untouched and raised "Gmail / 1 new".

Also adds a background-app probe to Settings, which is what turned this
from guesswork into measurement, and drops the nav's blocked-element
count and the hide-element button.
2026-09-01 14:10:31 +02:00
Vincent 21a9f065f0 Make notifications appear on screen, and be clickable
Three separate faults, all mine:

set_application was never called, so mac-notification-sys looked up an
app named "use_default", failed, and posted every notification as
com.apple.Finder - wearing Finder's alert style rather than this app's.

NSUserNotificationCenter suppresses the banner whenever the posting app
is frontmost unless the delegate implements shouldPresentNotification:.
The crate's delegate implements only delivery and activation, so the
method is added to its class at runtime. Without it, Gmail notifying
while you sit in Odoo - same window, still frontmost - is never seen,
which is the case the whole design exists for.

send_notification only waits for a response when the options ask it to.
Passing None returned instantly with NotificationResponse::None, so the
click branch was unreachable. Waiting parks a thread and a notification
left unread never resolves, so waiters are capped at 32.

The diagnostic now records the raise before blocking, since with
wait_for_click a notification sitting on screen otherwise read as
"none yet".
2026-09-01 13:25:02 +02:00
Vincent 3525d454bf Drop cookie import; click-through notifications; per-app zoom
The cookie import is gone. It worked mechanically - 43 cookies decrypted
from Arc and verifiably visible to the page - but Google, Microsoft and
Odoo all refused the imported sessions, because each binds a session to
the browser that created it. Signing in once inside the app is simpler
and actually works, so the whole path is deleted rather than kept as a
feature that mostly fails. That takes rusqlite, aes, cbc, pbkdf2, hmac,
sha1 and sha2 out of the build with it.

Notifications are now raised through mac-notification-sys rather than
Tauri's notification plugin, because the plugin cannot report that one
was clicked. A click switches to the app that raised it and then runs
the page's own click handler - the only thing that knows which message
the notification was about.

Zoom is per app, on a fixed ladder so Cmd+0 returns to exactly 100%.
The shortcuts are menu-bar accelerators rather than a key listener,
since the keystroke has to work while a remote page has focus.

The hidden-element count is off the nav rows.
2026-09-01 12:53:52 +02:00
Vincent f057268103 Prove cookie injection lands, rather than assuming it
setCookie is fire-and-forget, so the import's count was what WebKit was
handed, not what it kept. A probe from inside the page reads back the
other end: pairing against Arc gave `names=tz,cids,frontend_lang` for
aputure.odoo.com, and `tz` exists only in Arc's store — so the import
demonstrably landed.

The sites still ask for sign-in. That is the far end refusing the
session, not a broken import, and the two are now distinguishable
instead of being guessed at.
2026-09-01 12:21:41 +02:00
Vincent ff4a0c6bc4 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.
2026-09-01 12:17:31 +02:00
Vincent e369c82774 Fix collapsed Settings fields; add the ship script
INPUT carried w-full, which lost the specificity coin-toss against the
group select's w-[110px] and starved the name and URL fields to stubs.
Width now belongs to the call site.

Settings lists apps in the nav's order rather than by position alone,
so the two never disagree.

scripts/ship.sh builds, replaces /Applications/Work.app and relaunches.
2026-09-01 11:42:42 +02:00
Vincent a6c8e4336b Work App: a dedicated browser for work tools
A Tauri 2 shell with one child webview per configured tool. Nav on the left
with groups and a collapsible icon rail; links between configured apps switch
tabs, everything else leaves for the real browser.

Design spec in docs/superpowers/specs/2026-09-01-work-app-design.md.
2026-09-01 11:37:35 +02:00