Chapter 11 of 13

The UI Overhaul: Adopting My Own Component Library

Everything up to this point (login, shell, forms, tables) was hand-written Tailwind utility classes over native HTML elements. It worked, but every button, every form field, every status pill was its own one-off implementation. Around the same time, I’d been building @letsprogram/ng-oat, an Angular component library on top of knadh’s Oat CSS framework, as a side project of its own. Gatify became the real test of whether it actually worked for something beyond a demo app, so I migrated the whole dashboard onto it.

Getting the plumbing right first

Before touching a single page, I needed the actual CSS load order right, Oat’s base stylesheet and design tokens have to come before Tailwind’s utilities so component-level styles don’t get silently overridden:

"styles": [
  "node_modules/@letsprogram/ng-oat/assets/oat/oat.css",
  "node_modules/@letsprogram/ng-oat/src/lib/tokens/tokens.css",
  "node_modules/@letsprogram/ng-oat/assets/ng-oat-utilities.css",
  "apps/web/src/styles.css"
]

And provideNgOat() needs to be registered in app.config.ts, alongside an optional provideNgOatTheme() call for tweaking design tokens like corner radius:

provideNgOat(),
provideNgOatTheme({ tokens: { '--oat-radius-medium': '4px' } }),

Small thing, but I missed the token stylesheet on the first pass and got a flash of completely unstyled sidebar and toolbar before I traced it back to a missing import.

Signal Forms plus a component library is not always obvious

NgOatInput, NgOatSelect, and friends implement Angular’s FormValueControl<string> contract so they plug into [formField] directly, no ControlValueAccessor needed. That’s genuinely nice when it lines up, but it means the control’s bound field has to be string-typed, even for something that’s conceptually a number, like inputPricePerMillionTokens. I initially modeled that field as a number in the Signal Forms model, which type-checked against min(), but broke the template binding to NgOatInput, since NgOatInput only knows how to bind to a Field<string, ...>. The fix was keeping the model field as a string, converting with Number() right before the API call, and swapping the built-in min() validator (which requires TValue extends number) for a custom validate() that parses the string and checks it manually:

validate(p.inputPricePerMillionTokens, ({ value }) =>
  isNonNegativeNumber(value())
    ? undefined
    : { kind: 'min', message: 'Must be 0 or greater' },
);

The second one took longer to track down. Validation messages simply weren’t appearing under invalid fields, even though the field’s own invalid state and red outline were rendering correctly. I dug into it and found that NgOatFormError only shows a message when the bound field is both invalid() and touched(), and touched() was never becoming true on blur. The reason turned out to be that the Field directive from @angular/forms/signals listens for a specific output named touch on the control (readonly touch?: OutputRef<void>), and the ng-oat controls at that version only exposed their own local touched model plus a touchedChange output, never the touch output the directive actually listens for. Visually the field looked invalid because that styling came from the control’s own local state, but the underlying Signal Forms field genuinely never got marked touched.

Since I maintain ng-oat too, I fixed it at the source rather than patching around it in Gatify: added a proper touch = output<void>() to the affected controls and emitted it alongside the existing touched model on blur, then bumped the dependency (you can see this in the version history, 0.6.3 in one commit and 0.6.6 a couple commits later). Being the author of both the app and the library it depends on turned what would normally be a “wait for upstream” problem into a same-day fix, which is one of the nicer side effects of building your own tooling.

What got replaced

The custom modal component got replaced entirely by NgOatDialogComponent, which gave me native dialog semantics, focus trapping, and backdrop-close behavior for free instead of hand-rolled overlay logic. The old fixed sidebar became NgOatAppSidebar with a real mobile drawer and a dedicated NgOatAppSidebarTrigger for the hamburger button, NgOatBadge replaced hand-styled status pills, NgOatSkeleton and NgOatAlert took over loading and error states, and NgOatBreadcrumb gave the virtual key detail page proper back-navigation instead of a plain link.

I deliberately left two things as native HTML: the operational tables (deployments, virtual keys, usage logs) stayed as semantic <table> markup, since Oat’s CSS already styles tables well and the version of NgOatTable I had at the time didn’t support the kind of custom per-row action cells (edit, revoke, activate) these screens need. And the model-permission checkbox group on the virtual key form stayed as a plain Set-backed native checkbox list, since it’s a genuinely custom multi-select behavior that didn’t map cleanly onto anything in the library yet. I’d rather leave something as plain HTML than force it through an abstraction that doesn’t quite fit.

Next: actually shipping this to a server. This is the chapter I expect to be the most useful to anyone reading along, because it’s a real, unedited sequence of Docker build failures on a Hostinger VPS through Coolify, and how each one got diagnosed.