GovernanceDesign system
Affinis design system
v0.1 · tokens --af-*The reference for every screen in Affinis, written for implementers. Each token is a CSS custom property, each rule is testable, and each component below is the live component rather than a picture of one. Where this page and a screen disagree, this page wins.
1Principles
Six rules the rest of the system follows from. Where a later decision had to break one, it is written down in section 12 rather than quietly taken.
2Colour
One cool-neutral ramp, one cobalt accent, four status hues. Every ratio below is computed from the token the browser resolved, against this app's two grounds — the panel surface and the page background. A text token that fell below 4.5:1 would be marked here, and a unit test asserts the same numbers against the ones the written design system publishes.
Neutrals
| Swatch | Token | Value | Role | Use | vs surface | vs background |
|---|---|---|---|---|---|---|
| --af-bg | — | ground | App background | — | — | |
| --af-surface | — | ground | Panels, tables, dialogs | — | — | |
| --af-surface-sunken | — | ground | Segmented track, read-only fields, row dividers | — | — | |
| --af-border | — | non-text | Panel borders, dividers | — | — | |
| --af-border-strong | — | non-text | Control borders | — | — | |
| --af-text | — | text | Primary text, data | — | — | |
| --af-text-2 | — | text | Secondary text, axis lines | — | — | |
| --af-text-3 | — | text | Labels, captions. The lightest text the system allows. | — | — | |
| --af-masked | — | non-text | Masked wells, asymptotes | — | — |
Accent
| Swatch | Token | Value | Role | Use | vs surface | vs background |
|---|---|---|---|---|---|---|
| --af-accent | — | text | Primary action, selection, links, focus ring | — | — | |
| --af-accent-hover | — | text | Hover and pressed state of accent | — | — | |
| --af-accent-strong | — | text | Text on accent-subtle, active nav item | — | — | |
| --af-accent-subtle | — | ground | Selected rows, active nav, CI band | — | — |
Status
| Swatch | Token | Value | Role | Use | vs surface | vs background |
|---|---|---|---|---|---|---|
| --af-ok | — | text | Approved, QC passed, included | — | — | |
| --af-ok-subtle | — | ground | Approved panel ground | — | — | |
| --af-danger | — | text | Rejected, QC failed, destructive | — | — | |
| --af-danger-subtle | — | ground | Rejected panel ground | — | — | |
| --af-warn | — | text | Flagged, threshold breach | — | — | |
| --af-warn-subtle | — | ground | Flagged badge ground | — | — | |
| --af-warn-mark | — | non-text | LC wells, replicate 2 marker | — | — |
Banner and viewer
| Swatch | Token | Value | Role | Use | vs surface | vs background |
|---|---|---|---|---|---|---|
| --af-banner-bg | — | ground | Demo banner ground | — | — | |
| --af-banner-accent | — | non-text | “Demo” keyword; H-bonds in 3D | — | — | |
| --af-viewer-bg | — | ground | 3D viewport ground | — | — | |
| --af-viewer-border | — | non-text | 3D viewport borders | — | — |
3Data scales
Data colours are separate from the interface colours and are never reused for meaning elsewhere. Each scale keeps its order in grayscale, and the value is always printed as well — the colour only ranks it.
Plate heatmap — viridis, 10 stops
--af-seq-viridis-0…9Interpolated linearly between stops. Raw mode maps the plate's fixed range (about 4,000–48,000 RFU); % inhibition maps −10 to 110 %. A masked well overrides to --af-masked with a strike line, so it never reads as data.
Potency — cobalt, 7 bins
--af-seq-potency-0…6Bins are 0.5 log units from pIC50 5.5; the last is open-ended. Text turns white from bin 4. The number is printed in every cell — a reader must never have to decode a shade to know a potency.
Plate layout
Compound wells run a grey ramp from the lowest concentration to the highest, and the column groups are labelled above the plate as well as coloured.
Series in charts
The marks are the real ones the dose–response chart draws. A series must be distinguishable by shape alone, so a masked point is hollow and slashed rather than merely a different colour — and it stays on the chart, counted, at 100 nM like every other point.
4Typography
IBM Plex Sans for interface text, IBM Plex Mono for identifiers, hashes, timestamps and every number. Both are self-hosted woff2; there is no CDN request at run time. All numerals are tabular, so a column of values lines up.
| Token | Utility | Use | Specimen |
|---|---|---|---|
| --af-type-page | text-page | Page title, one per screen | Plate review |
| --af-type-section | text-section | Dialog and section titles | Electronic signature |
| --af-type-panel | text-panel | Panel headers | Plate QC |
| --af-type-body | text-body | Default text and controls | Plate failing any rule are rejected automatically. |
| --af-type-small | text-small | Table cells, metadata | Read 2026-11-04 16:42:19 UTC |
| --af-type-label | text-label uppercase | Column headers, field labels | Concentration |
| --af-type-data | font-data text-data | Identifiers, values, hashes | AFN-000215-01 |
| --af-type-metric | font-data text-metric | One key figure per panel | 0.72 |
A label is uppercased by CSS, not by the text, so a unit inside one keeps its case — µM must never arrive on screen as MM.
5Numbers, units and identifiers
Every rule below is enforced by one shared module with unit tests. A component never formats a number itself — including this page, whose right-hand column is the module's own output.
| Quantity | Rule | Affinis prints | Never |
|---|---|---|---|
| Concentration | Three significant figures, in the unit that keeps the value in [1, 1000). µ, never u. | 123 nM3.33 µM0.508 nM | 123.457nM · 0.00333 mM · 3.33 uM |
| Potency | pIC50 to two decimals. IC50 to three significant figures with its 95 % CI. | pIC50 7.6219.8–29.1 nM | IC50 = 0.000000024 |
| Censored values | Keep the qualifier; never coerce it to a number. | > 10.0 µM< 0.508 nM | 10000 · 0 |
| Log scale | Name the base and the unit, and use the true minus sign. | −7.43 | log conc · -7.43 |
| Unit spacing | A non-breaking space between number and unit, % included. Fold change uses × with none. | 54.2 %34× | 54.2% · 34x |
| Large numbers | Comma for thousands and a point for decimals, whatever the browser's locale is. | 42,180 RFU34,656 | 42.180 · 4.2e4 |
| Timestamps | ISO 8601 in UTC, to the second, with the zone written out. Never a relative time alone. | 2026-11-04 16:42:19 UTC | 04/11/26 5:42 PM · 3 hours ago |
| Hashes | Mono, algorithm named, middle-ellipsized; the whole value is there on copy. | sha256 3f9a…c21e | 3f9a2b… |
A value that was never measured is an em dash with the reason beside it, never a zero and never a blank cell. A predicted value is marked (pred.) and a computed one (calc.) in the column header, every time.
6Space, shape and density
A 4 px grid, four radii and a fixed set of heights. Page padding 24, panel padding 16, 16 between panels and 8 between controls. There are no resting shadows: a panel is a one-pixel border, and the only elevation in the system belongs to a dialog.
Spacing
- --af-space-0_5—
- --af-space-1—
- --af-space-2—
- --af-space-3—
- --af-space-4—
- --af-space-5—
- --af-space-6—
- --af-space-8—
- --af-space-10—
Radius
Density
- --af-h-control—Buttons, inputs, selects
- --af-h-tab—Segmented tab
- --af-h-row—Table row
- --af-h-row-compact—Dense table row
- --af-h-row-sar—SAR row, with its 2D depiction
- --af-h-banner—Demo banner
- --af-h-topbar—Top bar
- --af-w-nav—Left navigation
7Components
Every specimen below is the live component. One primary button per view; a trailing ellipsis means the action asks for something first — a reason, a signature, a constraint — because nothing that changes a record is one click.
Button
Disabled is opacity .45 and a not-allowed cursor, never a button that silently does nothing.
Status
Icon, word and colour — all three, every time, so the screen still reads in grayscale.
Segmented
Changes the view of the same data; it is a radiogroup, not three buttons.
Chip
Filters data. The pressed state is on the element, so a screen reader hears it.
Potency cell
The scale ranks it; the number says what it is. A censored result keeps its qualifier.
Fold bar
A log track to 100×, bold from 30×. The bar never starts anywhere but zero.
Threshold value
Amber text when the value is outside its MPO threshold. No fill, so the grid stays calm.
Metric
One key figure per panel, with the rule that judges it beside the number.
Key / value
- Raw signal
- 23,410 RFU · original, read-only
- % inhibition
- 54.2 % · derived, norm. v2
A raw value says it is original and read-only; a derived one names the version it came from.
Avatar
The initials disc from the top bar. Always decorative: the name it abbreviates is beside it.
8Data-integrity patterns
ALCOA+ in the shape of the interface. Raw values are read-only and labelled; every derivation is a new version with its own reason; masked data stays visible and counted; and nothing that changes a record happens without a reason or a signature.
Electronic signature
Electronically signed
M. Laurent (mlaurent)
2026-11-04 17:40:00 UTC
Meaning: Approved
sha256 3f9a…c21e
- The dialog states the record, its version and its key values before anything is signed.
- The signer picks a meaning — Approved or Reviewed — and it is written onto the signature.
- The password is re-entered for every signature, even inside an active session.
- The manifestation always shows name, meaning and UTC time together, with the record's hash.
- After approval the record is locked by the database. The only remaining action is a corrected version, with its own reason and its own signature.
Audit — this record
| Timestamp (UTC) | Actor | Event | Detail |
|---|---|---|---|
| 2026-11-04 17:03:11 UTC | mlaurent | Well D15 masked | Dispense error — tip dispense error flagged in instrument log for D15 |
| 2026-11-04 17:03:11 UTC | system | Normalization v2 computed | Excludes D15 · v1 retained, read-only |
| 2026-11-04 17:40:00 UTC | mlaurent | Re-authenticated | Password verified for signing |
| 2026-11-04 17:40:00 UTC | mlaurent | Signed — Approved | Meaning: Approved · fit v2 |
Append-only, and there is no edit or delete affordance anywhere on it — because there is none in the database either: the tables refuse an UPDATE and a DELETE, and the chain of hashes is recomputed on demand from the regulatory screen.
9Charts
Drawn with this project's own scale and path helpers — copy-and-own from Exalus, not a charting library. The live examples are on dose–response; the marks themselves are in section 3.
- Axes
- Axis line and ticks in --af-text-2, gridlines in --af-surface-sunken, tick labels in Plex Mono 11 px. Every axis carries a title with its unit.
- Dose–response
- x is log₁₀ concentration in M, increasing right; y is pinned to −10…110 % so two curves from different screens can be compared by eye. Fitted curve 2 px ink, asymptotes dashed in --af-masked, the IC50 drop line dashed accent, the 95 % CI an accent-subtle band.
- Annotations are HTML
- The IC50 pill is a positioned element over the SVG, not SVG text: it stays selectable, it wraps, and it is read out in order.
- Scale honesty
- Bars start at zero. A log scale says it is one. Nothing auto-zooms to make a difference look bigger, and a fit is never drawn past the concentrations that were tested.
10Structures
Two renderers, both written here. The 2D depiction is SVG drawn from coordinates RDKit computed at build time; the 3D viewer is WebGPU impostors with a Canvas 2D fallback. Neither calls a structure library at run time.
2D depiction — live, from the registry
- Bond stroke 1.15 px with round caps; bond length is scaled to the box and never above 17 px.
- A double bond is an inner line offset 20 % of the bond length towards the ring centre and trimmed 16 % at each end.
- Bonds stop 30 % short of a heteroatom label, so the label sits in a gap rather than on a painted mask.
- Condensed groups read outward from the attachment point: OMe to the right, MeO to the left. NH draws its hydrogen below.
- Every depiction has an accessible name — the compound and its R groups — and copies as SMILES.
Atoms — 2D depiction, ink on white
| Swatch | Token | Value | Role | Use | vs surface | vs background |
|---|---|---|---|---|---|---|
| --af-atom2d-c | — | text | Carbon and the bond skeleton | — | — | |
| --af-atom2d-n | — | text | Nitrogen | — | — | |
| --af-atom2d-o | — | text | Oxygen | — | — | |
| --af-atom2d-halogen | — | text | F, Cl, Br, I | — | — | |
| --af-atom2d-s | — | text | Sulfur, phosphorus | — | — |
Atoms — 3D viewer, marks on the dark viewport
| Swatch | Token | Value | Role | Use | vs viewport |
|---|---|---|---|---|---|
| --af-atom3d-ligand-c | — | non-text | Ligand carbon | — | |
| --af-atom3d-protein-c | — | non-text | Protein carbon | — | |
| --af-atom3d-n | — | non-text | Nitrogen | — | |
| --af-atom3d-o | — | non-text | Oxygen | — | |
| --af-atom3d-s | — | non-text | Sulfur | — | |
| --af-atom3d-halogen | — | non-text | Halogen | — | |
| --af-atom3d-dot | — | non-text | Pocket surface dots | — | |
| --af-hbond | — | non-text | Hydrogen bonds and their distance pills | — | |
| --af-measure | — | non-text | Measured distances and angles | — |
The 3D palette is checked against the viewport ground rather than white, which is why every row of it is marked non-text: nothing in the viewer carries a glyph. The labels that do — residue names and distance pills — are HTML over the canvas, in --af-viewer-text and --af-hbond on a translucent dark backing. See the structural context screen for both in use.
11Accessibility
WCAG 2.2 AA, checked rather than claimed: the e2e suite runs axe over every route and a violation fails the build.
- WCAG 2.2 AA. Every text token clears 4.5:1 on both grounds, and the colour table above computes that rather than quoting it.
- State is never carried by colour alone: every status is an icon, a word and a colour; masked data keeps a strike line; chart series differ by shape.
- Focus ring: 2 px accent, offset 2 px, on every interactive element — including the plate wells and the canvas of the 3D viewer.
- Pointer targets are at least 24 × 24 px (SC 2.5.8). A 384-well plate's wells are 26 px.
- The plate is a grid widget: arrow keys walk the wells and each one has a name a screen reader can read (“H07, compound 123 nM, 23,410 RFU, masked”).
- No single key commits a record. The signature dialog is the only path to an approval, and it asks for the password again.
- Every route is checked with axe in the e2e suite; a violation fails the build.
12Implementation, and where it differs
The tokens live in styles/tokens/affinis.css and are exposed to Tailwind as its theme. Everything on this page reads them at run time, so nothing here can quote a value the app does not use.
- CSS Modules → Tailwind on the tokens
- The handoff's §12 asks for plain CSS Modules. The app uses Tailwind 4 with the tokens as its theme, so a component writes `bg-surface` and `text-ink-2` rather than importing a stylesheet. The rule the handoff was protecting — no raw value in a component — is enforced instead by two lint checks: one rejects a hex, px or ms outside the token files, the other rejects any Tailwind class that is not in its canonical form.
- tokens.ts mirror → read the stylesheet
- A typed mirror of the tokens was specified for canvas and WebGPU code. There is none: the 3D viewer reads the tokens it needs with `getComputedStyle` at run time, and this page does the same. A mirror is a copy, and a copy drifts.
- Cartoon ribbons → not in v1
- §10 lists a cartoon toggle for the 3D viewer. A ribbon needs secondary-structure assignment and a tube mesher; ADR 0008 ruled both out of v1. The control is in the toolbar, disabled, with a title that says so.
- `/` to focus search
- §11 specifies it and the top bar prints the hint. The shortcut itself is not wired up yet.