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.
Work
A desktop browser for one thing only: the web tools you work in. A left nav lists them, clicking one shows it, and links between them navigate inside the app. Everything else opens in your real browser.
Tauri 2 · Rust · React 19 · Tailwind CSS 4 · Simple Icons · macOS
There is no address bar, no tab strip, and no way to reach a site that is not on the list. That is the point.
How it works
Every app is its own webview. Not an iframe — Google, Microsoft and most SaaS send
X-Frame-Options: DENY, so an iframe-based version of this app cannot exist. Each tool
gets a real child webview (Window::add_child), and they all stay loaded, so switching
keeps your scroll position, your half-typed draft, and anything counting down.
The shell measures, Rust positions. A child webview is a native view that takes no part
in CSS layout. The React shell leaves an empty <div id="stage">, measures it with a
ResizeObserver, and reports the rect; Rust sizes the active webview to it and hides the
rest. It also means a native view paints over anything the shell draws, which is why
opening Settings hides the stage first.
Links are routed by intent, not by URL alone. An injected script catches genuine user
clicks and target=_blank, and only those. A URL outside the app's own hosts is handed to
Rust over a made-up workapp-route: scheme — deliberately not Tauri IPC, which would mean
granting google.com the ability to call into this app. Rust then decides:
| Target | What happens |
|---|---|
| The current app's hosts | Nothing — ordinary navigation |
| A known identity provider | Stays inside, so SSO can complete |
| Another app on your list | Switches to that app and navigates it |
| Anything else | Opens in your default browser |
Redirects are never blocked. on_navigation returns true for everything that is not the
sentinel, because a strict navigation filter breaks every OAuth chain the moment it bounces
through accounts.google.com.
Sessions persist. Each webview keeps its cookies across restarts, so you log into a tool once and it sticks. Every app also claims a real Chrome user agent by default, because Google refuses logins from anything it identifies as an embedded webview.
Notifications work, and clicking one lands on the message. WKWebView defines
window.Notification but it is inert: constructing one throws nothing and shows nothing,
so a page believes it notified you and you never hear about it. It is replaced with a shim
that forwards to a real macOS notification carrying the app's name.
Clicking the banner switches to that app and then runs the page's own click handler,
which is the only thing that knows which message it was about — Gmail opens the thread,
Chat opens the conversation. This is why notifications are raised directly through
mac-notification-sys rather than Tauri's notification plugin: the plugin has no way to
report that a notification was clicked.
Service-worker push in the background is not covered — only notifications a page raises while it is open.
Zoom is per app. ⌘+ and ⌘− step a ladder that always returns to exactly 100% with ⌘0, and each app remembers its own size. The shortcuts are menu-bar accelerators rather than a key listener, because they have to work while a remote page has focus.
Anything on a page can be hidden. Right-click it and choose Hide this element, or
use the eye button in the nav to point at one (arrow-up widens the selection to the
parent, Escape cancels). Selectors are stored per app, listed in Settings, editable, and
reversible. The rule is re-asserted on every navigation and re-added if a single-page app
rewrites <head>, and cached in the page's own storage so a reload hides it before the
first paint rather than after it has flashed on screen.
Apps and groups
An app owns the exact host of its URL — mail.google.com, not google.com — or Gmail
and Drive would each swallow the other's links. Where two scopes match, the longest wins.
Extra hosts can be added per app.
Groups are for the nav only. Deleting one keeps its apps, ungrouped: deleting a folder should never be a way to lose the things inside it.
The nav collapses to a 72px icon rail that is still clickable, so switching apps never requires expanding it first. It is that wide because the macOS traffic lights have to fit inside it: there is no title bar and no toolbar, so the nav carries the drag strip.
Back, forward, reload and hide-an-element sit next to the cog at the top of the nav. There is no toolbar — two-finger swipe goes back and forward.
Running it
npm install && npm run tauri dev
Shipping it
Build, replace the copy in /Applications, and relaunch — one command:
npm run ship
That replaces /Applications/Work.app and relaunches it.
To hand a build to someone else:
npm run ship -- --dmg
which also writes ~/Desktop/Work-<version>.dmg from the same build, so a tester runs
byte for byte what was just verified rather than a second build that drifted. It is the
ordinary drag-to-Applications disk image.
The app is ad-hoc signed, so the first launch on someone else's Mac needs right-click → Open rather than a double-click. Gatekeeper refuses it silently otherwise.
Configuration
apps.json, under ~/Library/Application Support/com.vincent.workapp/. Hand-editing it is
supported — missing fields fall back to their defaults, and an app with no scope gets its
URL's host.
Tests
cd src-tauri && cargo test
Routing and config are pure and carry real tests: scope matching and its precedence, identity-provider passthrough, the routing decisions, sentinel round-trips, and config round-trips. Webview orchestration and bounds sync have no seam a unit test can reach and are verified by running the app.