Skip to content

FUN-18 NLDD Design System in plugin UIs

Committed

1. Introduction

Plugins provide custom UIs for the CRDs they manage (FUN-16). Those UIs should look and behave like the rest of the Console — same components, same tokens, same fonts, same light/dark behaviour — so a user moving between the Console and a plugin view sees one coherent product. The Console is built with the NLDD Design System (@nldd/design-system): a set of framework-agnostic Lit web components (<nldd-button>, <nldd-text-field>, <nldd-dropdown>, <nldd-checkbox-field>, …) plus design tokens and the Rijkshuisstijl fonts.

This FUN records how plugin UIs get the NLDD Design System without trusting plugin code, duplicating the design system, or letting it drift from the host. It builds on the dedicated-origin isolation model in FUN-17 — which supersedes FUN-16's opaque-origin sandbox — and the styling conventions in FUN-10.

2. The constraint: no CDN, and the shared-vs-bundle question

Under FUN-17 a plugin UI runs on a real, dedicated origin (plugin-proxy.fundament.io) with sandbox="allow-scripts allow-same-origin", so its subresources load same-origin under script-src 'self' — the opaque-origin CORS gymnastics FUN-16 forced on every asset fetch are gone (FUN-17, "Origin model" and "The CSP confines outbound network calls"). Delivering the NLDD Design System is therefore an ordinary same-origin <script>/<link> load. What remains open is where the bytes come from and whether they are shared or bundled per plugin.

A public CDN is ruled out immediately: air-gapped and government deployments cannot depend on one, and it removes the platform’s control over which version is served. That leaves two credible options, and the choice between them is not the slam dunk it might appear.

2.1. Why not simply let each plugin depend on @nldd/design-system?

Each plugin’s UI is already an npm project with its own package.json, so it could just add the NLDD Design System as a dependency and bundle it. That option is genuinely attractive, and the size argument against it is weaker than it looks. Measured against 0.8.63:

Whole design system Only the 5 components the OpenFSC views use

NLDD Design System JS

1.0 MB raw / 215 KB gzip

204 KB raw / 47 KB gzip

NLDD Design System CSS

718 KB

718 KB — does not tree-shake

The package exposes per-component subpath exports (@nldd/design-system/button, /text-field, …), so the JavaScript tree-shakes roughly fivefold. With a single plugin, bundling is therefore smaller on the wire than the shared channel (~922 KB vs ~1.7 MB). Any claim that bundling costs "~1.6 MB per plugin" assumes no tree-shaking and is wrong.

What does not tree-shake is the CSS, and 512 KB of that 718 KB (71%) is the four Rijkshuisstijl @font-face rules inlined as base64. That — the fonts — is the only payload that genuinely duplicates per plugin. The shared channel amortises it across every plugin and pays for itself at roughly two or three plugins, assuming a user visits more than one plugin UI within a cache lifetime.

So the decision does not rest on bytes. It rests on consistency: one host-pinned version means a plugin UI cannot drift from the Console, or from other plugins. That is the property this FUN exists to guarantee, and it is worth being honest about its limits (see Consequences).

3. Decision: serve the NLDD Design System once, host-pinned, opt-in per plugin

console-frontend owns a single pinned @nldd/design-system version — the same version the host Console renders — and builds it into two files:

  • nldd-design-system.js — a classic IIFE that self-registers every <nldd-*> custom element as a side effect of loading.

  • nldd-design-system.css — NLDD Design System global styles and tokens plus the host’s token overrides, with the woff2 fonts inlined as base64 data: URIs.

These are served to the plugin iframe from the plugin-proxy origin under /plugins/sdk/v1/, the same shared channel that serves the plugin SDK (FUN-17), so they load same-origin under the plugin CSP’s script-src 'self'. A plugin opts in by calling loadNlddDesignSystem() alongside loadSdk(); loadNlddDesignSystem() injects the <link>/<script> for the shared bundle from /plugins/sdk/v1/. loadSdk() stays lean for plugins that don’t want the NLDD Design System. The plugin’s own UI code is a separate Vite app served from /console/, and it externalizes the design system’s runtime: it uses <nldd-*> tags whose element registrations arrive from the shared bundle, and ships none of the NLDD Design System’s code itself. It still depends on @nldd/design-system at build time, as a devDependency, purely for its types (see "Types without the runtime").

This gives every plugin one host-pinned version, zero duplication, no drift, and modern authoring (TypeScript, HMR) at the same time.

3.1. Why classic IIFE + inlined fonts

The bundle is a self-registering classic IIFE <script> with the fonts inlined as base64 data: URIs. Neither is forced by the transport any more — under FUN-17's real, same-origin plugin-proxy origin an ES module and separate font files would load just as well. They are kept because they are the simplest option: a self-registering IIFE needs no per-view import wiring, and inlining the fonts keeps the whole design system to a single <script>+<link> pair with no extra @font-face request, which the existing font-src 'self' data: CSP already covers.

3.2. Theming

The NLDD Design System selects light/dark via :root[data-scheme]color-scheme → the CSS light-dark() function. The plugin SDK already reports the host theme by toggling .light/.dark on <body> (FUN-16). loadNlddDesignSystem() installs a MutationObserver that mirrors that body class onto <html data-scheme>, so the design tokens follow the host theme without each view threading the theme through.

3.3. Host token overrides

Serving the NLDD Design System’s stock global.css is not enough: the Console overrides some of its tokens on :root, and a plugin that misses them renders subtly unlike the page it is embedded in. The NLDD Design System ships an 18px base font, for example, while the Console pins it to 16px — an unpatched iframe visibly renders larger than the Console around it.

nldd-design-system.css is therefore built from console-frontend/src/plugin-sdk/nldd-design-system.css, which imports the NLDD Design System and re-applies those overrides after it. Any :root token the Console overrides in src/styles.css and that plugins should inherit belongs there too, or the two drift. (The values are duplicated rather than shared, because styles.css reaches them through Tailwind’s theme() and the plugin bundle is plain CSS.)

4. Authoring a plugin UI with the NLDD Design System

4.1. Finding components

The canonical reference is the NLDD Design System storybook: https://minbzk.github.io/storybook/. It documents every <nldd-> component and the icon gallery (icons are referenced by kebab-case identifiers such as info-circle). The visual conventions that govern *how to compose these — colour, typography, spacing, sentence-case headers, button placement — live in FUN-10.

4.2. Types without the runtime

A plugin depends on @nldd/design-system as a devDependency, and imports from it with import type only:

import type { NLDDCheckboxField } from '@nldd/design-system/checkbox-field';

Type-only imports are erased at build time, so this adds zero bytes to the plugin bundle (verified: the OpenFSC bundle is byte-for-byte identical with and without the dependency) while giving real, checked types — including the package’s HTMLElementTagNameMap declarations, so querySelector('nldd-checkbox-field') is typed automatically.

Do not hand-roll structural types for the components. An approximation that drifts from the real component does not fail at build time; it fails silently in the iframe. The devDependency must be pinned to exactly the version console-frontend serves, because the types describe the bundle the host actually ships — a unit test asserts the two pins match (src/nldd-design-system-version.test.ts).

4.3. Component quirks to plan for

NLDD Design System components are web components, not native form controls, so a few patterns differ from plain HTML. The OpenFSC create form (plugins/openfsc/console-ui/src/form.ts) is the reference implementation; the recurring rules are:

  • nldd-dropdown wraps a slotted native <select> — keep the <select> and its options; the value lives on the <select>.

  • nldd-checkbox-field is not form-associated — read .checked off the elements directly, not via an input[name]:checked query.

  • nldd-button renders its native <button> in shadow DOM, so a type="submit" does not submit the light-DOM form. Drive submission from a click handler.

  • Validation is manual: form.reportValidity() does not see NLDD Design System controls. Surface errors with nldd-form-field (error-message) rather than native constraint validation.

5. Implementation

5.1. Asset delivery

Under FUN-17 the plugin iframe, its own assets, and the shared bundles all live on the same plugin-proxy origin, so every load is same-origin under script-src 'self':

Asset Served from

Plugin HTML + its own JS/TS

the plugin runtime via plugin-proxy (/clusters/{id}/plugins/{name}/{version}/console/…, go:embed + http.FileServer)

Plugin SDK (plugin-sdk.{js,css}) and the NLDD Design System (nldd-design-system.{js,css})

plugin-proxy, under /plugins/sdk/v1/ — the version-pinned bundle console-frontend builds

The plugin’s own app is a Vite app (vanilla TS) in plugins/<name>/console-ui/, built to plugins/<name>/console/ and compiled into the plugin binary by go:embed console/. It is *multi-page: one HTML entry point per view, whose filenames must match definition.yaml’s `customComponents. OpenFSC is the reference implementation.

Because everything is same-origin on the plugin-proxy origin, there is no cross-origin @font-face or console-asset CORS to arrange: the opaque-origin workaround FUN-16 needed — kube-api-proxy stamping Access-Control-Allow-Origin: * on plugin console-asset responses — is superseded by FUN-17 and has been removed.

5.2. Build wiring

  • Console imageconsole-frontend/Dockerfile runs bun run build, whose prebuild hook runs build:plugin-ui, regenerating the /plugin-ui/ assets into every image. CI builds this image, so the assets always exist at deploy time.

  • Shared plugin bundleplugin-proxy’s Dockerfile runs the same `build:plugin-ui and serves the output at /plugins/sdk/v1/, so plugin iframes load the SDK and NLDD Design System same-origin (FUN-17). This keeps those bundles byte-identical to the ones the Console serves at /plugin-ui/.

  • Plugin imageplugins/<name>/Dockerfile has a console-ui (bun) stage that runs the Vite build; the Go stage COPY --from`s the result in before `go build, so go:embed captures it. plugins/<name>/console is `.dockerignore’d, making that stage the single source of the embedded UI.

  • Mock modekube-api-proxy’s Dockerfiles run the same `console-ui stage, because mock mode serves the plugin’s own console assets from disk rather than from the plugin binary.

5.3. Generated-artifact policy

Build outputs are gitignored and regenerated, never committed — the source of truth is package.json plus the pinned dependency:

  • console-frontend/public/plugin-ui/{plugin-sdk,nldd-design-system}.{js,css}

  • console-frontend/src/proto-version.gen.ts

  • each plugin’s built console/ dist (a committed .gitkeep is the only tracked file, so go build works on a fresh checkout)

They are produced by the prebuild / prestart / prewatch / prelint hooks and by the Docker image builds, so every deploy path regenerates them. Because an unbuilt console/ would otherwise yield a silently blank iframe, the plugin runtime fails fast at startup when the embedded console FS contains no HTML.

5.4. Local development

  • just openfsc console-preview — builds the shared /plugin-ui/ bundle and the Vite app, then serves the built console/ against the live cluster via a kubectl-backed stand-in SDK.

  • just openfsc console-dev — the same stand-in backend behind the Vite dev server (which proxies /api/ and /plugin-ui/ to it), giving HMR against the live cluster.

6. Consequences

  • Bundle size — we ship the whole design system, untree-shaken. nldd-design-system.js (1.0 MB) + nldd-design-system.css (718 KB) load per plugin page that calls loadNlddDesignSystem(), where a bundling plugin would ship ~204 KB of JS for the same five components. We accept that: the assets are opt-in, cached once across all plugins, and a view that uses no <nldd-*> components (e.g. a pure table view) should not call loadNlddDesignSystem() and pays nothing. If the cost ever bites, the cheapest wins are dropping the JetBrains Mono @font-face rules (code-editor only, ~236 KB, unused by these forms) before reopening the bundling question.

  • One version for everyone — which is both the point and the risk. All plugins share exactly one NLDD Design System version, so none can drift from the host. The flip side is a hard coupling: bumping the Console’s version is a simultaneous, unstageable change for every plugin, including third-party ones the platform does not control, and the version is effectively a public API. Note also that the guarantee is a convention, not a constraint — nothing prevents a plugin from bundling its own copy anyway. The shared channel is already version-segmented (/plugins/sdk/v1/), so if a third-party plugin ever needs a different major it can be served alongside at /plugins/sdk/v2/ and the two coexist.

  • Bundling remains a legitimate alternative, and the door is left open. Should the coupling above start to hurt, the sharper split is: let plugins bundle the tree-shaken components (independently versioned, properly typed) and keep a shared channel for only the tokens + fonts CSS — the 512 KB that actually duplicates. Under FUN-17 both halves still load same-origin from the plugin-proxy origin, so this needs no cross-origin @font-face handling.

  • Dogfooding. First-party plugins use the same loadNlddDesignSystem() channel third-party authors do — there is no internal shortcut — which keeps the design-system integration honest (FUN-16).

  • The console-ui build stage is currently per-plugin. OpenFSC is named explicitly in its own Dockerfile and in the three kube-api-proxy Dockerfiles (which need the assets on disk for mock mode). That is tolerable for one plugin, but the second plugin with a Vite UI should turn this into a convention the proxy Dockerfiles iterate over, the way the asset-extraction step already loops over ./plugins/*/console.

7. References