list. is not a nickname pasted onto a weekend project. It is the first part of list.mckinnon.ai, the private address my family opens in a browser.
On a device connected to Tailscale, that address resolves inside our private network. Caddy reads the hostname and sends the request to the list service. Outside Tailscale, the app does not open. The same pattern holds for todo.mckinnon.ai, shop.mckinnon.ai, fit.mckinnon.ai, and the rest of the small tools we use.
That is the heart of the system. The product name, browser address, access boundary, icon label, and app-switcher label all come from the same word. The visual design did not create that family. It made the private address book visible.
The family should not have to remember which weekend produced which app. A grocery list, a trip idea, and a workout may run on unrelated code, but on a phone they should feel like doors in the same house.
That is why the names are todo. list. shop. fit. golf. watch. home. trips. and vault. — a lowercase word, then a period. The period became the smallest possible family resemblance. The shared icons, colors, and switcher do the rest.
![]()
Underneath, the apps use four different frameworks and several deployment paths. The consistency is for the people opening them. The disagreement underneath is mine to maintain.
The product name is also a route
The period is the cut line. Everything before it identifies the product. Everything after it is the private domain the products share.
list.mckinnon.ai ─┐
todo.mckinnon.ai ─┼─ Tailscale ── Caddy ── the matching private app
shop.mckinnon.ai ─┘
Caddy’s side of this is intentionally ordinary:
list.mckinnon.ai {
reverse_proxy <private-service>:<private-port>
}
The placeholders are the useful security boundary. The architecture is worth publishing; the machine coordinates are not. A wildcard DNS record and wildcard certificate mean a new app does not need a new networking project. It needs a name and one Caddy route. I wrote about the certificate and routing mechanics in the longer infrastructure post.
This also explains why the launcher matters. It is not a gallery of related logos. It is the human version of the routing table. Choose shop. and the browser goes to shop.mckinnon.ai. The name does not need translation at any layer.
The rule that does most of the work
I stole this rule from the email side of my infrastructure. There, it is written down:
The bones are identical across every product. Each product gets its identity through accent color plus one signature element — never through the type stack or the layout.
The reader knows the shape first. Then the reader knows which one by its color.
Two sentences hold the entire design philosophy. Each temptation to make shop. feel different from todo. is the temptation to make nine hobby projects. A special font, a special layout, rounder corners — all refused. Identity gets exactly one channel. Everything else is shared.
Consistency is the cheapest credibility there is. Nine apps share a wordmark, a type stack, and a palette. Nobody looks at that and thinks “hobby project”. They think a person is in charge.
The visual system
The name is the logo. A lowercase word and a period, set in JetBrains Mono at weight 500. The period is the only colored item on the screen: #1F4DC2, a flat cobalt blue. That is all. No mark, no glyph, no wordmark from a designer. todo. is the logo of todo.
Monospace works for a wordmark for a reason people do not usually say. The period is normally the least important character in typography. In a monospace face, the period occupies a full character cell. In a proportional face, a period is a crumb. In a mono face, it is an event. Give the period the accent color, and punctuation becomes a brand.
The palette is paper, not app. A white surface. Near-black ink with a cool tint (#14171D). A stepped gray ramp for rules and metadata. Cobalt for the accent, plus three status colors. No gradients, no drop shadows, a border radius of 2–4px at most, and hard hairline rules between sections.
The chrome is an IDE. Every app has a fixed tabbar at the top and a modeline at the bottom. These two pieces of furniture make a code editor recognizable from across a room. The metaphor is deliberate. These are tools for a person who lives in a terminal. So they have the shape of one.
The type has a strict job split. Mono is for headlines, chrome, labels, timestamps, and numerals. Inter is for anything a person reads as a sentence. That rule exists because of a real bug. During a migration, a token that pointed to a serif face silently started to point to JetBrains Mono. Every call site that used that token for prose rendered its paragraphs in monospace.
The worst case was a 1,200-word email, set fully in code font. It arrived in my inbox with the look of a ransom note. I wrote the rule down that day: mono is for kickers, metadata, and display numerals; Inter is for sentences.
The icon template is a specification, not a vibe
This is the part of the system I would defend in an argument. “Make the icons consistent” is the type of instruction that quietly produces nine different icons.
The app icon is a white rounded square. One window dot sits in the top-left corner. Four tabbar bars sit along the bottom-left. The first bar is cobalt, and the other bars are at 25% ink. The lowercase wordmark ends in the cobalt period. It is the IDE chrome again, at 512 pixels.
One rule keeps names of different lengths in the same family:
Hold the optical width constant — about 64% of the icon width. Do not hold the font size constant.
So fit. (three glyphs plus the period) sets at font-size 31. todo. and shop. (four glyphs plus the period) set at 24. The letter-spacing is −0.035em. The baseline sits at y = 50 + 0.2494 × fs on a 100-unit viewbox. Every raster is a render of the SVG through rsvg-convert — never a hand-edited PNG. Hand-edited PNGs are how a family drifts.
Favicons are a separate problem, and they get a separate answer. The full icon at 32px is unreadable mush. So the favicon is the first letter plus the blue period on a transparent background — t. s. f. The letter form is a decision, not a simplification.
The consistency check that embarrassed me
When the newest icons were done, I ran a lineup test. I put an unlabeled row of icons in front of a grader. The row held the two new candidates next to the shipped fit. and shop. icons. The grader had never seen any of them. I asked one question: which icons do not belong?
The grader could not find the new icons. It flagged the real, live, shipped icons as the outliers instead. The witness cleared the suspects and pointed at the police.
Pixel forensics confirmed the result. shop. had square corners and no tabbar bars at all. It never conformed to the template. fit. had a wordmark 25–40% too large, with the baseline in the wrong place. Both errors were invisible at thumbnail size and obvious at 192px. That is exactly how design debt survives. It is only wrong when you look at it correctly.
The note I wrote afterward is the useful part: the family template is tighter than the family itself. A written spec does not conform the apps to it. Somebody must audit the apps that shipped before the spec existed.
The visual system still needs a source of truth
The address chooses the app. One file makes the apps look related:
:root {
--paper:#FFFFFF; --paper-deep:#F4F4F5; --paper-tint:#FAFAFA;
--ink:#14171D; --ink-soft:#2D313A; --muted:#6E727A; --muted-2:#A1A4AB;
--rule:#E6E6E8; --rule-strong:#CFCFD2;
--accent:#1F4DC2; --accent-soft:#5A77CC; --accent-bg:#E1E9F8;
--green:#2C7A4B; --amber:#B26A00; --red:#B23A2A;
--mono:"JetBrains Mono",ui-monospace,"SF Mono",Menlo,monospace;
--sans:"Inter",ui-sans-serif,system-ui,-apple-system,sans-serif;
}
A drift audit checks four items per app. The checks are mechanical on purpose, so they run without judgment. No hardcoded hex value duplicates a token value. The accent is cobalt, and any orange is drift. Headlines use mono, body text uses sans, and no stray fonts appear. The app embeds the fleet switcher in its top chrome.
An edit to that tokens file does not silently reformat nine apps. It adds one backlog item per other app — “conform <app> to tokens vN”. Changes to the shared foundation are pull-request-only. They never ship automatically. A design system that can change nine apps while you sleep is not a design system. It is a liability.
tokens.css ──────────────┐
(one file, the language) │
▼
┌──────────┬──────────┬──────────┬──────────┐
│ todo. │ shop. │ watch. │ vault. │ … nine apps,
│ Preact/ │ PWA in │ Astro │ Astro │ four stacks
│ Astro │ docker │ SSR │ static │
└──────────┴──────────┴──────────┴──────────┘
each adds: its name, and nothing else
The switcher is the trick that makes the stack diversity survivable. Every app has a small launcher to the rest of the suite. The launcher is a plain Web Component: a custom element, shadow DOM, no build step, no bundler, no framework:
class FleetSwitcher extends HTMLElement {
connectedCallback() {
const current = this.getAttribute("current") || "";
const root = this.attachShadow({ mode: "open" });
const links = APPS.filter(a => a.id !== current)
.map(a => `<a href="${a.url}">${a.label}</a>`).join("");
// ...
}
}
customElements.define("fleet-switcher", FleetSwitcher);
One <script src> tag works from Astro, Svelte, Preact, or a raw HTML file. Its styles use the fleet tokens with inline fallbacks (var(--mono, ui-monospace, monospace)). So it renders correctly inside a host app that has not adopted the token file. The shadow DOM stops the host app’s CSS from reaching in and breaking it.
I like two small decisions in there. The switcher filters out the current app, because a self-link in a nav reads as a duplicate. A divider and a small muted “apps” label come before it. So it reads as the rest of the suite, not as this app’s own tabs.
PWA caches will serve you last month’s app
The icon rollout taught me more about caches than about design.
Browser favicons and installed-app icons are separate cache surfaces. An updated PNG is not sufficient for either one. This checklist worked in the end:
- Version the favicon link, the Apple touch icon link, and the manifest link in the page HTML.
- Version every icon
srcinside the web manifest. - Check the generated production HTML. The PWA plugin will auto-inject an unversioned manifest link even when your source HTML is correct.
- Fetch the live manifest and every icon URL after each deploy.
- Hash-compare at least one live production icon per app against the local source file.
Four apps needed a post-build transform. The transform rewrites the auto-injected link with a version query. The final audit checked all nine apps. The HTML returned 200. Every manifest icon returned 200. The live primary icon was byte-identical to its source file.
Two platform behaviors are worth knowing. Chrome keeps favicons in a store of their own, so you need a hard refresh or a fresh tab. iOS bakes the icon and the label at add-to-home-screen time. An installed app shows last year’s icon forever, until you delete the app and add it again. That is not a cache you can clear. That is a cache you must ask a human to clear.
What is still not correct
vault. never got the letter treatment. It still has its own three-bar favicon. I considered the work, and I parked it. That is how most design debt happens.
trips. is a deliberate exception to the icon family. It has richer editorial artwork, because that product’s identity is truly different. The generator has a flag for it. A deliberate exception is fine. The danger: a flag makes a second exception easy.
Under the hood, almost nothing is shared. There are four frameworks, several deployment mechanisms, and much duplicated plumbing. But the way the apps are reached is shared completely: each product word becomes a private subdomain, Tailscale supplies the boundary, and Caddy sends the name to the right service. The design system makes that address system legible on a phone.
If another app earns a place, the private address comes first, then the shared tokens and switcher. My family does not care whether the framework matches. They should be able to recognize the door, type its name, and arrive at the same place.