DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our design system is a markdown table of nineteen motifs, and the useful column is the one that says no

Most design systems I have worked in document what a component is and what props it takes. The part that decides whether an interface looks made or assembled is the part they usually leave out: where each thing is allowed to appear, and what it is never allowed to mean.

Nakodo is a field guide for finding creators, and its visual language is lifted from naturalists' plates and ledgers. docs/brand.md section 5.5 is a table of nineteen motifs with four columns: the motif, what it is, where it is used, and a rule. Some of those rules:

  • Catalogue numbers: "Always 4 digits for creators, 2 for guides. Sequence follows first sighted order, so numbers never change."
  • Pointer arrows: "Marketing and explanatory views only; never inside tables."
  • Hatching: "Always means not yet observed or locked. Never decorative in UI. Always paired with text."
  • Double rule: "Max one per screen."
  • Stamps: "Ink or cyano only, rotated at most -2 degrees. Never ochre, never red."
  • Live dot: "The only pill shape in the app."
  • Attention mark: "Max one kind of mark per row. Never used for status."
  • Tally marks: "Above 25, switch to a plain bar meter with numbers."

src/components/brand/marks.tsx is 228 lines and is that table turned into components. This post is about which of those rules the code can enforce and which ones it can only make easy.

Rules a type can hold

export function Stamp({ label, date, tone = "ink", className }: { label: string; date?: string; tone?: "ink" | "cyano"; className?: string }) {
  return (
    <span className={cn("inline-flex -rotate-2 items-center gap-1.5 rounded-sm border-[3px] border-double px-1.5 py-px",
      tone === "cyano" ? "border-cyano-700 text-cyano-700 dark:border-cyano-300 dark:text-cyano-300" : "border-foreground/70 text-foreground/80", className)}>
      <span className="label-caps">{label}</span>
      {date && <span className="catalogue">{date}</span>}
    </span>
  );
}
Enter fullscreen mode Exit fullscreen mode

"Never ochre, never red" is a union of two strings, so breaking it is a type error. The rotation is not a prop at all: -rotate-2 is baked in, because "at most -2 degrees" with a prop would have meant someone eventually passing -6. The double border is a real CSS double 3px border rather than two elements, which is the same trick the app uses for its horizontal double rules.

Same with the catalogue numbers:

export function CatalogueNo({ n, kind = "creator" }: { n: number; kind?: "creator" | "guide" }) {
  const label = kind === "creator" ? `No. ${String(n).padStart(4, "0")}` : `Guide ${String(n).padStart(2, "0")}`;
  ...
Enter fullscreen mode Exit fullscreen mode

Four digits or two, decided by the kind, not by the caller. The homepage carries seven of them; you can count the string No. 0 in the HTML.

Rules only CSS can hold

The specimen tag is "a small label with a notched left edge and a punched hole". It is one utility and one absolutely positioned dot:

@utility specimen-tag {
  position: relative;
  clip-path: polygon(7px 0, 100% 0, 100% 100%, 7px 100%, 0 50%);
  padding-left: 14px;
}
Enter fullscreen mode Exit fullscreen mode
<span className="absolute top-1/2 left-[6px] size-1 -translate-y-1/2 rounded-full bg-background" aria-hidden="true" />
Enter fullscreen mode Exit fullscreen mode

The hole is bg-background, not transparent, so it reads as punched through the tag to the page behind it. That is a cheat that only works because the tag never sits on anything but the page colour, which is itself a rule nobody wrote down. Two of these are in the homepage's HTML and two more on the pricing page, where they are the plan kickers: Notebook, Field kit, Expedition.

The plate marks ("small registration crosses at the corners of a framed area", marketing and ceremony only) are an array of four position class strings mapped over one SVG:

{(["-top-1.5 -left-1.5", "-top-1.5 -right-1.5", "-bottom-1.5 -left-1.5", "-right-1.5 -bottom-1.5"] as const).map((pos) => (
  <svg key={pos} viewBox="0 0 12 12" className={cn("absolute size-3 text-foreground/50", pos)} aria-hidden="true">
    <path d="M6 0v12M0 6h12" stroke="currentColor" strokeWidth="1" />
    <circle cx="6" cy="6" r="2.5" fill="none" stroke="currentColor" strokeWidth="1" />
  </svg>
))}
Enter fullscreen mode Exit fullscreen mode

Four crosses per plate, so the count in the live HTML divides by four: M6 0v12 appears twelve times on the homepage, which is three plates. The pricing page has none, because plates are for the landing and for ceremony, and nothing on the pricing page is a ceremony.

Hatching is the one motif with a forced-colours fallback, because a repeating gradient that means "locked" disappears in Windows high contrast mode and would take the meaning with it:

@media (forced-colors: active) {
  .hatch { background-image: none; border: 1px dashed CanvasText; }
}
Enter fullscreen mode Exit fullscreen mode

The tally meter, which is the rule table at its best

"Usage meters for small allowances (up to 25). Above 25, switch to a plain bar meter with numbers."

{limit <= 25 ? <Tally used={shown} limit={limit} /> : (
  <div className="h-1.5 overflow-hidden rounded-sm bg-paper-150 dark:bg-muted" role="presentation">
    <div className="grow-x h-full bg-cyano-700 dark:bg-cyano-400" style={{ width: `${(shown / limit) * 100}%` }} />
  </div>
)}
Enter fullscreen mode Exit fullscreen mode

That threshold is a product decision disguised as a design one. A paid plan on Nakodo contacts 25 new creators or businesses each weekday, which is exactly on the line, so a Pro account sees five groups of five strokes rather than a bar. Tally marks are countable; a bar at 72% is not. Above 25 the strokes would be unreadable and the bar wins.

Each group of five is one SVG with four uprights and a diagonal:

{Array.from({ length: Math.min(size, 4) }, (_, i) => (
  <Stroke key={i} d={`M${3 + i * 5} 1.5V12.5`} n={start + i} used={used} />
))}
{size === 5 && <Stroke d="M1 11.5L22 2.5" n={start + 4} used={used} />}
Enter fullscreen mode Exit fullscreen mode

and every stroke is drawn twice, once as a faint guide and once as the mark:

<path d={d} strokeWidth="1.5" className="stroke-paper-300 dark:stroke-ink-700" />
{n < used && <path d={d} pathLength={1} strokeWidth="1.5" className="draw-in stroke-foreground" style={{ "--i": n }} />}
Enter fullscreen mode Exit fullscreen mode

Rendering the unused strokes means the meter has a fixed width and shows the allowance, not just the usage. pathLength={1} normalises every stroke to one unit so one dash rule covers both the uprights and the longer diagonal:

@keyframes draw-in { from { stroke-dashoffset: 1; } }
/* A gap longer than the dash, so no round cap shows at the far end while
   a stroke waits to be drawn. */
.draw-in { stroke-dasharray: 1 2; }
[data-reveal="go"] .draw-in { animation: draw-in 0.16s ease-out calc(0.15s + var(--i, 0) * 45ms) both; }
Enter fullscreen mode Exit fullscreen mode

The comment above stroke-dasharray: 1 2 is the sort of thing you only write after seeing it: with 1 1, the round line cap of the waiting dash pokes out at the end of the path, and every tally shows a row of faint dots before it is drawn. The --i on each stroke is its index in the whole allowance, so twenty-five strokes are drawn in counting order at 45ms apart rather than all at once, and Reveal holds a meter below the fold until it is scrolled to.

Rules the code cannot hold

"Max one per screen" is three of the nineteen rules (double rule, margin annotation, and effectively the help button). Nothing enforces them. A component cannot know what else is on the page, and a lint rule would need to understand routing and layouts.

What the table does instead is make the limit cheap to check, because each motif has exactly one meaning, so counting them is counting a string. The homepage has three plates, seven catalogue numbers, two specimen tags and six field-mark bullets (M0 5H9), and the pricing page has two pointer arrows (M6 5H48) and no plates. Every one of those numbers came out of curl and grep on the live pages while writing this post, which is the whole argument for a vocabulary of fixed shapes: you can audit it from outside the app.

Two things I found doing that. Kicker is in this file and has three call sites, all of them in the campaigns list rather than above a marketing section heading, which matches the house rule that section names are a nav and page thing. And choiceCardClass, over in the forms module, is exported with no consumers at all, which is what happens to a shared constant when the thing that was going to use it got written differently.

The point of writing the rule column down is not governance. It is that a motif with one meaning can be used anywhere without thinking, and a motif with two meanings is just decoration with extra steps.

Top comments (0)