Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m
`docs/RELEASE-PROCESS.md` §8.

## Unreleased
- fix(design-reaches-app): **the CSS readers now normalise both stylesheets first and match whole classes (#670)**: a one-line `:root{--a:1;--b:2}` read as 1 token with `}` in its value, a minified built sheet as having no bare `:root`, a rule inside `@media` or a multi-line selector list was missed, and `var(--x, fallback)` did not count as using `--x`. All five fixed by one `normalise_css()` step (comments dropped, one selector/declaration/`}` per line, `;` inside `url(...)`/strings kept). On the committed real pair (design and built sheets of a fictional test app, 2026-11) tokens arrived went from 9 of 10 to 32 of 38 and theme knobs bound from 10 of 46 to 31 of 46; the check takes about 7 s instead of 3 s on that pair, because it now reads all the tokens. **Can turn a project red:** class presence was a substring match, so `.card` counted as arrived when only `.card-header` existed; it is now a whole-class match, and a project that passed on that false reading can now report `0 of N design-system classes` (FAIL) or fewer classes arrived. No verdict names or exit codes changed — #670; outside review of v2026.11.12

## v2026.11.12
- new(parallel-builds): **`skills/parallel-builds.md`, an optional reference outside the pipeline (experimental tier, no stage map, no agent stub; used only when the user asks): several builders on one app in git worktrees, and how to merge their branches without losing a module (#741)**: a plain `git merge` of the model left one branch's units unregistered and both build gates reported 0 errors; the skill sets up worktrees (ports, database, number blocks, owners), forbids the plain merge, names the merge paths per CLI version, and checks `mxcli fix hashes`, `show modules` and the shared units before the build gate; forbids `diag --check-units --fix` after a suspect merge; a field run, 2026-11, lost a 63-unit module. Verbatim `fix hashes` capture under `tests/wave2/fixtures/worktree-merge/`. Guard script, exec.sh merge mode and lint delta are not built — field reports #741 and #744; LoanDesk worktree merge test
Expand Down
76 changes: 64 additions & 12 deletions project-bin/check-design-reaches-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,56 @@ report() { printf 'FAIL %s\n %s\n' "$1" "$2"; VIOLATIONS=$((VIOLATIONS + 1
warn() { printf 'WARN %s\n %s\n' "$1" "$2"; WARNINGS=$((WARNINGS + 1)); }
fault() { printf 'FAULT %s\n %s\n' "$1" "$2"; FAULTS=$((FAULTS + 1)); }

# ── Normalise both stylesheets once (#670) ─────────────────────────────────────────────────
# Every reader below is line-based and was written for a pretty-printed sheet: one declaration
# per line, `:root {` alone on its line. A minified or one-line sheet (`:root{--a:1;--b:2}`)
# read as ONE token whose value kept its `}`, and a minified built sheet as having no bare
# :root at all — the report then printed denominators like "1 of 1" over almost nothing.
# So: drop /* comments */, then put every selector (`sel {`), declaration (`prop: value;`) and
# closing `}` on a line of its own. `;` `{` `}` inside (...) or "..." / '...' (url(data:...;base64,..)) do not
# split; a multi-line selector list collapses onto one line; a rule inside @media gets its own
# line. A sheet that was already one-declaration-per-line comes out with the same lines.
normalise_css() {
awk '
function emit(s) { gsub(/[ \t\r]+/, " ", s); sub(/^ /, "", s); sub(/ $/, "", s); return s }
BEGIN { SPECIAL = "[/*\"(){};\\\\\047]"; buf = ""; depth = 0; q = ""; inc = 0 }
{
line = $0
if (!inc && q == "" && depth == 0 && line !~ SPECIAL) { buf = buf " " line; next }
n = length(line)
for (i = 1; i <= n; i++) {
c = substr(line, i, 1)
if (inc) { if (c == "*" && substr(line, i + 1, 1) == "/") { inc = 0; i++; buf = buf " " } continue }
if (q != "") {
buf = buf c
if (c == "\\") { buf = buf substr(line, i + 1, 1); i++ } else if (c == q) q = ""
continue
}
if (c == "/" && substr(line, i + 1, 1) == "*") { inc = 1; i++; continue }
if (c == "\"" || c == "\047") { q = c; buf = buf c; continue }
if (c == "\\") { buf = buf c substr(line, i + 1, 1); i++; continue }
if (c == "(") { depth++; buf = buf c; continue }
if (c == ")") { if (depth > 0) depth--; buf = buf c; continue }
if (depth == 0 && c == "{") { s = emit(buf); print (s == "" ? "{" : s " {"); buf = ""; continue }
if (depth == 0 && c == ";") { s = emit(buf); if (s != "") print s ";"; buf = ""; continue }
if (depth == 0 && c == "}") { s = emit(buf); if (s != "") print s; print "}"; buf = ""; continue }
buf = buf c
}
q = ""; buf = buf " "
}
END { s = emit(buf); if (s != "") print s }
' "$1"
}
NORM_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cdra.XXXXXX")"
trap 'rm -rf "$NORM_DIR"' EXIT
DESIGN_N="$NORM_DIR/design.css"; BUILT_N="$NORM_DIR/built.css"
normalise_css "$DESIGN" > "$DESIGN_N"
normalise_css "$BUILT" > "$BUILT_N"

# A whole-class match: `.card` is present when `.card` is followed by a character that cannot
# continue a class name, never when only `.card-header` exists (a substring match counted it).
has_class() { grep -qE -- "$(printf '%s' "$2" | sed 's/\./\\./g')([^A-Za-z0-9_-]|\$)" "$1"; }

# ── Pass 0: read the design system ──────────────────────────────────────────────────────────
# Tokens: custom properties declared in a :root block. Values too, because a knob may be bound
# by repeating the literal rather than by var() — still bound, and still correct.
Expand All @@ -179,15 +229,15 @@ TOKENS="$(awk '
inroot && /^[[:space:]]*}/ { inroot = 0 }
inroot && match($0, /--[a-z0-9-]+[[:space:]]*:/) {
name = substr($0, RSTART, RLENGTH); sub(/[[:space:]]*:$/, "", name)
rest = substr($0, RSTART + RLENGTH); sub(/;.*$/, "", rest)
rest = substr($0, RSTART + RLENGTH); sub(/;[[:space:]]*$/, "", rest)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", rest)
if (!(name in seen)) { seen[name] = 1; print name "\t" rest }
}
' "$DESIGN")"
' "$DESIGN_N")"
TOKEN_COUNT="$(printf '%s\n' "$TOKENS" | grep -c '^--' || true)"

# Classes: any `.name` appearing at the head of a rule. Compound selectors contribute each.
CLASSES="$(grep -oE '^[^{@}]*\{' "$DESIGN" \
CLASSES="$(grep -oE '^[^{@}]*\{' "$DESIGN_N" \
| grep -oE '\.[A-Za-z_-][A-Za-z0-9_-]*' | sort -u)"
CLASS_COUNT="$(printf '%s\n' "$CLASSES" | grep -c '^\.' || true)"

Expand All @@ -211,7 +261,7 @@ BUILT_ROOT="$(awk '
map[name] = rest
}
END { for (k in map) print k "\t" map[k] }
' "$BUILT")"
' "$BUILT_N")"

# Themed palettes. A design system may keep its whole palette in scoped blocks,
# `:root.theme-light { ... }`, `:root.theme-dark { ... }`, with no bare `:root` at all. Pass 1
Expand All @@ -227,21 +277,21 @@ scoped_root() {
inscope && /^[[:space:]]*\}/ { inscope = 0; next }
inscope && match($0, /--[a-zA-Z0-9-]+[[:space:]]*:/) {
name = substr($0, RSTART, RLENGTH); sub(/[[:space:]]*:$/, "", name)
rest = substr($0, RSTART + RLENGTH); sub(/;.*$/, "", rest)
rest = substr($0, RSTART + RLENGTH); sub(/;[[:space:]]*$/, "", rest)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", rest)
key = sel "\t" name; if (!(key in map)) order[++n] = key; map[key] = rest
}
END { for (i = 1; i <= n; i++) print order[i] "\t" map[order[i]] }
' "$1"
}
DESIGN_SCOPED="$(scoped_root "$DESIGN")"
BUILT_SCOPED="$(scoped_root "$BUILT")"
DESIGN_SCOPED="$(scoped_root "$DESIGN_N")"
BUILT_SCOPED="$(scoped_root "$BUILT_N")"
# Names the design declares in a bare `:root`; every other token is themed-only.
DESIGN_BARE_NAMES="$(awk '
/^[[:space:]]*:root[[:space:]]*\{[[:space:]]*$/ { inroot = 1; next }
inroot && /^[[:space:]]*\}/ { inroot = 0; next }
inroot && match($0, /--[a-zA-Z0-9-]+[[:space:]]*:/) { n = substr($0, RSTART, RLENGTH); sub(/[[:space:]]*:$/, "", n); print n }
' "$DESIGN" | sort -u)"
' "$DESIGN_N" | sort -u)"

KNOB_TOTAL=0
KNOB_BOUND=0
Expand All @@ -263,10 +313,12 @@ if [ -f "$CUSTOM_VARS" ]; then
val="$(printf '%s\n' "$BUILT_ROOT" | awk -F'\t' -v k="$knob" '$1 == k { v = $2 } END { print v }')"
[ -z "$val" ] && continue
bound=0
vals="$(printf '%s' "$val" | tr -d ' ')"
# (a) bound by reference
while IFS="$(printf '\t')" read -r tname tval; do
[ -z "$tname" ] && continue
case "$val" in *"var($tname)"*) bound=1; break ;; esac
# `var(--x)` and `var(--x, fallback)` both use --x
case "$vals" in *"var($tname)"*|*"var($tname,"*) bound=1; break ;; esac
# (b) bound by repeating the literal. Only for values that look like a value, so an
# empty or inherit-ish token cannot mark every knob bound.
if [ -n "$tval" ] && [ "${#tval}" -ge 3 ]; then
Expand All @@ -289,7 +341,7 @@ fi
TOKENS_ARRIVED=0
while IFS="$(printf '\t')" read -r tname tval; do
[ -z "$tname" ] && continue
grep -qE -- "$tname[[:space:]]*:" "$BUILT" && TOKENS_ARRIVED=$((TOKENS_ARRIVED + 1))
grep -qE -- "$tname[[:space:]]*:" "$BUILT_N" && TOKENS_ARRIVED=$((TOKENS_ARRIVED + 1))
done <<EOF
$TOKENS
EOF
Expand Down Expand Up @@ -347,7 +399,7 @@ EOF
CLASSES_ARRIVED=0
MISSING_CLASS_SAMPLE=""
for cls in $CLASSES; do
if grep -qF -- "$cls" "$BUILT"; then
if has_class "$BUILT_N" "$cls"; then
CLASSES_ARRIVED=$((CLASSES_ARRIVED + 1))
elif [ "$(printf '%s' "$MISSING_CLASS_SAMPLE" | wc -w)" -lt 6 ]; then
MISSING_CLASS_SAMPLE="$MISSING_CLASS_SAMPLE $cls"
Expand Down Expand Up @@ -389,7 +441,7 @@ if [ -n "$MDLSOURCE" ]; then
| sed "s/.*'\\(.*\\)'/\\1/" | tr ' ' '\n' | grep -E '^[A-Za-z_-][A-Za-z0-9_-]*$' | sort -u)"
for cls in $MODEL_CLASSES; do
MODEL_CLASS_TOTAL=$((MODEL_CLASS_TOTAL + 1))
if ! grep -qF -- ".$cls" "$BUILT"; then
if ! has_class "$BUILT_N" ".$cls"; then
MODEL_CLASS_MISSING=$((MODEL_CLASS_MISSING + 1))
if [ "$(printf '%s' "$MODEL_MISSING_SAMPLE" | wc -w)" -lt 8 ]; then
MODEL_MISSING_SAMPLE="$MODEL_MISSING_SAMPLE $cls"
Expand Down
11 changes: 11 additions & 0 deletions tests/wave2/fixtures/design-reaches-app/CAPTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,14 @@ Measured there on 2026-09-28, with the built sheet passed by hand and nothing el
The 35 declarations include some that name no knob the built bare `:root` defines; those
drop out of the denominator. The "before" verdict is the defect: a pass that read nothing,
reported as clean.

## #670 captures (2026-11)

`real-ds.css`, `real-built-excerpt.css` and `real-custom-variables.scss` come from a fictional
visitor-pass test app (a throwaway test project, 2026-11), copied unedited. `real-ds.css` is its
`design/ds.css` whole (131 lines, several `--token:value;` per line). `real-custom-variables.scss`
is its `theme/web/custom-variables.scss` whole. `real-built-excerpt.css` is verbatim line ranges of
its `deployment/web/theme.compiled.css` (974 KB, too large to commit), concatenated in this order,
no edits: lines 6376-6380 (`@media` + `.lead`), 6718-6722 (`@media` + `.container`), 3397-3449
(a bare `:root` carrying the design's tokens), 18118-18131 (the multi-line selector list
`.card, .mx-layoutgrid > ...`), 26662-26664 (`.card-body`).
80 changes: 80 additions & 0 deletions tests/wave2/fixtures/design-reaches-app/real-built-excerpt.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
@media (min-width: 768px) {
.lead {
font-size: 21px;
}
}
@media (min-width: 768px) {
.container {
width: 750px;
}
}
:root {
/* ---- the one colour that defines the app --------------------------- */
--mxt-brand: #2F5D62;
--mxt-brand-hover: #244A4E;
--mxt-brand-ink: #FFFFFF;
/* text on a brand-filled surface */
/* ---- semantic status ----------------------------------------------- */
--mxt-success: #2E7D5B;
--mxt-warning: #B7791F;
--mxt-danger: #B4412F;
--mxt-info: #3B6EA5;
/* ---- surfaces and ink ----------------------------------------------- */
--mxt-ground: #F6F3EE;
/* the app background */
--mxt-surface: #FFFFFF;
/* cards, modals, panels */
--mxt-surface-alt: #FAF8F5;
/* striped rows, subtle fills */
--mxt-surface-hover: #F1EEE8;
--mxt-surface-selected: #E6EFEF;
--mxt-ink: #1E2A32;
--mxt-ink-muted: #56636C;
--mxt-ink-faint: #8A949B;
--mxt-line: #E4DED5;
/* ---- the navigation rail -------------------------------------------- */
--mxt-rail: #243138;
--mxt-rail-line: #34454D;
--mxt-rail-ink: #BCC8CD;
--mxt-rail-ink-active: #FFFFFF;
/* ---- status pill tones ---------------------------------------------- */
--mxt-tint-ok: #E3F1EA;
--mxt-tone-ok: #1F5C41;
--mxt-tint-warn: #FBEFD9;
--mxt-tone-warn: #7A4E0C;
--mxt-tint-risk: #F8E4DF;
--mxt-tone-risk: #8A2E1F;
--mxt-tint-info: #E3ECF5;
--mxt-tone-info: #274D78;
--mxt-tint-neutral: #ECE9E3;
/* ---- shape, density, elevation --------------------------------------- */
--mxt-radius: 8px;
--mxt-radius-lg: 14px;
--mxt-row-height: 56px;
--mxt-control-height: 40px;
--mxt-shadow: 0 1px 2px rgba(30,42,50,.06), 0 4px 14px rgba(30,42,50,.06);
--mxt-focus-halo: 0 0 0 3px rgba(15, 110, 107, 0.16);
/* ---- type ------------------------------------------------------------ */
--mxt-font: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mxt-font-heading: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mxt-font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--mxt-font-size: 15px;
--mxt-line-height: 1.5;
}
Cards
========================================================================== */
.card, .mx-layoutgrid > .row.cards > .col,
.mx-layoutgrid > .row.cards > [class*=col-], .mx-layoutgrid.cards > .row > .col,
.mx-layoutgrid.cards > .row > [class*=col-] {
border: var(--card-border);
border-radius: var(--card-border-radius);
background-color: var(--card-bg);
overflow: hidden;
position: relative;
padding: var(--card-padding);
box-shadow: var(--card-shadow);
margin-bottom: var(--card-margin-bottom);
}
.card-body {
padding: var(--spacing-large);
}
Loading
Loading