HAR without Figma: an AAOS 17 cluster
Bypassing DesignCompose's .dcf pipeline when you don't have Figma API access — the trade-offs, the silent bugs, and what it cost.
There is a particular flavour of despair that comes from a build that succeeds, a service that stays running, a log with zero warnings — and a completely black screen.
That was my Tuesday. I had just replaced the entire asset-loading layer of an Android Automotive instrument cluster, the build came back clean, the process was alive, the renderer reported a rendered frame. And the driver's display showed nothing at all.
It took me two days to find out why. The answer was four words long.
Let me back up, because the interesting part isn't the bug — it's why I was there in the first place.
How the DesignCompose .dcf pipeline works
The cluster — the digital panel behind a car's steering wheel — was rendered by a Rust engine I'll just call the renderer. It's an unusual beast: no SurfaceFlinger, no Android window manager. It paints straight to DRM, because a speedometer that drops a frame is a safety problem, not a UX one.
On top of that sits Google's DesignCompose, and I want to be fair to it before I explain why I went around it, because the idea is genuinely excellent.
The workflow is: a designer authors the cluster in Figma. A build tool fetches that document over the Figma REST API and compiles it into a .dcf file — a length-delimited protobuf carrying the full node tree, styles, images, and design tokens. The running app then binds live vehicle data into named design nodes. The speedometer text node is called something; the code says "put the speed in that node"; nobody negotiates pixels.
What the .dcf path buys you is real:
- The designer owns the design. Change a colour in Figma, refetch, done. No engineer in the loop, no translation step, no drift between the mock and the build.
- Full fidelity. Auto-layout, constraints, component variants, vectors, gradients, blend modes, design-token variables and theme modes — all of it survives the trip, because the converter was written by the people who own both ends.
- Component variants for free. A gear selector that swaps between P/R/N/D states is one Figma component with four variants, and the runtime just sets the variant. No conditional layout code.
- A checked-in artifact. The
.dcfis a single reproducible binary in the build. Same input, same pixels, every time.
That's a good system. I'd use it. Which brings us to the problem.
The blocker: no Figma REST API access
The brief was a new cluster design: different canvas size, digital-only, node names matching nothing in the existing code. We had the .fig export sitting in a shared folder.
We did not have Figma REST API access.
That sounds like a paperwork problem, and it is — but paperwork problems have shapes. Programmatic REST access to a Figma file isn't something you switch on with a checkbox; it means the file lives in an organisation on a paid enterprise plan, with seats provisioned, a token issued, and — for a platform build running in CI inside an automotive programme — a network path from the build machine out to api.figma.com that somebody in security has signed off on. Multiply that by every engineer who needs to iterate on the cluster and every build agent that needs to refetch.
For a production programme, you do that work. It's worth it. For prototyping a design nobody has committed to yet, it's an absurd dependency chain to put in front of "does this layout even look right on the panel?"
So I did the triage. Three artifacts were floating around and everyone, including me, had been using the names interchangeably:
| Artifact | What it actually is | Usable? |
|---|---|---|
| .fig | Figma’s proprietary kiwi binary format | NoNothing in the entire source tree parses it. |
| .dcf | Length-delimited protobuf: header + definition | YesBut we cannot produce one. |
| DesignCompose fetch | Build tool that emits .dcf | NoIt builds from three Figma REST JSON blobs. |
The pipeline was Figma REST → fetch → .dcf → renderer. We held the one artifact that entered nowhere and lacked the credential that unlocked the only entrance.
I spent an afternoon costing out a .fig parser and concluded it was somewhere between three weeks and a career.
Pricing the workaround before building it
So: build a second path that produces the same end state without the file. Before writing a line, I wrote down what that would cost, because a workaround you haven't priced is just optimism.
What the no-.dcf path gives up — honestly:
- The designer stops owning the design. Every visual change now routes through an engineer editing a manifest. That's a real regression in the thing DesignCompose exists to fix.
- Fidelity ceiling. I'd support what I explicitly implement — rectangles, text, images — and nothing else. No auto-layout, no constraints, no blend modes, no blur.
- No component variants. The one feature I could not synthesize, and it bit hard (more on that below).
- Font substitution. Three fonts were bundled on the device image. The design used none of them.
- A second source of truth. The manifest and the Figma file can now disagree, and nothing will tell you.
What it gives back:
- Zero licensing and zero network dependency. No enterprise seat, no token rotation, no egress rule, no
api.figma.comin a CI allowlist. The design content is a JSON file in the repo. - Iteration in seconds. Edit JSON, rebuild one Rust crate, relaunch. No refetch, no designer round-trip, no build-tool step.
- It runs from a
.figand a pair of eyes. Read geometry out of the export, put it in the manifest. Whatever access you have is enough. - Reversible. If the licensing lands later, the
.dcfpath is still sitting there untouched. This is a parallel road, not a replacement.
For prototyping, that trade is obviously correct. And critically — it's a gate, not a fork. Both loaders ship in the same binary; a build flag picks one.
The seam: synthesizing the protobuf instead of the file
I stopped looking at where .dcf files were produced and went looking for where one was read. And there it was, a trait:
trait FigmaDocumentLoader { fn load_document(&self, id: &str) -> Result<(DesignComposeDefinitionHeader, DesignComposeDefinition), Error>;}The shipped implementation opened a .dcf off disk and deserialized it. That was its entire job.
Which means the renderer's real input was never a file. It was a DesignComposeDefinition — a protobuf struct, in memory. The file was just one way of arriving at one.
So I wrote a second implementation of that trait. It parses a JSON manifest describing the design and synthesizes the protobuf directly:
impl FigmaDocumentLoader for GeneratedFigmaLoader { fn load_document(&self, id: &str) -> Result<(DesignComposeDefinitionHeader, DesignComposeDefinition), Error> { let manifest: Manifest = serde_json::from_str(manifest_for(id))?; let header = DesignComposeDefinitionHeader::current(/* … */); Ok((header, generator::build( &manifest, self.surface_w, self.surface_h, &self.root_node_name, ))) }}The manifest is deliberately boring — a canvas size and a flat list of absolutely-positioned nodes, because absolute positioning is the one layout model I could guarantee I'd reproduce faithfully:
{ "canvas": { "width": 1536, "height": 1080 }, "background": "#0A080B", "nodes": [ { "kind": "text", "name": "hud/speed", "bounds": { "x": 624, "y": 355, "w": 288, "h": 189 }, "text": "000", "size": 148, "color": "#D9D9D9", "align": "center", "weight": 500 }, { "kind": "text", "name": "hud/speed-unit", "bounds": { "x": 737, "y": 513, "w": 62, "h": 31 }, "text": "KM/H", "size": 24, "color": "#A38B86", "align": "center", "weight": 700 } ]}Design content lives in JSON, never in Rust, so the codebase doesn't grow as the design does.
The genuinely lucky part is what I didn't have to touch. Everything downstream binds by node name, not by file. Live vehicle data, gear state, telltales, notifications — all of it keys off annotations in the model layer:
#[Design(node = "hud/speed", customizer = "SpeedCustomizer")]pub speed: f32,Give the generated nodes the right names and the entire existing pipeline lights up unchanged. I found the narrowest point where I could substitute my own behaviour, and changed exactly one thing.
Bug 1: every node laid out at 0×0
Clean build. Service running. Log line confirming a synthesized in-memory document. Frame rendered.
Black.
No warning. No error. Nothing in dmesg, no tombstone. Every node I'd built was, as far as the system was concerned, perfectly fine.
I found it by reading the layout engine's source instead of its output. Every node carries geometry in two places: a layout style (position, width, height) and a node style. I had set the layout style — obviously, it's the one that sounds like it does the thing.
But the layout pipeline sizes from node_style.node_size, which I'd left as None.
So every node laid out at 0×0 and painted precisely nothing. Silently — because the pipeline's "skipping node" warning only fires when a node's bounds are absent, not when they're present and zero. A 0×0 box is a perfectly valid box. It's just invisible.
The fix is now the first comment in the file, so nobody repeats it:
fn absolute_style(bounds: Bounds) -> ViewStyle { let (x, y, w, h) = bounds; let mut style = ViewStyle::new_default(); let ls = style.layout_style_mut(); ls.position_type = PositionType::POSITION_TYPE_ABSOLUTE.into(); ls.left = DimensionProto::new_points(x); ls.top = DimensionProto::new_points(y); ls.width = DimensionProto::new_points(w); ls.height = DimensionProto::new_points(h); // INVARIANT #1: the layout pipeline sizes from node_size, not layout dims. style.node_style_mut().node_size = Some(Size { width: w, height: h, ..Default::default() }).into(); style}Bug 2: text nodes render nothing without a font family
Rectangles appeared. Dark background, coloured panel, exactly as designed. Zero text.
Same shape of bug, different field: font_family defaults to None, and a text node with no font renders as nothing rather than as an error.
// INVARIANT #2: text nodes always get a font the device actually loads.ns.font_family = Some("Barlow".to_string());Elegant typography is a negotiation with the filesystem.
Bug 3: variant overrides abort the renderer
With static rendering working, I wired live data — which meant renaming nodes to the contract names the model layer expected.
The renderer aborted instantly. Tombstone, service stopped, black screen:
'Component has to be defined for overridden views. Component missing for Prnd'This was the most genuinely architectural discovery of the project, and it's the workaround's fidelity ceiling made concrete. The binding contract isn't one thing — it's two tiers:
- Tier 1 — text substitution, opacity, show/hide. Works fine on plain generated nodes.
- Tier 2 — component and variant overrides, like that P/R/N/D gear selector. These require the node to carry
ComponentInfometadata, which only the real Figma converter emits.
Name a plain node with a tier-2 contract name and the presenter tries to apply a variant override to something with no component. Hard abort — not a warning, not a skipped node, the whole renderer.
That's exactly the DesignCompose benefit I listed at the top, refusing to be faked. The fix was to stop reaching for tier 2 at all: every generated node lives in a private hud/* namespace and binds through tier-1 customizers only. The gear selector stopped being one component with four variants and became four plain text nodes — hud/gear-p through hud/gear-d — with the active one lit and the rest dimmed through an opacity customizer. Same pixels on the panel, no ComponentInfo required. I wrote the rule into the manifest schema so the next person doesn't rediscover it by tombstone.
Debugging with no screencap and minutes per iteration
Worth describing the conditions, because they shaped every decision above.
This renderer paints straight to DRM. There's no SurfaceFlinger, which means adb screencap does not work — it won't even link. The only way to see a pixel is to look at the actual display output. Every visual check was a human being looking at a screen and telling me what they saw.
Nor could I iterate quickly. adb reboot left the virtio-gpu scanout busy (Failed to swap buffers: ResourceBusy). Restarting the renderer on a live guest was worse: it sets a "gRPC started" property early in boot, which triggers a different service that grabs the single-open DRM master before the renderer gets back to it, so the renderer just loops cannot open card(0): busy forever. The only reliable verification was a full VM relaunch from a freshly built image — several minutes per attempt, and one where a stale image silently gives you yesterday's answer.
So the loop was: reason from source, make one change, pay minutes to build, verify the UI.
That price per attempt is what made guessing untenable. Every one of the three bugs above — the zero-sized box, the fontless text, the variant panic — was found by reading the code that would have complained, not by iterating until something appeared.
The corollary: when reading isn't enough, put the instrument inside the system rather than around it. The one time I was genuinely stuck, the answer came from a temporary log::info! dropped into the renderer's own drawing path, printing the three values it was deciding on. Three fields, and a question four rounds of theorising hadn't settled collapsed immediately.
Results and remaining debt
The cluster renders from JSON. No Figma dependency, no enterprise licence, no network egress in the build, no .dcf.
Because the substitution point was one trait implementation, the capability compounded further than I'd planned. The loader became resolution-independent, fit-scaling one authored canvas onto panels of different sizes and DPIs with no per-display config.
The cluster is deliberately minimal — a wallpaper, a gear indicator, and one enormous thin numeral. The wallpaper is a single raster drawn once on the base layer beneath the cluster; everything the generator synthesizes is a live-bound node.
The manifest exists in two places with no generator to sync them. Tier-2 component variants remain unreachable. The designer still doesn't own the design.
That's fine. This was always the road around the licensing problem, not a replacement for solving it — and the day the enterprise access lands, the .dcf loader is still sitting in the binary, one flag away.
Five takeaways
- 01
Find the seam, don’t fight the format.
I nearly wrote a parser for a proprietary binary. The answer was a ~100-line trait implementation, available from day one — I just had to look at where data was consumed rather than where it was produced.
- 02
Price the workaround, and make it reversible.
Writing down what I’d lose — designer ownership, variants, fidelity, a second source of truth — took twenty minutes and made every later decision easy. When the tier-2 panic hit, it wasn’t a surprise; it was a line item. The other half is refusing to burn the original road: both loaders ship in the same binary, and a flag picks one. A workaround you can’t back out of isn’t a workaround, it’s a migration you didn’t agree to.
- 03
Silence is the most expensive failure mode.
Two of the three bugs failed silently: a zero-sized box and a None font. Both are valid states. Neither is an error. When a system is quiet and wrong, stop reading logs and start reading the code that would have logged.
- 04
When you can’t see, instrument from the inside.
No screencap, minutes per iteration, a colleague as my only display. Guessing is priced per build, so the fastest move is either reading the source that decides the outcome, or printing the values it decides on — not another round of theory.
- 05
Write down the compromises.
The substituted font, the unreachable variant tier, the hand-mirrored manifest — all recorded as known debt with the reasoning attached. The next person to touch this, quite possibly me, needs the why far more than the what.
The cluster renders. Any resolution, live vehicle data, no design file in sight.
And somewhere in that codebase there's a line setting a field called node_size, with a comment that amounts to: without this, everything is invisible and nothing complains.