Repository navigation
DesignSystem
🌐 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.
-
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, aDataPaletteof eight fixed series slots, a sequential ramp, a diverging pair, the status steps and a de-emphasis gray, audited byPaletteAudit(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 betweenLabel13 andTitle20),LabelSmall(11, for status rails, inspector read-outs, specimen captions) andOverline(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.Monoselects the monospaced one (the system's own, SF Mono on a Mac), andTypeStyle.Italicthe 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 unitlessline-height), andTypeStyle.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'sclamp(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 whatSizeholds. A size given withWithSizeis a size in dp again. It is for a paragraph or a role: aTextRuntakes 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 (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).
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.
- 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.jsonor 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-onceButtonderives its pressed and hover fills from them (a filled variant swaps Base for Pressed on the same shape, Outline and Ghost takeSurfaceSubtle).
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.
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.
Authoring is 100% typed C# (BoxStyle, StyleDiff, tokens), and there is no CSS plane. Three laws govern the web realization:
- One semantics: the same style vocabulary drives dp values on Photon and CSS on web.
-
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. -
No rewrite by construction: every regular declaration lowers to ONE deduplicated atomic class (
eq-+ FNV-1a hash ofprop:value); theme colors referencevar(--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).
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.
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 } }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.
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.
🌐 English · Português
🏁 Start here
🏗️ Architecture
- Architecture Overview
- Write-Once Components
- Declarative Surface
- Package Architecture
- Components
- Styling
- Localization
- Analytics & GTM
📱 Write-once
⚙️ Compilation
⚡ Runtime
🔌 Server
🎨 Ecosystem
🚀 Development