---
title: 'Visual Audit'
source: 'https://academia.sh/en/courses/design-systems/visual-audit'
course: 'Design Systems'
language: en
updated: '2026-08-19T05:19:55+00:00'
license: 'CC BY-SA 4.0'
---

# Visual Audit

Machine extraction of a value inventory from existing interfaces' style source, threshold-based clustering of near-duplicates, how the linking rule changes the count, and a per-product deviation map.

The previous lesson computed when a system pays for itself and showed that scope is the
most decisive decision. Scope says which decisions enter the system; knowing that requires
knowing which decisions are actually repeated. A scope drawn by estimation either produces
unused tokens or leaves out the most frequently used decision.

A **visual audit** is the counting of values that existing interfaces actually use. The
audit's distinguishing feature is that its source is not memory but the **artifact**: no
one is asked "how many pixels is the card's inner padding," it is read from the style
source. This lesson builds the calculation that performs that reading and separates which
share of the inventory is a genuine decision and which share is unearned variety.

## The Audit Is Read from the Artifact

The catalog institution has four products: the catalog interface, the admin panel, the
member profile, and the shelf layout screen. The four were written at different times,
partly by different teams. The Repetition and Consistency lesson counted the catalog
interface's four screens by hand; here the count expands to all four products and is done
not by hand but by parsing the source.

The difference between counting by hand and counting by machine is not only speed. A hand
count finds the values that catch the counter's eye; a machine count also finds the
declarations no one has looked at. This is exactly where the audit's value lies: what it is
looking for is not the inconsistency that is already known, but the inconsistency that is
not.

The count runs over five property classes: spacing, font size, corner radius, border
thickness, and color. For the first four, the closeness criterion is pixel difference, and
the thresholds are the distinguishability thresholds from the Repetition and Consistency
lesson. For color the criterion has two parts: two colors are counted as carrying the same
decision only if their contrast ratio against each other is below 1.10 and their largest
channel difference is below 16. Contrast ratio alone is not enough, because two colors of
different hue can share the same relative luminance.

```js
// audit.mjs — extracting an inventory from four catalog products' style source and clustering near-duplicates

// Declarations taken from the four products' style source. Each line is prefixed with a
// product code: C catalog, A admin panel, M member profile, S shelf layout screen.
const SOURCE = `
C .search-field { padding: 8px 12px; font-size: 14px; border-radius: 4px; border: 1px solid #b7bcc2; color: #212327; }
C .search-button { padding: 8px 16px; font-size: 14px; border-radius: 4px; background: #275ea5; color: #ffffff; }
C .record-card { padding: 16px; border-radius: 8px; border: 1px solid #d3d6d9; background: #ffffff; }
C .record-title { font-size: 20px; color: #212327; margin-bottom: 4px; }
C .record-author { font-size: 16px; color: #5e656e; margin-bottom: 8px; }
C .record-metadata { font-size: 13px; color: #757e8a; }
C .record-tag { padding: 2px 8px; font-size: 12px; border-radius: 12px; background: #dee9f7; color: #15335b; }
C .results-list { gap: 24px; padding: 32px 24px; }
C .pagination { gap: 8px; font-size: 14px; padding: 24px 0; }
C .confirm-dialog { padding: 24px; border-radius: 8px; background: #ffffff; border: 1px solid #d3d6d9; }
C .confirm-warning { font-size: 14px; color: #ab3321; margin-top: 8px; }
A .panel-heading { font-size: 24px; color: #1f2226; margin-bottom: 16px; }
A .panel-card { padding: 14px; border-radius: 6px; border: 1px solid #d5d8db; background: #fdfdfd; }
A .panel-table { font-size: 13px; color: #333333; }
A .panel-table-heading { font-size: 12px; color: #6b7280; padding: 6px 10px; }
A .panel-row { padding: 10px 12px; border-bottom: 1px solid #e9eaec; }
A .panel-button { padding: 6px 14px; font-size: 13px; border-radius: 3px; background: #2a5fa8; color: #ffffff; }
A .panel-secondary-button { padding: 6px 14px; font-size: 13px; border-radius: 3px; border: 1px solid #b7bcc2; color: #275ea5; }
A .panel-warning { font-size: 12px; color: #a83224; padding: 10px; border-radius: 3px; }
A .panel-section { gap: 20px; padding: 20px; }
M .profile-heading { font-size: 22px; color: #212327; margin-bottom: 12px; }
M .profile-card { padding: 20px; border-radius: 8px; border: 1px solid #d3d6d9; background: #ffffff; }
M .borrow-row { padding: 12px 16px; font-size: 15px; color: #22252a; border-bottom: 1px solid #e9eaec; }
M .borrow-date { font-size: 13px; color: #5e656e; }
M .overdue-warning { font-size: 13px; color: #ab3321; padding: 8px 12px; border-radius: 4px; background: #fbeae7; }
M .profile-button { padding: 10px 18px; font-size: 15px; border-radius: 6px; background: #275ea5; color: #ffffff; }
M .profile-section { gap: 16px; padding: 28px 24px; }
S .shelf-heading { font-size: 18px; color: #212327; margin-bottom: 8px; }
S .shelf-cell { padding: 12px; border-radius: 4px; border: 2px solid #b7bcc2; }
S .shelf-code { font-size: 12px; color: #757e8a; }
S .shelf-occupied { background: #e9eaec; border: 2px solid #969da6; }
S .shelf-selected { border: 2px solid #3075cf; background: #dee9f7; }
S .shelf-grid { gap: 6px; padding: 18px; }
S .shelf-warning { font-size: 13px; color: #b03526; padding: 8px; border-radius: 4px; }
`;

// --- extraction ----------------------------------------------------------------
const LENGTH_PROPERTY = {
  spacing: /^(padding|margin-top|margin-bottom|gap)$/,
  "font size": /^font-size$/,
  "corner radius": /^border-radius$/,
};
const CLASSES = ["spacing", "font size", "corner radius", "border thickness", "color"];
const inventory = Object.fromEntries(CLASSES.map((s) => [s, []]));

for (const line of SOURCE.trim().split("\n")) {
  const product = line[0];
  const body = line.slice(line.indexOf("{") + 1, line.lastIndexOf("}"));
  for (const declaration of body.split(";")) {
    const i = declaration.indexOf(":");
    if (i < 0) continue;
    const property = declaration.slice(0, i).trim();
    const value = declaration.slice(i + 1).trim();

    for (const [cls, pattern] of Object.entries(LENGTH_PROPERTY)) {
      if (!pattern.test(property)) continue;
      for (const p of value.split(/\s+/)) {
        const m = p.match(/^(\d+(?:\.\d+)?)px$/);
        if (m) inventory[cls].push({ value: Number(m[1]), product });
      }
    }
    if (/^border(-bottom)?$/.test(property)) {
      const m = value.match(/^(\d+(?:\.\d+)?)px/);
      if (m) inventory["border thickness"].push({ value: Number(m[1]), product });
    }
    for (const m of value.matchAll(/#([0-9a-f]{6})/g)) {
      inventory.color.push({ value: "#" + m[1], product });
    }
  }
}

// --- closeness criteria ---------------------------------------------------------
const THRESHOLD = { spacing: 2, "font size": 1, "corner radius": 2, "border thickness": 0.5 };
const toChannels = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
function channel(v) {
  const s = v / 255;
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}
const channelDiff = (a, b) => Math.max(...a.map((v, i) => Math.abs(v - b[i])));

// Do two values carry the same decision? For color the criterion is both contrast
// ratio and channel difference together.
function close(cls, a, b) {
  if (cls !== "color") return Math.abs(a - b) <= THRESHOLD[cls];
  return contrast(toChannels(a), toChannels(b)) < 1.1 && channelDiff(toChannels(a), toChannels(b)) < 16;
}
function sortValues(cls, values) {
  const unique = [...new Set(values)];
  return cls === "color"
    ? unique.sort((a, b) => luminance(toChannels(b)) - luminance(toChannels(a)))
    : unique.sort((a, b) => a - b);
}

// Chain linking: finding a single close member is enough to join a cluster.
function chainLink(cls, values) {
  const clusters = [];
  for (const d of sortValues(cls, values)) {
    const k = clusters.find((k) => k.some((o) => close(cls, d, o)));
    if (k) k.push(d);
    else clusters.push([d]);
  }
  return clusters;
}

// Diameter-bounded linking: joining a cluster requires being close to EVERY member in it.
function diameterBound(cls, values) {
  const clusters = [];
  for (const d of sortValues(cls, values)) {
    const k = clusters.find((k) => k.every((o) => close(cls, d, o)));
    if (k) k.push(d);
    else clusters.push([d]);
  }
  return clusters;
}

console.log("property           usage  distinct values  chain-link clusters  diameter-bound clusters");
let totalUsage = 0;
let totalDistinct = 0;
let totalChain = 0;
let totalDiameter = 0;
const diameterClusters = {};
for (const cls of CLASSES) {
  const values = inventory[cls].map((k) => k.value);
  const z = chainLink(cls, values);
  const c = diameterBound(cls, values);
  diameterClusters[cls] = c;
  const distinct = new Set(values).size;
  totalUsage += values.length;
  totalDistinct += distinct;
  totalChain += z.length;
  totalDiameter += c.length;
  console.log(
    `${cls.padEnd(18)} ${String(values.length).padStart(6)} ${String(distinct).padStart(16)} ` +
      `${String(z.length).padStart(21)} ${String(c.length).padStart(24)}`
  );
}
console.log(
  `${"total".padEnd(18)} ${String(totalUsage).padStart(6)} ${String(totalDistinct).padStart(16)} ` +
    `${String(totalChain).padStart(21)} ${String(totalDiameter).padStart(24)}`
);
console.log(
  `unearned variety (diameter-bound): ${totalDistinct - totalDiameter} values, ` +
    `${((100 * (totalDistinct - totalDiameter)) / totalDistinct).toFixed(1)}%`
);

console.log("\ndiameter-bound clusters (same decision, different spelling)");
for (const cls of CLASSES) {
  for (const k of diameterClusters[cls]) {
    if (k.length > 1) console.log(`  ${cls.padEnd(18)} ${k.join(", ")}`);
  }
}

// --- per-product deviation -------------------------------------------------------
// A cluster's most-used member is the "canonical spelling"; the rest count as deviation.
const count = {};
for (const cls of CLASSES) {
  for (const record of inventory[cls]) {
    const a = `${cls}:${record.value}`;
    count[a] = (count[a] || 0) + 1;
  }
}
const canonical = new Map();
for (const cls of CLASSES) {
  for (const k of diameterClusters[cls]) {
    const top = k.reduce((a, b) => ((count[`${cls}:${b}`] || 0) > (count[`${cls}:${a}`] || 0) ? b : a));
    for (const member of k) canonical.set(`${cls}:${member}`, top);
  }
}
console.log("\nproduct  usage  deviates from canonical  deviation rate");
for (const product of ["C", "A", "M", "S"]) {
  let total = 0;
  let deviating = 0;
  for (const cls of CLASSES) {
    for (const record of inventory[cls].filter((k) => k.product === product)) {
      total++;
      if (canonical.get(`${cls}:${record.value}`) !== record.value) deviating++;
    }
  }
  console.log(
    `${product.padStart(7)} ${String(total).padStart(6)} ${String(deviating).padStart(24)} ${((100 * deviating) / total).toFixed(1).padStart(14)}%`
  );
}
```

```
property           usage  distinct values  chain-link clusters  diameter-bound clusters
spacing                45               13                     4                        8
font size              22                9                     5                        7
corner radius          14                5                     2                        3
border thickness       11                2                     2                        2
color                  43               23                    15                       15
total                 135               52                    28                       35
unearned variety (diameter-bound): 17 values, 32.7%

diameter-bound clusters (same decision, different spelling)
  spacing            2, 4
  spacing            6, 8
  spacing            10, 12
  spacing            14, 16
  spacing            18, 20
  font size          12, 13
  font size          14, 15
  corner radius      3, 4
  corner radius      6, 8
  color              #ffffff, #fdfdfd
  color              #e9eaec, #dee9f7
  color              #d5d8db, #d3d6d9
  color              #b03526, #ab3321, #a83224
  color              #2a5fa8, #275ea5
  color              #22252a, #212327, #1f2226

product  usage  deviates from canonical  deviation rate
      C     46                        3            6.5%
      A     37                       20           54.1%
      M     31                        6           19.4%
      S     21                        5           23.8%
```

## The Linking Rule Determines the Count

The first table's two cluster columns produce two different results from the same data: 28
and 35. The difference looks small, but it concentrates in a single row. For the spacing
class, chain linking finds 4 clusters, diameter-bounded linking finds 8.

The reason is that **chain linking** only requires finding a single close member to join a
cluster. Because the spacing values progress two at a time — 2, 4, 6, 8, 10, 12, 14, 16, 18,
20 — every value is close to the one before it, and the chain never breaks. As a result, 2
and 20 end up in the same cluster, even though a 2-pixel gap and a 20-pixel gap are not the
same decision.

**Diameter-bounded linking** requires being close to every member of a cluster to join it.
This bounds the cluster's width by the threshold and breaks the chain. In the same data, 2
and 4 stay in one cluster, and 6 opens a new one.

This distinction directly affects the audit report's reliability. Chain linking's count
overstates the near-duplicate problem: "thirteen spacing values are really four decisions"
is the audit's most striking sentence and its most wrong one. The correct count is eight,
and unearned variety totals 17 of 52 values — 32.7%.

## Clustering Produces a Recommendation, Not a Decision

The second block shows what the clusters contain, and this is where the criterion's limit
becomes visible.

Most of the color clusters are correct: `#22252a`, `#212327`, and `#1f2226` are three
separate dark text colors from three products, and they cannot be told apart. The trio
`#b03526`, `#ab3321`, `#a83224` is, in the same way, three separate error reds. `#2a5fa8`
and `#275ea5` are two separate primary-action blues. All of these should have been a single
decision, and each was made three times.

One cluster, though, is wrong: `#e9eaec` and `#dee9f7`. The first is a neutral gray, the
second a pale blue fill. Because their relative luminance is close and their channel
difference stays under the threshold, the criterion counts them as the same; but the two do
different jobs — one is the fill of an occupied shelf cell, the other the highlight of a
selected record. The criterion cannot know this, because it looks only at the value, not at
the role the value carries.

The rule that follows from this holds for the entire audit: **clustering is a list of
recommendations, not a list of decisions.** Every cluster is read by a human and asked two
questions: do these values do the same job, and would merging them lose a distinction. A
cluster answered no to the first question is split; a cluster answered yes to the second is
recorded as is.

## Deviation Concentrates in One Place

The third table is the audit's most directly actionable output. Each cluster's most-used
member is taken as the **canonical spelling**; the other members count as deviation.

The result does not distribute evenly. Of the catalog interface's 46 uses, only 3 are
deviation: 6.5%. Of the admin panel's 37 uses, 20 are deviation: 54.1%. The member profile
is at 19.4%, the shelf screen at 23.8%.

This distribution gives the scope and sequencing decision together. The admin panel alone
carries the largest share of total deviation; fixing it first in the migration to the
system yields more gain than any of the other three products. It also opens a question: why
is the admin panel so far off? The answer is usually either that it was written by a
separate team or at a different time — in either case, the fix does not end with changing
values, it requires understanding why that team's or that period's decisions diverged.

The catalog interface's low deviation rate also needs a separate reading. The canonical
spellings are, to a large extent, already its own values, because the most usage is there.
This is where the assumption "the most-used value is correct" quietly enters the audit. The
assumption is not always true; the most-used value is the value of the product with the
most screens written — a result of its volume, not its quality.

## What the Audit Delivers

A visual audit produces three documents, and all three feed the next steps.

**The value inventory**: the values used for each property class, along with their usage
counts. These counts determine which value is kept when the scale is built.

**The cluster report**: the near-duplicate clusters and the human decision made for each
one — merge, split, or record as is.

**The deviation map**: how far each product is from the canonical spelling. This map gives
the migration order and the cost estimate.

What the audit does not deliver is the scale itself. The inventory says what is used, not
what should be used. The transition between the two is a separate decision, and it can be
made in two different ways.

## Summary

- A visual audit is the machine counting of an interface's actually used values from its
  style source; its source is the artifact, not memory, and what it looks for is unknown
  inconsistency.
- The near-duplicate criterion varies by class: pixel difference for lengths, and both
  contrast ratio and the largest channel difference together for color.
- The linking rule determines the result: chain linking collapses 13 spacing values into 4
  clusters and overstates near-duplication; diameter-bounded linking finds 8 clusters.
- Across the four measured products, 17 of 52 distinct values are unearned variety; this
  32.7% share can be closed at no cost.
- Clustering produces recommendations; every cluster is read together with its role,
  because the criterion looks at the value, not at the value's job.
- The deviation map gives the migration order: if one of the four products carries the
  largest share of total deviation, the migration starts there.

## Next Step

Once the inventory is in hand, two paths open up. The first path builds the scale from
principles and fits the existing values onto it; the second path declares the most-used
existing values a scale and continues from there. The two paths differ in both migration
cost and the quality of the scale they produce, and both are computable. The next lesson
applies these two strategies to the same inventory; it compares how many values will change
visibly, how many components will need touching, and whether the derived scales pass the
criteria from the Spacing Scale lesson.
