mlola-ui 1.0.4 → 1.0.6

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.
@@ -1,54 +1,5 @@
1
1
  /**
2
- * Where a floating layer goes next to its anchor, as a pure function of
3
- * rectangles: prefer the requested side, flip when it does not fit, and shift
4
- * along the edge to stay inside the viewport. No DOM, so it is testable.
2
+ * Where a floating layer goes next to its anchor. The math is shared with the
3
+ * framework-free runtime, so it lives in @mlola-ui/behavior/logic.
5
4
  */
6
- export type Side = "top" | "bottom" | "left" | "right";
7
- export type Align = "start" | "center" | "end";
8
-
9
- export interface Rect {
10
- x: number;
11
- y: number;
12
- width: number;
13
- height: number;
14
- }
15
-
16
- export interface Placement {
17
- x: number;
18
- y: number;
19
- side: Side;
20
- }
21
-
22
- const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
23
-
24
- export function placeFloating(
25
- anchor: Rect,
26
- floating: { width: number; height: number },
27
- viewport: { width: number; height: number },
28
- { side = "bottom", align = "center", offset = 8, padding = 8 }: { side?: Side; align?: Align; offset?: number; padding?: number } = {},
29
- ): Placement {
30
- const room: Record<Side, number> = {
31
- top: anchor.y - padding,
32
- bottom: viewport.height - (anchor.y + anchor.height) - padding,
33
- left: anchor.x - padding,
34
- right: viewport.width - (anchor.x + anchor.width) - padding,
35
- };
36
- const needs = (candidate: Side) => (candidate === "top" || candidate === "bottom" ? floating.height : floating.width) + offset;
37
- let chosen = side;
38
- if (room[side] < needs(side) && room[OPPOSITE[side]] > room[side]) chosen = OPPOSITE[side];
39
-
40
- const vertical = chosen === "top" || chosen === "bottom";
41
- let x: number;
42
- let y: number;
43
- if (vertical) {
44
- y = chosen === "bottom" ? anchor.y + anchor.height + offset : anchor.y - floating.height - offset;
45
- x = align === "start" ? anchor.x : align === "end" ? anchor.x + anchor.width - floating.width : anchor.x + anchor.width / 2 - floating.width / 2;
46
- } else {
47
- x = chosen === "right" ? anchor.x + anchor.width + offset : anchor.x - floating.width - offset;
48
- y = align === "start" ? anchor.y : align === "end" ? anchor.y + anchor.height - floating.height : anchor.y + anchor.height / 2 - floating.height / 2;
49
- }
50
- // Shift along the edge to stay on screen.
51
- x = Math.min(Math.max(x, padding), Math.max(padding, viewport.width - floating.width - padding));
52
- y = Math.min(Math.max(y, padding), Math.max(padding, viewport.height - floating.height - padding));
53
- return { x: Math.round(x), y: Math.round(y), side: chosen };
54
- }
5
+ export { placeFloating, type Align, type Placement, type Rect, type Side } from "@mlola-ui/behavior/logic";
@@ -44,6 +44,24 @@ function hideBackground(portal: HTMLElement) {
44
44
  };
45
45
  }
46
46
 
47
+ // Safari does not focus a button it clicks, so when a dialog opens with
48
+ // nothing focused, focus returns on close to the control just pressed.
49
+ let lastPressed: HTMLElement | null = null;
50
+ let pressedAt = 0;
51
+ let tracking = false;
52
+ function trackPresses() {
53
+ if (tracking) return;
54
+ tracking = true;
55
+ document.addEventListener(
56
+ "pointerdown",
57
+ (event) => {
58
+ lastPressed = event.target instanceof Element ? event.target.closest<HTMLElement>(FOCUSABLE) : null;
59
+ pressedAt = performance.now();
60
+ },
61
+ true,
62
+ );
63
+ }
64
+
47
65
  export function useDialogLayer({
48
66
  open,
49
67
  onClose,
@@ -61,6 +79,7 @@ export function useDialogLayer({
61
79
  closeRef.current = onClose;
62
80
 
63
81
  React.useEffect(() => {
82
+ trackPresses();
64
83
  const node = document.createElement("div");
65
84
  node.setAttribute("data-ml-portal", "");
66
85
  document.body.appendChild(node);
@@ -73,8 +92,13 @@ export function useDialogLayer({
73
92
 
74
93
  React.useEffect(() => {
75
94
  if (!open || !portal) return;
95
+ const active = document.activeElement;
76
96
  restoreFocusRef.current =
77
- document.activeElement instanceof HTMLElement ? document.activeElement : null;
97
+ active instanceof HTMLElement && active !== document.body
98
+ ? active
99
+ : lastPressed?.isConnected && performance.now() - pressedAt < 2000
100
+ ? lastPressed
101
+ : null;
78
102
  const releaseScroll = lockScroll();
79
103
  const restoreBackground = hideBackground(portal);
80
104
  const focusPanel = () => {
@@ -47,7 +47,12 @@ const AvatarGroup = React.forwardRef<HTMLDivElement, AvatarGroupProps>(
47
47
  return (
48
48
  <div ref={ref} role="group" aria-label={`Avatar group, ${items.length} people`} className={cx("ml-avatar-group", className)} {...props}>
49
49
  {visible.map((child, index) => <span key={index} className="ml-avatar-group-item">{child}</span>)}
50
- {overflow ? <span aria-label={`${overflow} more people`} className="ml-avatar-overflow">+{overflow}</span> : null}
50
+ {overflow ? (
51
+ <span className="ml-avatar-overflow">
52
+ <span aria-hidden="true">+{overflow}</span>
53
+ <span className="ml-visually-hidden">{`${overflow} more ${overflow === 1 ? "person" : "people"}`}</span>
54
+ </span>
55
+ ) : null}
51
56
  </div>
52
57
  );
53
58
  }
@@ -257,7 +257,8 @@ export function Calendar(props: CalendarProps) {
257
257
  ))}
258
258
  </div>
259
259
  {monthGrid(first, weekStart).map((week) => (
260
- <div key={week[0].date} role="row" className="ml-calendar-row">
260
+ // A week drawn only to keep months aligned has no day to read, so it is hidden whole.
261
+ <div key={week[0].date} role="row" className="ml-calendar-row" aria-hidden={(week.every(({ inMonth }) => !inMonth) && (!showOutsideDays || months > 1)) || undefined}>
261
262
  {week.map(({ date, inMonth }) => {
262
263
  if (!inMonth && (!showOutsideDays || months > 1)) return <span key={date} role="gridcell" className="ml-calendar-cell" aria-hidden="true" />;
263
264
  const selected = isSelected(date);
@@ -43,12 +43,14 @@ export function Shortcut({ keys, className, ...props }: ShortcutProps) {
43
43
  const shown = parts.map((key) => SYMBOLS[key]?.[apple ? "apple" : "other"] ?? key.toUpperCase());
44
44
  const spoken = parts.map((key) => (apple ? SYMBOLS[key]?.spoken : SYMBOLS[key]?.spokenOther ?? SYMBOLS[key]?.spoken) ?? key.toUpperCase()).join(" ");
45
45
  return (
46
- <kbd className={cx("ml-shortcut", className)} aria-label={spoken} {...props}>
46
+ <kbd className={cx("ml-shortcut", className)} {...props}>
47
47
  {shown.map((key, index) => (
48
48
  <kbd key={index} className="ml-kbd" aria-hidden="true">
49
49
  {key}
50
50
  </kbd>
51
51
  ))}
52
+ {/* A kbd cannot carry a label, so the words are text a screen reader reads. */}
53
+ <span className="ml-visually-hidden">{spoken}</span>
52
54
  </kbd>
53
55
  );
54
56
  }
@@ -12,6 +12,8 @@ export interface ModalProps {
12
12
  open: boolean;
13
13
  onClose: () => void;
14
14
  size?: ModalSize;
15
+ /** "alertdialog" for a confirmation that interrupts: it is announced at once, and the backdrop does not dismiss it. */
16
+ role?: "dialog" | "alertdialog";
15
17
  closeOnBackdrop?: boolean;
16
18
  closeOnEscape?: boolean;
17
19
  label?: string;
@@ -24,7 +26,8 @@ export function Modal({
24
26
  open,
25
27
  onClose,
26
28
  size = "md",
27
- closeOnBackdrop = true,
29
+ role = "dialog",
30
+ closeOnBackdrop = role !== "alertdialog",
28
31
  closeOnEscape = true,
29
32
  label = "Dialog",
30
33
  description,
@@ -45,7 +48,7 @@ export function Modal({
45
48
  >
46
49
  <div
47
50
  ref={panelRef}
48
- role="dialog"
51
+ role={role}
49
52
  aria-modal="true"
50
53
  aria-label={label}
51
54
  aria-describedby={description ? descriptionId : undefined}
@@ -134,7 +134,8 @@ export function Resizable({
134
134
  aria-valuemin={collapsible ? 0 : min}
135
135
  aria-valuemax={max}
136
136
  aria-valuenow={Math.round(shown)}
137
- aria-expanded={collapsible ? !isFolded : undefined}
137
+ // A separator has no expanded state: folded is its value at 0.
138
+ aria-valuetext={isFolded ? "Collapsed" : undefined}
138
139
  className="ml-resizable-handle"
139
140
  onPointerDown={(event) => {
140
141
  dragging.current = true;
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { cx } from "../_internal/react";
4
+ import { composeRefs, cx } from "../_internal/react";
5
5
 
6
6
  type TableSize = "sm" | "md";
7
7
  type TableAlign = "left" | "center" | "right";
@@ -9,11 +9,33 @@ export type SortedState = "asc" | "desc" | false;
9
9
  interface Sortable { sorted: SortedState; onToggle: () => void; label?: string; }
10
10
  interface TableProps extends React.HTMLAttributes<HTMLDivElement> { size?: TableSize; striped?: boolean; }
11
11
  const Table = React.forwardRef<HTMLDivElement, TableProps>(
12
- ({ size = "md", striped = false, className, children, ...props }, ref) => (
13
- <div ref={ref} className={cx("ml-table-container", className)} {...props}>
14
- <table data-size={size} data-striped={striped ? "" : undefined} className="ml-table">{children}</table>
15
- </div>
16
- )
12
+ ({ size = "md", striped = false, className, children, ...props }, ref) => {
13
+ // A table wider than its container scrolls sideways, and then the keyboard must reach it too.
14
+ const container = React.useRef<HTMLDivElement | null>(null);
15
+ const [scrolls, setScrolls] = React.useState(false);
16
+ React.useEffect(() => {
17
+ const element = container.current;
18
+ if (!element || typeof ResizeObserver === "undefined") return;
19
+ const measure = () => setScrolls(element.scrollWidth > element.clientWidth + 1);
20
+ const observer = new ResizeObserver(measure);
21
+ observer.observe(element);
22
+ if (element.firstElementChild) observer.observe(element.firstElementChild);
23
+ measure();
24
+ return () => observer.disconnect();
25
+ }, []);
26
+ const named = Boolean(props["aria-label"] || props["aria-labelledby"]);
27
+ return (
28
+ <div
29
+ ref={composeRefs(ref, container)}
30
+ className={cx("ml-table-container", className)}
31
+ tabIndex={scrolls ? 0 : undefined}
32
+ role={scrolls && named ? "region" : undefined}
33
+ {...props}
34
+ >
35
+ <table data-size={size} data-striped={striped ? "" : undefined} className="ml-table">{children}</table>
36
+ </div>
37
+ );
38
+ }
17
39
  );
18
40
  Table.displayName = "Table";
19
41
  const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
@@ -181,6 +181,7 @@ export const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(functi
181
181
  ref={control}
182
182
  className="ml-tag-input-control"
183
183
  data-disabled={disabled || undefined}
184
+ aria-disabled={disabled || undefined}
184
185
  data-invalid={message ? "" : undefined}
185
186
  onMouseDown={(event) => {
186
187
  // Clicking the field's empty space puts the caret in the input.
@@ -1,6 +1,6 @@
1
1
  {
2
- "packages/components/_internal/anchor.ts": "sha256-Jo9N3iIaWnS1CJzYhgTv7vnW+8MLyzL8X4GRNjeEp+g=",
3
- "packages/components/_internal/dialog.ts": "sha256-Q7DPI2BipgFjPd3T8g2GGicrVal0cp0eMKXWFqcj64k=",
2
+ "packages/components/_internal/anchor.ts": "sha256-OPm/4Jf4UUo8IG+/4TAKpGDHtYpvxvGwyq3/RSZQIGc=",
3
+ "packages/components/_internal/dialog.ts": "sha256-796y3oNOVWarLvICTdi+d0/2axqSrzLVJiyBeh9X7bU=",
4
4
  "packages/components/_internal/floating.ts": "sha256-+WxgC4CtAlSr2qA1EkoDTgto535XLIPsvtOfdOK6CHk=",
5
5
  "packages/components/_internal/match.ts": "sha256-nKBpKWeow5ubAHUhOW8yOjlrKs0lqYdjbaYYfE8VoII=",
6
6
  "packages/components/_internal/react.ts": "sha256-SH63/haiB4Q93uFjMIMe5xZbaxENu3VI+GcidV8HELM=",
@@ -10,11 +10,11 @@
10
10
  "packages/components/activity-heatmap/calendar.ts": "sha256-NB+AjXY5Xc+KzR8MMTRBgZxdzOeoHhkW0KQOW7WJPuQ=",
11
11
  "packages/components/alert/alert.tsx": "sha256-j680akXvCIh4MTVnUo79acvVrv51XyQ3UBJ5FBAy8Dw=",
12
12
  "packages/components/app-shell/app-shell.tsx": "sha256-XAq82XOTH3ZR+hxTkYFu8P7Ih2aI6ERy/TpXc2VMUwM=",
13
- "packages/components/avatar/avatar.tsx": "sha256-hvZUGhatN95ZyX6p6NBhTRf6h1/rrJ+bJrqBMtIspq0=",
13
+ "packages/components/avatar/avatar.tsx": "sha256-EjqC8ytKqRtsTVdK6vncaDfhO/i7ICe+jwaYCg5bjIw=",
14
14
  "packages/components/badge/badge.tsx": "sha256-flEH8S2jZ/64n3Y/ROhC9+9rvF7wXaN0xLcMMwqNOVQ=",
15
15
  "packages/components/breadcrumb/breadcrumb.tsx": "sha256-Mt9mvmwwDgdp5/+mj5byIrh8+hicsp7R6UcvUlK0yog=",
16
16
  "packages/components/button/button.tsx": "sha256-khqYxcV3hlZ+Q+cYNTRXEpAfIijDFSvkuDJXMPtlk7g=",
17
- "packages/components/calendar/calendar.tsx": "sha256-uE22LI4jpU8ErR/oBmAkeKiu+Rp0bz3IXZVCurycpwc=",
17
+ "packages/components/calendar/calendar.tsx": "sha256-rWiS4YAueZfVoo6j5Fi3uN7A+yApe5U6HMm1woTVjys=",
18
18
  "packages/components/calendar/dates.ts": "sha256-mygYkoC70av2ZQ0vkxf885trwW97Wd10I4dptYQe+4A=",
19
19
  "packages/components/card/card.tsx": "sha256-aMKbv5DWYYWq7GZAFR9O1fuYE0H61WCjkvEbRMOd894=",
20
20
  "packages/components/carousel/carousel.tsx": "sha256-pamugpB6Ujt9Ylxd37jNF/ZwSb8iRkW/MoYe12nY3i4=",
@@ -30,8 +30,8 @@
30
30
  "packages/components/empty-state/empty-state.tsx": "sha256-uCKqYpr9vkozXZHpdl4GFtdX9ijcGOriCHvgyarBqRw=",
31
31
  "packages/components/hover-card/hover-card.tsx": "sha256-AyRbxfqDOYBgGlgjgrtQZm5DipArLEzQn6XnzjPd0ro=",
32
32
  "packages/components/input/input.tsx": "sha256-lbbheo+ydQTzyujTf0W7DD96TogOjifCyqj9c3h3W0g=",
33
- "packages/components/kbd/kbd.tsx": "sha256-wrPqzn1e82zJYoKzO7u94Bgq4+nPc0DXLc8GJ/4/3nY=",
34
- "packages/components/modal/modal.tsx": "sha256-vybxgBGYCrMYvJRnO1wDAc2VFQylu+mQIf5OZIcabX4=",
33
+ "packages/components/kbd/kbd.tsx": "sha256-e8EO2Gw6JiUyrU0YcEZOaQNMel1pe/EcJrxwwgd1Ggk=",
34
+ "packages/components/modal/modal.tsx": "sha256-rIytVY+hz8aabJ53UGQ0VADgSlW0sAG920iMQaoak+0=",
35
35
  "packages/components/number-input/number-input.tsx": "sha256-BDOyvCNtMULD/HK7uWHsYbGg79lrRDrVqtGQQrYHpmM=",
36
36
  "packages/components/number-input/number.ts": "sha256-IrHCQrEhI5ein9ifMFuSViSoxQUBoT3tNmmCu/paGGM=",
37
37
  "packages/components/otp-input/otp-input.tsx": "sha256-QURn8g5HqCQXBTdeENPbVxoPlqs3ZtoAPLRlFjQDxmI=",
@@ -42,7 +42,7 @@
42
42
  "packages/components/progress/progress.tsx": "sha256-9OjecRNPWn7Bfe+C5IyXaKsW1NJae6X5gco1o0X5XkE=",
43
43
  "packages/components/radio-group/radio-group.tsx": "sha256-jhnSEBLkdpe8dv5us/Q1sMsQF4sKGRgDXWGKm2JSFew=",
44
44
  "packages/components/rating/rating.tsx": "sha256-zgPOQXzAiRE4qGNPQ8qat3SP1XSI/7567kA5VaT51P0=",
45
- "packages/components/resizable/resizable.tsx": "sha256-40sK2UyIR9Ek72KAu5nvbr44tKEY6JhC5Ftb++Wf6cI=",
45
+ "packages/components/resizable/resizable.tsx": "sha256-Yxlq88qElhv2Q5DooPdriZkawRS0QWx08nd6ybz5oeY=",
46
46
  "packages/components/segmented-control/segmented-control.tsx": "sha256-MCBG5XHFWCwbACctsYKbqe8mvSSR6pf60tyRgdCDKDA=",
47
47
  "packages/components/select/select.tsx": "sha256-m8QmKUGC4386XcklosSntal0XwUAFjKosTBQSF0dduQ=",
48
48
  "packages/components/sheet/sheet.tsx": "sha256-EIn3OeRDmXGHbYf/uihRh8aw3LmzarIFiyC9DUsL1jg=",
@@ -51,9 +51,9 @@
51
51
  "packages/components/status-icon/labels.ts": "sha256-EE8q+geQBbJUs7I3RmaHZnPwn7Vjk2GjCfxvI4ruXKM=",
52
52
  "packages/components/status-icon/status-icon.tsx": "sha256-TtrTeoePyfH1VhKuWC0v12FuwfgND1CHnH5x8fyf4AM=",
53
53
  "packages/components/stepper/stepper.tsx": "sha256-uI/dRmkbijlixHiGUceWcpaDzpEixrzQ1UTmoPbGZAY=",
54
- "packages/components/table/table.tsx": "sha256-ed1Qn2sIpxRvGdPDAb5ih5AcXr5PmHTHdlrJLYDYRp8=",
54
+ "packages/components/table/table.tsx": "sha256-xJlWzCH76Lrgg/HrlnVTHkG5dfCkbzjWXEdTVG5Jk4w=",
55
55
  "packages/components/tabs/tabs.tsx": "sha256-kWAQjE9mshqZL7V3NJTUiurp6M5Nu3vx5O1LsIgdV5Y=",
56
- "packages/components/tag-input/tag-input.tsx": "sha256-EK7xJTe3Y/S5Kn3vS0zBQg9lC+RVoGvky6HOAt+DnAw=",
56
+ "packages/components/tag-input/tag-input.tsx": "sha256-lc4ofz1/lUnb7B/GlFElVKXLbPwtJFAjE5vWwAY99fA=",
57
57
  "packages/components/textarea/textarea.tsx": "sha256-C9jMYZ63w+m6bgdLvHjvULRuoCe26q5zbgS4+ahWzr8=",
58
58
  "packages/components/time-picker/time-picker.tsx": "sha256-og8Cbf+e8oFYewfagUgjRzLHSIIlAZh+1OpAn9yNq70=",
59
59
  "packages/components/time-picker/times.ts": "sha256-V/4uqFZPn9gVnyx8cB9vwp/RocmTYaPmLxjfy8DPQOI=",
package/src/assets.js CHANGED
@@ -10,6 +10,9 @@ import { BUNDLED_REGISTRY_ROOT, readJson } from "./registry.js";
10
10
  * Their files are not bundled with the CLI: a project wants three of them,
11
11
  * not every model. The bundled index lists each file with its integrity; the
12
12
  * files download from the site and are checked before anything is written.
13
+ * Each request names the integrity it expects, and the site answers with
14
+ * exactly those bytes, kept for every released version: an asset redrawn
15
+ * after this CLI was published still installs the version this CLI lists.
13
16
  *
14
17
  * Static files (SVG, GLB, posters) land in the `assets` target, public/mlola
15
18
  * by default, so a web app serves them as /mlola/2d/… and /mlola/3d/…. A 2D
@@ -43,7 +46,7 @@ export async function addAssets(cwd, config, ids, { source, fetcher, overwrite =
43
46
  for (const id of ids) {
44
47
  const item = known.get(id);
45
48
  for (const file of item.files) {
46
- const url = `${source}/${item.kind}/${item.id}/${file.path}`;
49
+ const url = `${source}/${item.kind}/${item.id}/${file.path}?integrity=${encodeURIComponent(file.integrity)}`;
47
50
  let response;
48
51
  try {
49
52
  response = await fetcher(url);
@@ -52,7 +55,9 @@ export async function addAssets(cwd, config, ids, { source, fetcher, overwrite =
52
55
  }
53
56
  if (!response.ok) throw new Error(`Could not download ${url}: the server answered ${response.status}.`);
54
57
  const bytes = Buffer.from(await response.arrayBuffer());
55
- if (integrityOf(bytes) !== file.integrity) throw new Error(`${item.id}/${file.path} failed integrity validation; nothing was written.`);
58
+ if (integrityOf(bytes) !== file.integrity) {
59
+ throw new Error(`${item.id}/${file.path} failed integrity validation; nothing was written. The server has a different version of this file: update the CLI (npx mlola-ui@latest) and try again.`);
60
+ }
56
61
  planned.push({ item, file, bytes, relative: assetTarget(config, item, file) });
57
62
  }
58
63
  }
package/src/knowledge.js CHANGED
@@ -117,12 +117,25 @@ export function describeItem(name, { cwd = process.cwd(), includeSource = false
117
117
  category: item.category,
118
118
  variants: item.variants ?? [],
119
119
  sizes: item.sizes ?? [],
120
+ // Every prop with a fixed set of values, read from the TypeScript source.
121
+ options: item.options ?? [],
120
122
  usage,
121
123
  install: `npx mlola-ui add ${name}`,
122
124
  dependsOn: item.registryDependencies ?? [],
123
125
  engineDependencies: item.engineDependencies ?? {},
124
- elements: elementsOf(name),
126
+ // Every class the component's stylesheet defines, when its example lists them.
127
+ elements: bundled("examples.json")?.[name]?.elements ?? elementsOf(name),
125
128
  };
129
+ // The same example the docs show: React, the HTML it renders, and the
130
+ // framework-free markup when @mlola-ui/behavior drives it.
131
+ const example = bundled("examples.json")?.[name];
132
+ if (example) {
133
+ result.example = {
134
+ react: example.react,
135
+ html: example.behavior?.html ?? example.html,
136
+ script: example.needs === "behavior" ? `@mlola-ui/behavior, data-ml="${example.behavior.name}"` : example.needs === "nothing" ? "none" : "React, or your own script setting the attributes",
137
+ };
138
+ }
126
139
  if (includeSource) {
127
140
  result.source = (item.files ?? [])
128
141
  .filter((file) => !file.path.includes("/_internal/"))
@@ -159,6 +172,63 @@ const SHARED = new Set(["data-tone", "data-variant", "data-size", "data-state",
159
172
  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:)/;
160
173
  const COLOR = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i;
161
174
 
175
+ /** Every opening tag in HTML or JSX, with its string-valued attributes. */
176
+ function tagsOf(markup) {
177
+ const tags = [];
178
+ for (const match of markup.matchAll(/<([A-Za-z][\w.-]*)((?:\s+(?:[^\s"'>={}]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}))?))*)\s*\/?>/g)) {
179
+ const attributes = new Map();
180
+ for (const attribute of match[2].matchAll(/([^\s"'>={}]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\{(?:[^{}]|\{[^{}]*\})*\})))?/g)) {
181
+ const [, name, double, single, expression] = attribute;
182
+ const value = double ?? single ?? (expression ? /^\{\s*["'`]([^"'`$]*)["'`]\s*\}$/.exec(expression)?.[1] ?? null : "");
183
+ attributes.set(name, value);
184
+ if (expression) attributes.set(`${name}:raw`, expression);
185
+ }
186
+ const classValue = attributes.get("class") ?? attributes.get("className");
187
+ tags.push({ tag: match[0], attributes, classes: typeof classValue === "string" ? classValue.split(/\s+/).filter(Boolean) : [] });
188
+ }
189
+ return tags;
190
+ }
191
+
192
+ let knownValues = null;
193
+ /**
194
+ * The values each element may carry on each shared attribute: the ones its
195
+ * stylesheet draws, the ones its component's props declare, and the ones the
196
+ * component renders in its example. A default such as data-size="md" is
197
+ * rendered but not drawn, and is as correct as any other.
198
+ */
199
+ function valuesByElement() {
200
+ if (knownValues) return knownValues;
201
+ knownValues = new Map();
202
+ const add = (cls, attribute, values) => {
203
+ if (!knownValues.has(cls)) knownValues.set(cls, new Map());
204
+ const byAttribute = knownValues.get(cls);
205
+ if (!byAttribute.has(attribute)) byAttribute.set(attribute, new Set());
206
+ for (const value of values) byAttribute.get(attribute).add(value);
207
+ };
208
+ for (const [cls, attributes] of Object.entries(bundled("contract.json")?.elements ?? {})) {
209
+ for (const [attribute, values] of Object.entries(attributes)) add(cls, attribute, values);
210
+ }
211
+ const examples = bundled("examples.json") ?? {};
212
+ for (const item of loadRegistry().items) {
213
+ const classes = examples[item.name]?.elements?.map((element) => element.class) ?? [];
214
+ for (const option of item.options ?? []) {
215
+ for (const cls of classes) add(cls, `data-${option.prop}`, option.values);
216
+ }
217
+ }
218
+ for (const example of Object.values(examples)) {
219
+ for (const html of [example.html, example.behavior?.html]) {
220
+ if (!html) continue;
221
+ for (const { attributes, classes } of tagsOf(html)) {
222
+ for (const [name, value] of attributes) {
223
+ if (!SHARED.has(name) || typeof value !== "string") continue;
224
+ for (const cls of classes) if (cls.startsWith("ml-")) add(cls, name, [value]);
225
+ }
226
+ }
227
+ }
228
+ }
229
+ return knownValues;
230
+ }
231
+
162
232
  /**
163
233
  * Checks HTML or JSX against the element contract. It reads string-valued
164
234
  * class, className, style and data-* attributes; expressions in braces are
@@ -169,20 +239,12 @@ export function checkMarkup(markup) {
169
239
  const known = new Set(contract?.classes ?? []);
170
240
  const proPrefixes = (bundled("catalog.json")?.items ?? []).map((item) => `ml-${item.name}`);
171
241
  const themeIds = new Set(themes().map((theme) => theme.id));
242
+ const tokenNames = new Set((designData()?.tokens ?? []).flatMap((group) => group.names));
243
+ const values = valuesByElement();
172
244
  const issues = [];
173
245
  const note = (severity, tag, message, fix) => issues.push({ severity, element: tag.slice(0, 120), message, ...(fix ? { fix } : {}) });
174
246
 
175
- for (const match of markup.matchAll(/<([A-Za-z][\w.-]*)((?:\s+(?:[^\s"'>={}]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}))?))*)\s*\/?>/g)) {
176
- const tag = match[0];
177
- const attributes = new Map();
178
- for (const attribute of match[2].matchAll(/([^\s"'>={}]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\{(?:[^{}]|\{[^{}]*\})*\})))?/g)) {
179
- const [, name, double, single, expression] = attribute;
180
- const value = double ?? single ?? (expression ? /^\{\s*["'`]([^"'`$]*)["'`]\s*\}$/.exec(expression)?.[1] ?? null : "");
181
- attributes.set(name, value);
182
- if (expression) attributes.set(`${name}:raw`, expression);
183
- }
184
- const classValue = attributes.get("class") ?? attributes.get("className");
185
- const classes = typeof classValue === "string" ? classValue.split(/\s+/).filter(Boolean) : [];
247
+ for (const { tag, attributes, classes } of tagsOf(markup)) {
186
248
  const mlola = classes.filter((cls) => cls.startsWith("ml-"));
187
249
 
188
250
  for (const cls of mlola) {
@@ -210,25 +272,27 @@ export function checkMarkup(markup) {
210
272
  }
211
273
  if (!SHARED.has(name) || !mlola.length) continue;
212
274
  const allowed = new Set();
213
- let reacts = false;
214
- for (const cls of mlola) {
215
- const values = contract?.elements?.[cls]?.[name];
216
- if (values) {
217
- reacts = true;
218
- for (const entry of values) allowed.add(entry);
219
- }
220
- }
221
- if (reacts && allowed.size && !allowed.has(value)) {
222
- note("error", tag, `${name}="${value}" is not a value ${mlola.join(" ")} reacts to.`, `Allowed: ${[...allowed].sort().join(", ")}.`);
223
- } else if (!reacts && mlola.every((cls) => known.has(cls))) {
275
+ for (const cls of mlola) for (const entry of values.get(cls)?.get(name) ?? []) allowed.add(entry);
276
+ if (allowed.size && !allowed.has(value)) {
277
+ note("error", tag, `${name}="${value}" is not a value ${mlola.join(" ")} takes.`, `Allowed: ${[...allowed].sort().join(", ")}.`);
278
+ } else if (!allowed.size && mlola.every((cls) => known.has(cls))) {
224
279
  note("warning", tag, `${name} has no effect on ${mlola.join(" ")}.`, `See get_component for the attributes it reacts to.`);
225
280
  }
226
281
  }
227
282
 
228
- // A style string, or a JSX style object, with a color written into it.
283
+ // A color written by hand in a style string or a JSX style object. A
284
+ // component's own custom property carries data (a swatch's color); a
285
+ // theme token set inline overrides the theme and is flagged too.
229
286
  const style = attributes.get("style") ?? attributes.get("style:raw");
230
- if (typeof style === "string" && COLOR.test(style)) {
231
- 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).");
287
+ if (typeof style === "string") {
288
+ const overridden = [...style.matchAll(/(--ml-[\w-]+)\s*:/g)].map((match) => match[1]).filter((token) => tokenNames.has(token));
289
+ const declarations = style.replace(/--[\w-]+\s*:[^;,}]*/g, "");
290
+ if (COLOR.test(declarations)) {
291
+ 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).");
292
+ }
293
+ if (overridden.length) {
294
+ 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.");
295
+ }
232
296
  }
233
297
  }
234
298
  return issues;
package/src/mcp.js CHANGED
@@ -66,7 +66,7 @@ function tools({ cwd, run }) {
66
66
  {
67
67
  name: "get_component",
68
68
  title: "How to use a Mlola component",
69
- description: "Everything needed to use one item: import, variants, sizes, the classes it styles and the data-* values each reacts to, dependencies and the install command. Set include_source to read a free component's source.",
69
+ description: "Everything needed to use one item: import, every prop with fixed values (variant, tone, size, side…) and its default, the classes it styles and the data-* values each reacts to, dependencies and the install command. Set include_source to read a free component's source.",
70
70
  inputSchema: {
71
71
  type: "object",
72
72
  properties: { name: { type: "string" }, include_source: { type: "boolean", default: false } },