// Android AutomotiveAug 202612 min read

Customizing the HAR framework to render an AAOS 17 cluster — without a Figma file

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:

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:

ArtifactWhat it actually isUsable?
.figFigma’s proprietary kiwi binary formatNoNothing in the entire source tree parses it.
.dcfLength-delimited protobuf: header + definitionYesReadable — but we cannot produce one.
DesignCompose fetchBuild tool that emits .dcfNoIt 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:

What it gives back:

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:

the only thing the renderer actually needs
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.

We held the one artifact that entered nowhere, and lacked the credential that unlocked the only entrance. The seam is the second implementation of a trait that was never really about files.

So I wrote a second implementation of that trait. It parses a JSON manifest describing the design and synthesizes the protobuf directly:

GeneratedFigmaLoader
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:

manifest.json
{  "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, telltales, notifications, gauge animation — all of it keys off annotations in the model layer:

#[Design(node = "#driving/speedo-gauge", customizer = "SpeedMeterCustomizer")]speedo: 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:

invariant #1
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
// 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.

Text nodes finally rendering: gear indicator and speed, synthesized from JSON. No raster primitive existed yet, so the wallpaper is absent and the authored 1536×1080 canvas sits fit-scaled and pillarboxed inside the 1920×720 scanout — the black bars are the fit-scaling doing its job.

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:

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 a naming discipline — static art gets non-contract names in a private hud/* namespace, and only nodes with a tier-1 customizer get a contract name — plus a note in the manifest schema stating flatly that group containers must use display-tier customizers, not variants, because variants panic.

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.

Which is why my favourite bug was solved by log::info!. The animated gauge tick marks — fifteen slanted bars that fill as power draw rises — rendered as nothing. Value binding looked right. Truncation maths looked right. Instead of guessing again, I dropped a temporary log line inside the renderer's own path-drawing function:

METER-DBG stroke_cmds=26 fill_empty=true is_closed=true

Three fields, and the whole thing collapsed. stroke_cmds=26 — the geometry was there. fill_empty=true — I'd emitted the ticks as stroked centerlines with nothing in the fill channel. And is_closed=true — the killer, because the renderer only switches to stroke-drawing when it believes a path is open, and its open/closed heuristic looks for repeated endpoints and misfired on my multi-subpath ticks.

So it filled a set of zero-area lines. Perfectly, and into nothing.

The fix was to stop describing ticks as lines and describe them as shapes — each one a closed filled quad, which always routes through fill rendering and sidesteps the heuristic entirely. One log line, three values, a bug that four rounds of theorising hadn't touched.

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.

And the design it ended up serving turned the fidelity ceiling into an advantage. 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 debt is documented and real. 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.

The same generated nodes with the raster layer in place. Wallpaper on the base layer, gear state and speed synthesized from the manifest and bound to live vehicle data — no design file anywhere in the pipeline.

Five takeaways

  1. 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.

  2. 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.

  3. 03

    Silence is the most expensive failure mode.

    Every serious bug here failed silently: a zero-sized box, a None font, a filled zero-area line. All valid states. None of them errors. When a system is quiet and wrong, stop reading logs and start reading the code that would have logged.

  4. 04

    When you can’t see, instrument.

    No screencap, minutes per iteration, a human as my only display. The bug I solved fastest was the one where I stopped theorising and printed three values from inside the render path.

  5. 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.