mlola-ui 1.1.25 → 1.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,9 +44,44 @@ Tailwind's preflight in either import order.
44
44
  | `add <items…> [--no-install]` | copy items and their dependencies into the project, and install the packages they import |
45
45
  | `list [--json]` | print every installable item |
46
46
  | `doctor` | report project and registry problems |
47
+ | `check [path…] [--json] [--strict] [--all]` | check every file's markup and CSS against the contract, and count the colors and spacing typed by hand |
47
48
  | `login <token>` | save a Mlola Pro token (from /account) for this user |
48
49
  | `logout` | forget the saved token |
49
50
 
51
+ ## Checking a project
52
+
53
+ `check` runs the same check as the MCP server's `check_markup` over every
54
+ HTML, JSX, TSX, Vue, Svelte, Astro and CSS file in the project (or the paths
55
+ you name), and reports each issue with its line and fix:
56
+
57
+ ```bash
58
+ npx mlola-ui check # the whole project
59
+ npx mlola-ui check src --json # one folder, every issue as data
60
+ ```
61
+
62
+ It ends with the drift: how many different colors and spacing values were
63
+ typed by hand instead of read from the tokens, and which ones most. It works
64
+ in any project, so it can measure one before it uses Mlola. In a Mlola
65
+ project it leaves out what the CLI installed (`--all` takes the copied source
66
+ in; the theme it builds, where the tokens are defined, is never checked); where
67
+ Tailwind, UnoCSS or Windi is installed it leaves utility classes alone and
68
+ counts only the values typed into them (`bg-[#fafafa]`, `p-[13px]`).
69
+
70
+ It exits 1 when it finds an error (with `--strict`, a warning too), and on
71
+ GitHub Actions each issue is also an annotation on the pull request:
72
+
73
+ ```yaml
74
+ # .github/workflows/ui.yml
75
+ name: UI
76
+ on: pull_request
77
+ jobs:
78
+ mlola:
79
+ runs-on: ubuntu-latest
80
+ steps:
81
+ - uses: actions/checkout@v4
82
+ - run: npx -y mlola-ui@latest check src
83
+ ```
84
+
50
85
  ## Coding agents
51
86
 
52
87
  `init` (or `agents`, in an existing project) tells the project's coding agents
@@ -63,7 +98,8 @@ that its UI is Mlola:
63
98
  this CLI, offline: `get_design_rules`, `search_components`, `get_component`,
64
99
  `get_tokens`, `check_markup` (invented classes, wrong `data-*` values, utility
65
100
  classes, and hand-written colors, spacing off the `--ml-space-*` scale or
66
- faded text in `style` attributes and `<style>` blocks), `add_components` and `init_project`. For Claude Code
101
+ faded text in `style` attributes, `<style>` blocks and utility classes, each
102
+ with its line), `add_components` and `init_project`. For Claude Code
67
103
  without init:
68
104
 
69
105
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlola-ui",
3
- "version": "1.1.25",
3
+ "version": "1.1.26",
4
4
  "mcpName": "io.github.mlolahq/mlola-ui",
5
5
  "description": "Source-copy CLI that writes framework-free Mlola UI items into your project",
6
6
  "type": "module",
@@ -815,7 +815,7 @@
815
815
  "name": "generative-ui",
816
816
  "type": "registry:ui",
817
817
  "title": "Generative UI",
818
- "description": "Renders a JSON interface a model streams — metrics, charts, tables, alerts, buttons — from the library's own components as it arrives",
818
+ "description": "Renders a JSON interface a model streams (metrics, charts, tables, alerts, buttons) from the library's own components as it arrives",
819
819
  "category": "ai-agents",
820
820
  "tags": [
821
821
  "generative ui",
@@ -2,7 +2,7 @@
2
2
  "$schema": "./schema/registry-index.schema.json",
3
3
  "schemaVersion": 2,
4
4
  "name": "mlola-ui",
5
- "version": "1.1.25",
5
+ "version": "1.1.26",
6
6
  "homepage": "https://ui.mlola.com",
7
7
  "themeContract": {
8
8
  "attribute": "data-theme",
@@ -177,8 +177,8 @@
177
177
  },
178
178
  "registryDependencies": [],
179
179
  "engineDependencies": {
180
- "@mlola-ui/behavior": "^1.1.25",
181
- "@mlola-ui/icons": "^1.1.25"
180
+ "@mlola-ui/behavior": "^1.1.26",
181
+ "@mlola-ui/icons": "^1.1.26"
182
182
  },
183
183
  "options": [
184
184
  {
@@ -239,7 +239,7 @@
239
239
  },
240
240
  "registryDependencies": [],
241
241
  "engineDependencies": {
242
- "@mlola-ui/icons": "^1.1.25"
242
+ "@mlola-ui/icons": "^1.1.26"
243
243
  },
244
244
  "options": [
245
245
  {
@@ -401,7 +401,7 @@
401
401
  },
402
402
  "registryDependencies": [],
403
403
  "engineDependencies": {
404
- "@mlola-ui/icons": "^1.1.25"
404
+ "@mlola-ui/icons": "^1.1.26"
405
405
  },
406
406
  "options": [
407
407
  {
@@ -493,7 +493,7 @@
493
493
  },
494
494
  "registryDependencies": [],
495
495
  "engineDependencies": {
496
- "@mlola-ui/icons": "^1.1.25"
496
+ "@mlola-ui/icons": "^1.1.26"
497
497
  },
498
498
  "options": [],
499
499
  "variants": [],
@@ -546,7 +546,7 @@
546
546
  "spinner"
547
547
  ],
548
548
  "engineDependencies": {
549
- "@mlola-ui/motion": "^1.1.25"
549
+ "@mlola-ui/motion": "^1.1.26"
550
550
  },
551
551
  "options": [
552
552
  {
@@ -714,8 +714,8 @@
714
714
  },
715
715
  "registryDependencies": [],
716
716
  "engineDependencies": {
717
- "@mlola-ui/behavior": "^1.1.25",
718
- "@mlola-ui/icons": "^1.1.25"
717
+ "@mlola-ui/behavior": "^1.1.26",
718
+ "@mlola-ui/icons": "^1.1.26"
719
719
  },
720
720
  "options": [
721
721
  {
@@ -784,7 +784,7 @@
784
784
  },
785
785
  "registryDependencies": [],
786
786
  "engineDependencies": {
787
- "@mlola-ui/icons": "^1.1.25"
787
+ "@mlola-ui/icons": "^1.1.26"
788
788
  },
789
789
  "options": [],
790
790
  "variants": [],
@@ -839,7 +839,7 @@
839
839
  "modal"
840
840
  ],
841
841
  "engineDependencies": {
842
- "@mlola-ui/icons": "^1.1.25"
842
+ "@mlola-ui/icons": "^1.1.26"
843
843
  },
844
844
  "options": [],
845
845
  "variants": [],
@@ -903,8 +903,8 @@
903
903
  },
904
904
  "registryDependencies": [],
905
905
  "engineDependencies": {
906
- "@mlola-ui/behavior": "^1.1.25",
907
- "@mlola-ui/icons": "^1.1.25"
906
+ "@mlola-ui/behavior": "^1.1.26",
907
+ "@mlola-ui/icons": "^1.1.26"
908
908
  },
909
909
  "options": [
910
910
  {
@@ -993,7 +993,7 @@
993
993
  "input"
994
994
  ],
995
995
  "engineDependencies": {
996
- "@mlola-ui/icons": "^1.1.25"
996
+ "@mlola-ui/icons": "^1.1.26"
997
997
  },
998
998
  "options": [],
999
999
  "variants": [],
@@ -1218,8 +1218,8 @@
1218
1218
  },
1219
1219
  "registryDependencies": [],
1220
1220
  "engineDependencies": {
1221
- "@mlola-ui/behavior": "^1.1.25",
1222
- "@mlola-ui/icons": "^1.1.25"
1221
+ "@mlola-ui/behavior": "^1.1.26",
1222
+ "@mlola-ui/icons": "^1.1.26"
1223
1223
  },
1224
1224
  "options": [
1225
1225
  {
@@ -1272,7 +1272,7 @@
1272
1272
  "path": "packages/components/modal/modal.tsx",
1273
1273
  "target": "{{aliases.components}}/modal.tsx",
1274
1274
  "type": "registry:ui",
1275
- "integrity": "sha256-gqrMncs7lEgUjsWXXEl5FYIUoYAxj93gcVh1pZTuatQ="
1275
+ "integrity": "sha256-oxAjgygXXMPjL8+lXjW5LhAYBAmuPaBurWdplEYQiW4="
1276
1276
  }
1277
1277
  ],
1278
1278
  "themeContract": {
@@ -1370,7 +1370,7 @@
1370
1370
  },
1371
1371
  "registryDependencies": [],
1372
1372
  "engineDependencies": {
1373
- "@mlola-ui/icons": "^1.1.25"
1373
+ "@mlola-ui/icons": "^1.1.26"
1374
1374
  },
1375
1375
  "options": [],
1376
1376
  "variants": [],
@@ -1421,7 +1421,7 @@
1421
1421
  },
1422
1422
  "registryDependencies": [],
1423
1423
  "engineDependencies": {
1424
- "@mlola-ui/behavior": "^1.1.25"
1424
+ "@mlola-ui/behavior": "^1.1.26"
1425
1425
  },
1426
1426
  "options": [
1427
1427
  {
@@ -1683,7 +1683,7 @@
1683
1683
  "path": "packages/components/rating/rating.tsx",
1684
1684
  "target": "{{aliases.components}}/rating.tsx",
1685
1685
  "type": "registry:ui",
1686
- "integrity": "sha256-zgPOQXzAiRE4qGNPQ8qat3SP1XSI/7567kA5VaT51P0="
1686
+ "integrity": "sha256-prhfNzlCqLRWqURtq0MrX6MLdtuDpIOf5fA1Ie26L54="
1687
1687
  }
1688
1688
  ],
1689
1689
  "themeContract": {
@@ -1799,7 +1799,7 @@
1799
1799
  },
1800
1800
  "registryDependencies": [],
1801
1801
  "engineDependencies": {
1802
- "@mlola-ui/behavior": "^1.1.25"
1802
+ "@mlola-ui/behavior": "^1.1.26"
1803
1803
  },
1804
1804
  "options": [],
1805
1805
  "variants": [],
@@ -1852,8 +1852,8 @@
1852
1852
  "input"
1853
1853
  ],
1854
1854
  "engineDependencies": {
1855
- "@mlola-ui/behavior": "^1.1.25",
1856
- "@mlola-ui/icons": "^1.1.25"
1855
+ "@mlola-ui/behavior": "^1.1.26",
1856
+ "@mlola-ui/icons": "^1.1.26"
1857
1857
  },
1858
1858
  "options": [
1859
1859
  {
@@ -1895,7 +1895,7 @@
1895
1895
  "path": "packages/components/select/select.tsx",
1896
1896
  "target": "{{aliases.components}}/select.tsx",
1897
1897
  "type": "registry:ui",
1898
- "integrity": "sha256-IInFSuCUOPVgNMdtEsnjpqwMeVYYk+4fx33K21z0xQE="
1898
+ "integrity": "sha256-7u1sshW6KAQgjgwQ1siN1P5GkBxboOiOJOdgLanForE="
1899
1899
  }
1900
1900
  ],
1901
1901
  "themeContract": {
@@ -1931,8 +1931,8 @@
1931
1931
  },
1932
1932
  "registryDependencies": [],
1933
1933
  "engineDependencies": {
1934
- "@mlola-ui/behavior": "^1.1.25",
1935
- "@mlola-ui/icons": "^1.1.25"
1934
+ "@mlola-ui/behavior": "^1.1.26",
1935
+ "@mlola-ui/icons": "^1.1.26"
1936
1936
  },
1937
1937
  "options": [
1938
1938
  {
@@ -1992,7 +1992,7 @@
1992
1992
  "path": "packages/components/sheet/sheet.tsx",
1993
1993
  "target": "{{aliases.components}}/sheet.tsx",
1994
1994
  "type": "registry:ui",
1995
- "integrity": "sha256-gmc8rZ87NHfN/bErEWHm6HlaAoeNT/Fw6y2PrW88WFI="
1995
+ "integrity": "sha256-U5uuIle4HMkKILrxBqp+cimv7aKGurWa8s7RYhbuIM8="
1996
1996
  }
1997
1997
  ],
1998
1998
  "themeContract": {
@@ -2078,7 +2078,7 @@
2078
2078
  "input"
2079
2079
  ],
2080
2080
  "engineDependencies": {
2081
- "@mlola-ui/behavior": "^1.1.25"
2081
+ "@mlola-ui/behavior": "^1.1.26"
2082
2082
  },
2083
2083
  "options": [
2084
2084
  {
@@ -2223,7 +2223,7 @@
2223
2223
  },
2224
2224
  "registryDependencies": [],
2225
2225
  "engineDependencies": {
2226
- "@mlola-ui/behavior": "^1.1.25"
2226
+ "@mlola-ui/behavior": "^1.1.26"
2227
2227
  },
2228
2228
  "options": [
2229
2229
  {
@@ -2355,7 +2355,7 @@
2355
2355
  },
2356
2356
  "registryDependencies": [],
2357
2357
  "engineDependencies": {
2358
- "@mlola-ui/icons": "^1.1.25"
2358
+ "@mlola-ui/icons": "^1.1.26"
2359
2359
  },
2360
2360
  "options": [
2361
2361
  {
@@ -2443,8 +2443,8 @@
2443
2443
  "spinner"
2444
2444
  ],
2445
2445
  "engineDependencies": {
2446
- "@mlola-ui/behavior": "^1.1.25",
2447
- "@mlola-ui/icons": "^1.1.25"
2446
+ "@mlola-ui/behavior": "^1.1.26",
2447
+ "@mlola-ui/icons": "^1.1.26"
2448
2448
  },
2449
2449
  "options": [
2450
2450
  {
@@ -2599,7 +2599,7 @@
2599
2599
  },
2600
2600
  "registryDependencies": [],
2601
2601
  "engineDependencies": {
2602
- "@mlola-ui/behavior": "^1.1.25"
2602
+ "@mlola-ui/behavior": "^1.1.26"
2603
2603
  },
2604
2604
  "options": [
2605
2605
  {
@@ -2676,7 +2676,7 @@
2676
2676
  },
2677
2677
  "registryDependencies": [],
2678
2678
  "engineDependencies": {
2679
- "@mlola-ui/behavior": "^1.1.25"
2679
+ "@mlola-ui/behavior": "^1.1.26"
2680
2680
  },
2681
2681
  "options": [
2682
2682
  {
@@ -2758,7 +2758,7 @@
2758
2758
  },
2759
2759
  "registryDependencies": [],
2760
2760
  "engineDependencies": {
2761
- "@mlola-ui/icons": "^1.1.25"
2761
+ "@mlola-ui/icons": "^1.1.26"
2762
2762
  },
2763
2763
  "options": [
2764
2764
  {
@@ -2830,7 +2830,7 @@
2830
2830
  "popover"
2831
2831
  ],
2832
2832
  "engineDependencies": {
2833
- "@mlola-ui/icons": "^1.1.25"
2833
+ "@mlola-ui/icons": "^1.1.26"
2834
2834
  },
2835
2835
  "options": [
2836
2836
  {
@@ -2900,7 +2900,7 @@
2900
2900
  "popover"
2901
2901
  ],
2902
2902
  "engineDependencies": {
2903
- "@mlola-ui/icons": "^1.1.25"
2903
+ "@mlola-ui/icons": "^1.1.26"
2904
2904
  },
2905
2905
  "options": [],
2906
2906
  "variants": [],
@@ -3098,8 +3098,8 @@
3098
3098
  "input"
3099
3099
  ],
3100
3100
  "engineDependencies": {
3101
- "@mlola-ui/behavior": "^1.1.25",
3102
- "@mlola-ui/icons": "^1.1.25"
3101
+ "@mlola-ui/behavior": "^1.1.26",
3102
+ "@mlola-ui/icons": "^1.1.26"
3103
3103
  },
3104
3104
  "options": [
3105
3105
  {
@@ -3188,7 +3188,7 @@
3188
3188
  "input"
3189
3189
  ],
3190
3190
  "engineDependencies": {
3191
- "@mlola-ui/icons": "^1.1.25"
3191
+ "@mlola-ui/icons": "^1.1.26"
3192
3192
  },
3193
3193
  "options": [],
3194
3194
  "variants": [],
@@ -3243,7 +3243,7 @@
3243
3243
  "input"
3244
3244
  ],
3245
3245
  "engineDependencies": {
3246
- "@mlola-ui/icons": "^1.1.25"
3246
+ "@mlola-ui/icons": "^1.1.26"
3247
3247
  },
3248
3248
  "options": [],
3249
3249
  "variants": [],
@@ -3306,7 +3306,7 @@
3306
3306
  "popover"
3307
3307
  ],
3308
3308
  "engineDependencies": {
3309
- "@mlola-ui/icons": "^1.1.25"
3309
+ "@mlola-ui/icons": "^1.1.26"
3310
3310
  },
3311
3311
  "options": [
3312
3312
  {
@@ -3334,7 +3334,7 @@
3334
3334
  "path": "packages/components/color-picker/color-picker.tsx",
3335
3335
  "target": "{{aliases.components}}/color-picker.tsx",
3336
3336
  "type": "registry:ui",
3337
- "integrity": "sha256-/UyEbxqknsHheX444Imc3WphyixZbvj8hGL+r+zyG5w="
3337
+ "integrity": "sha256-Y0Wjx1ypiiKofe0vL1RpjgZG9uoa/a5RB0etmWW5d3A="
3338
3338
  },
3339
3339
  {
3340
3340
  "path": "packages/components/color-picker/color.ts",
@@ -3377,7 +3377,7 @@
3377
3377
  },
3378
3378
  "registryDependencies": [],
3379
3379
  "engineDependencies": {
3380
- "@mlola-ui/icons": "^1.1.25"
3380
+ "@mlola-ui/icons": "^1.1.26"
3381
3381
  },
3382
3382
  "options": [
3383
3383
  {
@@ -3496,8 +3496,8 @@
3496
3496
  },
3497
3497
  "registryDependencies": [],
3498
3498
  "engineDependencies": {
3499
- "@mlola-ui/behavior": "^1.1.25",
3500
- "@mlola-ui/icons": "^1.1.25"
3499
+ "@mlola-ui/behavior": "^1.1.26",
3500
+ "@mlola-ui/icons": "^1.1.26"
3501
3501
  },
3502
3502
  "options": [],
3503
3503
  "variants": [],
@@ -3561,7 +3561,7 @@
3561
3561
  },
3562
3562
  "registryDependencies": [],
3563
3563
  "engineDependencies": {
3564
- "@mlola-ui/behavior": "^1.1.25"
3564
+ "@mlola-ui/behavior": "^1.1.26"
3565
3565
  },
3566
3566
  "options": [
3567
3567
  {
@@ -3648,8 +3648,8 @@
3648
3648
  },
3649
3649
  "registryDependencies": [],
3650
3650
  "engineDependencies": {
3651
- "@mlola-ui/behavior": "^1.1.25",
3652
- "@mlola-ui/icons": "^1.1.25"
3651
+ "@mlola-ui/behavior": "^1.1.26",
3652
+ "@mlola-ui/icons": "^1.1.26"
3653
3653
  },
3654
3654
  "options": [],
3655
3655
  "variants": [],
@@ -3723,7 +3723,7 @@
3723
3723
  "sheet"
3724
3724
  ],
3725
3725
  "engineDependencies": {
3726
- "@mlola-ui/icons": "^1.1.25"
3726
+ "@mlola-ui/icons": "^1.1.26"
3727
3727
  },
3728
3728
  "options": [],
3729
3729
  "variants": [],
@@ -3846,7 +3846,7 @@
3846
3846
  "button"
3847
3847
  ],
3848
3848
  "engineDependencies": {
3849
- "@mlola-ui/icons": "^1.1.25"
3849
+ "@mlola-ui/icons": "^1.1.26"
3850
3850
  },
3851
3851
  "options": [
3852
3852
  {
@@ -3950,8 +3950,8 @@
3950
3950
  "input"
3951
3951
  ],
3952
3952
  "engineDependencies": {
3953
- "@mlola-ui/behavior": "^1.1.25",
3954
- "@mlola-ui/icons": "^1.1.25"
3953
+ "@mlola-ui/behavior": "^1.1.26",
3954
+ "@mlola-ui/icons": "^1.1.26"
3955
3955
  },
3956
3956
  "options": [
3957
3957
  {
@@ -248,7 +248,7 @@ export const ColorPicker = React.forwardRef<HTMLButtonElement, ColorPickerProps>
248
248
  </div>
249
249
  ) : null}
250
250
  <p className="ml-color-picker-contrast">
251
- <span className="ml-color-picker-sample" style={{ "--ml-color": solid, color: onWhite >= onBlack ? "#fff" : "#000" } as React.CSSProperties} aria-hidden="true">
251
+ <span className="ml-color-picker-sample" style={{ "--ml-color": solid, "--ml-color-ink": onWhite >= onBlack ? "#fff" : "#000" } as React.CSSProperties} aria-hidden="true">
252
252
  Aa
253
253
  </span>
254
254
  <span>
@@ -49,7 +49,6 @@ export function Modal({
49
49
  <div
50
50
  data-ml-portal=""
51
51
  className="ml-modal-overlay"
52
- data-state="open"
53
52
  onMouseDown={(event) => {
54
53
  if (closeOnBackdrop && event.target === event.currentTarget) onClose();
55
54
  }}
@@ -57,38 +57,3 @@ export function Rating({
57
57
  </span>
58
58
  );
59
59
  }
60
-
61
- export default function Showcase() {
62
- return (
63
- <div className="ml-rating-showcase">
64
- <section className="ml-showcase-group">
65
- <h3 className="ml-showcase-group-label">Fractions</h3>
66
- <p className="ml-showcase-note">
67
- The fill is clipped to the exact percentage, so a half star is really half.
68
- </p>
69
- <div className="ml-showcase-stack">
70
- {[5, 4.5, 4, 3.7, 3, 2.5, 1, 0].map((value) => (
71
- <Rating key={value} value={value} showValue />
72
- ))}
73
- </div>
74
- </section>
75
-
76
- <section className="ml-showcase-group">
77
- <h3 className="ml-showcase-group-label">With review count</h3>
78
- <div className="ml-showcase-stack">
79
- <Rating value={4.8} count={214} />
80
- <Rating value={4.2} count={89} />
81
- <Rating value={3.5} count={12} />
82
- </div>
83
- </section>
84
-
85
- <section className="ml-showcase-group">
86
- <h3 className="ml-showcase-group-label">Other scales</h3>
87
- <div className="ml-showcase-stack">
88
- <Rating value={7.5} max={10} showValue />
89
- <Rating value={2.5} max={3} showValue />
90
- </div>
91
- </section>
92
- </div>
93
- );
94
- }
@@ -293,7 +293,7 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
293
293
  ) : null}
294
294
  </div>
295
295
  {open && !disabled && portal ? createPortal(
296
- <div ref={popoverRef} data-ml-portal="" className="ml-select-popover" data-state="open" data-side="bottom">
296
+ <div ref={popoverRef} data-ml-portal="" className="ml-select-popover" data-side="bottom">
297
297
  {searchable ? (
298
298
  <div className="ml-select-search-wrap">
299
299
  <input
@@ -46,7 +46,7 @@ function Sheet({
46
46
  if (!portal || !open) return null;
47
47
  const named = title ?? props["aria-label"] ?? props["aria-labelledby"];
48
48
  return createPortal(
49
- <div data-ml-portal="" className="ml-sheet-layer" data-state="open">
49
+ <div data-ml-portal="" className="ml-sheet-layer">
50
50
  <div
51
51
  aria-hidden="true"
52
52
  className="ml-sheet-overlay"
@@ -22,7 +22,7 @@
22
22
  "packages/components/card/card.tsx": "sha256-YNqJWotnswv0UiYGtmJV9KxylKopXC0EZD7hTt8eQ0E=",
23
23
  "packages/components/carousel/carousel.tsx": "sha256-WxvQoG9/u190ka4x6cpZT8ZS6XyhEaAlJZ/JyXG+ACU=",
24
24
  "packages/components/checkbox/checkbox.tsx": "sha256-hz4YbGwOxv/aJpJHrLXR4MKSWJGyjzoJEIGvdhyML9M=",
25
- "packages/components/color-picker/color-picker.tsx": "sha256-/UyEbxqknsHheX444Imc3WphyixZbvj8hGL+r+zyG5w=",
25
+ "packages/components/color-picker/color-picker.tsx": "sha256-Y0Wjx1ypiiKofe0vL1RpjgZG9uoa/a5RB0etmWW5d3A=",
26
26
  "packages/components/color-picker/color.ts": "sha256-YqSgYjzn7XfPWAHuyzbwOhbg/ScaSfB7V73e9w+OjJs=",
27
27
  "packages/components/combobox/combobox.tsx": "sha256-YMQrR7HUd3aU6O5MqttPNphwtiVyPWIME+Qn/5kkIYM=",
28
28
  "packages/components/command-menu/command-menu.tsx": "sha256-wZnlj17zVlcfp5I08ziggHtchfdqjlvNl+MyiP+Rdbo=",
@@ -35,7 +35,7 @@
35
35
  "packages/components/hover-card/hover-card.tsx": "sha256-xLBQc3vVIfQbnN85F34r9SHMGQf8FubSYfk7uP+IS2g=",
36
36
  "packages/components/input/input.tsx": "sha256-K0YrpEsEJZTzw64t+Cyg/OGYwlU6LU1/yXTNUxYUIAI=",
37
37
  "packages/components/kbd/kbd.tsx": "sha256-e8EO2Gw6JiUyrU0YcEZOaQNMel1pe/EcJrxwwgd1Ggk=",
38
- "packages/components/modal/modal.tsx": "sha256-gqrMncs7lEgUjsWXXEl5FYIUoYAxj93gcVh1pZTuatQ=",
38
+ "packages/components/modal/modal.tsx": "sha256-oxAjgygXXMPjL8+lXjW5LhAYBAmuPaBurWdplEYQiW4=",
39
39
  "packages/components/number-input/number-input.tsx": "sha256-mo7rVoOQ4p/I0Hhx5w+SwXUtl3sI4VOt3QX9FwdB2q4=",
40
40
  "packages/components/number-input/number.ts": "sha256-IrHCQrEhI5ein9ifMFuSViSoxQUBoT3tNmmCu/paGGM=",
41
41
  "packages/components/otp-input/otp-input.tsx": "sha256-0KeyDLCC27/GqOocMWXggJ255PCEpCOCcINAcabksSk=",
@@ -46,11 +46,11 @@
46
46
  "packages/components/priority-icon/priority-icon.tsx": "sha256-3D6hFRpht3YuWouLhakE2DFdldavmJx0B/povEDwrtQ=",
47
47
  "packages/components/progress/progress.tsx": "sha256-oDxj1Ccl1pKV4+e6A7aaJL1FThW8qs9r4Gb0hEmExeM=",
48
48
  "packages/components/radio-group/radio-group.tsx": "sha256-jhnSEBLkdpe8dv5us/Q1sMsQF4sKGRgDXWGKm2JSFew=",
49
- "packages/components/rating/rating.tsx": "sha256-zgPOQXzAiRE4qGNPQ8qat3SP1XSI/7567kA5VaT51P0=",
49
+ "packages/components/rating/rating.tsx": "sha256-prhfNzlCqLRWqURtq0MrX6MLdtuDpIOf5fA1Ie26L54=",
50
50
  "packages/components/resizable/resizable.tsx": "sha256-RFBpg/a2O82mmx+O5wT+um+y3y2w0rUMJJdBzRu9ONU=",
51
51
  "packages/components/segmented-control/segmented-control.tsx": "sha256-MCBG5XHFWCwbACctsYKbqe8mvSSR6pf60tyRgdCDKDA=",
52
- "packages/components/select/select.tsx": "sha256-IInFSuCUOPVgNMdtEsnjpqwMeVYYk+4fx33K21z0xQE=",
53
- "packages/components/sheet/sheet.tsx": "sha256-gmc8rZ87NHfN/bErEWHm6HlaAoeNT/Fw6y2PrW88WFI=",
52
+ "packages/components/select/select.tsx": "sha256-7u1sshW6KAQgjgwQ1siN1P5GkBxboOiOJOdgLanForE=",
53
+ "packages/components/sheet/sheet.tsx": "sha256-U5uuIle4HMkKILrxBqp+cimv7aKGurWa8s7RYhbuIM8=",
54
54
  "packages/components/skeleton/skeleton.tsx": "sha256-2Fk72NbLr3+CrXH7jGbHNsc3t+vskMqSyySe/UwbGCs=",
55
55
  "packages/components/slider/slider.tsx": "sha256-o37Xi4J8+d/fhWFf2LdpPluuKDVXECNl4quKTlrqCU4=",
56
56
  "packages/components/spinner/spinner.tsx": "sha256-FZQmcGkQ8N0485n9bYsLTpTnolbM729YKjkQrXriz9A=",
package/src/agents.js CHANGED
@@ -37,7 +37,8 @@ This project's interface is built with Mlola UI. Before writing or changing UI:
37
37
  classes. \`data-theme\` and \`data-mode\` belong to the engine.
38
38
  - The \`mlola\` MCP server (\`npx mlola-ui mcp\`) searches components, explains
39
39
  tokens and checks markup against the contract. Check new markup with its
40
- \`check_markup\` tool before finishing.`;
40
+ \`check_markup\` tool before finishing; without the server, run
41
+ \`npx mlola-ui check <files>\`.`;
41
42
 
42
43
  /** Replaces the marked section, or appends it; returns what happened. */
43
44
  function mergeSection(filename, body) {
package/src/check.js ADDED
@@ -0,0 +1,257 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { CONFIG_FILENAME } from "./config.js";
5
+ import { checkMarkup, contractFrom } from "./knowledge.js";
6
+
7
+ /**
8
+ * `mlola-ui check`: check_markup over a whole project. It reads every file
9
+ * with markup or CSS, reports each issue with its file and line, and counts
10
+ * drift: how many colors and spacing values were typed by hand instead of
11
+ * read from the tokens. It works in any project, Mlola or not.
12
+ */
13
+
14
+ const MARKUP = new Set([".html", ".htm", ".jsx", ".tsx", ".js", ".mjs", ".vue", ".svelte", ".astro"]);
15
+ const STYLES = new Set([".css"]);
16
+ const SKIPPED = new Set(["node_modules", ".git", ".next", ".nuxt", ".svelte-kit", ".astro", ".output", ".vercel", ".turbo", ".cache", "dist", "build", "out", "coverage", "vendor"]);
17
+ const LARGEST = 1024 * 1024;
18
+
19
+ const checkable = (file) => {
20
+ const extension = path.extname(file).toLowerCase();
21
+ return (MARKUP.has(extension) || STYLES.has(extension)) && !/\.min\.(js|css)$/i.test(file);
22
+ };
23
+ const inSkipped = (file) => file.split(/[\\/]/).some((part) => SKIPPED.has(part));
24
+
25
+ /** The files under each target, relative to cwd: git's list when there is one, so .gitignore holds; a walk otherwise. */
26
+ function listFiles(cwd, targets) {
27
+ const git = spawnSync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", ...targets], { cwd, encoding: "utf8" });
28
+ let files;
29
+ if (!git.error && git.status === 0) {
30
+ files = git.stdout.split("\0").filter(Boolean);
31
+ } else {
32
+ files = [];
33
+ const walk = (relative) => {
34
+ const absolute = path.join(cwd, relative);
35
+ const stat = fs.statSync(absolute);
36
+ if (stat.isFile()) return files.push(relative);
37
+ for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) {
38
+ if (entry.isDirectory() && (SKIPPED.has(entry.name) || entry.name.startsWith("."))) continue;
39
+ if (entry.isDirectory() || entry.isFile()) walk(path.join(relative, entry.name));
40
+ }
41
+ };
42
+ for (const target of targets) walk(target);
43
+ }
44
+ return [...new Set(files.map((file) => path.normalize(file)))]
45
+ .filter((file) => checkable(file) && !inSkipped(file))
46
+ .filter((file) => {
47
+ const stat = fs.statSync(path.join(cwd, file), { throwIfNoEntry: false });
48
+ return stat?.isFile() && stat.size <= LARGEST;
49
+ })
50
+ .sort();
51
+ }
52
+
53
+ const readJsonIn = (cwd, name) => {
54
+ try {
55
+ return JSON.parse(fs.readFileSync(path.join(cwd, name), "utf8"));
56
+ } catch {
57
+ return null;
58
+ }
59
+ };
60
+
61
+ /** The project's own theme ids: the one mlola.config.json names and the one mlola.theme.json builds. */
62
+ export function projectThemes(cwd) {
63
+ const ids = [readJsonIn(cwd, CONFIG_FILENAME)?.theme, readJsonIn(cwd, "mlola.theme.json")?.id];
64
+ return [...new Set(ids.filter((id) => typeof id === "string" && id))];
65
+ }
66
+
67
+ /** The utility framework the project uses, if any: there, utility classes do something. */
68
+ function utilityFramework(cwd) {
69
+ const manifest = readJsonIn(cwd, "package.json") ?? {};
70
+ const declared = { ...manifest.dependencies, ...manifest.devDependencies };
71
+ const name = ["tailwindcss", "unocss", "windicss"].find((entry) => entry in declared);
72
+ if (name) return name;
73
+ const config = fs.readdirSync(cwd).find((file) => /^(tailwind|uno)\.config\.[cm]?[jt]s$/.test(file));
74
+ return config ? (config.startsWith("uno") ? "unocss" : "tailwindcss") : null;
75
+ }
76
+
77
+ /**
78
+ * What the CLI fills from Mlola itself, from mlola.config.json: `generated`
79
+ * (<styles>/mlola, the engine entry and the built theme, which is where the
80
+ * tokens are defined) is never checked; `copied` source is, with --all.
81
+ */
82
+ function installedByMlola(cwd) {
83
+ const targets = readJsonIn(cwd, CONFIG_FILENAME)?.targets;
84
+ if (!targets) return { generated: [], copied: [] };
85
+ const styles = typeof targets.styles === "string" && targets.styles ? targets.styles : null;
86
+ const copied = ["components", "blocks", "pages", "templates", "lib"].map((key) => targets[key]).filter((value) => typeof value === "string" && value);
87
+ if (styles) copied.push(path.join(styles, "mlola-pro"), path.join(styles, "mlola-pro.css"));
88
+ return { generated: styles ? [path.normalize(path.join(styles, "mlola"))] : [], copied: copied.map((place) => path.normalize(place)) };
89
+ }
90
+
91
+ const within = (file, place) => file === place || file.startsWith(`${place}${path.sep}`);
92
+
93
+ /** Every file under a folder, relative to cwd; nothing when it is missing. */
94
+ function filesUnder(cwd, place, pattern) {
95
+ const found = [];
96
+ const walk = (relative) => {
97
+ const stat = fs.statSync(path.join(cwd, relative), { throwIfNoEntry: false });
98
+ if (!stat) return;
99
+ if (stat.isFile()) {
100
+ if (pattern.test(relative)) found.push(relative);
101
+ return;
102
+ }
103
+ for (const entry of fs.readdirSync(path.join(cwd, relative), { withFileTypes: true })) {
104
+ if (entry.isDirectory() && SKIPPED.has(entry.name)) continue;
105
+ walk(path.join(relative, entry.name));
106
+ }
107
+ };
108
+ walk(place);
109
+ return found;
110
+ }
111
+
112
+ /**
113
+ * The classes and values of the Pro items this project installed: their
114
+ * stylesheets (a Pro component's in <styles>/mlola-pro/, a block's, page's
115
+ * or template's beside it) and what their copied source renders. Pro's
116
+ * classes are not published, so the check learns them here.
117
+ */
118
+ export function projectContract(cwd) {
119
+ const targets = readJsonIn(cwd, CONFIG_FILENAME)?.targets ?? {};
120
+ const place = (key) => (typeof targets[key] === "string" && targets[key] ? targets[key] : null);
121
+ const read = (file) => fs.readFileSync(path.join(cwd, file), "utf8");
122
+ const stylesheets = [
123
+ ...(place("styles") ? filesUnder(cwd, path.join(place("styles"), "mlola-pro"), /\.css$/) : []),
124
+ ...["blocks", "pages", "templates"].map(place).filter(Boolean).flatMap((folder) => filesUnder(cwd, folder, /\.css$/)),
125
+ ].map(read);
126
+ if (!stylesheets.length) return contractFrom();
127
+ const markups = ["components", "blocks", "pages", "templates"].map(place).filter(Boolean).flatMap((folder) => filesUnder(cwd, folder, /\.(tsx|jsx|html)$/)).map(read);
128
+ return contractFrom({ stylesheets, markups });
129
+ }
130
+
131
+ /** Counts each value, most used first. */
132
+ function tally(values) {
133
+ const counts = new Map();
134
+ for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
135
+ return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([value, count]) => ({ value, count }));
136
+ }
137
+
138
+ /**
139
+ * Checks a project. `targets` are paths relative to cwd (the whole project
140
+ * when empty); `all` includes the source Mlola copied in.
141
+ */
142
+ export function checkProject(cwd, { targets = [], all = false } = {}) {
143
+ for (const target of targets) {
144
+ if (!fs.existsSync(path.join(cwd, target))) throw new Error(`${target} does not exist.`);
145
+ }
146
+ const installed = installedByMlola(cwd);
147
+ const skipped = [...installed.generated, ...(all ? [] : installed.copied)];
148
+ const files = listFiles(cwd, targets.length ? targets : ["."]).filter((file) => !skipped.some((place) => within(file, place)));
149
+ const themes = projectThemes(cwd);
150
+ const contract = projectContract(cwd);
151
+ const utilities = utilityFramework(cwd);
152
+ const issues = [];
153
+ for (const file of files) {
154
+ const text = fs.readFileSync(path.join(cwd, file), "utf8");
155
+ // A stylesheet is checked as a page's <style> block; its lines stay where they are.
156
+ const markup = STYLES.has(path.extname(file).toLowerCase()) ? `<style>${text}</style>` : text;
157
+ for (const issue of checkMarkup(markup, { themes, contract })) {
158
+ // Where a utility framework reads them, utility classes are not dead; the values typed into them still count.
159
+ if (utilities && issue.rule === "utility-class") continue;
160
+ issues.push({ file: file.split(path.sep).join("/"), ...issue });
161
+ }
162
+ }
163
+ // Files in order, and within a file, lines in order.
164
+ issues.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
165
+ const errors = issues.filter((issue) => issue.severity === "error").length;
166
+ return {
167
+ files: files.length,
168
+ utilities,
169
+ skipped: skipped.filter((place) => fs.existsSync(path.join(cwd, place))).map((place) => place.split(path.sep).join("/")),
170
+ all,
171
+ errors,
172
+ warnings: issues.length - errors,
173
+ issues,
174
+ drift: {
175
+ colors: tally(issues.filter((issue) => issue.rule === "color").flatMap((issue) => issue.values ?? [])),
176
+ spacing: tally(issues.filter((issue) => issue.rule === "spacing").flatMap((issue) => issue.values ?? [])),
177
+ },
178
+ };
179
+ }
180
+
181
+ const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
182
+ const listed = (entries) => entries.slice(0, 6).map((entry) => (entry.count > 1 ? `${entry.value} ×${entry.count}` : entry.value)).join(", ") + (entries.length > 6 ? `, and ${entries.length - 6} more` : "");
183
+ // GitHub reads ::error lines as annotations on the pull request; these characters would end one early.
184
+ const escape = (text) => String(text).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
185
+ const escapeProperty = (text) => escape(text).replace(/:/g, "%3A").replace(/,/g, "%2C");
186
+
187
+ /** Up to this many issues are listed one by one; past it, the report names the files with the most. */
188
+ const LISTED = 60;
189
+ const ANNOTATED = 50;
190
+ const KINDS = [
191
+ [["color"], "colors typed by hand"],
192
+ [["spacing"], "spacing typed by hand"],
193
+ [["unknown-class", "variant-class"], "classes Mlola does not have"],
194
+ [["value", "no-effect"], "data-* values an element does not take"],
195
+ [["theme", "mode"], "theme and mode attributes"],
196
+ [["theme-token"], "theme tokens overridden"],
197
+ [["opacity"], "text faded with opacity"],
198
+ [["utility-class"], "utility classes"],
199
+ ];
200
+
201
+ /** The report a person reads, and on GitHub Actions the annotations a pull request shows. */
202
+ export function printReport(report, output, { github = false } = {}) {
203
+ const byFile = new Map();
204
+ for (const issue of report.issues) {
205
+ if (!byFile.has(issue.file)) byFile.set(issue.file, []);
206
+ byFile.get(issue.file).push(issue);
207
+ }
208
+ if (report.issues.length <= LISTED) {
209
+ for (const [file, issues] of byFile) {
210
+ output.log(file);
211
+ for (const issue of issues) {
212
+ output.log(` ${String(issue.line).padStart(4)} ${issue.severity.padEnd(7)} ${issue.message}`);
213
+ if (issue.fix) output.log(`${" ".repeat(17)}${issue.fix}`);
214
+ }
215
+ output.log("");
216
+ }
217
+ } else {
218
+ output.log("By kind:");
219
+ for (const [rules, label] of KINDS) {
220
+ const count = report.issues.filter((issue) => rules.includes(issue.rule)).length;
221
+ if (count) output.log(` ${String(count).padStart(6)} ${label}`);
222
+ }
223
+ output.log("");
224
+ output.log("Most issues:");
225
+ for (const [file, issues] of [...byFile].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0])).slice(0, 12)) {
226
+ output.log(` ${String(issues.length).padStart(6)} ${file}`);
227
+ }
228
+ output.log("");
229
+ output.log("Every issue with its line and fix: add --json, or check one folder at a time (npx mlola-ui check src/components).");
230
+ output.log("");
231
+ }
232
+ if (github) {
233
+ for (const issue of report.issues.slice(0, ANNOTATED)) {
234
+ output.log(`::${issue.severity === "error" ? "error" : "warning"} file=${escapeProperty(issue.file)},line=${issue.line},title=${escapeProperty(`Mlola: ${issue.rule}`)}::${escape(issue.fix ? `${issue.message} ${issue.fix}` : issue.message)}`);
235
+ }
236
+ if (report.issues.length > ANNOTATED) output.log(`Annotated the first ${ANNOTATED} of ${report.issues.length} issues; --json lists every one.`);
237
+ }
238
+ if (!report.issues.length) {
239
+ output.log(`✓ Checked ${plural(report.files, "file")}: no issues.`);
240
+ } else {
241
+ output.log(`Checked ${plural(report.files, "file")}: ${plural(report.errors, "error")} and ${plural(report.warnings, "warning")} in ${plural(byFile.size, "file")}.`);
242
+ }
243
+ const { colors, spacing } = report.drift;
244
+ if (colors.length || spacing.length) {
245
+ output.log("Drift, typed by hand instead of read from the tokens:");
246
+ if (colors.length) output.log(` ${plural(colors.length, "color")}: ${listed(colors)}`);
247
+ if (spacing.length) output.log(` ${plural(spacing.length, "spacing value")}: ${listed(spacing)}`);
248
+ }
249
+ if (report.utilities) output.log(`Utility classes are left alone: this project uses ${report.utilities}. Values typed into them (bg-[#fafafa], p-[13px]) still count.`);
250
+ if (report.skipped.length) {
251
+ output.log(
252
+ report.all
253
+ ? `Left out the stylesheets Mlola generates (${report.skipped.join(", ")}), where the tokens are defined.`
254
+ : `Left out what Mlola installed (${report.skipped.join(", ")}); --all checks the copied source too.`,
255
+ );
256
+ }
257
+ }
package/src/cli.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  writeDefaultConfig,
8
8
  } from "./config.js";
9
9
  import { writeAgentFiles } from "./agents.js";
10
+ import { checkProject, printReport } from "./check.js";
10
11
  import { addAssets, assetHint, loadAssetIndex } from "./assets.js";
11
12
  import { runDoctor } from "./doctor.js";
12
13
  import { collectEngineDependencies, installItems } from "./installer.js";
@@ -28,6 +29,7 @@ Usage:
28
29
  mlola-ui logout
29
30
  mlola-ui list [--kind component|block|template|asset] [--json]
30
31
  mlola-ui doctor
32
+ mlola-ui check [path...] [--json] [--strict] [--all]
31
33
  mlola-ui theme pull <theme-id | url> [--host <url>] [--overwrite]
32
34
  mlola-ui theme build [--file mlola.theme.json]
33
35
 
@@ -41,6 +43,7 @@ Examples:
41
43
  npx mlola-ui theme pull https://ui.mlola.com/t/th-4k2x9qf7wz3m
42
44
  npx mlola-ui theme build (renders your mlola.theme.json, offline)
43
45
  npx mlola-ui mcp (an MCP server for Claude Code, Cursor, Codex…)
46
+ npx mlola-ui check src (colors and spacing typed by hand, classes that do not exist)
44
47
  `;
45
48
 
46
49
  const CLI_VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
@@ -345,6 +348,15 @@ export async function run(argv, options = {}) {
345
348
  return 0;
346
349
  }
347
350
 
351
+ if (command === "check") {
352
+ const targets = args.filter((argument) => !argument.startsWith("--"));
353
+ const report = checkProject(cwd, { targets, all: hasFlag(args, "--all") });
354
+ if (hasFlag(args, "--json")) output.log(JSON.stringify(report, null, 2));
355
+ else printReport(report, output, { github: env.GITHUB_ACTIONS === "true" });
356
+ // A CI step fails on errors; with --strict, on warnings too.
357
+ return report.errors || (hasFlag(args, "--strict") && report.warnings) ? 1 : 0;
358
+ }
359
+
348
360
  if (command === "doctor") {
349
361
  const findings = runDoctor(cwd);
350
362
  for (const item of findings) {
package/src/knowledge.js CHANGED
@@ -192,6 +192,13 @@ export function themes() {
192
192
  const SHARED = new Set(["data-tone", "data-variant", "data-size", "data-state", "data-status"]);
193
193
  const UTILITY = /^(-?(m|p)[trblxy]?-\d|flex$|grid$|block$|inline|hidden$|items-|justify-|gap-|w-|h-|min-|max-|text-(xs|sm|base|lg|xl|\d|[a-z]+-\d)|font-(bold|medium|semibold|light)|bg-|border-|rounded|shadow|ring-|space-[xy]-|leading-|tracking-|opacity-|z-\d|absolute$|relative$|fixed$|sticky$|overflow-|col-span|row-span|sm:|md:|lg:|xl:|hover:|focus:|dark:)/;
194
194
  const COLOR = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i;
195
+ /** A margin-left utility (ml-4, ml-auto), which only looks like a Mlola class. */
196
+ const MARGIN_UTILITY = /^-?ml-(?:\d|auto$|px$|\[)/;
197
+ /** An arbitrary value in a utility class: a color (bg-[#fafafa]) or a spacing length (p-[13px]) typed by hand. */
198
+ const ARBITRARY_COLOR = /-\[(#[0-9a-f]{3,8}|(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\([^\]]*\))\]$/i;
199
+ const ARBITRARY_SPACING = /^(?:[\w-]+:)*-?(?:[pm][trblxyse]?|gap(?:-[xy])?|space-[xy]|inset(?:-[xy])?|top|right|bottom|left)-\[(-?(?:\d*\.)?\d+(?:px|rem|em))\]$/;
200
+ const COLORS = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\([^)]*\)/gi;
201
+ const LENGTH = /-?(?:\d*\.)?\d+(?:px|rem|em)\b/g;
195
202
 
196
203
  /*
197
204
  * Spacing written by hand: a padding, margin, gap or inset whose length is
@@ -201,7 +208,24 @@ const COLOR = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i;
201
208
  */
202
209
  const SPACING_CSS = /(?:^|[;{\s])((?:padding|margin|inset)(?:-(?:top|right|bottom|left|inline|block)(?:-(?:start|end))?)?|(?:row-|column-)?gap)\s*:\s*([^;}]+)/gi;
203
210
  const SPACING_JS = /(?:^|[{,\s])((?:padding|margin|inset)(?:Top|Right|Bottom|Left|Inline|Block)?(?:Start|End)?|(?:row|column)?[gG]ap)\s*:\s*("[^"]*"|'[^']*'|`[^`]*`|-?\d+(?:\.\d+)?(?=\s*[,}]))/g;
204
- const byHand = (value) => !/var\(--ml-/.test(value) && [...value.matchAll(/-?(?:\d*\.)?\d+(?:px|rem|em)\b/g)].some((length) => parseFloat(length[0]) !== 0);
211
+ const byHand = (value) => !/var\(--ml-/.test(value) && [...value.matchAll(LENGTH)].some((length) => parseFloat(length[0]) !== 0);
212
+
213
+ /** The lengths a hand-written spacing declaration uses (a bare JSX number is pixels), for counting drift. */
214
+ const lengthsOf = (declarations) =>
215
+ declarations.flatMap((declaration) => {
216
+ const value = declaration.slice(declaration.indexOf(":") + 1).trim();
217
+ if (/^-?\d+(?:\.\d+)?$/.test(value)) return [`${Number(value)}px`];
218
+ return [...value.matchAll(LENGTH)].map((length) => length[0]).filter((length) => parseFloat(length) !== 0);
219
+ });
220
+
221
+ /** Text of the same length with nothing in it, so what is left keeps its offsets. */
222
+ const blank = (text) => text.replace(/[^\n]/g, " ");
223
+
224
+ /** A color derived from a token (oklch(from var(--ml-primary-text) l c h / 50%)) follows the theme, so it is not written by hand. */
225
+ const underived = (text) => text.replace(/\b(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(\s*from\s+var\(--ml-[\w-]+\)[^)]*\)/gi, blank);
226
+
227
+ /** The colors a declaration writes by hand, lower-cased so #FFF and #fff count once. */
228
+ const colorsOf = (text) => [...underived(text).matchAll(COLORS)].map((match) => match[0].toLowerCase().replace(/\s+/g, " "));
205
229
 
206
230
  /** Each spacing property in a style string, a JSX style object or a stylesheet whose length is written by hand. */
207
231
  function handSpacing(text) {
@@ -218,7 +242,7 @@ function handSpacing(text) {
218
242
  const SPACING_FIX = "Measure with the scale: var(--ml-space-2), var(--ml-space-4)… (get_tokens spacing). A calc() or clamp() of scale steps is fine; a value invented between them is not.";
219
243
 
220
244
  /** Every opening tag in HTML or JSX, with its string-valued attributes. */
221
- function tagsOf(markup) {
245
+ export function tagsOf(markup) {
222
246
  const tags = [];
223
247
  for (const match of markup.matchAll(/<([A-Za-z][\w.-]*)((?:\s+(?:[^\s"'>={}]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}))?))*)\s*\/?>/g)) {
224
248
  const attributes = new Map();
@@ -231,7 +255,7 @@ function tagsOf(markup) {
231
255
  if (expression) attributes.set(`${name}:raw`, expression);
232
256
  }
233
257
  const classValue = attributes.get("class") ?? attributes.get("className");
234
- tags.push({ tag: match[0], attributes, classes: typeof classValue === "string" ? classValue.split(/\s+/).filter(Boolean) : [] });
258
+ tags.push({ tag: match[0], index: match.index, attributes, classes: typeof classValue === "string" ? classValue.split(/\s+/).filter(Boolean) : [] });
235
259
  }
236
260
  return tags;
237
261
  }
@@ -282,23 +306,93 @@ function valuesByElement() {
282
306
  return knownValues;
283
307
  }
284
308
 
309
+ /**
310
+ * The classes some stylesheets define and the data-* values each element
311
+ * takes: the ones the CSS draws, and the ones the markup renders itself (a
312
+ * default such as data-tone="primary" is drawn by the base rule, and as
313
+ * correct as any other). Pro's classes are not published, so a check learns
314
+ * them this way from the Pro items a project has installed.
315
+ */
316
+ export function contractFrom({ stylesheets = [], markups = [] } = {}) {
317
+ const classes = new Set();
318
+ const elements = new Map();
319
+ const add = (cls, attribute, value) => {
320
+ if (!elements.has(cls)) elements.set(cls, new Map());
321
+ if (!elements.get(cls).has(attribute)) elements.get(cls).set(attribute, new Set());
322
+ elements.get(cls).get(attribute).add(value);
323
+ };
324
+ for (const text of stylesheets) {
325
+ const css = text.replace(/\/\*[\s\S]*?\*\//g, "");
326
+ for (const match of css.matchAll(/\.(ml-[a-z0-9-]+)/g)) classes.add(match[1]);
327
+ for (const match of css.matchAll(/\.(ml-[a-z0-9-]+)((?:\[data-[a-z-]+(?:=["']?[^\]"']*["']?)?\])+)/g)) {
328
+ for (const [, attribute, value] of match[2].matchAll(/\[(data-[a-z-]+)=["']?([^\]"']*)["']?\]/g)) add(match[1], attribute, value);
329
+ }
330
+ }
331
+ for (const markup of markups) {
332
+ for (const { attributes, classes: rendered } of tagsOf(markup)) {
333
+ for (const [name, value] of attributes) {
334
+ if (!name.startsWith("data-") || typeof value !== "string" || !value) continue;
335
+ for (const cls of rendered) if (classes.has(cls)) add(cls, name, value);
336
+ }
337
+ }
338
+ }
339
+ return { classes, elements };
340
+ }
341
+
285
342
  /**
286
343
  * Checks HTML or JSX against the element contract. It reads string-valued
287
344
  * class, className, style and data-* attributes; expressions in braces are
288
345
  * skipped, since their value is only known at runtime.
346
+ *
347
+ * Each issue names its line, the rule it breaks (`rule`) and, for a color or
348
+ * a spacing written by hand, the values themselves (`values`), which
349
+ * `mlola-ui check` counts as drift across a project.
350
+ *
351
+ * `themes` adds the project's own theme ids; `contract` (from contractFrom)
352
+ * adds the classes and values of the Pro items the project installed.
289
353
  */
290
- export function checkMarkup(markup) {
354
+ export function checkMarkup(markup, { themes: ownThemes = [], contract: installed } = {}) {
291
355
  const contract = bundled("contract.json");
292
- const known = new Set(contract?.classes ?? []);
356
+ const known = new Set([...(contract?.classes ?? []), ...(installed?.classes ?? [])]);
293
357
  const proPrefixes = (bundled("catalog.json")?.items ?? []).map((item) => `ml-${item.name}`);
294
- const themeIds = new Set(themes().map((theme) => theme.id));
358
+ // The five themes, and the project's own (mlola.theme.json) when the caller knows the project.
359
+ const themeIds = new Set([...themes().map((theme) => theme.id), ...ownThemes]);
295
360
  const tokenNames = new Set((designData()?.tokens ?? []).flatMap((group) => group.names));
296
- const values = valuesByElement();
361
+ const shared = valuesByElement();
362
+ const values = {
363
+ get: (cls) => {
364
+ const own = installed?.elements.get(cls);
365
+ if (!own) return shared.get(cls);
366
+ const merged = new Map(shared.get(cls) ?? []);
367
+ for (const [attribute, list] of own) merged.set(attribute, new Set([...(merged.get(attribute) ?? []), ...list]));
368
+ return merged;
369
+ },
370
+ };
297
371
  const issues = [];
298
- const note = (severity, tag, message, fix) => issues.push({ severity, element: tag.slice(0, 120), message, ...(fix ? { fix } : {}) });
372
+ const breaks = [];
373
+ for (let index = markup.indexOf("\n"); index !== -1; index = markup.indexOf("\n", index + 1)) breaks.push(index);
374
+ const lineAt = (offset) => {
375
+ let low = 0;
376
+ let high = breaks.length;
377
+ while (low < high) {
378
+ const middle = (low + high) >> 1;
379
+ if (breaks[middle] < offset) low = middle + 1;
380
+ else high = middle;
381
+ }
382
+ return low + 1;
383
+ };
384
+ let at = 0;
385
+ const note = (severity, tag, message, fix, rule, values) =>
386
+ issues.push({ severity, line: lineAt(at), rule, element: tag.slice(0, 120), message, ...(fix ? { fix } : {}), ...(values?.length ? { values } : {}) });
299
387
 
300
- for (const { tag, attributes, classes } of tagsOf(markup)) {
301
- const mlola = classes.filter((cls) => cls.startsWith("ml-"));
388
+ for (const { tag, index, attributes, classes } of tagsOf(markup)) {
389
+ // An issue points at its attribute, so a tag written over several lines reports the right one.
390
+ const attributeAt = (pattern) => {
391
+ const found = tag.search(pattern);
392
+ at = index + Math.max(0, found);
393
+ };
394
+ attributeAt(/\sclass(?:Name)?\s*=/);
395
+ const mlola = classes.filter((cls) => cls.startsWith("ml-") && !MARGIN_UTILITY.test(cls));
302
396
 
303
397
  for (const cls of mlola) {
304
398
  if (known.has(cls) || proPrefixes.some((prefix) => cls === prefix || cls.startsWith(`${prefix}-`))) continue;
@@ -308,28 +402,39 @@ export function checkMarkup(markup) {
308
402
  tag,
309
403
  `"${cls}" is not a Mlola class.`,
310
404
  base ? `Mlola has no variant classes: use "${base}" with a data-* attribute (data-variant, data-tone, data-size).` : "Search with search_components, or name your own element with your own prefix (not ml-).",
405
+ base ? "variant-class" : "unknown-class",
311
406
  );
312
407
  }
313
- const utilities = classes.filter((cls) => !cls.startsWith("ml-") && UTILITY.test(cls));
408
+ const utilities = classes.filter((cls) => (!cls.startsWith("ml-") || MARGIN_UTILITY.test(cls)) && UTILITY.test(cls));
314
409
  if (utilities.length) {
315
- note("warning", tag, `Utility classes (${utilities.slice(0, 4).join(" ")}) do nothing here: Mlola ships no utility framework.`, "Use a component or composition primitive; for your own CSS, read --ml-* tokens.");
410
+ note("warning", tag, `Utility classes (${utilities.slice(0, 4).join(" ")}) do nothing here: Mlola ships no utility framework.`, "Use a component or composition primitive; for your own CSS, read --ml-* tokens.", "utility-class");
411
+ }
412
+ // Values typed into utility classes are drift whichever framework reads them.
413
+ const arbitraryColors = classes.map((cls) => ARBITRARY_COLOR.exec(cls)).filter(Boolean);
414
+ if (arbitraryColors.length) {
415
+ note("error", tag, `A color is written by hand in a class (${arbitraryColors.map((match) => match.input).slice(0, 4).join(" ")}).`, "Read a token: var(--ml-text), var(--ml-primary-text), var(--ml-surface)… (get_tokens).", "color", arbitraryColors.map((match) => match[1].toLowerCase()));
416
+ }
417
+ const arbitrarySpacing = classes.map((cls) => ARBITRARY_SPACING.exec(cls)).filter(Boolean);
418
+ if (arbitrarySpacing.length) {
419
+ note("warning", tag, `Spacing is written by hand in a class (${arbitrarySpacing.map((match) => match.input).slice(0, 4).join(" ")}).`, SPACING_FIX, "spacing", arbitrarySpacing.map((match) => match[1]));
316
420
  }
317
421
 
318
422
  for (const [name, value] of attributes) {
319
423
  if (!name.startsWith("data-") || typeof value !== "string") continue;
424
+ attributeAt(new RegExp(`\\s${name}\\s*=`));
320
425
  if (name === "data-theme" && value && !themeIds.has(value) && !/^th-[0-9a-z]{12}$/.test(value)) {
321
- note("error", tag, `data-theme="${value}" is not a theme.`, `Use one of: ${[...themeIds].join(", ")}, or a Studio theme id.`);
426
+ note("error", tag, `data-theme="${value}" is not a theme.`, `Use one of: ${[...themeIds].join(", ")}, or a Studio theme id.`, "theme");
322
427
  }
323
428
  if (name === "data-mode" && !["light", "dark", "system"].includes(value)) {
324
- note("error", tag, `data-mode is "light", "dark" or "system"; "${value}" belongs in an attribute of your own.`, "data-theme and data-mode belong to the engine. Use data-kind or data-state for a component's own meaning.");
429
+ note("error", tag, `data-mode is "light", "dark" or "system"; "${value}" belongs in an attribute of your own.`, "data-theme and data-mode belong to the engine. Use data-kind or data-state for a component's own meaning.", "mode");
325
430
  }
326
431
  if (!SHARED.has(name) || !mlola.length) continue;
327
432
  const allowed = new Set();
328
433
  for (const cls of mlola) for (const entry of values.get(cls)?.get(name) ?? []) allowed.add(entry);
329
434
  if (allowed.size && !allowed.has(value)) {
330
- note("error", tag, `${name}="${value}" is not a value ${mlola.join(" ")} takes.`, `Allowed: ${[...allowed].sort().join(", ")}.`);
435
+ note("error", tag, `${name}="${value}" is not a value ${mlola.join(" ")} takes.`, `Allowed: ${[...allowed].sort().join(", ")}.`, "value");
331
436
  } else if (!allowed.size && mlola.every((cls) => known.has(cls))) {
332
- note("warning", tag, `${name} has no effect on ${mlola.join(" ")}.`, `See get_component for the attributes it reacts to.`);
437
+ note("warning", tag, `${name} has no effect on ${mlola.join(" ")}.`, `See get_component for the attributes it reacts to.`, "no-effect");
333
438
  }
334
439
  }
335
440
 
@@ -338,45 +443,63 @@ export function checkMarkup(markup) {
338
443
  // theme token set inline overrides the theme and is flagged too.
339
444
  const style = attributes.get("style") ?? attributes.get("style:raw");
340
445
  if (typeof style === "string") {
341
- const overridden = [...style.matchAll(/(--ml-[\w-]+)\s*:/g)].map((match) => match[1]).filter((token) => tokenNames.has(token));
342
- const declarations = style.replace(/--[\w-]+\s*:[^;,}]*/g, "");
446
+ attributeAt(/\sstyle\s*=/);
447
+ // A custom property is named bare in CSS and quoted in a JSX style object ("--ml-color": …).
448
+ const overridden = [...style.matchAll(/(--ml-[\w-]+)["']?\s*:/g)].map((match) => match[1]).filter((token) => tokenNames.has(token));
449
+ const declarations = underived(style.replace(/["']?--[\w-]+["']?\s*:[^;,}]*/g, ""));
343
450
  if (COLOR.test(declarations)) {
344
- note("error", tag, "A color is written by hand in style.", "Read a token: var(--ml-text), var(--ml-primary-text), var(--ml-surface)… (get_tokens).");
451
+ note("error", tag, "A color is written by hand in style.", "Read a token: var(--ml-text), var(--ml-primary-text), var(--ml-surface)… (get_tokens).", "color", colorsOf(declarations));
345
452
  }
346
453
  if (overridden.length) {
347
- note("error", tag, `${overridden.join(", ")} is a theme token; setting it inline overrides the theme.`, "Pick a theme, or change the theme's spec (mlola.theme.json), instead of one element's tokens.");
454
+ note("error", tag, `${overridden.join(", ")} is a theme token; setting it inline overrides the theme.`, "Pick a theme, or change the theme's spec (mlola.theme.json), instead of one element's tokens.", "theme-token");
348
455
  }
349
456
  const spacing = handSpacing(style);
350
- if (spacing.length) note("warning", tag, `Spacing is written by hand in style (${spacing.join("; ")}).`, SPACING_FIX);
457
+ if (spacing.length) note("warning", tag, `Spacing is written by hand in style (${spacing.join("; ")}).`, SPACING_FIX, "spacing", lengthsOf(spacing));
351
458
  }
352
459
  }
353
460
 
354
461
  // The page's own stylesheet: the same rules hold in a <style> block.
355
- for (const [, css] of markup.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) {
356
- for (const rule of css.replace(/\/\*[\s\S]*?\*\//g, "").split("}")) {
462
+ for (const block of markup.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) {
463
+ // Comments become blanks of the same length, so every offset still points into the markup.
464
+ const css = block[1].replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " "));
465
+ let offset = block.index + block[0].indexOf(">") + 1;
466
+ for (const rule of css.split("}")) {
467
+ const start = offset;
468
+ offset += rule.length + 1;
357
469
  const open = rule.lastIndexOf("{");
358
470
  if (open < 0) continue;
471
+ const bodyAt = start + open + 1;
472
+ at = bodyAt;
359
473
  const selector = rule.slice(0, open).split("{").pop().trim();
360
474
  // A keyframe (from, to, 50%) is a step of an animation, not a rule for text at rest.
361
475
  if (/^(from|to|\d+(\.\d+)?%)(\s*,\s*(from|to|\d+(\.\d+)?%))*$/i.test(selector)) continue;
362
- const body = rule.slice(open + 1).replace(/url\([^)]*\)/g, "");
476
+ // url() and custom properties are blanked, not removed, so a match's index is its place in the markup.
477
+ const body = rule.slice(open + 1).replace(/url\([^)]*\)/g, blank);
478
+ const own = underived(body.replace(/--[\w-]+\s*:[^;]*/g, blank));
479
+ const pointAt = (found) => {
480
+ at = bodyAt + Math.max(0, found);
481
+ };
363
482
  const where = `<style> ${selector}`;
364
483
  const overridden = [...body.matchAll(/(--ml-[\w-]+)\s*:/g)].map((match) => match[1]).filter((token) => tokenNames.has(token));
365
- if (COLOR.test(body.replace(/--[\w-]+\s*:[^;]*/g, ""))) {
366
- note("error", where, "A color is written by hand in the stylesheet.", "Read a token: var(--ml-text), var(--ml-primary-text), var(--ml-surface)… (get_tokens). The theme then keeps its contrast in every mode.");
484
+ if (COLOR.test(own)) {
485
+ pointAt(own.search(COLOR));
486
+ note("error", where, "A color is written by hand in the stylesheet.", "Read a token: var(--ml-text), var(--ml-primary-text), var(--ml-surface)… (get_tokens). The theme then keeps its contrast in every mode.", "color", colorsOf(own));
367
487
  }
368
488
  if (overridden.length) {
369
- note("error", where, `${overridden.join(", ")} is a theme token; setting it here overrides the theme.`, "Pick a theme, or change the theme's spec (mlola.theme.json), instead of redefining its tokens.");
489
+ pointAt(body.indexOf(overridden[0]));
490
+ note("error", where, `${overridden.join(", ")} is a theme token; setting it here overrides the theme.`, "Pick a theme, or change the theme's spec (mlola.theme.json), instead of redefining its tokens.", "theme-token");
370
491
  }
371
492
  const spacing = handSpacing(body);
372
- if (spacing.length) note("warning", where, `Spacing is written by hand (${spacing.join("; ")}).`, SPACING_FIX);
493
+ if (spacing.length) pointAt(body.search(new RegExp(`(?:^|[;{\\s])${spacing[0].slice(0, spacing[0].indexOf(":"))}\\s*:`)) + 1);
494
+ if (spacing.length) note("warning", where, `Spacing is written by hand (${spacing.join("; ")}).`, SPACING_FIX, "spacing", lengthsOf(spacing));
373
495
  // Faded text: its contrast now depends on the theme behind it.
374
496
  // opacity: 0 hides; only a value between 0 and 1 fades what is still read.
375
497
  const opacity = /(?:^|;)\s*opacity\s*:\s*(0?\.\d+)\s*(?:;|$)/.exec(body);
376
498
  // A shape (an SVG area, line or mark, painted with fill or stroke) holds no text to fade.
377
499
  const shape = /(?:^|;)\s*(?:fill|stroke)\s*:/.test(body) || /\b(?:path|rect|circle|ellipse|line|polyline|polygon|svg)\b/.test(selector);
378
500
  if (opacity && Number(opacity[1]) > 0 && !shape && !/disabled|:empty|::?placeholder|\[hidden\]|inert/.test(selector)) {
379
- note("warning", where, `opacity: ${opacity[1]} fades whatever text is inside, and how far it falls below the contrast floor depends on the theme and the fill behind it.`, "For quieter text use color: var(--ml-text-muted); on a filled control, its -foreground role. Keep opacity for disabled or decorative parts.");
501
+ pointAt(opacity.index + opacity[0].indexOf("opacity"));
502
+ note("warning", where, `opacity: ${opacity[1]} fades whatever text is inside, and how far it falls below the contrast floor depends on the theme and the fill behind it.`, "For quieter text use color: var(--ml-text-muted); on a filled control, its -foreground role. Keep opacity for disabled or decorative parts.", "opacity");
380
503
  }
381
504
  }
382
505
  }
package/src/mcp.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import readline from "node:readline";
4
+ import { projectContract, projectThemes } from "./check.js";
4
5
  import { checkMarkup, describeItem, designData, designGuide, searchItems, suggest, themes, tokens } from "./knowledge.js";
5
6
 
6
7
  /**
@@ -45,7 +46,7 @@ export function noBuildSetup(version) {
45
46
  }
46
47
 
47
48
  /** Tools that only read the registry: the local and the remote server share them. */
48
- function readTools({ cwd, version }) {
49
+ function readTools({ cwd, version, project = false }) {
49
50
  return [
50
51
  {
51
52
  name: "get_design_rules",
@@ -104,11 +105,12 @@ function readTools({ cwd, version }) {
104
105
  {
105
106
  name: "check_markup",
106
107
  title: "Check markup against the Mlola contract",
107
- description: "Checks HTML or JSX: classes that do not exist, variant classes, data-* values a class does not react to, utility classes, misuse of data-theme or data-mode, and in style attributes and <style> blocks hand-written colors, spacing written by hand instead of the --ml-space-* scale, overridden theme tokens and text faded with opacity. Run it on markup you wrote before finishing: pass the whole page or file, <style> blocks included, not an excerpt, since it only checks what it is given.",
108
+ description: "Checks HTML or JSX: classes that do not exist, variant classes, data-* values a class does not react to, utility classes, misuse of data-theme or data-mode, and in style attributes, <style> blocks and utility classes (bg-[#fafafa]) hand-written colors, spacing written by hand instead of the --ml-space-* scale, overridden theme tokens and text faded with opacity. Each issue names its line. Run it on markup you wrote before finishing: pass the whole page or file, <style> blocks included, not an excerpt, since it only checks what it is given.",
108
109
  inputSchema: { type: "object", properties: { markup: { type: "string" } }, required: ["markup"], additionalProperties: false },
109
110
  annotations: { readOnlyHint: true },
110
111
  handler: ({ markup }) => {
111
- const issues = checkMarkup(markup);
112
+ // The local server knows the project: its own theme is a theme, and the Pro items it installed exist.
113
+ const issues = checkMarkup(markup, project ? { themes: projectThemes(cwd), contract: projectContract(cwd) } : {});
112
114
  return text(issues.length ? issues : "No issues: every Mlola class exists and every data-* value is one its element reacts to.");
113
115
  },
114
116
  },
@@ -125,7 +127,7 @@ function tools({ cwd, run, version }) {
125
127
  return { code, output: lines.join("\n") };
126
128
  };
127
129
  return [
128
- ...readTools({ cwd, version }),
130
+ ...readTools({ cwd, version, project: true }),
129
131
  {
130
132
  name: "add_components",
131
133
  title: "Install Mlola components",