Skip to content

DesignSystem

Edgar Mesquita edited this page Oct 11, 2026 · 20 revisions

Photon Design System

🌐 This page in: English · Português

The design system behind the write-once components: a complete token/component specification (preserved in-repo at docs/design/Photon-Design-System.dc.html, the source of truth) implemented as typed C# tokens in eQuantic.UI.Primitives and consumed by every realization target.

Token layers (§01–§09)

  • Colors: every color is a paired light/dark ColorToken; components never hold raw colors. Interactive variants resolve five sub-tokens (Base, OnBase, Pressed, a real token rather than an overlay, Subtle, OnSubtle). Disabled is not a color: it's a 38% opacity group.

  • Data colours: what a chart draws data with lives on the theme too — IAppTheme.Data, a DataPalette of eight fixed series slots, a sequential ramp, a diverging pair, the status steps and a de-emphasis gray, audited by PaletteAudit (see Styling).

  • Type scale: role-driven (Display … BodyM, Caption, Label) with Dynamic Type clamps per role, plus the dense end every desktop chrome lives in: TitleSmall (15/700, the rung between Label 13 and Title 20), LabelSmall (11, for status rails, inspector read-outs, specimen captions) and Overline (10/800, tracked, the uppercase eyebrow over a group). A scale that stops at 12dp is a touch scale; a sidebar, a toolbar and a status bar all live below it, and without these rungs every one of them comes out a fifth too large. A style also carries its face: TypeStyle.Mono selects the monospaced one (the system's own, SF Mono on a Mac), and TypeStyle.Italic the slanted cut (since 0.2.0-preview.28 it is an AXIS, so bold italic and italic code both exist), which the measurer, the rasterizer and the raster cache all honour because they already key on the style.

  • Line boxes follow the size: TypeStyle.WithSize(size) scales the line height by the same ratio (CSS's unitless line-height), and TypeStyle.OfSize(size, weight) derives one at the typographic 1.25×. Patching only the size leaves a bigger glyph in the old box, which is how a descender ends up outside its line.

  • A size can follow the window (since 0.2.0-preview.61): TypeStyle.WithFluidSize(min, percentOfWindow, max) is CSS's clamp(34px, 4.2vw, 54px), a heading that grows with the window between a floor and a ceiling instead of jumping at each window class. The web writes the clamp and a unitless line height, Photon resolves the size from the window it lays out against (TypeStyle.AtWindow), and a target that knows no window (an email) sets the ceiling, which is what Size holds. A size given with WithSize is a size in dp again. It is for a paragraph or a role: a TextRun takes only a size from its override, so a fluid run is set at its ceiling, and a run that should grow with the window grows with its paragraph.

    var display = TypeStyle.OfSize(40, FontWeight.Bold).WithFluidSize(34, 4.2f, 54);
    Text("Escolha o seu distrito.", styleOverride: display, headingLevel: 1);
  • Spacing (Space.S1–S16, 4dp base, gap-owned, no margin exists), radius (Radius.Xs–Full, engine-clamped), icon sizes (§07 whitelist 16/20/24/32; arbitrary sizes throw), touch (≥48dp hit contract under a finger, a 24dp floor under a pointer), elevation (one analytic shadow per node), motion (durations + curves + spring).

Density: the same button, 32dp under a thumb and 26dp under a mouse

Density (Comfortable | Compact) is a property of the target, never of the call site: an app never asks for a smaller button on the Mac; the Mac asks for a denser app. It is Material's density and Apple's control size, and it is what keeps ONE tree honest on a phone and on a desktop.

Sizing.Height, PaddingX, LabelSize and HitTarget resolve by it (Small 32→26, Medium 40→32, Large 48→40, XLarge 56→48), and so does the selection ladder (SwitchWidth/SwitchHeight/SwitchThumb/SwitchTravel, SelectionBox, RadioDot). Under a pointer the hit target stops inflating: the §08 minimum is a finger's contract, and on a toolbar of 26dp buttons those invisible margins would overlap each other. It keeps a floor instead, Touch.MinPointerTarget (24dp), the minimum WCAG 2.2 SC 2.5.8 asks of a target: the Compact rungs are past it and grow nothing, and a Checkbox or a Radio without a label, a 20dp box, is a 24 × 24 target. On the web the slop lies under the control's own content, so a button keeps its hover and a control inside a Pressable keeps its own hits, also when the control's child draws no box of its own (an InView, an Adaptive, a light and dark Image): the first things under it that draw one are lifted above the slop (since 0.2.0-preview.61).

It reaches components through ComponentContext.Density, the same door the theme comes through, so no Build signature changed. Who decides is the target: the macOS shell says Compact; the web boot reads (pointer: fine).

The server renders at the browser's density

Since 0.2.0-preview.61

The server cannot see the pointer, so the browser tells it: the runtime leaves the density its pointer asks for in a session cookie (eq-density), and every later request of the session is built at it, from its first byte. The page says which density it was built at, and hydration lowers at that one, so it adopts the served markup as it is. On the first request of a session the server does not know yet and builds Comfortable; under a mouse the whole page then switches to Compact at once, right after hydration. It used to keep the served density until each component next re-rendered, and a page under a mouse showed a mix of both.

The cookie holds a display fact about the device and goes when the browser closes; there is nothing to configure.

The ladder lives in the token layer, always. A component never keeps a private copy of its own size table, because a copy cannot follow the density the target asks for. Sizing.Avatar(size) and the selection rungs exist so a caller reads the same number the component draws.

Spec fidelity is tested, not aspired to

  • Token values are pinned, and contrast is recomputed in tests for every claimed pair, twice: the WCAG 2 ratio and the APCA lightness contrast (Lc 75 for body text, 60 for muted text, links and labels, 15 for a field's boundary), in both themes. APCA is why the dark palette's fills are lighter, with dark text on them.
  • The design system's own pages are held to the SDK: a figure a page prints is compared with its token, a block's status is derived from the public API, and every public token is published in docs/design/tokens.json or named as exempt.
  • Component metrics come from the spec tables (e.g. Button's size table: 32/40/48/56 heights) and are asserted on every axis: C# web realizer pins, native golden images (light + dark), transpiled-fixture execution in vitest.
  • The style resolver rules live in target-neutral C#: every variant's colours, the derived Outline/Ghost/Link included, come from IAppTheme.Colors(variant), and the write-once Button derives its pressed and hover fills from them (a filled variant swaps Base for Pressed on the same shape, Outline and Ghost take SurfaceSubtle).

The "generated, never hand-written" rule

Client-side artifacts carrying design-system values are generated from the C# single source and byte-pinned in CI (a drift fails the build; env-var flows regenerate):

Artifact Generator Regenerate with
Normative stylesheet (custom properties, .eq-type-*, .eq-elevation-*, motion vars) PhotonCssGenerator (pure function, tested per value)
design-system.generated.ts (tokens, theme object, Button size table) DesignSystemTsGenerator EQ_UPDATE_DESIGN_TS=1
icons.generated.ts (glyph path data) IconTsGenerator EQ_UPDATE_ICONS_TS=1
Embedded transpiled component modules live eqc output EQ_UPDATE_TRANSPILED=1

The same rule powers hydration: SSR (C# realizer) and client lowering (TS) are held byte-identical by cross-pinned literals in both test suites.

Icons pipeline (spec A10)

A curated Icons enum (23 glyphs and growing: Menu, the compact shell's navigation trigger, arrived in 0.2.0-preview.26) with the path data living once in the C# IconRegistry (24×24 single-path alpha masks): the web emits inline <svg fill="currentColor"> (the tint rides the color token exactly like text), the TS side consumes the generated module, and the future native glyph atlas rasterizes from the same registry. Outline ↔ filled are distinct glyphs; icons ignore Dynamic Type.

Styling without CSS: the atomic engine

Authoring is 100% typed C# (BoxStyle, StyleDiff, tokens), and there is no CSS plane. Three laws govern the web realization:

  1. One semantics: the same style vocabulary drives dp values on Photon and CSS on web.
  2. No expressiveness ceiling: Wrap, Grid, AspectRatio, AlignSelf, group Opacity, static Transform2D, Hover/Focus state diffs, window-size-class adaptivity (AdaptiveNode), Pinned, Layer, both-axis scroll.
  3. No rewrite by construction: every regular declaration lowers to ONE deduplicated atomic class (eq- + FNV-1a hash of prop:value); theme colors reference var(--eq-color-*); SSR collects the page's rules into a single <style id="eq-atomic"> the client adopts by identity (hydration = class equality, enforced end-to-end by the e2e identity test). ~118 rules / 4.8 KB covered the entire showroom.

Pseudo-states lower to pseudo-variant atomic rules (:hover/:focus-visible, zero JS); size classes to fixed media-gate classes. The C# StyleAtomizer and the TS style-atomizer.ts are byte-identical twins (cross-pinned fixture).

Theming = providing an IAppTheme

One interface (colors as light/dark ColorToken pairs, type roles, shape scale, elevation specs, disabled opacity), selected via AddUI(...).UseTheme(...) and bridged SSR→client.

Switching light/dark at run time is a SERVICE: IThemeController (Mode + Apply) is taken through a constructor like any capability, and the component never learns which target answered. The native runner repaints the window against the other palette, the browser flips one color-scheme declaration (the whole web palette is authored as light-dark() pairs, so the swap costs no re-render). ITextClipboard is offered the same way for a page's own Copy button. Two implementations ship: PhotonTheme (the default) and MaterialTheme (real Material 3: MaterialTheme.Instance baseline or FromSeed(color) dynamic color via a CAM16/HCT port validated against Google's values). Shape is theme-driven; the same component tree rebrands with one line.

Naming a typeface

Since 0.2.0-preview.51

A theme names the faces it sets text in. Before this the vocabulary had no face at all and every target rendered the system's, so a design drawn against a brand typeface could not be built — nothing failed and nothing warned, the app simply came out in a different typeface.

Two properties, because they answer different questions. TypeStyle.Family is per ROLE, so a theme can set headings in one face and body in another. IAppTheme.MonoFamily is the CODE face, and it has to be separate: Mono is a per-NODE property — a branch name is monospaced and the word beside it is not, both TypeRole.Caption — so a role can never tell you which text wanted the other face.

public sealed class BrandTheme(IAppTheme inner) : IAppTheme
{
    public TypeStyle Type(TypeRole role) => inner.Type(role) with { Family = "IBM Plex Sans" };
    public string? MonoFamily => "JetBrains Mono";
    // …the rest delegates to inner
}

A Text may also name one for itself, and specificity wins the way it does everywhere else here: a style that NAMES a family keeps it, and only an unnamed monospaced style takes the theme's code face.

Text("git log", TypeRole.Caption, mono: true)                     // takes MonoFamily
Text("git log", TypeRole.Caption) { StyleOverride = style with { Family = "Fira Code", Mono = true } }

A face that is not there is REPORTED

Every text engine substitutes for an unknown family rather than refusing — CoreText, DirectWrite and Android all do — so a brand face the machine lacks would otherwise be a silent difference between the design and the running app. Photon names it on the line the frame summary already uses, on all four shells:

[photon] screenshot: out.png — FACE NOT FOUND: IBM Plex Sans

The web needs no report and gets none: the lowering quotes the named face in front of the stack the page would have used anyway, so an unavailable family lands on the document's own font rather than on the browser's guess. Naming what to fall back TO is the one thing CSS does better here.

This does not make a face available. Registering a font the system has never seen is a separate piece and does not exist yet; until it does, a family only resolves if it is installed.

What a family may be spelled with

A family name is the only free-form text in the style pipeline, and it lands inside a <style> element and inside JSON in a <script> element. Quoting is not a defence there — neither a CSS string nor a JSON string neutralises </style> or </script> for the HTML parser — so a family is CHECKED rather than escaped: Unicode letters and digits, space, and -, _, ., +. A CJK or Cyrillic family passes; anything that means something to CSS, JSON or HTML does not, and a rejected name is reported as a face this run asked for and did not get rather than dropped in silence.

Clone this wiki locally