The visitor's gallery
The safe part of the client portal was already built. One function, two gates, a build rule. I thought the rest was a weekend of CSS. The rest was making bosun-x wear a client's brand well enough that it reads as something their own studio stood up, while underneath it stays the exact tool I run the work in. No second database, no export, no monthly deck.
The gallery was meant to be the quick part
The part I was nervous about was the gate and the fence, and it went in cleanly. One function that projects a client’s slice of the store, two gates a project has to pass before it is in that slice, and a build rule that fails the whole build if any other client-facing file reads the real data directly. After a couple of days I had a working projection and no portal. Just safe data coming out of a function with nothing on the other end to render it.
The rest looked like a weekend of CSS. It was closer to two weeks, and almost none of it was the CSS. It was the gap between “a login that shows a client their project” and “a page a client’s studio would be happy to put their name on”.
It has to be their gallery, not mine
Why it is here. A client portal that looks like my tool with the client’s logo dropped in the corner sends one message, and it is the wrong one: you have been given a guest pass to somebody else’s system. The whole point is the reverse. A client should open the page and read it as something their studio built for them, on their own domain, in their own colours.
How it works. The portal has no visible design of its own. It carries the client’s public site. The first one runs for cgburchell.com, so it has that site’s header, the one that starts transparent and fades to a glass panel as you scroll, the same underline that grows under a nav link, the same button, the same footer. What changes per client is a short block in a config file: a name, two colours, a heading font and a body font, a tagline, a link back to the real site, an address for the footer. Name a Google font and it loads itself; name anything else and it falls back to a system stack. That is the whole knob set. Six or seven values, and defaults for everything underneath them.
It is deliberately not a page builder. A page builder is a second product, with its own bugs and its own support load, and it exists so that once a year somebody can move a box four pixels. The portal picks a lane. The structure and the motion are the house style, the skin is the client’s, and there is no third setting.
and then it was three pixels out
Matching a real website is a fussier job than building one from nothing. cgburchell.com uses a plain CSS reset. The portal is a Next.jsA widely used framework for building websites and web apps in React — it handles routing, rendering pages on the server, and the build step. app running Tailwind, whose reset puts a line-height on every element, and that quietly pushed the header nav and the sign-out button a few pixels taller than the real site. The footer was worse: every line about three pixels too tall, so the whole strip sat wrong beside the real one. A client comparing the two in adjacent tabs would have seen it in a second.
The fix was one dull line in two places, pinning the line-height back to normal on the header and the footer. Finding it was an afternoon with the two headers overlaid at 400% zoom, nudging a value and reloading. That is the tax on “it should look exactly like the real site”. You do not pay it in the architecture. You pay it in an afternoon of squinting.
Underneath, it is still the same tool
Why it is here. The obvious way to build a client portal is a second store that you feed the client-safe parts into, on a schedule. I argued against that last time on safety grounds: two sources of truth, and a sync that breaks quietly while a client reads last week thinking it is today. It turns out the same argument is the only reason the feature is worth building at all. If the portal were a separate system, it would be a worse version of every project-management tool with a client login, and there are plenty of those.
How it works. The portal is bosun-x. The same image, started a second time, reading the same files. The projection has not changed since the note about it: a client sees a project only if it is tagged as their work and shared with them by name, and every field in the response is built from an allow-list rather than trimmed down from the full record.
What got built on top is the part a client actually touches. They see their projects, and inside each one the tasks I chose to show, because sharing a project does not share its tasks; the one where I have written the last developer did something strange with the auth flow stays on my side. Above the tasks is a paragraph I write by hand about where the project really is. The portal used to generate that line from the tech stack and the status field, and it read like the back of a cereal box. A client can reply on anything I have shared and it comes back to my board flagged for a look. There is a button that approves or signs off. There is a message thread per client for the things that do not belong to a project. And when a client comes back after a week away, the page opens with the short list of what moved while they were gone.
the threads were showing their working
One that got past me for a while. The notes on a task are stored as a single
plain-text field with --- Me · 2026-09-04 --- header lines between the turns.
The operator dashboard parses those into a tidy conversation. The portal, the
first time I wired it, did not: it printed the field as-is, header scaffolding
and all, so a client saw the raw --- ... --- lines sitting between messages
like exposed studwork. I had reused the storage and forgotten it also needed the
reader. A small parser on the client side sorted it, and a client’s own replies
now come up in the accent colour so a thread reads as a conversation and not a
log file.
There is no publish step
Why it is here. This is the whole reason to build it this way instead of buying something.
How it works. I never publish to the portal. There is no export, no build step, no button that says send update. I tick a box on a project that says this client may see it, and from that second the client is reading the same file I have open in my editor. I add a line to the status this afternoon and it is on their page this afternoon, because it is the same line in the same file on the same disk.
Every client portal I have seen sold as a product is a second store fed from the first. Somebody keeps the two roughly aligned. The client’s view trails the real one by however long it has been since anyone updated it, and nobody can tell you that number. The monthly deck was true on the first of the month. bosun-x’s portal cannot drift from my view, because it is my view, read through a filter and painted in another colour.
The shape of it on the box
Why it is here. The operator dashboard and the client portal are the same application. They only differ in what they are told to be at start-up.
How it works. The portal is one more service in the compose file. Same
image, started with BOSUN_MODE=portal and the slug of the brand it should
wear. It mounts the same data directory as the dashboard, and it is read-mostly:
the only thing it ever writes is a client’s reply into a thread. It gets its own
domain and its own sign-in, because the way a client gets in should have nothing
to do with the way I do. No Docker socketThe local file (/var/run/docker.sock) that programs talk to in order to ask Docker what is running or to control it. Mounting it into a container, even read-only, is powerful — handle with care., no host
paths; a client-facing service has no business holding either.
Adding an agency is that service block and the theme values. Adding a client under it is a name, an email address, and the two tags that share their first project. There is no onboarding flow, because there is almost nothing to onboard.
What is verified vs assumed
Verified. cgburchell’s portal is live on its own subdomain, in the site’s own colours. Signed in as the client, the project comes through the way it should: the hand-written summary, only the tasks I shared, a thread that takes a reply. I re-pointed the theme block at a throwaway brand and the whole portal re-skinned from that one change, header to footer. From my own login on the portal I can preview the exact projection any one client gets, down to a list of what is sitting in their portal that I have not shared yet, which is the check that stops a leak being a surprise.
Assumed, and worth watching. That “six or seven values” stays enough as more than one agency wants a say in the layout, and that I hold the line on it being a skin and not a builder. And the real one: that a client reads the page instead of messaging me anyway. The portal only earns its keep the week I do not get a “how’s it going” message, because the page already said. Not enough logins yet to know.
A client signs in to a gallery in their own colours, with their own boat up on the blocks in it, and reads it as something their studio stood up for them. Their studio ticked a box and picked two hex codes. Both of those are true at once, and I’ll be damned if I’m the one to tell them which did the work.
-x