Note
tlah-chia-LO-nee in Nahuatl means viewer (a device for viewing, seeing)
A minimal local browser for frontend development: a frameless window that renders a local dev server edge-to-edge with real Chromium DevTools docked inside the same window, and essentially no other browser chrome.
Each window is one target, and you can open as many windows as you have dev servers (⌘N), each fully independent.
The only UI is transient and keyboard-first:
-
⌘N: open a new window (blank, with the location entry focused and the logo watermark on the empty page);⌘Wcloses it
-
⌘P: toggle the command palette — type a target like:5173, or run a command;Tab/Shift+Tabcycle its groups (Location, DevTools, View, Theme, Extensions, Other)
-
⌘L: edit the current target, with recent pages listed and grouped by origin
-
⌘T: jump to the theme picker
-
Drag from the top of the window at any time, pointer only; hovering the top edge or resting there briefly reveals the strip
-
⌘B: pin the window strip on screen (and, with it, the macOS window controls)
-
⇧⌘F: toggle titlebar mode: the strip docks permanently as a title bar and the page moves below it, so the target and controls are always visible without covering the page
-
⌘⌥J: toggle DevTools;⌘1/2/3dock it bottom/right/left;⌘Jmoves keyboard focus between the page and DevTools
-
⌘⇧C: element picker with hover highlight
-
⌘R/⇧⌘R: reload / hard reload — a rim wraps the page while it loads; the same rim mirrors link clicks and history moves that keep the current page up, while a site switch still uses the full-screen veil
-
⌘←/⌘→: back / forward (native text behavior inside inputs) -
Two-finger horizontal swipe on the trackpad — back / forward, with an armed edge overlay that grows as the swipe commits and recedes as it cancels (the strip's back/forward buttons reflect the same availability)
Everything is themed with Tlapalli (eight mineral variants, dark/light following the system) in Source Code Pro. Theme is per-window: each window can use its own variant and color mode, and the docked DevTools are repainted to match it — chrome, syntax colours and selection included. Only the guest page still follows the OS.
Chrome extensions can be installed at runtime from the command palette — nothing is bundled. Paste a Chrome Web Store link or extension ID and choose Install extension …, or choose Install Extension from Folder for an unpacked extension. Installs show a transient status surface with a spinner, the current stage, and a download progress bar; each installed extension also appears in the palette as a row you can enable, disable, update, or remove.
Extensions load only into the guest page, never into the shell UI. Electron
runs Manifest V3 background service workers, but a worker that throws while
starting up is torn down — and Electron doesn't compile chrome.debugger, so a
worker that touches it dies and takes its registration with it. That matters
more than it sounds: some framework developer tools use that background as the
bus between their DevTools panel and the page, so with the worker dead Angular
DevTools reports that your app isn't there while it can plainly see it. A
Manifest V2 background page survives the same throw, so when an extension has a
service worker background the app writes an MV2 rewrite of its manifest
alongside the installed copy and loads that instead, which gives the extension a
background context that stays alive. Only the manifest changes — no extension
code is patched, and the installed copy stays exactly as the store served it.
Because that changes how your extension declares itself, it says so: installing
or re-enabling one raises a warning that stays until you dismiss it, and its
palette row carries an MV3→MV2 badge so you can see at a glance which
extensions were converted. If a rewrite fails to load, the app falls back to the
authored copy and the row keeps its MV3 badge, because then the authored
worker is what runs and may not survive.
The rewrite is not applied to everything, because Manifest V2 cannot express
everything Manifest V3 can. Two cases are handled rather than converted: an
extension that runs part of itself in the page's own JavaScript world
(world: "MAIN", which MV2 has no equivalent for) is left on its authored
manifest, and a background worker that loads its code with importScripts (also
MV3-only) has those imports hoisted into the MV2 scripts list. Converting the
first kind would break the extension outright to fix a background it may not even
need — Vue.js devtools is the case in point, as it reaches the page without its
background at all, worked before, and would not have worked after.
Two limits survive all of this. chrome.debugger is the reason the rewrite
exists at all — Electron doesn't compile the namespace, so signal breakpoints
inside framework debuggers won't work — and chrome.scripting is Chrome-only
surface Electron doesn't compile either, so an extension that leans on it for
injection still won't. axe DevTools leans on chrome.debugger for its entire
product, and Electron does not ship that namespace at all, so expect its panel to
open and then report that it cannot analyse the tab. That is the accurate outcome,
not a bug to chase. See specs/019-mv2-background-shim/spec.md for the details
and spikes/mv2-background-shim/ for the spike that established this works on
Electron 44 — and the correction below it, which records that Electron does host
MV3 service workers and that chrome.debugger is what kills them.
A rewritten extension only affects pages that load after it: content scripts are injected when a page loads, so an extension activated afterwards never reaches the page already open. Reload the page, then re-select the extension's DevTools panel — it stops looking for the application after about ten seconds.
A page can embed a frame that refuses to load. Chromium then paints nothing
useful inside it — no error page, just an opaque white canvas — so a refused
embed looks like a white hole punched in the page. Tlachialoni marks it: the
frame's own rectangle is filled with the window's base surface, so the place
reads as empty down to the bottom of the window, and the address and reason sit
inside it. When the browser reports which policy refused the frame, the reason
names it (Content-Security-Policy: frame-ancestors or X-Frame-Options);
otherwise it says the response headers blocked the frame. Connection refused, an
unresolvable name, an offline network, and a timeout use the same wording as the
main-frame failure view.
Each failed frame gets its own marker, and the marker is drawn inside the page, so it moves in the same layout pass as the frame: no trail when you scroll, zoom, or drag the window edge. Chrome and Firefox put their own error document in a blocked frame slot; Electron has no such hook, so the box is the closest equivalent. It is one transient node in a closed shadow root, it cannot take input, and it is removed when its iframe leaves the page or is repointed, or when the page navigates. It cannot be dismissed, because removing it would bring the white canvas back. The page's own main-frame failure still uses the full failure view.
- macOS 13+ (Apple silicon)
- Node 24.21+ and pnpm 11+
pnpm install
pnpm dev # build and launch with renderer HMR
pnpm build # production build
pnpm preview # run the production build
pnpm check # format, lint, and type checks (Vite+)
pnpm test # unit tests (Vitest)The default target is http://localhost:3000.
A development run is a separate app from the installed one, in every place macOS can tell two apps apart:
| Installed app | pnpm dev |
|
|---|---|---|
| Dock / ⌘Tab name | Tlachialoni | Tlachialoni Dev |
| Bundle identifier | …tlachialoni |
…tlachialoni.dev |
| Profile directory | …/Tlachialoni |
…/Tlachialoni Dev |
The separate profile is the point: cookies, installed extensions, and saved
windows live in ~/Library/Application Support/<name>, so a shared profile means
an HMR session overwrites what the installed app restores on its next launch.
app.setName() alone cannot rename the dev app, because macOS reads the visible
name from the bundle. scripts/dev-identity.mjs (run automatically before
dev, dev:inspect, preview, and screenshots) rewrites the three relevant
Info.plist keys in node_modules/electron/dist/Electron.app and re-signs it
ad-hoc. Two consequences:
- Reinstalling
electronreverts it. Nothing breaks — the nextpnpm devdetects the original plist and re-patches. - Editing that plist invalidates its signature, which is why the script re-signs. Removing the re-sign makes Electron fail to launch with a message that gives no hint of the cause.
To start over, delete ~/Library/Application Support/Tlachialoni Dev.
The transient shell surfaces are hard to trigger on demand (a slow server for the loading veil, a dead one for the failure view, a real download for the install status, a site that refuses to be framed). In a dev build, the Developer menu puts each one into a representative state and holds it there so you can style it with live HMR:
- Preview Loading Veil
- Preview Failure View
- Preview Extension Install (loops every phase, including progress)
- Preview Frame Notice (a frame refused by its own response headers)
- Preview History Navigation (alternates the armed edge overlay)
- Stop Preview
The extension preview can also start with the app:
TLACHIALONI_DEMO_STATUS=1 pnpm devThe Developer menu is absent from packaged builds. See
specs/008-surface-preview/spec.md.
The docked DevTools are repainted with the window's Tlapalli variant and colour
mode — chrome, syntax colours and selection included — instead of following the
OS. Only the guest page still follows the system appearance. See
specs/022-devtools-theme/spec.md.
Two dev-only affordances make the palette tunable:
pnpm dev:inspect # dock DevTools and inspect the DevTools front-end itselfpnpm dev:inspect runs dev with TLACHIALONI_INSPECT_DEVTOOLS=1, which docks
the panel and opens a second window inspecting the DevTools front-end over the
remote debugging protocol — so its Elements and Styles panes show which custom
property each rule consumes. The port defaults to 9333
(TLACHIALONI_INSPECT_PORT). It appends a local debug port, so it is gated to
unpackaged builds.
devtools-theme.override.json at the repo root (gitignored) is read live: edit
it and the panel repaints within about a second, no restart. Any custom property
can be moved, and it is applied last, so it beats the generated palette:
{ "dark": { "--sys-color-tonal-container": "hsl(178 75% 13%)" }, "light": {} }The path is printed at startup as devtools theme override: …, and every load
logs a summary line. Because the file is gitignored, VS Code's search skips it
until Search: Use Ignore Files is turned off.
pnpm package # build a standalone macOS app into release/
pnpm package:local # build a testable variant into release-local/This produces release/mac-arm64/Tlachialoni.app plus a .dmg and a .zip.
Copy the .app to /Applications and launch it like any other Mac app — no
repository or terminal required.
pnpm package:local builds release-local/mac-arm64/Tlachialoni Local.app,
which differs from the shipped app in its bundle id, its Finder name, and its
profile directory. Use it to check a build without disturbing real state.
It exists because a build sitting in release/ alongside an installed copy is
its own hazard: both carry com.ackzell.tlachialoni, so macOS treats them as one
app and opening the second activates the first rather than launching it. Either
use package:local, or keep the installed app and delete
release/mac-arm64/Tlachialoni.app — don't keep both.
The build is unsigned (ad-hoc) for local use, so macOS blocks the first launch of a copy that carries a quarantine flag. To open it, right-click the app → Open, or run:
xattr -dr com.apple.quarantine /Applications/Tlachialoni.appThe icon is generated from committed artwork by scripts/build-icons.sh
(resources/logo.svg → resources/icon.png → resources/icon.icns). Developer ID
signing and notarization are intentionally deferred; electron-builder.yml leaves
room for them.
Releases are cut from Conventional Commit messages. The version lives only in
package.json and drives the app bundle, the artifact filenames, and the About
panel.
pnpm tag:first # once: tag the current version as the baseline (e.g. v0.1.0)
pnpm tag # bump by the commits since the last tag, update CHANGELOG.md, commit, tag vX.Y.Z
pnpm tag:minor # force a minor bump
pnpm tag:major # force a major bumpTag first, then package, so the artifact carries the release's date:
pnpm tag && pnpm packageThe About panel shows the logo, the version, and the release date taken from the
v<version> tag's date (a build made before the tag is created falls back to the
build date).
Pushing a v* tag to GitHub triggers a workflow that builds the app and creates a
GitHub Release with the .dmg and .zip artifacts attached:
git push --follow-tagsThe workflow (.github/workflows/release.yml) runs on a macOS runner, verifies
the tag version matches package.json, builds with pnpm package, and publishes
the release with the changelog as the release notes. Artifacts are ad-hoc signed
(no Developer ID), matching local builds. See specs/021-github-release-artifacts/spec.md
for details.
This project is built spec-first with Spec Kit. See:
.specify/memory/constitution.md— project principlesspecs/001-minimal-browser/spec.md— the feature specificationspecs/001-minimal-browser/plan.mdandtasks.mdspecs/003-standalone-packaging/spec.md— standalone app packagingspecs/004-shell-motion/spec.md— motion for the transient shell surfacesspecs/006-release-versioning-about/spec.md— release versioning and the About panelspecs/007-extension-support/spec.md— installing and managing extensionsspecs/008-surface-preview/spec.md— previewing transient shell surfaces in developmentspecs/009-macos-traffic-lights/spec.md— native window controls bound to the stripspecs/010-palette-groups/spec.md— grouped command palette and host-grouped recentsspecs/011-grouped-os-menu/spec.md— domain-grouped application menuspecs/012-multi-window/spec.md— multiple independent windowsspecs/013-always-on-drag-region/spec.md— always-draggable window with a hover-revealed stripspecs/014-blank-watermark/spec.md— the logo watermark on a blank, never-loaded windowspecs/015-trackpad-swipe-navigation/spec.md— two-finger swipe history navigationspecs/017-macos-dock-menu/spec.md— the macOS Dock window menu and Dock-icon behavior (macOS now stays open when the last window closes, so the Dock stays available)specs/019-mv2-background-shim/spec.md— running extensions whose MV3 background dies herespecs/020-extension-load-narrowing/spec.md— pending: narrow that rewrite to the extensions that need it, so most run as authoredspecs/022-devtools-theme/spec.md— repainting the docked DevTools from the window's Tlapalli variantspecs/028-frame-load-status/spec.md— reporting frames inside the page that could not load
MIT — see LICENSE. Theme color values are derived from Tlapalli (MIT); see
NOTICE.



