This guide teaches dxn, the language of design and dimensions, one concept at a time. Each section adds one idea, says why you need it, and ends with a file you can check yourself. It covers the language as it is today; the last section lists what is still being designed. The decisions behind it, and the questions still open, are in design.md. To try it end to end first, with one button, see the tutorials.
Take one small element of a website: the wordmark in its toolbar. It sounds simple, but ask which cases it has to handle and the answers multiply:
- With a mouse it can be hovered; with a finger it cannot.
- With a keyboard it can be focused; without one it cannot.
- It has to work at every screen size, from a phone to a desktop.
Every one of those is a question of which combination. Multiply the inputs, the screen sizes and the states together and a single wordmark has dozens of combinations, most of which nobody ever writes down. The designer draws some of them, the developer builds some of them, and the two sets drift apart without anyone noticing.
dxn is where you write those combinations down once, in a form a tool can check. You state what an element's states are, what the world around it varies by, and which states exist under which conditions. The tool then answers the questions that are otherwise guesswork: which states does this element have on a phone? which combinations exist at all? what did I forget?
The same file says what is recorded about those combinations: who drew or built one, when, and where. That data is stored next to the file, checked against the declarations, and kept in sync with them when a name changes, so the specification, the design and the code can be compared case by case.
The language is written for people and for AI agents alike. Everything is declared explicitly, so a reader, human or not, can see what an element depends on without running anything, and every mistake is reported at the place it was made, with what to write instead.
dxn is not a general-purpose programming language: nothing in it runs. It is a declarative specification language, with its own syntax, over finite sets of values. Each part of it comes from a family of languages you may know:
| What it has | Where the idea comes from |
|---|---|
dimensions, environments, profiles and only when |
variability modeling, as in the feature models of software product lines: profiles are the configurations that exist |
| every combination listed and checked | finite-state specification, as in Alloy or TLA+: because every set is finite, the whole space is checked, not sampled |
| records, scalars and stored data | schema languages such as Protobuf, JSON Schema or CUE: types that data is stored as and checked against |
dxn rename, which rewrites the stored data |
schema evolution: every stored value is typed, so a rename knows exactly which values to change |
| evidence that actors record as they draw and build | model-based testing: the model says which cases must exist, and the evidence records which ones the design and the code show |
Put together, dxn is a typed specification of what varies: every case can be counted, and recorded evidence says which cases the real system shows.
A dxn file is plain text, and its name ends in .dxn. The dxn check
command reads one, reports any problems, and otherwise says in one line what
it declares:
dxn check wordmark.dxnwordmark.dxn: 2 enums, 1 type
It needs no project, so you can try everything in this guide in an empty folder. Seven commands work on a file, each introduced where it is needed:
| Command | What it does |
|---|---|
dxn check <file> |
reports every problem, or says what the file declares |
dxn states <file> <Type or path> |
evaluates a type, or a child in place: its states in each profile |
dxn cases <file> <Type or path> |
lists the cases that can be told apart, and what each covers |
dxn validate <file> |
checks every stored record against its declaration |
dxn attach <file> <path> --actor --data … |
records evidence for an element in the combinations given |
dxn status <file> |
counts which combinations have evidence, and which are stale or gone |
dxn review <file> |
writes a page per tree to review its evidence by, with pictures |
dxn rename <file> <Enum>.<value> <new> |
renames an enum's value in the file and its stored records |
Each prints a readable answer for you. A tool or an agent adds --json to any
of them, and gets one JSON document instead: the answer with its problems,
the file and its problems when it fails, or { "error": … } when the command
refuses.
A # starts a comment, which runs to the end of the line. Use comments for
the why that the declarations cannot say:
# The wordmark in the toolbar: the site's name, linking home.
The first thing to say about the wordmark is what states it can be in. Its hover is either at rest or hovered; its focus is either blurred or focused. Each of these is a closed set of values, and a closed set is an enum:
enum Hover = rest | hovered
enum Focus = blurred | focused
An enum lists every value it has, split by |. A long one can carry on to the
next line, before or after a |, and can start its values on the line after
the =. A pipe is required between values, with none before the first or
following the last. Blank lines and comments can come
between the lines, so a value can be commented out:
enum Size = xs | sm | md |
lg | xl
enum Size = xs
# | sm
| md
An enum can have a single value.
Why closed sets? Because a finite set can be counted. If Hover has two
values and Focus has two, there are exactly four combinations, and the tool
can list every one and tell you which you have not covered. An open set, like
"any string", could never be checked that way.
Three rules keep enums unambiguous:
- An enum's name starts with a capital letter. In this language, a name for a set of possibilities is capitalized, as is every other name declared at the top of a file (a profile, a modifier, an actor), and a name for a single thing inside one (a value, a state) starts with a lowercase letter. You can tell which one you are reading at a glance.
- A value is lowercase letters and digits, with single hyphens only
between them:
rest,no-keyboard,19px, but notRest,hovered-orhov--ered. So no two values differ only in case. A value can be a keyword, such ason, but notwhenorelse, which start a derived state and awhen's last branch. - Each value appears once, and each name is declared once in the file.
An enum on its own is just a set of values. To say that the wordmark has a hover and a focus, declare a type:
enum Hover = rest | hovered
enum Focus = blurred | focused
type Wordmark {
state hover: Hover, focus: Focus
}
A type is the kind of element you are describing. Each state line declares
one or more of its state fields, split by commas: each has a lowercase name, a
colon, and the enum its values come from. A type has as many state lines as
it needs, so a state can have a line, and a comment, of its own:
type Link {
state hover: Hover, focus: Focus
# the current page's link is selected
state selection: Selection
}
A type's states are independent of each other, so the wordmark above can be in any of the four combinations of hover and focus. You can ask the tool for them:
dxn states wordmark.dxn Wordmark| hover | focus |
| ------------- | ---------------- |
| rest, hovered | blurred, focused |
Each state has a column, listing the values it can have: every value, because nothing yet says otherwise. That is about to change.
The checker holds you to what you declared. Misspell a state's enum, or name a state with a capital letter, and it says so, with the line and column:
wordmark.dxn:3:23: error: state-name: state names start with a lowercase letter: `Focus`
wordmark.dxn:3:30: error: unknown-enum: `Focus` is not an enum (`Hover`)
Some enums and types only make sense inside one type: a menu's open or closed state, or the button that opens it. Listing them all at the top of the file hides which type they belong to. They can be declared inside it instead:
enum Hover = rest | hovered
type Menu {
enum State = closed | open
state menu: State
type Toggle {
state hover: Hover
state expanded: State
}
}
type Toolbar {
state menu: Menu.State
}
Inside Menu, its enum is just State, and a type nested in it, like
Toggle, sees it by that name too: a declaration sees everything declared
around it, and at the top of the file. Outside, each is named after the type
it is in: Menu.State, Menu.Toggle. Hover stays at the top, because
several types use it.
dxn states menu.dxn Menu.Toggle| hover | expanded |
| ------------- | ------------ |
| rest, hovered | closed, open |
A nested name is not visible from a sibling, only from inside its own type.
Written state menu: State in Toolbar, it is reported, with the names that
would do:
menu.dxn:14:15: error: unknown-enum: `State` is not an enum (`Hover`, `Menu.State`)
And it cannot repeat a name from around it, so a bare name always means one
thing: an enum Hover inside Menu, with Hover at the top, is a
duplicate-name. A type sees what is declared around it, but not the states
of the types around it: those it has to be given.
A hovered state only makes sense with a mouse, and a focused one only with a keyboard. Whether the reader has a mouse or a keyboard is not something the wordmark decides: it is a fact about the world the wordmark is shown in. A fact that varies like this is a dimension:
dimension Pointer = mouse | touch
dimension Keyboard = keyboard | no-keyboard
A dimension is something the world varies by, such as the screen size or the
kind of pointer, not a size: its values are names, like sm, never
measurements.
A dimension is written like an enum: a capitalized name and its values. The difference is what it means. An enum's values belong to an element: the wordmark is hovered. A dimension's values belong to the surroundings: the reader has a mouse. Later, a dimension's value will flow down the page from whatever provides it to every element inside, which an enum's never does.
A dimension always lists its own values. There is no way to make a dimension out of an enum, so there is never a question of which of the two to use: if it describes the element, it is an enum; if it describes the world around it, it is a dimension.
Now the wordmark depends on the reader's pointer and keyboard. Say so on the type, with needs:
type Wordmark needs Pointer, Keyboard {
state hover: Hover, focus: Focus
}
needs lists the dimensions a type depends on. It is a promise in both
directions: the type will only ask about these dimensions, and whatever
places the type will have to provide them.
Why write this down, when the tool could work it out from the rules below?
Because then it would be checked against nothing. With needs, what a type
depends on is visible on its first line, to a person skimming the file and to
an agent reading it, and the rules inside are checked against it. When a rule
uses a dimension the type does not need, the error is at that rule, and says
exactly what to declare:
wordmark.dxn:5:29: error: unneeded-dimension: `Wordmark` does not need `Pointer`; declare `type Wordmark needs Pointer`
A need is also a use on its own: a type that needs a dimension can differ by it, even with no rule on it. A wordmark drawn bigger on a large screen needs the screen size, and nothing more. A need that makes no difference at all, declared alike throughout and used by nothing else, is a warning, so a stale need does not linger unnoticed:
logo.dxn:2:17: warning: unused-need: `Logo` needs `Screen`, but nothing in it uses it, and it is alike throughout
A warning does not fail the check: the file is still usable. alike comes
later, in its own section.
With the dimensions declared and needed, you can say what was true all along: the wordmark can only be hovered with a mouse, and only focused with a keyboard. A rule says that a state's value exists only when a dimension has a value:
dimension Pointer = mouse | touch
dimension Keyboard = keyboard | no-keyboard
enum Hover = rest | hovered
enum Focus = blurred | focused
type Wordmark needs Pointer, Keyboard {
state hover: Hover, focus: Focus
hover = hovered only when Pointer = mouse
focus = focused only when Keyboard = keyboard
}
A rule names a state, one of its values, and a condition on a needed
dimension. Values without a rule, like rest and blurred, exist
everywhere. A rule only ever takes a value away where its condition is false;
it never adds one.
When a value exists under several of a dimension's values, list them with
in:
dimension Breakpoint = base | sm | lg | xl
enum Hover = rest | hovered
type BlogLink needs Breakpoint {
state hover: Hover
hover = hovered only when Breakpoint in (lg, xl)
}
A condition can join comparisons with and, where all of them hold, or with
or, where one does. Each comparison is on a dimension the type needs:
dimension Breakpoint = base | sm | lg | xl
dimension Pointer = mouse | touch
enum Hover = rest | hovered
type BlogLink needs Breakpoint, Pointer {
state hover: Hover
hover = hovered only when Pointer = mouse and Breakpoint in (lg, xl)
}
and and or mix only in brackets, so the order is always written, never
left to a rule of precedence. Without them, the error gives both ways to
bracket it:
link.dxn:7:65: error: syntax-error: `and` and `or` need brackets to mix: write `(Pointer = mouse and Breakpoint = lg) or Breakpoint = xl` or `Pointer = mouse and (Breakpoint = lg or Breakpoint = xl)`
Brackets nest, and a pair around a whole condition, or around one comparison,
changes nothing. There is no not: every dimension's values are listed, so
not Pointer = touch is always Pointer = mouse, or an in of the others.
A condition is checked as a whole too. In one and, each dimension is
compared once: Pointer = mouse and Pointer = touch is a
repeated-comparison, since its values can be given once, with in. One
that is never true is an error, since the value would exist nowhere; one that
is always true is a warning, since the rule restricts nothing. Never and
always are judged against the combinations the type really has: with its
other rules, its fixed and derived states, and its profiles. A condition on
enabled = enabled is never true where another rule leaves only disabled:
link.dxn:7:3: error: impossible-condition: `(Pointer = mouse and Breakpoint = lg) and (Pointer = touch and Breakpoint = lg)` is never true, so `hovered` exists nowhere
link.dxn:7:3: warning: vacuous-condition: `Pointer = mouse or Pointer = touch` is always true, so the rule restricts nothing
Each state value has at most one rule, so there is a single place to read where it exists, and no question of whether a second one adds to the first, narrows it or replaces it. A second rule for the same value is an error, which gives the one rule to write instead:
link.dxn:8:3: error: duplicate-rule: `hover = hovered` already has a condition at line 7; join them in one: `hover = hovered only when Pointer = mouse and Keyboard = keyboard`
A long condition carries on to the next line, before or after an and or an
or, and inside brackets, so each part can have a comment of its own:
hover = hovered only when Pointer = mouse # touch has no hover
and enabled = enabled # nor does a disabled button
A line break never changes the order: and and or still mix only in
brackets.
A condition can also compare another of the type's own states, where two states are not independent: a disabled button is never hovered.
dimension Pointer = mouse | touch
enum Enabled = enabled | disabled
enum Hover = rest | hovered
type Button needs Pointer {
state enabled: Enabled
state hover: Hover
hover = hovered only when Pointer = mouse and enabled = enabled
}
The matrix then has a row for each value of a state another depends on, and shows that state next to the dimensions, since it says where the row is. So every row still reads one state at a time:
| Breakpoint | layout |
| ---------- | ------- |
| base | compact |
| sm | compact |
| lg | inline |
| xl | inline |
A state can depend on one that depends on another, but no state can depend on itself, directly or through others:
button.dxn:7:29: error: rule-cycle: `focus` depends on `hover`, which depends on `focus`; no state can depend on itself
Hover is a state the reader changes: both values happen, and rules only say
where each can. Some states are not like that. The toolbar is compact on
narrow screens and lays its links out in line on wide ones: nobody switches
it, and at each screen size it is exactly one. That is a derived state,
and a when says what it is:
dimension Breakpoint = base | sm | lg | xl
enum Layout = compact | inline
# The toolbar is compact on narrow screens, and lays its links out in line
# on wide ones.
type Toolbar needs Breakpoint {
state layout: Layout = when Breakpoint {
base, sm -> compact
lg, xl -> inline
}
}
Each line of the when lists some of the dimension's values and, after
->, the state's value for them. Every value of the dimension is covered,
exactly once, so the state has exactly one value in each row:
| Pointer | enabled | hover |
| ------- | -------- | ------------- |
| mouse | enabled | rest, hovered |
| mouse | disabled | rest |
| touch | enabled | rest |
| touch | disabled | rest |
A value left out is reported, not quietly given a default:
toolbar.dxn:7:31: error: incomplete-when: `when Breakpoint` does not cover `xl`
Unless the when says so with else, its last branch, which gives one value
to every value the others do not list:
state layout: Layout = when Breakpoint {
base, sm -> compact
else -> inline
}
An else also covers a value added to the dimension later, without a
report, so it is for a value that stands for "anything else", not for
saving a line. An else with nothing left to cover is a warning,
unused-else.
The dimension must be one the type needs, as for a rule. A derived state is
declared on a state line of its own, and has no only when rules: its
when already decides it.
A state can also be fixed: one value, always. The article page is the blog's page, so which section is current is decided once:
type ArticlePage {
state current: Section = blog
}
Like a derived state, a fixed state has no rules, no action can change it,
and a parent cannot bind it. A stored context still gives its value, which
must be the fixed one, as a derived state's must be the one its when gives
there.
An element is often made of others. The toolbar holds the wordmark, and, by its layout, either the links or a menu; the menu holds its toggle, and a panel while it is open. A type says what it contains with has:
dimension Breakpoint = base | sm | lg | xl
enum Layout = compact | inline
type Wordmark {}
type Links {}
type Menu {
enum State = closed | open
state menu: State
type Toggle {}
type Panel {}
has toggle: Toggle
has panel: Panel only when menu = open
}
type Toolbar needs Breakpoint {
state layout: Layout = when Breakpoint {
base, sm -> compact
lg, xl -> inline
}
has wordmark: Wordmark
has links: Links only when layout = inline
has menu: Menu only when layout = compact
}
Each has line names a child, lowercase like a state, and gives its type. A
child is always there unless only when says where: on one of the type's own
states (layout = compact, menu = open), or on a dimension it needs
(Breakpoint in (lg, xl)). The case of the name tells which: a lowercase name
is a state, a capitalized one a dimension. Its condition is written as a
rule's, with and, or and brackets, and can compare states and dimensions
together: only when menu = open and Breakpoint = base. A child that is never
there is an error, and one that is always there a warning. A child's own children come from
its type, so the tree nests as deep as the types do.
The matrix shows where each child is:
dxn states toolbar.dxn Toolbar| Breakpoint | layout | has wordmark | has links | has menu |
| ---------- | ------- | ------------ | --------- | -------- |
| base | compact | ✓ | — | ✓ |
| sm | compact | ✓ | — | ✓ |
| lg | inline | ✓ | ✓ | — |
| xl | inline | ✓ | ✓ | — |
Where a child depends on a free state, the cell says for which of its values it is there:
| menu | has toggle | has panel |
| ------------ | ---------- | ----------- |
| closed, open | ✓ | menu = open |
Where it depends on several free states, the cell gives its condition:
menu = open and (size = big or Breakpoint = lg).
A child's name is unique in its type, among its children and its states; its type must be a type; and a type cannot contain itself, directly or through its children, since the tree would have no end. Like everything a type uses, a condition sees only the type's own states and the dimensions it needs, not the states of the types around it.
The menu's toggle shows ☰ while the menu is closed and ✕ while it is open. It does not decide that: it shows the menu's state. The toggle declares the state, and the menu sets it where it places the toggle, with a binding:
type Menu {
enum State = closed | open
state menu: State
type Toggle needs Pointer, Keyboard {
state hover: Hover, focus: Focus
state expanded: State # closed: ☰ Menu; open: ✕ Close
}
has toggle: Toggle { expanded = menu }
}
{ expanded = menu } reads: the toggle's expanded is the menu's menu.
On its own, Toggle is self-contained, with an expanded that can be
either; placed in the menu, it always follows the menu. Both states must take
their values from one enum, and each of the child's states is set at most
once. A state the child decides itself cannot be set: one a when or a value
decides, one it receives, or one its own actions change. The bindings come
right after the child's type, before any only when.
A binding can also fix a child's state to one of its values: wherever the parent places it, that state is always that value. A screen's tab bar is always on its own section:
type FeedScreen {
has tabs: TabBar { section = feed }
}
{ section = feed } reads: the tab bar's section is always feed. A
lowercase name after = is one of the parent's states when the parent has a
state of that name, and otherwise a value of the child's state, which must be
one of its values. A name that is both, a state of the parent and a value of
the child's state, is reported, ambiguous-binding, rather than read one way
or the other: rename one of them.
A feed shows articles, as many as there are. A child whose type is in brackets is a list: one or more of its type.
type FeedScreen {
enum Content = articles | empty
state content: Content
has articles: [ArticleRow] only when content = articles
has follow: Button { style = primary } only when content = empty
}
Each row can be in any of ArticleRow's combinations, so the list adds no states:
five rows are not five times the states. A list that is there has at least
one row. No rows is the list not being there, which its only when decides,
so a screen that can be empty says so with a state of its own, as content
does here, and shows something else there, or nothing. A list without an
only when is never empty.
Bindings set every row's states: [ArticleRow] { reading = unread } is a
list of unread rows.
A list can have rows of several kinds. Its members go one on each line
inside the brackets, each a tag and a type, with its own bindings and its own
only when:
type FeedScreen {
enum Plan = free | paid
state plan: Plan
has feed: [
article: ArticleRow
ad: AdRow only when plan = free
]
}
Each row is one of the members: an article or, on the free plan, an ad, in
any mix, and the list has at least one row. This is a tagged union: the
choices are closed, nothing but what the list names, and each carries its
tag, which tells them apart even when two are of one type,
unread: ArticleRow { reading = unread } and read: ArticleRow { reading = read }. Each member is a child of the type in place, by its path,
FeedScreen.feed.ad, counted and recorded as any child is; the list itself
has no combinations. An only when after the ] is the whole list's, every
member's too. Bindings go on each member's line, never after the ], since
the members may be of different types. A member that adds no rows, whose
every row another member of its type allows too, is a warning,
redundant-member.
A state lives in the lowest type that all its users share: the menu's in the menu, since the toggle and the panel both follow it. Some states have users far below: which section the page is, which decides whether the toolbar's blog link is selected. Passing it down level by level would make every type between carry it. Instead, the type that owns it provides it, and the types that need it receive it:
enum Section = home | blog
type ArticlePage {
state current: Section = blog
provides current
has toolbar: Toolbar
}
type Links receives current: Section {
has blog: Link only when current = blog
}
Toolbar, between the page and the links, says nothing about it. A type
declares what it receives on its first line, after what it needs, with the
enum it expects; inside, a received state is used like one of its own, in
conditions and bindings, but it has no rules and no parent can bind it. A
type provides only its own states, each once: not one it receives itself.
A received state often decides a child's own state rather than whether it is
there: the blog link is always there, but selected only on the blog's pages.
A binding can map one state onto another with a when, a branch for each of
its values, split by commas or lines:
type Links receives current: Section {
has blog: ToolbarLink {
selection = when current { blog -> selected, home -> unselected }
}
}
Like every when, it covers each value of the state it is on once, and each
branch gives a value of the child's state. In place, the blog link is
selected exactly where the page is the blog's.
Where a received state comes from is known in place: the nearest type above on the path that provides it, with the same enum. In place, it is one of the parents' states, by its provider's name:
dxn states site.dxn ArticlePage.toolbar.links.blog| current | selection |
| ------- | --------- |
| blog | selected |
On its own, a receiving type has the state free, with every value; and a
path where nothing above provides it is refused, by dxn states, by
dxn status, and by an expectation that reaches it:
dxn: `current` is received by `Links`, but nothing above it provides it
dxn states on a type gives what that type can be on its own. A child
inside a page is narrower: it is only where its parents put it, and some of
its states are set by them. Give dxn states a path from a type, and it
evaluates the child in place:
dimension Breakpoint = base | sm | lg | xl
dimension Pointer = mouse | touch
dimension Keyboard = keyboard | no-keyboard
enum Hover = rest | hovered
enum Focus = blurred | focused
enum Layout = compact | inline
type Menu {
enum State = closed | open
state menu: State
type Toggle needs Pointer, Keyboard {
state hover: Hover, focus: Focus
state expanded: State
hover = hovered only when Pointer = mouse
focus = focused only when Keyboard = keyboard
}
has toggle: Toggle { expanded = menu }
}
type Toolbar needs Breakpoint {
state layout: Layout = when Breakpoint {
base, sm -> compact
lg, xl -> inline
}
has menu: Menu only when layout = compact
}
environment Device = Breakpoint x Pointer x Keyboard
profile Phone: Device { Breakpoint = base, Pointer = touch, Keyboard = no-keyboard }
profile Laptop: Device { Breakpoint = lg, Pointer = mouse, Keyboard = keyboard }
dxn states toolbar.dxn Toolbar.menu.toggleIn every row: Phone, Breakpoint = base, Pointer = touch, Keyboard = no-keyboard, layout = compact
| menu.menu | hover | focus | expanded |
| --------- | ----- | ------- | -------- |
| closed | rest | blurred | closed |
| open | rest | blurred | open |
For each profile, it walks down the path: the toolbar is compact on a phone,
so the menu is there; the menu can be closed or open; and in each, the
toggle is there, with expanded set to match. On a laptop the toolbar is
inline, so there is no menu, and no toggle: no row. Its parents' states
that decide where it is, or that it is given, are named by their path:
layout is the toolbar's, menu.menu the menu's. What is the same in every
row is said once, above the table: the toggle is only ever there on a phone,
where the toolbar is compact; the rest varies, row by row.
This is where the language pays off. Ask for the wordmark's state again:
dxn states wordmark.dxn Wordmark| Pointer | Keyboard | hover | focus |
| ------- | ----------- | ------------- | ---------------- |
| mouse | keyboard | rest, hovered | blurred, focused |
| mouse | no-keyboard | rest, hovered | blurred |
| touch | keyboard | rest | blurred, focused |
| touch | no-keyboard | rest | blurred |
There is one row for each combination of the dimensions the type needs, in the order it needs them, and each state's cell lists the values it has there. This table is the matrix the introduction talked about: every case the wordmark has to handle, worked out from four lines of rules. A designer can draw from it, a developer can build from it, and nobody has to guess which cases exist.
The table is drawn to be read: a key the same in every row is said once, above
it, and where every row is on every profile, there is no profiles column.
With --json, the command prints the full matrix as JSON instead, a row for
each world, for tools and agents.
Four rows is manageable, but add a screen size with four breakpoints and the wordmark has sixteen. Many of those combinations never happen: nobody uses a touch-only laptop with a keyboard and an extra-large screen. The world is not every combination; it is a handful of real devices.
The first step is to say which dimensions make up a device. That is an
environment: a product of dimensions, written with x, read "by":
environment Device = Breakpoint x Pointer x Keyboard
Read it the way you would read the size of a grid: a Device is a Breakpoint
by a Pointer by a Keyboard. Put spaces around the x; a long product can
carry on to the next line after one. The x is only special here, between
dimension names (which are always capitalized), so x is still an ordinary
value everywhere else.
An environment is the shape; a profile is one real instance of it, giving each of its dimensions a value:
dimension Breakpoint = base | sm | lg | xl
dimension Pointer = mouse | touch
dimension Keyboard = keyboard | no-keyboard
enum Hover = rest | hovered
enum Focus = blurred | focused
type Wordmark needs Pointer, Keyboard {
state hover: Hover, focus: Focus
hover = hovered only when Pointer = mouse
focus = focused only when Keyboard = keyboard
}
environment Device = Breakpoint x Pointer x Keyboard
profile Phone: Device { Breakpoint = base, Pointer = touch, Keyboard = no-keyboard }
profile Tablet: Device { Breakpoint = lg, Pointer = touch, Keyboard = no-keyboard }
profile Laptop: Device { Breakpoint = lg, Pointer = mouse, Keyboard = keyboard }
A profile's name is capitalized, as a type's is. After the colon comes the environment it is an instance of, then a value for each of that environment's dimensions, split by commas or lines.
Now the matrix has one row per profile instead of one per combination. Rows
that read the same are one row, naming every profile in it: the wordmark does
not need Breakpoint, so on a phone and on a tablet it is the same:
| profiles | Pointer | Keyboard | hover | focus |
| ------------- | ------- | ----------- | ------------- | ---------------- |
| Phone, Tablet | touch | no-keyboard | rest | blurred |
| Laptop | mouse | keyboard | rest, hovered | blurred, focused |
These are the cases that matter, named the way the team talks about them.
A profile must be complete and correct: every dimension of its environment, once, with one of its values. All profiles in a file share one environment, so they can be compared row by row.
wordmark.dxn:16:54: error: unknown-value: `pen` is not a value of `Pointer` (`mouse`, `touch`)
wordmark.dxn:18:9: error: incomplete-profile: `Laptop` does not give `Keyboard`, which `Device` requires
A type can need a dimension that the profiles do not give. The theme, for instance, is not part of the device: any device can be light or dark. Such a dimension still goes through each of its values, within each profile:
dimension Pointer = mouse | touch
dimension Theme = light | dark
enum Hover = rest | hovered
enum Tone = plain | inverted
type Wordmark needs Pointer, Theme {
state hover: Hover, tone: Tone
hover = hovered only when Pointer = mouse
tone = inverted only when Theme = dark
}
environment Device = Pointer
profile Phone: Device { Pointer = touch }
profile Laptop: Device { Pointer = mouse }
| profiles | Pointer | Theme | hover | tone |
| -------- | ------- | ----- | ------------- | --------------- |
| Phone | touch | light | rest | plain |
| Phone | touch | dark | rest | plain, inverted |
| Laptop | mouse | light | rest, hovered | plain |
| Laptop | mouse | dark | rest, hovered | plain, inverted |
Some devices change with an accessory. A tablet is a touch screen without a keyboard, until it gets a keyboard with a trackpad: then it has a mouse and a keyboard, and the wordmark can be hovered and focused on it. That is not a new device, and it fits more than one: it is a modifier.
modifier MagicKeyboard on Tablet {
Pointer = mouse, Keyboard = keyboard
}
A modifier's name is capitalized, like a profile's. After on come the
profiles it fits, split by commas, and in braces the dimensions it sets, as in
a profile; the dimensions it does not set keep the profile's values. For each
profile it is on, it makes one more, named <profile>+<modifier>, and the
matrix has a row for it:
| profiles | Pointer | Keyboard | hover | focus |
| ----------------------------- | ------- | ----------- | ------------- | ---------------- |
| Phone, Tablet | touch | no-keyboard | rest | blurred |
| Laptop, Tablet+MagicKeyboard | mouse | keyboard | rest, hovered | blurred, focused |
A tablet with the magic keyboard has the same values as the laptop, so the
two are one combination, and share a row that names them both. Nothing about
either is lost: both stay declared, and a context still names the one profile
its evidence was recorded on, "profile": "Laptop" or
"profile": "Tablet+MagicKeyboard", which then counts for the whole row. If
the two ever differ, the row splits, and each piece of evidence stays with the
profile it named. In JSON, each row of dxn states lists its profiles.
A modifier sets dimensions of the profiles' environment, each once, with one of their values, and is on profiles that exist. One that changes nothing on a profile it is on is a warning, since that profile already exists:
wordmark.dxn:20:19: warning: unchanged-profile: `Magic` changes nothing on `Laptop`, which already has `Keyboard = keyboard`
Two combinations can differ in the matrix and still be impossible to tell apart. A button at rest looks the same on a touch screen as with a mouse: the pointer decides whether it can be hovered, not how it looks at rest. To draw, build or test it, there are fewer cases than combinations. alike says which:
dimension Pointer = mouse | touch
enum Enabled = enabled | disabled
enum Hover = rest | hovered
type Button needs Pointer {
state enabled: Enabled
state hover: Hover
hover = hovered only when Pointer = mouse and enabled = enabled
alike Pointer
}
alike Pointer reads: combinations that differ only in the pointer cannot
be told apart. dxn cases lists the cases that can. Where a case covers
more than one combination, it says what it covers: its profiles, when the
file has profiles, or otherwise each combination, by the values the case
does not show:
dxn cases button.dxn ButtonButton: 3 cases of 5 combinations
| enabled | hover | combinations |
| -------- | ------- | -------------------------------- |
| enabled | rest | Pointer = mouse; Pointer = touch |
| enabled | hovered | Pointer = mouse |
| disabled | rest | Pointer = mouse; Pointer = touch |
Without alike, every combination is its own case, and dxn cases lists
them one by one: Button: 5 cases. With --combinations, it lists every
combination even where some are alike, to see what alike merged. Only what a type
declares can tell its combinations apart: its own states and the dimensions
it needs. A dimension that only the types around it need never does. A logo
drawn bigger on a large screen needs the screen size; an icon that is the
same on every screen does not, and it is one case wherever it is placed.
alike can also make some values alike, with in, and only where a
condition holds, with when:
alike Breakpoint in (lg, xl) # lg and xl cannot be told apart
alike hover in (rest, hovered) when selection = selected
It is on one of the type's states or a dimension it needs, and in names
two or more of its values. With --json, each case lists the values that
tell it apart and every combination it covers.
Everything so far describes the matrix: what an element's states are, and where each one exists. But the tools around the matrix also keep data about it. When a designer draws a combination, the evidence they record says when, and where the drawing is; when a reviewer looks at it, the review says what they decided, and why. That data has a shape, and a shape is worth checking just as much as the states are.
A record is that shape: a named group of typed fields.
enum Verdict = approved | changes
record Comment {
# what the reviewer wrote
text: NonEmptyString
}
record Review {
verdict: Verdict
comment: Comment
}
A record's name is capitalized, like every other set of possibilities. Its
fields come one to a line, each a lowercase name, a colon, and its type: an
enum (Verdict), another record (Comment), or a scalar (NonEmptyString,
below). A short record can sit on one line:
record Note { text: NonEmptyString }.
A record is not a type. A type is an element: it needs dimensions and has states that come and go with them. A record is plain data, with nothing that varies: every field is given, every time. That is also why a record cannot contain itself, directly or through other records: it could never be written down.
recursive-record: `Review` contains itself: `Review.comment` is a `Comment`, and `Comment.reply` is a `Review`
Some data can never be an enum: the moment evidence was recorded, for instance, is one of endlessly many. A scalar is a built-in type whose values are checked rather than listed:
| Scalar | A valid value |
|---|---|
Timestamp |
an ISO 8601 date and time with a time zone, e.g. 2026-10-31T08:50:05Z |
NonEmptyString |
at least one character, with no spaces at either end and no control characters |
RelativePath |
a path from the repository root: parts split by /, none of them empty, . or .., none with a \ or spaces at either end, nor starting with a drive such as C: |
Scalars only type a record's fields. A state or a dimension cannot have one: their values must be listed, so a type's combinations can always be counted. A scalar's name is built in, so a file cannot declare it.
One record is built in as well: PenNode, a node of a pen.dev design
file. It is how a record says where a drawing is, and what the drawing was
when it was recorded:
| Field | What it holds |
|---|---|
file |
the design file, a RelativePath |
frame |
the frame the drawing is in, a NonEmptyString |
node |
the node inside the frame, a NonEmptyString; left out when the drawing is the frame |
fingerprint |
the node's fingerprint, or the frame's without a node, when it was recorded |
The design file's path is relative to the nearest Git root above the .dxn
file, including a worktree's .git file. Outside Git it is relative to the
.dxn file's directory. The CLI and editor use the same rule; running a command
from another directory does not change which drawing it reads. Outside Git,
paths previously written relative to the CLI working directory may need updating.
The frame is there even with a node: when the node is deleted, the frame
still says where it was. With them, Evidence can hold everything a designer
records: when, and where the drawing is.
record Evidence {
at: Timestamp
source: PenNode
}
Who records it is the next section but one.
Some of a record's data follows from another field: what happens next
follows from a review's verdict. A field can be derived with a when, as a
state can:
enum Verdict = approved | changes
enum Next = ship | redraw
record Review {
verdict: Verdict
next: Next = when verdict {
approved -> ship
changes -> redraw
}
}
A derived field is an enum's, on another field of the record, not itself,
that is an enum's and is not derived itself, and its when covers every
value of that field once. It is not stored: its value always follows, so a stored record
that gives it is reported.
Evidence is always about one case of the matrix: this drawing shows the wordmark on a laptop, in the dark theme, hovered. Someone records it, and is responsible for it: an actor. An actor is declared with the record it records, one record each, and is named as records are, with a capital letter and then letters and digits:
actor DesignAgent records Evidence
A record an actor records is stored, each one a fact about one combination:
it has a context. Every stored Evidence then has a context: the one row of
dxn states it is about. The field is not listed among the record's fields,
because every record an actor records has it, in the same shape:
| Part | What it holds |
|---|---|
type |
the type it is about |
profile |
the profile, when the file declares profiles; it fixes the dimensions it gives |
dimensions |
a value for each dimension the type needs that the profile does not give, or all of them without profiles |
states |
a value for every state of the type |
"context": {
"type": "Wordmark",
"profile": "Laptop",
"dimensions": { "Theme": "dark" },
"states": { "hover": "hovered", "tone": "inverted" }
}A context is always complete: one exact cell, nothing left out and nothing
extra. Dimensions and states each have their own group, so a state can be
named anything, even profile. And it is checked against the matrix, not
only against the enums: hovered is a value of hover, but there is no
hovered wordmark on a phone, so a context that says so is reported.
Evidence is usually about one element on a page, not about any element of a
type: this toggle, inside this toolbar's menu. Such a context names the
child by its path instead of a type, and is one row of dxn states for that
path, with the parents' states that place it:
"context": {
"path": "Toolbar.menu.toggle",
"profile": "Phone",
"dimensions": {},
"parents": { "layout": "compact", "menu.menu": "closed" },
"states": { "hover": "rest", "focus": "blurred", "expanded": "closed" }
}A context names a type or a path, never both. A path starts at a type
nothing contains, so it names one element of a tree. With a path, the dimensions
are those of every type on the way, and parents gives each parent state
that decides where the child is or that it is given, by its path. It is
checked against the child in place: it must be there in that world, with
those parents, and its bound states must be the ones its parents give it.
evidence/toolbar.json:14:21: error: unknown-value: $[0].context.states.expanded: `open` is not a value of `expanded` on `Phone` with `layout = compact`, `menu.menu = closed` (`closed`)
Only a record an actor records can be stored, since stored data is a fact
about the matrix. Such a record cannot be a field of another record, which
would give it a second context, and it cannot declare a field named
context, nor, since the actor is stored with it, one named actor. A
record no actor records, like Comment, is a part of the facts
that contain it.
A record's data is stored by convention, so no declaration has to say where:
a record is stored in a folder named after it, next to the file that
declares it. The folder's name is the record's name in kebab-case, with no
plural: Evidence is stored in evidence/, DesignReview in
design-review/. Every .json file in the folder holds a list of the record,
so the data can be split across as many files as you like:
evidence.dxn # declares Evidence, and the actor who records it
evidence/
wordmark.json # [ {…}, {…} ]
menu-button.json
A record with no folder is not stored: Comment above only ever appears
inside Review, so it needs none. Only a record an actor records can have
a folder; for any other, dxn validate stops before checking anything:
dxn: `Comment` is stored in comment/, but no actor records it; declare `actor <name> records Comment`
Each stored record is an object with its context, its actor, the name
of an actor who records it, and a key for every field: a record field holds an object, an enum field one of the enum's values
as a string, and a scalar field a string that is a valid value of the scalar.
[
{
"context": {
"type": "Wordmark",
"profile": "Laptop",
"dimensions": { "Theme": "dark" },
"states": { "hover": "hovered", "tone": "inverted" }
},
"actor": "DesignAgent",
"at": "2026-10-31T08:50:05Z",
"source": {
"file": "design/website.pen",
"frame": "R353q",
"fingerprint": "8056e176dcb1b7aa7e3c6ce6aee142848862fa5a"
}
}
]dxn validate checks every stored record against its declaration:
dxn validate evidence.dxnevidence/wordmark.json:19:28: error: unknown-value: $[1].context.states.hover: `hovered` is not a value of `hover` on `Phone`, `Theme dark` (`rest`)
evidence/wordmark.json:21:14: error: unknown-actor: $[1].actor: `Designer` is not an actor who records `Evidence` (`DesignAgent`)
Every field must be given, each of its type, and no others, and the actor
must be one who records the record. Two stored records that are the same in
everything but their PenNodes' fingerprints, which only say what a drawing
was when it was recorded, are duplicates, in one file or across the
record's files; dxn attach replaces a stored record rather than making one. Each
problem has its file, line and column, and its JSON path, so a person or an
agent can go straight to it. When everything is valid, the command says what
it checked, a line for each stored record:
evidence.dxn: Evidence, 2 stored records in 1 file in evidence/
An actor records evidence with dxn attach: the element by its path, the
actor with --actor, the record's fields as one JSON object with --data,
and the context as key=value pairs, the same keys a context has. Every key
is given, and a|b lists several values: it records one stored record for each
combination listed that exists in place.
dxn attach toolbar.dxn Toolbar.menu.toggle --actor DesignAgent \
--data '{"at": "2026-10-31T08:50:05Z", "source": {"file": "design/website.pen", "frame": "l5Z3Z"}}' \
profile=Phone layout=compact "menu.menu=closed|open" \
hover=rest focus=blurred "expanded=closed|open"Attached 2 stored records to evidence/toolbar.json
Of the four combinations listed, two exist: the toggle is expanded exactly
when its menu is open. The record is the one the actor records, here
Evidence. Each PenNode is given without its fingerprint: dxn attach
checks that the file is there, that the frame is in it and the node in the
frame, and computes the fingerprint itself, of the node, or of the frame
without one. In a pen.dev file, a fingerprint covers the node and the
components and variables it uses, but not where it sits; in any other file,
it is the whole file's.
dxn: node "OcEJ9" is not in frame l5Z3Z of design/website.pen
Recording the same combination and fields again replaces the stored record, with the fingerprint the node has now, in whichever of the record's files it is stored. The file is relative to the repository, and a new stored record goes in the record's folder, in a file named after the type the path starts at.
With evidence recorded for children, dxn status says how much of the
model it covers: every combination of every element in the file's trees, in place,
and which of them a stored record is about. An element's combinations in place are
its own: its states and the dimensions it needs. Its parents decide which of
them occur, but never add any, so two places that differ only in a parent's
state, and give the element the same states, are one combination of it.
A type declared at the top of the file is also a component: it is counted on its own, as a tree of its own, though another type contains it, so a design system can draw each of its components once, in all its combinations. A stored record about an element, on its own or in any of its places, also covers it everywhere else it is, in the same combination: the same states, dimensions and profile. A type declared inside another is a part of it, counted only in place; and a type that receives a state from its parent cannot be on its own, so it is counted only in place too.
dxn status toolbar.dxntoolbar.dxn: Evidence 38/55 combinations recorded
| path | combinations | recorded | stale | gone | missing |
| ----------------------------------- | ------------ | -------- | ----- | ---- | ------- |
| ArticlePage | 1 | 0 | 0 | 0 | 1 |
| ArticlePage.toolbar | 4 | 0 | 0 | 0 | 4 |
| ArticlePage.toolbar.wordmark | 15 | 15 | 0 | 0 | 0 |
| ArticlePage.toolbar.links | 2 | 0 | 0 | 0 | 2 |
| ArticlePage.toolbar.links.blog | 9 | 9 | 0 | 0 | 0 |
| ArticlePage.toolbar.menu | 4 | 2 | 0 | 0 | 2 |
| ArticlePage.toolbar.menu.toggle | 12 | 12 | 0 | 0 | 0 |
| ArticlePage.toolbar.menu.panel | 2 | 0 | 0 | 0 | 2 |
| ArticlePage.toolbar.menu.panel.blog | 6 | 0 | 0 | 0 | 6 |
A tree starts at each type that no type contains, and every child below it
is an element, by its path. Profiles that differ only in dimensions an
element does not have count as one combination of it, as in dxn cases:
the page has no dimensions, so it is one combination on every profile, and
the menu is the same on a tablet with or without its keyboard. Evidence on
any of a combination's profiles covers it.
Each stored record is also checked against its drawings: each PenNode it has. A
PenNode is recorded while its fingerprint, of its node or, without one,
its frame, is the one recorded, stale once that has changed, and
gone once the file, the frame or the node is, or the node is no longer
in its frame. A stored record is as its least fresh PenNode is, and a combination
as its best stored record is, or missing without one. A stored record made before its element gained a state or a dimension does not say which value of it the drawing shows, so it matches no combination: it is counted apart as outdated, and dxn validate warns of it rather than fails, until it is given the value. A record with no PenNode
is counted as recorded, and the first line says that staleness is not
checked, so it never passes silently. Attaching again records the
fingerprint now, and the stored record is recorded again.
With --missing, it lists each combination that is not recorded, under
Missing, Stale and Gone, in the words a context would use:
Missing:
ArticlePage: current = blog
ArticlePage.toolbar.menu on TabletPortrait, TabletPortrait+MagicKeyboard with toolbar.layout = compact: menu = closed
With --json, every count is a number, and each path lists the combinations
that are not recorded under listed, as stale, gone and missing.
An enum's values are stored as text, so renaming one by hand leaves every
stored record that used it invalid. dxn rename renames it everywhere at
once: in the enum; wherever the file writes a value of it, in rules,
children's conditions, bindings, fixed and derived states, actions,
expectations and derived fields; and in every stored record's fields of that
enum, nested ones included, and in the states of every context.
dxn rename evidence.dxn Hover.hovered overRenamed Hover.hovered to over in evidence.dxn, evidence/wordmark.json (2 values)
It only touches the value where it means that enum's value: the same text in
another enum, a dimension, or a string field stays as it is, and so does the
rest of each file, comments and spacing included. It refuses a new name that
is already a value or is not written like one, and it changes nothing while
the file or its stored records have problems, so a rename never mixes with
them. A rename that would leave a problem of its own, such as a rule over
elements whose states of one name come from different enums, is refused,
with the problems it would leave. A rename in an editor, through the
language server, refuses the same, showing the first problem. Add
--dry-run to see what it would
rename, without writing: Would rename Hover.hovered to over in …;
with --json as well, it prints every file it would write, in full.
With these, evidence is checked by the language itself: its shape, its values, and that the combination it is about exists.
The matrix says which combinations exist; it does not say how the system moves between them. A menu is closed, then open, then closed again, and what makes it change is part of the design. An action names one change of a type's own state, from one of its values to another; an on says which event triggers it:
type Toggle {
event activate
state expanded: Menu.State
state focus: Focus
action take-focus: focus blurred -> focused
}
type Menu {
enum State = closed | open
event escape
state menu: State
has toggle: Toggle { expanded = menu }
has panel: Panel only when menu = open
action open: menu closed -> open
action close: menu open -> closed
on toggle.activate: open
on toggle.activate: close
on escape: close, toggle.take-focus
on panel.blog.activate: close, toggle.take-focus
}
An action's name is its own: not the name of one of the type's states or
children, so a name in a type always means one thing. An action changes a
state the type owns: not one a when or a value decides,
nor one it receives. Those follow from something else, so no action can change
them. Nor can a parent bind a state one of its child's actions changes: bound,
its value is the parent's. An action changes something: its from and to
are two values. It applies only in its from value: close does nothing
while the menu is closed. That is how one event can mean two things: a click
on the toggle opens a closed menu and closes an open one.
A trigger can also write its change in place, with no action to name: one of the type's own states, and the value it changes to.
type Button {
event activate, cancel
state press: Press
on activate: press -> pressed
on cancel: press -> released
}
press -> pressed applies wherever press is not yet pressed, as an
action applies only in its from value, and it is checked as an action is:
a state the type owns, a value of it. Name a change as an action when it is
part of the type's interface: when a parent calls it, as the toolbar calls
menu.close, or when several triggers make it, as the menu's close is made
by Escape, a link and a click outside. Write it in place when it belongs to
one trigger, where a name would only repeat the value. A change in place is
always on the type's own states; a child's are changed through its actions.
An event is a name the type declares, with event, lowercase like a state,
each once: event escape, outside. The language gives no event a meaning of
its own: escape is whatever the type says it is, so the same triggers
describe a menu, an order or a connection. An event in on is one of:
- one the type declares:
on escape; - one a child declares, by the child's path:
on toggle.activate,on panel.blog.activate. The child receives it, and the parent decides what it does; - a change of a dimension the type needs:
Breakpoint in (lg, xl), orBreakpoint = lg.
An event that is not declared is an unknown-event, with the ones there are.
An on can trigger a child's action by its path, so a parent decides what
its own events do to what it contains:
type Toolbar needs Breakpoint {
...
on wordmark.activate: menu.close
on outside: menu.close
on Breakpoint in (lg, xl): menu.close, links.blog.take-focus
}
A trigger can make several changes, split by commas, in the order written: its own actions, a child's, and changes in place. It changes each of its type's own states at most once:
on escape: close, toggle.take-focus
on activate: lamp -> on, bell -> on
A trigger can fire only where a condition holds, written at its end with
only when, as a rule's and a child's are. A key presses a button only
while it is focused:
on activate: press -> pressed only when focus = focused
The condition is checked as any other: on the type's states and the dimensions it needs, never true an error and always true a warning. Two triggers for one event are not ambiguous when their conditions can never both hold.
Moving focus is no word of the language's own: the child that takes it
declares an action for it, and a trigger calls that action as it calls any
child's, as in close, toggle.take-focus:
type Toggle {
state focus: Focus
action take-focus: focus blurred -> focused
}
One event makes one change: two actions that one event triggers, its own or
a child's, cannot both apply to the same value, and one event does not
trigger the same action twice. Events overlap when they are the same event,
or a change of one dimension to a value they share: Breakpoint in (base, lg) and Breakpoint = lg overlap. A dimension an event changes is one the
type needs, as for a rule.
Some things must stay true however the model changes: touch screens never
show hover; the menu's panel only exists while the menu is open. An
expectation writes such a rule down, and dxn check checks it against
the tree in place, every time:
expect "Touch screens never show hover" {
in ArticlePage.toolbar and below
when Pointer = touch
then hover = rest
}
expect "A Desktop link can be hovered and focused at once" {
in ArticlePage.toolbar.links.blog
when profile = Desktop
some hover = hovered, focus = focused
}
expect "The menu panel only exists while the menu is open" {
in ArticlePage.toolbar.menu.panel
when toolbar.menu.menu = closed
absent
}
The title is the rule in words, in quotes, once in the file. in names the
element by its path; with and below, every element under it too, each that
has every key the rule names. when picks its combinations by the keys a
context has: profile, a dimension, one of its states, or a parent's state
by its key in place. A profile a modifier makes is named as dxn states
names it: when profile = TabletPortrait+MagicKeyboard. Then one
assertion:
- then: every combination picked has these values;
- some: at least one has all of these values at once;
- absent: the element is not there in any of them.
With on, the assertion is about what happens after the event: the action
the element's own type triggers on it is applied to every combination when
picks. A trigger a parent has on
the element's action, like the toolbar's on wordmark.activate: menu.close, is
not applied yet.
expect "Escape closes the menu" {
in ArticlePage.toolbar.menu
when menu = open
on escape
then menu = closed
}
After an event, then can also name a child's state by its path, and it is
checked against what the event's trigger changes in that child, through the
child's actions:
type Menu {
has toggle: Button
on escape: close, toggle.take-focus
}
expect "Escape closes the menu and the toggle takes focus" {
in Page.menu
when menu = open
on escape
then menu = closed, toggle.ring = visible
}
A child's state the event does not change fails the rule, saying so.
Where the event fires nothing in a combination picked, the element stays as
it was, and the rule is checked on it unchanged: when focus = blurred, on activate, then press = released holds for the button above. Where that
does not hold, the rule fails, saying nothing happens.
A change that breaks a rule is an error, with one combination that breaks it:
toolbar.dxn:154:8: error: expectation-failed: Touch screens never show hover: `ArticlePage.toolbar.wordmark` on `Phone` has `hover = hovered`
A rule that picks nothing would always pass, so it is an error too: a profile that is not one, a key no element has, a value its key does not have, a path that leads nowhere, or one to an element that cannot be placed.
toolbar.dxn:194:8: error: expectation-empty: Nothing tests nothing: no element in `ArticlePage.toolbar.links` has `colour`
Every problem is reported as file:line:column: severity: code: message, in
the order it appears in the file, and every one is reported at once, so you can
fix them all in one pass. A problem in stored data also has its JSON path:
file:line:column: severity: code: path: message. The code is stable, for tools; the
message is for reading, and usually says what was expected or what to write
instead.
There are two kinds:
- An error has
error:before its code. The file cannot be used: every command exits with code 1, and nothing is evaluated, validated or renamed. - A warning has
warning:before its code. The file is still usable, and the command still succeeds.
With --json, problems are not printed as lines but listed in the one JSON
document, each with its file, line, column, severity, code and
message, and its path in stored data:
{
"file": "evidence.dxn",
"problems": [
{
"file": "evidence/wordmark.json",
"line": 21,
"column": 14,
"severity": "error",
"code": "unknown-actor",
"path": "$[1].actor",
"message": "`Designer` is not an actor who records `Evidence` (`DesignAgent`)"
}
]
}A mistake in one declaration does not cause a cascade of others. The tool reports the mistake, keeps whatever it read of that declaration, and carries on from the next line that starts a declaration. Uses of a declaration that did not parse are not reported again:
enum Hover = rest hovered
type Wordmark {
state hover: Hover
}
wordmark.dxn:1:19: error: syntax-error: expected `|` or the end of the line after `rest`, found `hovered`
Only the real mistake is reported: the missing |. Hover is still known to
be an enum, so the state that uses it is fine. The same holds inside a type:
a broken enum or type in it is not reported again where it is used. And a
declaration left open, with its } missing, is reported where the next
declaration starts, at the start of a line, which then reads as its own.
Where every brace is closed, the braces say what is inside a type, so a
declaration moved into one belongs to it even before it is indented, and
dxn fmt indents it.
The language grows one concept at a time, and the concepts below are designed or under discussion but not part of it yet:
- Provided state, checked by
dxn check. A receiving type placed where nothing above provides its state is found today when the path is evaluated: bydxn states,dxn statusor an expectation.dxn checkcould find it as soon as thehasis written. - Events and where things are. An
onthat calls a child's action, such astoggle.take-focus, does not yet say whether that child must be there before the event or after it. - Expectations on a parent's triggers, and on a dimension event, which would also move the dimension to its new value. A child's state can be checked after an event, but not yet without one.
- Dimension keys below a path.
when Pointer = touchwithand belowapplies only to the elements whose types needPointer, even where every profile gives it. - More renames.
dxn renamerenames an enum's value. Renaming a field, a record (and its folder), or an enum, dimension or type is still to come. - A runtime. Evidence today is recorded by the actors that draw and build. A runtime would check the real system itself, driving a browser or a device into each combination and recording what it shows.
- Evidence against
alike. Two combinations of one case recorded with different frames, or two cases with one frame, are not reported yet. - Kits. Domain words that alias the core, such as a UI kit, so the same
language can describe other domains. Actors are already the pattern: the
core knows nothing about any particular actor; a file or a kit declares
them.
PenNodeis built in for now, and would belong to a design kit. Events are the same: a UI kit could declare the usual ones, such asactivateandescape, for every type to use.
The language says what exists: the states, what they depend on, and which
combinations happen. It does not say what anything looks like: no colors,
sizes or icons. It can say which combinations cannot be told apart, with
alike, but not how they look; evidence shows that: the design frame each
case was drawn in, and later the rendering in the code.
The words dxn and an editor say the model's facts in, each for one thing. Each is said the same by both: the dxn command for agents, an editor for people.
| Word | Means |
|---|---|
| combination | one value of each state and each dimension of an element: what evidence is counted against. Tab, with two selections and three interactions, has six. |
| recorded | a combination's status when a stored record matches it, and its drawings, where it has any, are as they were recorded; else it is stale, gone or missing. A count says how many: 2/6 combinations recorded |
| stale | a combination's status when its stored record's drawing has changed since it was recorded: its fingerprint no longer matches. The evidence moved away from the model; look at the drawing, and record it again if it is right. Not outdated, where the model moved |
| gone | a combination's status when its stored record's drawing no longer exists: its file, frame or node was deleted |
| missing | a combination's status when no stored record matches it |
| set apart | a stored record with a problem, not counted until it is fixed: dxn status and an editor say how many, · 1 set apart, and what most of them say, 1 of 2 stored records says hover = bogus, which Hover does not have (rest, hovered) |
| outdated | a stored record made before its element gained a state or a dimension, which says no value of it: `hover` was added to `Button` after it was recorded. The model moved away from the evidence; it is a warning, counted apart, and its value is added to count it again. Not stale, where the drawing moved |
| stored record | one item of a record's stored files: a fact about one combination, recorded by an actor. A record is what a file declares, record Evidence; it is never one of these |
| Kind | Example | Written |
|---|---|---|
| enum | Hover |
capital letter, then letters and digits |
| dimension | Pointer |
capital letter, then letters and digits |
| type | Wordmark |
capital letter, then letters and digits |
| environment | Device |
capital letter, then letters and digits |
| record | Evidence |
capital letter, then letters and digits |
| actor | DesignAgent |
capital letter, then letters and digits |
| state | hover |
lowercase letter |
| profile | Laptop |
capital letter, then letters and digits |
| child | toggle |
lowercase letter |
| action | close |
lowercase letter |
| modifier | MagicKeyboard |
capital letter, then letters and digits |
| field | source |
lowercase letter |
| value | rest, no-keyboard, 19px |
lowercase letters, digits, hyphens between them |
Letters are ASCII, a to z and A to Z; no name or value has an
underscore.
An enum or a type can be declared inside a type, and is then named after it
from outside: Menu.State, Menu.Toggle. A name is looked up first in the
type it is used in, then in the types around it, then at the top of the file.
Every enum, dimension, type, environment, profile, modifier, record and actor shares one namespace: each name is declared once in a file.
file = { blank-line | comment | enum | dimension | type | environment | profile | modifier | record | actor | expect }
enum = "enum" Name "=" ( value | [lines] "|" value ) { [lines] "|" [lines] value } newline
dimension = "dimension" Name "=" ( value | [lines] "|" value ) { [lines] "|" [lines] value } newline
type = "type" Name [ "needs" Name { "," [newline] Name } ] [ "receives" field { "," [newline] field } ]
"{" { blank-line | state | rule | enum | type | has | provides | events | action | on | alike } "}"
alike = "alike" name [ "in" "(" value { "," value } ")" ] [ "when" condition ] newline
action = "action" action-name ":" state-name value "->" value newline
on = "on" event ":" change { "," change } [ "only" "when" condition ] newline
change = path | state-name "->" value # path: an action, or a child's
event = path | comparison # path: an event the type declares, or a child's
events = "event" event-name { "," event-name } newline
provides = "provides" state-name newline
has = "has" child-name ":" ( ( type-name | "[" type-name "]" ) [ bindings ] | members ) [ "only" "when" condition ] newline # [ ]: one or more
members = "[" newline member { member } "]" # a tagged union: each row one of them
member = child-name ":" type-name [ bindings ] [ "only" "when" condition ] newline
type-name = Name { "." Name }
bindings = "{" { binding ( "," | newline ) } "}"
binding = state-name "=" state-name # or a value of the child's state, which fixes it
| state-name "=" "when" state-name "{" { branch ( "," | newline ) } "}"
state = "state" field { "," [newline] field } newline
| "state" field "=" "when" Name "{" { branch ( "," | newline ) } "}" newline
| "state" field "=" value newline
branch = value { "," value } "->" value # a when's branches are split by commas or lines
| "else" "->" value # the last, for every value the others do not list
field = state-name ":" Name { "." Name }
rule = state-name "=" value "only" "when" condition newline
condition = term { [newline] "and" [newline] term } | term { [newline] "or" [newline] term } # mixed only in brackets
term = comparison | "(" [newline] condition [newline] ")"
comparison = name ( "=" value | "in" "(" value { "," value } ")" ) # on a dimension the type needs, or one of its states
environment = "environment" Name "=" Name { "x" [newline] Name } newline
profile = "profile" profile-name ":" Name "{" { assignment ( "," | newline ) } "}"
modifier = "modifier" modifier-name "on" profile-name { "," [newline] profile-name } "{" { assignment ( "," | newline ) } "}"
assignment = Name "=" value
record = "record" Name "{" { blank-line | record-field } "}"
record-field = field-name ":" Name { "." Name } [ "=" "when" field-name "{" { blank-line | branch } "}" ] newline
actor = "actor" Name "records" Name newline
expect = "expect" string "{" "in" path [ "and" "below" ] [ "when" pairs ] [ "on" event ] ( ( "then" | "some" ) pairs | "absent" ) "}"
pairs = key "=" value { "," key "=" value } # key: profile, a dimension, a state, or a parent's state;
# after an event, a child's state by its path
# a profile's value may be <profile> "+" <modifier>
comment = "#" to the end of the line
lines = newline { blank-line | comment newline } # in an enum or a dimension, between its values
Optional ## blocks attach directly to the following enum, type or state
declaration. A blank line breaks attachment. Attached blocks cannot mix # and
## markers. Prose documents the declaration; @value documents a value of that
enum and @state documents a state named by that state declaration. Indented text
continues the preceding tag description.
## Interaction modes.
## @value off No interaction.
## Continued description.
enum Mode = off | on
type Button {
## @state mode Current mode.
state mode: Mode
}
Hovering an enum or type shows its attached prose. Hovering a value or state
also shows its matching @value or @state description. Documented names can
show that information at their declarations as well as their uses. Ordinary
# comments and blank-line-separated ## blocks are not documentation for
enums, types or states.
Unknown tags, tags on the wrong kind of declaration, missing targets and targets outside the attached declaration are semantic errors. Repeating a valid tag for the same target warns at the later occurrence and keeps the first description. Descriptions may be empty; missing documentation is valid. See the syntax contract for the complete attachment rules and remaining editor verification work.
dxn fmt writes attached documentation with one space after ##, one space
between a tag and its target, and three spaces after ## for tag continuation
text. It preserves prose content and blank lines that break attachment. Ordinary
comments and detached documentation keep their content. Whitespace-only prose
markers retain a space where it is needed to preserve documentation meaning.
| Code | Severity | When |
|---|---|---|
syntax-error |
error | the text does not follow the grammar |
unknown-documentation-tag |
error | a documentation tag is neither @value nor @state |
documentation-tag-kind |
error | a known documentation tag is attached to the wrong declaration kind |
missing-documentation-target |
error | a documentation tag has no target name |
unknown-documentation-target |
error | a documentation target is not declared by the attached enum or state declaration |
duplicate-documentation-tag |
warning | a later valid tag repeats a target; the first description is retained |
enum-name, dimension-name, type-name, environment-name, record-name, actor-name, profile-name, modifier-name |
error | the name does not start with a capital letter, or has more than letters and digits |
state-name, field-name, child-name |
error | the name does not start with a lowercase letter |
action-name |
error | an action's name does not start with a lowercase letter |
value-name |
error | a value is not lowercase letters and digits, with single hyphens between, or is when or else |
duplicate-name |
error | a name is declared twice in the file, or an expectation's title |
duplicate-value |
error | a value appears twice in an enum, dimension, condition or when |
duplicate-state |
error | a type has two states with one name |
duplicate-rule |
error | a state value has two rules; the message gives the one rule that joins them |
duplicate-dimension |
error | a dimension appears twice in a type's needs, an environment, a profile or a modifier |
unknown-enum |
error | a state's values come from something that is not an enum |
unused-enum |
warning | no state or record field takes an enum's values; not with a syntax error, or the enum twice |
unknown-dimension |
error | a name used as a dimension is not one |
unknown-state |
error | a rule, a child's condition, a binding, a provides or an action names a state it does not have |
unknown-value |
error | a value is not one of its enum's or dimension's |
unneeded-dimension |
error | a rule, a when, a child condition or an event uses a dimension its type does not need |
incomplete-when |
error | a when leaves out a value of its dimension |
unused-else |
warning | a when's else covers nothing: its other branches cover every value |
repeated-comparison |
error | a dimension or state is compared twice in one and |
impossible-condition |
error | a rule's or a child's condition is never true |
vacuous-condition |
warning | a rule's or a child's condition is always true |
alike-one-value |
error | an alike with in names one value, which is alike only with itself |
rule-on-derived |
error | a rule is on a state that a when or a value decides, or that is received |
rule-cycle |
error | a state depends on itself through rules' conditions on other states |
unknown-field |
error | a derived field's when is on a field the record does not have |
unknown-environment |
error | a profile's environment is not one |
incomplete-profile |
error | a profile leaves out a dimension of its environment |
mixed-profiles |
error | profiles are instances of different environments |
unknown-profile |
error | a modifier is on a profile that is not one |
duplicate-profile |
error | a modifier lists a profile twice |
unchanged-profile |
warning | a modifier changes nothing on a profile it is on |
duplicate-field |
error | a record has two fields with one name |
unknown-type |
error | a field's type is not an enum, a record or a scalar, or a child's type is not a type |
recursive-record |
error | a record contains itself |
duplicate-child |
error | a type has two children, or a child and a state, with one name |
recursive-type |
error | a type contains itself through its children |
duplicate-binding |
error | a binding sets a child's state twice |
binding-mismatch |
error | a binding's two states take their values from different enums |
ambiguous-binding |
error | a binding's right side is both a state of the type and a value of the child's state |
bound-derived |
error | a binding sets a child's state that a when or a value decides, or that it receives |
duplicate-provides |
error | a type provides a state twice |
provides-received |
error | a type provides a state it receives, not one of its own |
duplicate-action |
error | an action has the name of another action, or of a state or a child of its type |
bound-action |
error | a binding sets a child's state that one of the child's actions changes |
action-on-derived |
error | an action changes a state that a when or a value decides, or that is received |
unchanged-action |
error | an action's from and to are one value, so it changes nothing |
unknown-child |
error | an event, an action's path or a focus names a child that is not one |
unknown-action |
error | an on triggers an action its type does not have |
ambiguous-event |
error | events that overlap trigger two actions that apply to one state's same value |
duplicate-trigger |
error | an event triggers the same action twice |
unknown-event |
error | an event in on is not one its type, or the child it names, declares |
event-name |
error | an event's name does not start with a lowercase letter |
duplicate-event |
error | a type declares an event twice |
conflicting-change |
error | a trigger changes one of its type's states twice |
expectation-failed |
error | the model breaks an expectation; the message gives a combination that breaks it |
expectation-empty |
error | an expectation picks nothing, so it would always pass |
derived-field |
error | a derived field is not an enum's, or is on a field that is not an enum's, or is derived |
nested-context |
error | a record's field is a record an actor records, which would be a second context |
reserved-name |
error | a declaration has a built-in name, or a recorded record has a field named context or actor |
unrecorded-record |
error | records are stored for a record no actor records; its stored records are not checked |
unused-need |
warning | a type needs a dimension that is alike throughout and that nothing in it uses |
Checking stored records with dxn validate reports these, after any problem with the
JSON itself:
| Code | When |
|---|---|
wrong-kind |
a value is not the kind its place needs: a list, an object or a string |
missing-field |
a record leaves out one of its fields, its context, or a part of its context |
unknown-field |
a record or a context has a field its declaration does not |
unknown-value |
an enum field's value is not one of the enum's; or a context's type, profile, dimension or state value is not one of the file's, or does not exist in that combination |
invalid-value |
a scalar field's value is not a valid value of the scalar |
derived-field |
a stored record gives a derived field, whose value follows from another's |
unknown-actor |
a stored record's actor is not one who records it |
outdated |
(a warning) a stored record leaves out a state or a dimension its element gained since it was recorded |
duplicate-record |
two stored records are the same in everything but their PenNodes' fingerprints |
drawing-mismatch |
a stored record's pen.dev drawing shows a type of the model more or less often than its element has it in its combination |
redundant-member |
(a warning) a list's member allows no row another member of its type does not |