Rhys Brett-Bowen Coding in the age of AI

You Can't Save a Layout You Don't Own

updated

“The aspects of things that are most important for us are hidden because of their simplicity and familiarity. (One is unable to notice something — because it is always before one’s eyes.)” — Ludwig Wittgenstein, Philosophical Investigations (1953)

Three unequal panes in one frame — contour lines on the left, a list of rows top right, ruled text lines bottom right — with handles on both seams. A dotted line leaves the frame and arrives at a smaller card, which carries a name above the same arrangement drawn in outline, with nothing in it.

The request was: let me arrange these panes how I like, give the arrangement a name, and hand it back to me tomorrow on a different laptop.

That is not an exotic feature. Editors have workspace presets. Terminal multiplexers have saved sessions. Every design tool has a right-hand panel you can collapse and it remembers. I would have estimated it in days.

What made it not days: the panes weren’t elements. The thing I was working on is a desktop application that hosts several unrelated web apps in one window — a map filling the left half, a message list top right, an editor under it, seams between them you can drag or collapse. Each of those apps is a native view. An operating-system rectangle, composited above the page rather than laid out inside it. Some of them are code we don’t own.

So there is no DOM node whose bounding box is the map. There is no stylesheet rule I can read back to find out where the editor is. “Save the layout” cannot mean “serialise the document,” because the document doesn’t contain the layout. It contains nothing at all where the layout is.

That’s the whole post. Everything below is what follows from it.

BLUF

  • A layout is a data structure you own. The screen is a render of it. The test is whether you can name a layout, serialise it, diff two of them, and rebuild one with nothing on screen. If you can’t, you don’t have a layout — you have whatever the last drag happened to leave behind.
  • You only find this out at the first region your layout engine can’t reach. Until then the browser keeps the model for you and you never see it. Reach for getBoundingClientRect() on something the browser doesn’t own and the whole layout turns out to have been implicit the entire time.
  • The bill comes in three parts, and they arrive in this order: positions become computed rather than declared, z-order stops being the stylesheet’s business, and lifetime becomes something you have to remember. The third one is the one that bites, because nothing tells you when you get it wrong.
  • Build the model before you build the feature. Presets, drag-to-rearrange, focus mode, dragging a pane into a second window, restore-on-a-new-machine — those are all one feature if a layout is a value, and five features if it’s an emergent property of the current window. I got to the model by writing the five, which is the expensive route.
  • The generalisation: any region where the browser’s layout engine stops deciding where things go puts you here. A native map or video surface on mobile, in full. A <canvas> or a cross-origin iframe, for everything inside the box rather than for the box itself. Same problem, same fix, one level in.
  • You can go looking for the boundary instead of walking into it. For any region you’re about to build, ask what holds its structure between renders and where that thing stops. Nothing flags the crossing, so it’s a question to ask before you promise the feature, not after.

What a layout is when the document doesn’t hold it

Start from the feature and work backwards. If a layout has a name and survives to a different machine, then it is a value: something you can write down, put in a list, hand to a restore path, and compare against another one. A value has to exist when nothing is on screen — that’s what makes tomorrow’s laptop possible.

So the model came first, and it looked like the boring thing it is. A tree of splits, each with a direction and a fraction. Leaves that identify an app rather than pointing at one, because the app isn’t running when the layout is sitting in storage. That’s the entire idea, and once it’s written down the feature list collapses. Rearranging is editing the tree. A preset is a saved tree. Focus mode is a tree with one leaf and the rest parked. Moving a pane to another window is a subtree that changes parents. Restoring on a new machine is deserialising the tree and letting the leaves find their apps.

Then the screen is a render of that tree. You walk it, you get rectangles, you tell each native view where to be. It is the same shape as a rendering pipeline anywhere else, and it is unremarkable except for one thing: I had to build it. Every previous layout in my career, the browser had built it for me and never mentioned it.

The bill, in three parts

Positions are computed, not declared. I moved layout out of the renderer and into the process that owns the window. Not because that’s elegant — because the renderer can’t reach the views. So a pane’s geometry stops being a consequence of a stylesheet and becomes a number I calculate and push. Flexbox doesn’t stop existing; it stops being the thing that lays out the panes. It still lays out everything inside one of them, which is exactly the confusing part. The boundary runs through the middle of your app and nothing marks it.

Z-order belongs to the compositor. A native view sits above the page, and it doesn’t care what your z-index says, because z-index is a rule about a stacking context and the view was never in one. The way this shows up is not as an architectural insight. It shows up as a popover that renders under the application bar. Then a dropdown in the sidebar that draws outside the window entirely. Then a toast that lands behind a pane. Each of those looks like a one-line CSS bug and is in fact the same fact arriving from a new direction: things that must float above everything have to be composited by whoever owns the compositing, and that isn’t the stylesheet any more.

Lifetime is yours to remember. I want to be exact here, because the sloppy version of this claim is that you lose garbage collection, and that isn’t true. The narrow version: the host retains the view independently of the document, and destroying it is an explicit call. Removing a DOM node doesn’t remove a view, because the view was never attached to one. So a view outlives its purpose exactly as long as you forget about it. This one is also the odd consequence of the three: computed positions and compositor-owned z-order follow necessarily from the surface sitting outside the browser’s layout, and no API design avoids them, whereas manual teardown follows only because the host API doesn’t bind a view’s lifetime to anything in the document. An API that did would take it off the list. None of the ones I’ve used do.

I shipped a working rearrangement and left the old views alive behind the new ones. They were still there, still rendering, invisible under what replaced them. Nothing failed. No error, no console warning, no test that could have known. The screen looked right, which is the entire problem — I had built a system where “looks right” and “is right” had come apart, and I hadn’t noticed that was a thing I’d done.

Here’s the connection I missed for a while: the leak and the missing model are the same defect. If the tree is the truth and the screen is a render, the render step knows every view that ought to exist, and reconciling that list against the views that do exist is a loop you write once. I hadn’t written the tree yet, so there was no list to reconcile against — I was mutating views directly and remembering, by hand, which ones. The leak wasn’t carelessness. It was the missing data structure, presenting as carelessness.

The browser was doing this the whole time

Everything above is what the browser does. It keeps a tree of boxes. It computes geometry from the tree. It owns stacking, including the escape hatches. It ties each box’s lifetime to a node, so removing the node removes the box and you never think about it. And then — the part I’d stopped seeing — it hands you a declarative language for editing that tree, so you get to describe the layout you want instead of maintaining it.

CSS is not the layout. CSS is declarative input to a layout model the browser keeps for you — you write rules, the browser keeps the tree, and the tree is the thing that actually decides where anything goes. That distinction is invisible right up until the tree is yours to keep. I think it’s also what’s underneath most complaints that CSS is fighting you: you are pushing against a model you didn’t design and can’t see. Take the model away and you find out what it was worth. My tree of splits and fractions is a worse box tree with none of the language on top, and building it taught me more about what the browser gives you than a decade of using it did.

That’s what “your layout engine stops at the edge of the DOM” actually means. You lose the model. The feature was downstream of it.

Tiling window managers already solved this

Tiling window managers have had this exact problem since long before anyone put a browser in a desktop app, and they solved it the way this post is arguing for.

i3 will serialise its layout tree to JSON — i3-save-tree — and restore it with append_layout. The restore is the interesting half. It creates placeholder windows arranged in the saved shape, each carrying a swallows specification: a pattern describing which window belongs in that slot. When a matching window is later mapped, i3 puts it in the placeholder’s place and kills the placeholder.

Read that as a design statement. The layout exists as a document with the windows absent. Slots are described by criteria rather than by pointers, because the thing that fills the slot doesn’t exist yet and may never. Real windows are matched into the shape afterwards. That is a saved layout, and it works precisely because the tree was never a description of what’s on screen — it’s a specification the screen is made to satisfy.

I arrived at a smaller version of the same design by writing features one at a time until the shape was forced on me. The window-manager people started there. If you’re at the beginning of this, start where they did.

Where else the edge shows up

I got here through a desktop shell hosting web apps, which is niche. The boundary isn’t.

The full version is any surface the browser has no box for: a native map or video punched through the view hierarchy on mobile, a PDF or media plugin, anything drawn by another process. There is no element to measure, so everything above applies as written.

Then there’s the half version, which is more common and easier to miss. A <canvas> does have a box, and so does a cross-origin iframe — the browser lays out those elements fine. What it can’t do is lay out anything inside them. So the boundary moves in one level rather than disappearing: whoever draws into the canvas owns the layout of everything in it, and a cross-origin child owns its own and can only post its size out to a parent that agreed in advance to listen. The outer geometry is still the browser’s. The inner geometry is yours, on exactly the terms this post describes.

The tell: for some region of your screen, the browser’s layout engine has stopped being the thing that decides where anything goes. Once that’s true, your layout stops being something you declare and becomes something you own.

The question to ask before you build it

I found this boundary by hitting it. The alternative is to ask.

For any region you’re about to build: what holds this together between renders, and where does that thing stop? Not what draws it — what keeps the structure when nothing is being drawn. If the answer is the browser, you’re inside the model and you get to declare things at it. If the answer is anything else — a compositor, another process, a canvas you paint into — then work out which version you’re in before you promise the feature: no box there at all, or the browser holding the outer box with the boundary one level in. Either way, something has to keep the model from that point on, and if nothing does, it’s you.

What this isn’t

Not an argument for building a layout engine. For nearly all UI the browser’s model is better than yours will be and free. This is about the regions where it doesn’t apply, and the specific mistake of not noticing you’ve entered one.

Not a rediscovery of MVC. Keep the durable state separate from its rendered view is older than the web, and if you already build that way, none of the shape here will surprise you. What I think is worth writing down is where the handoff lands. The pattern is old. Spotting the boundary is the part people miss, me included.

Not a complaint about the platform. Compositing a native view above the page is the right primitive; it’s how you get a map that isn’t a slideshow. The cost is real and it’s the correct trade.

Not a claim that a model prevents the bugs. I still had popovers in the wrong plane after I had a tree. What the model changes is that each bug has one place to be fixed, and the reconcile loop can be written once instead of remembered every time.

How sure I am: the receipts are mine and they’re from experience, not measurement — four years of building a desktop shell that tiles web apps, genericised here, with no numbers attached. Moving positioning out of the renderer, the leaked views behind a working rearrangement, and the popover/dropdown/toast plane bugs are all things I did or watched happen. The i3 mechanism is prior art and I checked the documentation while writing, so the placeholder-and-swallow description is verifiable rather than remembered. The framing — that CSS is declarative input to a model rather than the model — is my inference, not a citation. I have lived one instance of this: a desktop shell. The generalisation to native mobile surfaces, canvas-hosted UI and cross-origin iframes is reasoning from the same missing capability, not three more war stories, and the last two are the weaker cases because the host does lay out the element even though it can’t lay out the contents. If it breaks somewhere, it breaks there. The closing test generalises from that single instance rather than from a survey: I’ve been on the wrong side of this boundary once, in one domain, and the question I’d ask now is the one I wish I’d asked then.

Sources

  • The shell — my own work: a desktop application hosting several unrelated web apps in one window, four years of it, described generically throughout. Positions computed in the process that owns the window; native views composited above the page; view teardown explicit. Layout presets, drag-to-rearrange, focus mode, merging windows and moving a pane between windows arrived as separate features over that period, which is the “five features instead of one” this post argues against.
  • What the browser does — the box tree, computed geometry, stacking contexts and node-bound box lifetime are all specified behaviour, not my characterisation. CSS display and box generation: drafts.csswg.org/css-display. Stacking contexts: developer.mozilla.org/en-US/docs/Web/CSS/Guides/Positioned_layout/Stacking_context. Both checked while writing.
  • i3 layout saving — i3-save-tree serialises the layout tree; append_layout restores it as placeholder windows carrying swallows criteria, replacing each placeholder when a matching window is mapped. i3wm.org/docs/layout-saving.html, i3-save-tree(1). Checked while writing.
  • The framing — that CSS is declarative input to a layout model the browser keeps for you, and that the model rather than the language is what you lose at the boundary — is mine. So is the test in the closing section — ask what holds a region’s structure between renders, and where that thing stops. It generalises from one lived instance, and it’s offered as a question to ask, not a result.
  • The epigraph — Ludwig Wittgenstein, Philosophical Investigations (1953), on the aspects of things hidden by their own familiarity. Wording checked against the published translation while writing.

Related: The Raw Is the Trust Boundary (own the durable structure; treat the screen as a render of it); Thinking at the Speed of Code (spotting the boundary is the reading, and it’s the part people miss).