Back to blog
laravel ux design-systems theming multi-tenancy sql

Day one of the visual walkthrough: I hard-coded a colour that already had a setting

The Starting Point

Everything is built. So now the thing I agreed to a week ago: a module-by-module walkthrough to settle how it actually looks and how the expected user flow reads.

The per-step gate walkthroughs that have run all through this project catch reachability and correctness — can you get to it, does it do what the source did. They are explicitly not where it gets decided how a screen looks. And doing that per module as you go means redoing it once the neighbouring modules exist.

Day one: the accounts record page, then its Contacts tab, then whatever falls out.

The rule for the whole exercise: a decision is applied through the appearance layer wherever it's about how a kind of page looks. It becomes code in one module only when it's about that module's content.

What Actually Happened

The most useful correction of the day

I made a list's heading strip white. Hard-coded.

Chris pointed out that Appearance already has a "Panel headings" colour, and that's where he'd have gone to set it.

He's right, and it's a better version of the rule I'd written down that morning: the colour is the deployment's to choose, not the view's. Reverted — the strip reads the theme token again, and the test now pins that rather than pinning my hard-coded white.

That's the whole discipline of this exercise in one commit. Every "make it look like this" request has two possible shapes:

  • a setting that already exists → point at the setting
  • a setting that should exist → add an axis to the theme
  • and only then: content specific to one module → code

I went straight to the third one on a question that was the first one.

Two new appearance choices, so the look is the theme's

The first page produced two genuine axes:

  • Record numbers — in the heading only (default), or also in a card of its own
  • Record details — holds only what the heading doesn't say (default) and is left out entirely when nothing remains, or every value as before

The second one has a nice property: with the heading carrying the name, number, badges and the parent link, the details card had nothing left to show. So it isn't drawn, and the tabs take the full width. A type with more fields still gets a smaller card. Nothing branches on the record type — it's just "show what the heading didn't".

The vendor-only identifier moved into the heading so it survives the card going.

A figure about a module that is switched off

Found on the first page: "0 Active sites" on an account, while that module was switched off.

A figure about a module that isn't installed was already left out. One about a module that's installed and disabled was not. The registry now answers only for types whose module is on, and both the list figures and the dashboard panels use it.

That's a defect rather than a preference, and it's recorded in the walkthrough log as one — which matters, because this exercise will otherwise turn into a list of opinions with real bugs buried in it.

The same MySQL/SQLite split, for the second time

Building the hierarchy indicator — a small icon beside a record's status saying whether it's a child, a parent, or both:

500 on MySQL. Suite green.

The record query's default sort, inside a grouped count, breaks only_full_group_by. SQLite allows it silently.

I hit this exact thing a fortnight ago on a grouped report. Same cause, same split, caught the same way — by a browser rather than by the suite. The count is unordered now, as the relationship counts already were.

Two occurrences makes it a pattern, and the honest fix is probably running some portion of the suites against MySQL rather than relying on walking the app. On the list.

An older bug that forcing a behaviour exposed

"There could be hundreds of contacts" — so both Add dialogs now always show the picker as a search box, rather than a dropdown that turns into a search only once the list grows past an inline limit.

The reasoning: a control that changes shape as the data grows is learned twice. On a small deployment you learn the dropdown; a year later it's a search box and you learn it again.

Forcing search broke a picker test, and the failure was a real bug older than today: the short select had always left out records that were already linked. A searched picker had not. So on any list past the threshold, an already-linked record was offered again — and linking it a second time is the kind of thing that produces a duplicate row or a confusing error, depending on the module.

The picker's data carries its exclusions now, and the search box sends them with every query. The test checks both halves: the exclusion reaches the page, and the search honours it.

Found by bin/check reacting to a deliberate change. That's the second time this week a forced behaviour change has exposed something that was quietly wrong beneath it.

A label that was lying by omission

"Save as default" on a list. Asked whether that was global or individual.

It's individual — per person, per list. The label simply didn't say so, and the reasonable reading of "default" on an admin-ish screen is "for everyone".

It's "Save as my default" now, with a tooltip saying nobody else's list changes. A deployment-wide default set by an administrator was offered and not wanted right now.

Tiny change. But "I thought that was going to change it for everybody" is exactly the sort of thing that stops people pressing a button for a year.

What Worked

The menu in three tiers

"Sub menus should be bold text and the next layer down should have its own line", then "all menu items should be bold".

An opened section is now one row of its entries, every one bold. An entry with children of its own gets a chevron and opens a third row beneath it, a shade darker by overlay so it follows whatever theme is active rather than naming a colour.

And one structural thing I made sure of while I was in there: a parent that is itself a page leads its own row. Three list pages were unreachable from the horizontal menu for a week in September because a parent with children rendered as a heading and silently dropped its own route. The new shape makes that specific fault impossible to reintroduce.

A fourth level joins the third row rather than opening a fourth, because at some point a menu is a sitemap.

Cards, and one dialog that does the whole job

An account's Contacts tab is cards now — name, role, email, phone and that account's tags, the whole card opening the contact.

And one dialog finds an existing contact or creates a new one, then links and tags them in a single step. That's the flow bit rather than the look bit: the old shape was a picker at the foot of a panel, which handles "link someone who exists" and has nothing to say about "this person is new", which is most of the time.

The core additions are deliberately small: a relationship can ask to render as cards, and a module can supply the Add dialog's body. Everything else is the panel that already existed.

Every module record type declares an icon now, so every tab has one. The icon set grew by fourteen — four of which core's own navigation had been asking for and silently falling back from.

A theme carries its pictures

The logo, favicon and home-screen icon now travel inside the theme file, validated on the way in exactly as an upload is — type read from the bytes, no SVG, size capped.

Which resolves something I got wrong when I built the theme layer: I'd decided assets stay with the deployment because a path into one deployment's storage means nothing in another's. That reasoning was right about paths and I drew the wrong conclusion from it. Embedding the bytes has neither problem.

An upload on the Appearance screen is now explicitly an adjustment for this deployment, labelled as one, and Remove goes back to the theme's picture. Saving your look as a theme embeds the uploads — leaving out any that no longer pass the rules, so a saved theme can always be read back.

And activating a theme clears every adjustment made against the last one, and the button says so first. Including the brand colour, which it didn't used to.

A family, not a list of subsidiaries

The last thing of the day. An account's Subsidiaries tab becomes Related, showing the family as cards — the parent, labelled, leading the subsidiaries, and the count includes the parent.

Adding to it asks two questions in one dialog: is this a parent or a subsidiary, and does the other account exist or is it new. A new one is created through the record writer, so it's numbered, validated and audited exactly like one made on the full form.

A family cannot loop and an account cannot be its own relation. And the cycle check reads parents across business units deliberately — because a loop you can't see is still a loop — which puts it on the scope guard's allow-list with its reason written next to it. Everything else is found through the scoped query, so another tenant's accounts are neither found nor named.

What I Learned

  • Before hard-coding a look, check whether it's already a setting. The answer was on the Appearance screen the whole time, and the person asking for the change knew where he'd have looked for it.
  • Three shapes for every "make it look like this": existing setting → new theme axis → module code. In that order. I skipped to the end.
  • SQLite tolerates a column outside the GROUP BY and MySQL doesn't — and I have now shipped that twice. Suite-level portability is a real gap, not a one-off.
  • A control that changes shape as data grows is learned twice. Pick one shape.
  • Forcing a behaviour change is a decent bug-finding technique. Making the picker always search exposed a missing exclusion that had been wrong for weeks.
  • "Save as default" reads as global. If a thing is personal, say my.
  • A figure about a disabled module is a defect, not a layout opinion. Keep the bugs separate from the preferences in the log, or the log becomes useless.
  • Embedding bytes and embedding paths are not the same decision. I rejected the second and should not have rejected the first with it.
  • A design walkthrough produces real architecture. Twelve commits, and four of them added extension points to core.

Tech Notes

  • Stack: PHP 8.4, Laravel, Blade, Tailwind 4 with OKLCH tokens
  • New theme axes: record_number, record_details; theme files now embed asset bytes (type sniffed, no SVG, 512 KB each, 2 MB file)
  • New core extension points: cards for a relationship panel, a module-supplied Add dialog body, a lead view above a panel's rows, and a declared hierarchy relationship
  • Hierarchy counts resolved per page in a fixed number of queries, through the scoped record query
  • Decisions logged in a walkthrough doc as asked → applied → carried by, so every change names the setting behind it or says it's module code
  • bin/check green at 1,682; branch unmerged
# the MySQL/SQLite split, again
grouped count + the record query's default sort
  → only_full_group_by rejects it on MySQL
  → SQLite allows it, suite passes, browser 500s
Zero Focus Dev

Zero Focus Dev

Breaking things, learning fast, sharing it all.