A political conference and discussion platform, in Rust and Dioxus
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201#!/usr/bin/env nu# Design-system lint (design system #1). Three gates:## A. Spacing / type in the stylesheet must flow through the tokens# (--md-sys-spacing-* and the typescale size tokens), so a global density# or type-scale change is one edit rather than a find-and-replace.# B. The same rule for the COMPONENTS. A raw px in an rsx! `style:` attribute# escapes the scale exactly the way a raw px in the stylesheet does, and# gate A never saw it. Reach for a class or a token instead; an inline# style should carry only values that are genuinely per-instance.# C. Colour must flow through the roles too. A literal hex or rgb() cannot# follow the theme into dark mode or a palette change, so it is the one# place drift is invisible until someone switches themes. A handful are# legitimate (scrim and shadow alphas no role covers), hence a ratchet# rather than a ban.# D. Every custom property a rule references must be defined somewhere.# `var(--md-sys-motion-duration-medium4, 400ms)` reads as token-driven but# is a literal wearing a token's name: the fallback silently takes over and# the value stops tracking the scale.## A and B are RATCHETS: the counts may only ever go DOWN. When you migrate a# literal to a token, lower the matching BASELINE; never raise it.## Wire into CI / pre-commit: nu apps/wiki/scripts/check-css-spacing.nu
# Current sanctioned ceilings. Lower these as literals are migrated to tokens.const CSS_BASELINE = 0const RSX_BASELINE = 0# Raw colour literals outside comments. 31 at the time of writing; the honest# ones are scrim/shadow alphas, and everything else is a role waiting to be# used. Lower this as they go.## Two of them are the sheet a PDF is drawn on in the "As printed" view: white# paper and black ink, which are the one thing here that must NOT follow the# theme. A page reproduced at its own proportions is a page, and a page is white# in the dark.const COLOR_BASELINE = 32
# Custom properties that are deliberately undefined: a local API a rule sets on# itself and reads back through `var(--name, <default>)`, or one the Rust sets# inline on the element it belongs to. The measurement is the whole point of# these -- a rule's thickness and width, a table's stated width, how much of a# page was left blank, the board's aspect and zoom -- and it is not knowable# until the document is read, so the stylesheet carries the default and the# element carries the number.const LOCAL_PROPS = [ "--state-color" "--board-aspect" "--page-spare" "--space" "--rule-start" "--rule-thickness" "--rule-width" "--t-width" "--zoom"]
let root = ($env.FILE_PWD | path dirname)let assets = ($root | path join "assets")let src = ($root | path join "src")let spacing_props = '(padding|margin|margin-top|margin-bottom|margin-left|margin-right|gap|font-size)'mut failed = false
# โโ Gate A: raw px in the stylesheet โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโlet css_count = ( open --raw ($assets | path join "style.css") | ^rg --only-matching $'($spacing_props)[^;:]*:[^;]*[0-9]+px' | lines | where {|l| $l != "" } | length)print $"A. raw-px spacing/font-size in style.css: ($css_count) \(ceiling ($CSS_BASELINE)\)"if $css_count > $CSS_BASELINE { print "" print $"ERROR: raw-px spacing/font-size in style.css rose above ($CSS_BASELINE)." print "Use the tokens instead of literal px:" print " padding/margin/gap -> var\(--md-sys-spacing-N\) \(N: 1=4px 2=8px 3=12px 4=16px 5=20px 6=24px\)" print " font-size -> the typescale size tokens in m3-tokens.css" $failed = true} else if $css_count < $CSS_BASELINE { print $" Nice โ lower CSS_BASELINE to ($css_count) in this script."}
# โโ Gate B: raw px in the components' inline styles โโโโโโโโโโโโโโโโโโโโโโโโโ# Only `style:` attributes, so a px in a comment or in an unrelated Rust string# is not mistaken for a design-system escape.let rsx_hits = ( do -i { ^rg --line-number --only-matching --no-heading $'style: "[^"]*($spacing_props)[^;:"]*:[^;"]*[0-9]+px' -g '*.rs' $src } | lines | where {|l| $l != "" })print $"B. raw-px spacing/font-size in rsx! style: attributes: ($rsx_hits | length) \(ceiling ($RSX_BASELINE)\)"if ($rsx_hits | length) > $RSX_BASELINE { print "" print $"ERROR: raw-px spacing/font-size in components rose above ($RSX_BASELINE):" $rsx_hits | each {|l| print $" ($l)" } print "Prefer a class \(.stack / .mt-1 / .list-subheader / a component class\);" print "when the value really is per-instance, drive it through a token:" print " style: \"padding-left: calc\(var\(--md-sys-spacing-3\) * {depth}\);\"" $failed = true} else if ($rsx_hits | length) < $RSX_BASELINE { print $" Nice โ lower RSX_BASELINE to ($rsx_hits | length) in this script."}
# โโ Gate C: colour flows through the roles โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโlet css_nocomments = ( open --raw ($assets | path join "style.css") | str replace --all --regex '(?s)/\*.*?\*/' '')let color_hits = ( $css_nocomments | ^rg --only-matching '#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)' | lines | where {|s| $s != "" })print $"C. raw colour literals in style.css: ($color_hits | length) \(ceiling ($COLOR_BASELINE)\)"if ($color_hits | length) > $COLOR_BASELINE { print "" print "ERROR: a colour that cannot follow the theme. Use a role" print "(--md-primary, --md-surface-container, --md-on-surface, ...)." $color_hits | uniq | each {|h| print $" ($h)" } $failed = true} else if ($color_hits | length) < $COLOR_BASELINE { print $" Nice โ lower COLOR_BASELINE to ($color_hits | length) in this script."}
# โโ Gate D: every referenced custom property is defined โโโโโโโโโโโโโโโโโโโโโlet all_css = ( ls $assets | where name =~ '\.css$' | get name | each {|f| open --raw $f } | str join "\n")let defined = ( $all_css | ^rg --only-matching '^\s*(--[A-Za-z0-9_-]+)\s*:' -r '$1' | lines | each {|s| $s | str trim } | uniq)let referenced = ( (($all_css | ^rg --only-matching 'var\(\s*(--[A-Za-z0-9_-]+)' -r '$1' | lines) ++ (do -i { ^rg --no-filename --only-matching 'var\(\s*(--[A-Za-z0-9_-]+)' -r '$1' -g '*.rs' $src } | lines)) | each {|s| $s | str trim } | where {|s| $s != "" } | uniq | sort)let undefined = ($referenced | where {|r| $r not-in $defined and $r not-in $LOCAL_PROPS })print $"D. custom properties referenced but never defined: ($undefined | length)"if ($undefined | length) > 0 { print "" print "ERROR: these read as tokens but resolve to their inline fallback:" $undefined | each {|u| print $" ($u)" } print "Define the token, or point the reference at the one that already exists." $failed = true}
# โโ Gate E: the stylesheet actually parses โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ# A CSS comment ends at the FIRST `*/`, so a comment containing one โ a glob# like `p-*/m-*`, a URL, a regex โ closes early and everything after it is read# as CSS. What follows is usually swallowed into the next selector, which drops# a whole rule silently: no error, no warning, just a class that stops working.# That is not hypothetical. `.mt-1` was dead in every browser for months for# exactly this reason, on 17 elements across ten screens, and nothing in this# script or in the compiler could see it. Biome parses the file properly, so ask# it.let css_files = (ls $assets | where name =~ '\.css$' | get name)let parse_errors = ( $css_files | each {|f| # `complete` has to take the external command directly. Written as # `do -i { ^biome format $f } | complete` it receives the block's value # instead and nushell refuses it, which took the whole check down after # the four ratchets had already passed. `complete` captures a non-zero # exit without raising, so it needs no `do -i` around it. let out = (^biome format $f | complete) if $out.exit_code != 0 and ($out.stderr | str contains "parse") { $f } else { null } } | compact)print $"E. stylesheets that do not parse: ($parse_errors | length)"if ($parse_errors | length) > 0 { print "" print "ERROR: a CSS parser rejects these files. A rule is probably being" print "swallowed โ check for `*/` inside a comment." $parse_errors | each {|f| print $" ($f)" } $failed = true}
if $failed { exit 1 }