@godxjp/ui 27.3.1 → 27.5.0

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.
Files changed (53) hide show
  1. package/dist/components/data-entry/upload-file-kind.d.ts +25 -0
  2. package/dist/components/data-entry/upload-file-kind.js +88 -0
  3. package/dist/components/data-entry/upload.d.ts +2 -2
  4. package/dist/components/data-entry/upload.js +10 -6
  5. package/dist/components/general/icon.d.ts +31 -0
  6. package/dist/components/general/icon.js +23 -0
  7. package/dist/components/general/index.d.ts +2 -0
  8. package/dist/components/general/index.js +2 -0
  9. package/dist/components/layout/flex.d.ts +2 -2
  10. package/dist/components/layout/flex.js +7 -1
  11. package/dist/components/layout/topbar-item.d.ts +1 -0
  12. package/dist/components/layout/topbar-item.js +3 -1
  13. package/dist/components/ui/avatar.d.ts +1 -1
  14. package/dist/components/ui/avatar.js +2 -0
  15. package/dist/components/ui/segmented.d.ts +7 -2
  16. package/dist/components/ui/toggle.d.ts +2 -2
  17. package/dist/components/ui/toggle.js +4 -0
  18. package/dist/contracts/measurement.json +1 -1
  19. package/dist/props/components/data-display.prop.d.ts +14 -0
  20. package/dist/props/components/data-entry.prop.d.ts +25 -0
  21. package/dist/props/components/general.prop.d.ts +42 -1
  22. package/dist/props/components/layout.prop.d.ts +42 -0
  23. package/dist/props/registry.d.ts +37 -1
  24. package/dist/props/registry.js +43 -1
  25. package/dist/props/vocabulary/index.d.ts +1 -1
  26. package/dist/props/vocabulary/interaction.prop.d.ts +11 -0
  27. package/dist/styles/control.css +12 -0
  28. package/dist/styles/core.css +2 -0
  29. package/dist/styles/data-display-layout.css +35 -5
  30. package/dist/styles/data-entry-layout.css +22 -0
  31. package/dist/styles/icon-layout.css +37 -0
  32. package/dist/styles/index.css +2 -0
  33. package/dist/styles/shell-layout.css +10 -0
  34. package/dist/styles/text-layout.css +16 -8
  35. package/dist/tokens/components/control.css +1 -0
  36. package/dist/tokens/components/data-display.css +26 -0
  37. package/dist/tokens/components/segmented.css +4 -0
  38. package/dist/tokens/components/upload.css +4 -0
  39. package/docs/CONSUMER-RULES.md +2 -2
  40. package/docs/CUSTOMER-THEMING.md +26 -0
  41. package/docs/FRAME-COVERAGE-REPORT.md +3 -2
  42. package/docs/data-display/avatar.tsx +128 -11
  43. package/docs/data-display/prose.tsx +64 -0
  44. package/docs/data-entry/segmented.tsx +55 -5
  45. package/docs/data-entry/toggle-group.tsx +15 -3
  46. package/docs/data-entry/toggle.tsx +39 -1
  47. package/docs/data-entry/upload.tsx +38 -0
  48. package/docs/general/icon.tsx +215 -0
  49. package/docs/layout/flex.tsx +59 -0
  50. package/docs/layout/topbar-item.tsx +29 -1
  51. package/package.json +2 -2
  52. package/scripts/consumer-rule.md +18 -17
  53. package/scripts/ui-audit.mjs +128 -0
@@ -0,0 +1,215 @@
1
+ import {
2
+ ArrowUpRight,
3
+ Check,
4
+ Download,
5
+ FileWarning,
6
+ Globe,
7
+ Lock,
8
+ ShieldCheck,
9
+ Smartphone,
10
+ Unlock,
11
+ } from "lucide-react";
12
+
13
+ import { Card, CardContent, CardHeader, CardTitle } from "@godxjp/ui/data-display";
14
+ import { Button, Heading, Icon, Text } from "@godxjp/ui/general";
15
+ import { Flex, PageContainer, Separator } from "@godxjp/ui/layout";
16
+
17
+ /**
18
+ * Icon · a glyph on the `--icon-size-*` scale, and the only supported way to draw a standalone one.
19
+ *
20
+ * A lucide component ships `width="24" height="24"`. Four rules in this library override that
21
+ * (`.ui-button svg`, the menu row, the topbar cell, the ListRow leading slot) and nothing else
22
+ * does · so a glyph in a `Text`, a table cell or an `<a>` draws at 24px beside 14px type, 1.7× the
23
+ * text it annotates. `docs/CONSUMER-RULES.md` §3/§8 forbid the `size-4` / `w-[16px]` escape, which
24
+ * left a consumer with 38 oversized glyphs and no supported fix (gh#712).
25
+ *
26
+ * `Icon` renders ONTO the glyph · the sized element is the `<svg>` itself · so it composes
27
+ * anywhere a bare glyph does and adds no box to the row. Decorative (`aria-hidden`) by default;
28
+ * `label` switches it to `role="img"` with that accessible name.
29
+ *
30
+ * Composed only from real @godxjp/ui components. All copy is consumer-owned and localized.
31
+ */
32
+
33
+ /** The nine steps, with the px each resolves to at a 16px root · docs/TOKENS.md. */
34
+ const SIZES = [
35
+ { size: "2xs", px: "10px", note: "アバターの在席ドット" },
36
+ { size: "xs", px: "12px", note: "表の並べ替えカーソル" },
37
+ { size: "sm", px: "14px", note: 'size="sm" のコントロール' },
38
+ { size: "md", px: "16px", note: "既定のアイコン段" },
39
+ { size: "lg", px: "20px", note: "アラート・ランチャー" },
40
+ { size: "xl", px: "24px", note: "アップロードタイル" },
41
+ { size: "2xl", px: "36px", note: "StatCard のメダリオン" },
42
+ { size: "3xl", px: "40px", note: "ドロップゾーン" },
43
+ { size: "4xl", px: "48px", note: "空状態の枠" },
44
+ ] as const;
45
+
46
+ /** `tone` shares Text's vocabulary · one table for the glyph and the label beside it. */
47
+ const TONES = [
48
+ { tone: "default", label: "default · 前景色" },
49
+ { tone: "muted", label: "muted · 補足" },
50
+ { tone: "primary", label: "primary · 主要導線" },
51
+ { tone: "success", label: "success · 有効" },
52
+ { tone: "warning", label: "warning · 要確認" },
53
+ { tone: "destructive", label: "destructive · 失敗" },
54
+ { tone: "info", label: "info · 情報" },
55
+ { tone: "inherit", label: "inherit · 面の色を継承" },
56
+ ] as const;
57
+
58
+ /** The real screen: a security settings list whose status is carried by a glyph beside 14px text. */
59
+ const SESSIONS = [
60
+ {
61
+ id: "s1",
62
+ device: "MacBook Pro · 東京",
63
+ detail: "2026-09-17 09:12 · 192.0.2.24",
64
+ secure: true,
65
+ },
66
+ {
67
+ id: "s2",
68
+ device: "iPhone 15 · 大阪",
69
+ detail: "2026-09-16 21:40 · 198.51.100.7",
70
+ secure: true,
71
+ },
72
+ {
73
+ id: "s3",
74
+ device: "Windows PC · 未確認の拠点",
75
+ detail: "2026-09-15 02:05 · 203.0.113.19",
76
+ secure: false,
77
+ },
78
+ ];
79
+
80
+ export default function Demo() {
81
+ return (
82
+ <PageContainer
83
+ title="Icon"
84
+ subtitle='--icon-size-* の9段にグリフを載せる唯一の方法 · 既定は装飾(aria-hidden)· label で role="img"'
85
+ >
86
+ <Flex direction="col" gap="lg">
87
+ {/* ── The real screen · the defect this primitive exists for: a status glyph inline with
88
+ 14px body text, where no context rule would ever have sized it. ── */}
89
+ <Card>
90
+ <CardHeader>
91
+ <CardTitle level={2}>サインイン中の端末</CardTitle>
92
+ </CardHeader>
93
+ <CardContent>
94
+ <Flex direction="col" gap="md">
95
+ {SESSIONS.map((session) => (
96
+ <Flex key={session.id} direction="row" gap="sm" align="center" justify="between">
97
+ <Flex direction="row" gap="sm" align="center">
98
+ <Icon
99
+ as={session.secure ? ShieldCheck : FileWarning}
100
+ size="lg"
101
+ tone={session.secure ? "success" : "warning"}
102
+ label={session.secure ? "保護された接続" : "確認が必要な接続"}
103
+ />
104
+ <Flex direction="col">
105
+ <Text weight="medium">{session.device}</Text>
106
+ {/* A decorative glyph INSIDE 14px text · `sm` puts it on the type's own step. */}
107
+ <Text size="xs" tone="muted">
108
+ <Icon as={Globe} size="xs" /> {session.detail}
109
+ </Text>
110
+ </Flex>
111
+ </Flex>
112
+ <Button variant="outline" size="sm">
113
+ <Icon as={session.secure ? Lock : Unlock} size="sm" />
114
+ サインアウト
115
+ </Button>
116
+ </Flex>
117
+ ))}
118
+ </Flex>
119
+ </CardContent>
120
+ </Card>
121
+
122
+ {/* ── Every step of the scale ── */}
123
+ <Card>
124
+ <CardHeader>
125
+ <CardTitle level={2}>size · 9段のアイコンスケール</CardTitle>
126
+ </CardHeader>
127
+ <CardContent>
128
+ <Flex direction="col" gap="sm">
129
+ {SIZES.map((step) => (
130
+ <Flex key={step.size} direction="row" gap="md" align="center">
131
+ <Flex direction="row" gap="sm" align="center">
132
+ <Icon as={Smartphone} size={step.size} />
133
+ </Flex>
134
+ <Text size="sm" mono>
135
+ {step.size}
136
+ </Text>
137
+ <Text size="sm" tone="muted" mono>
138
+ {step.px}
139
+ </Text>
140
+ <Text size="sm" tone="muted">
141
+ {step.note}
142
+ </Text>
143
+ </Flex>
144
+ ))}
145
+ </Flex>
146
+ </CardContent>
147
+ </Card>
148
+
149
+ {/* ── Every tone, beside the Text that shares the vocabulary ── */}
150
+ <Card>
151
+ <CardHeader>
152
+ <CardTitle level={2}>tone · Text と同じ意味の語彙</CardTitle>
153
+ </CardHeader>
154
+ <CardContent>
155
+ <Flex direction="col" gap="sm">
156
+ {TONES.map((entry) => (
157
+ <Flex key={entry.tone} direction="row" gap="sm" align="center">
158
+ <Icon as={Check} size="md" tone={entry.tone} />
159
+ <Text size="sm" tone={entry.tone}>
160
+ {entry.label}
161
+ </Text>
162
+ </Flex>
163
+ ))}
164
+ </Flex>
165
+ </CardContent>
166
+ </Card>
167
+
168
+ {/* ── Where it composes · inside Text, inside a Button, standalone in a link ── */}
169
+ <Card>
170
+ <CardHeader>
171
+ <CardTitle level={2}>合成 · Text の中・Button の中・単独</CardTitle>
172
+ </CardHeader>
173
+ <CardContent>
174
+ <Flex direction="col" gap="md">
175
+ <Flex direction="col" gap="xs">
176
+ <Heading level={3}>Text の中</Heading>
177
+ <Text size="sm">
178
+ 請求書は <Icon as={Lock} size="sm" tone="muted" /> 暗号化されて保管されます。
179
+ </Text>
180
+ </Flex>
181
+
182
+ <Separator />
183
+
184
+ <Flex direction="col" gap="xs">
185
+ <Heading level={3}>Button の中 · 明示した段が Button の既定に勝つ</Heading>
186
+ <Flex direction="row" gap="sm" align="center">
187
+ <Button size="sm">
188
+ <Icon as={Download} size="sm" />
189
+ 書き出す
190
+ </Button>
191
+ <Button variant="outline">
192
+ <Icon as={Download} size="lg" />
193
+ 大きめのグリフ
194
+ </Button>
195
+ </Flex>
196
+ </Flex>
197
+
198
+ <Separator />
199
+
200
+ <Flex direction="col" gap="xs">
201
+ <Heading level={3}>単独 · 名前を持つグリフ</Heading>
202
+ <Flex direction="row" gap="sm" align="center">
203
+ <Text as="a" href="#icon-standalone" size="sm" tone="primary">
204
+ 監査ログを開く <Icon as={ArrowUpRight} size="sm" />
205
+ </Text>
206
+ <Icon as={ShieldCheck} size="md" tone="success" label="二要素認証は有効です" />
207
+ </Flex>
208
+ </Flex>
209
+ </Flex>
210
+ </CardContent>
211
+ </Card>
212
+ </Flex>
213
+ </PageContainer>
214
+ );
215
+ }
@@ -5,6 +5,7 @@ import {
5
5
  CardDescription,
6
6
  CardHeader,
7
7
  CardTitle,
8
+ ListRow,
8
9
  } from "@godxjp/ui/data-display";
9
10
  import { Button, Text } from "@godxjp/ui/general";
10
11
  import { Flex, PageContainer } from "@godxjp/ui/layout";
@@ -309,6 +310,64 @@ export default function Demo() {
309
310
  </Flex>
310
311
  </CardContent>
311
312
  </Card>
313
+ <Card>
314
+ <CardHeader>
315
+ <CardTitle level={2}>marker=&quot;none&quot; · 装飾のない意味的リスト</CardTitle>
316
+ <CardDescription>
317
+ as=&quot;ul&quot; は既定で中黒と字下げが付きます。設定メニューのような「行の一覧」では
318
+ marker=&quot;none&quot; を指定すると、ul と li の意味づけと gap トークンは残したまま、
319
+ 中黒も字下げも消えます。行は ListRow as=&quot;li&quot;
320
+ で、区切り線は行が自分で描きます。
321
+ </CardDescription>
322
+ </CardHeader>
323
+ <CardContent flush>
324
+ <Flex id="semantic-list" as="ul" marker="none" direction="col" gap="none">
325
+ <ListRow
326
+ as="li"
327
+ title="二要素認証"
328
+ description="認証アプリ · 有効"
329
+ trailing={
330
+ <Button size="xs" variant="outline">
331
+ 管理
332
+ </Button>
333
+ }
334
+ />
335
+ <ListRow
336
+ as="li"
337
+ title="パスキー"
338
+ description="2 件登録済み"
339
+ trailing={
340
+ <Button size="xs" variant="outline">
341
+ 管理
342
+ </Button>
343
+ }
344
+ />
345
+ <ListRow
346
+ as="li"
347
+ title="回復コード"
348
+ description="未発行"
349
+ trailing={
350
+ <Button size="xs" variant="outline">
351
+ 発行
352
+ </Button>
353
+ }
354
+ />
355
+ </Flex>
356
+ </CardContent>
357
+ <CardContent>
358
+ <Flex id="semantic-list-gap" as="ul" marker="none" gap="sm" wrap>
359
+ <Flex as="li">
360
+ <Badge variant="outline">監査ログ</Badge>
361
+ </Flex>
362
+ <Flex as="li">
363
+ <Badge variant="outline">IP 制限</Badge>
364
+ </Flex>
365
+ <Flex as="li">
366
+ <Badge variant="outline">SSO</Badge>
367
+ </Flex>
368
+ </Flex>
369
+ </CardContent>
370
+ </Card>
312
371
  <Card>
313
372
  <CardHeader>
314
373
  <CardTitle level={2}>軽い強調と行アクション</CardTitle>
@@ -9,7 +9,7 @@ import {
9
9
  CardHeader,
10
10
  CardTitle,
11
11
  } from "@godxjp/ui/data-display";
12
- import { Button, Text } from "@godxjp/ui/general";
12
+ import { Button, Icon, Text } from "@godxjp/ui/general";
13
13
  import { AppShell, Flex, PageContainer, Sidebar, Topbar, TopbarItem } from "@godxjp/ui/layout";
14
14
  import type { SidebarSectionProp } from "@godxjp/ui/layout";
15
15
  import {
@@ -22,6 +22,7 @@ import {
22
22
  } from "@godxjp/ui/navigation";
23
23
  import {
24
24
  Bell,
25
+ Building2,
25
26
  FileText,
26
27
  LayoutDashboard,
27
28
  LogOut,
@@ -150,6 +151,33 @@ export default function Demo() {
150
151
  />
151
152
  </CardContent>
152
153
  </Card>
154
+
155
+ <Card>
156
+ <CardHeader>
157
+ <CardTitle level={2}>包んだグリフ(icon スロットと Icon)</CardTitle>
158
+ <CardDescription>
159
+ `.ui-topbar-item &gt; svg` は直接の子だけを見ます。ブレークポイントで隠すために
160
+ グリフを包むと、その規則は届かず lucide 既定の 24px で描かれます(390px
161
+ のセルで実測)。 セル自身が持つ `icon` スロットに渡すか、包むなら `Icon`
162
+ を使えば、どちらもバーの段 (--topbar-icon-size)に載ります。
163
+ </CardDescription>
164
+ </CardHeader>
165
+ <CardContent>
166
+ <Topbar
167
+ start={
168
+ <>
169
+ <TopbarItem icon={<Building2 aria-hidden="true" />}>経理部</TopbarItem>
170
+ <TopbarItem aria-label="拠点を切り替える">
171
+ <Flex hideBelow="sm">
172
+ <Icon as={Building2} size="md" />
173
+ </Flex>
174
+ 東京本社
175
+ </TopbarItem>
176
+ </>
177
+ }
178
+ />
179
+ </CardContent>
180
+ </Card>
153
181
  </Flex>
154
182
  </PageContainer>
155
183
  </AppShell>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "27.3.1",
4
- "godxUiMcp": "27.3.1",
3
+ "version": "27.5.0",
4
+ "godxUiMcp": "27.5.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -98,23 +98,24 @@ Slack thì đặt lại **một dòng**, không fork `.app-nav-rail`.
98
98
  Hỏi MCP `godxjp-ui` (`search_components`, `get_component`). Đo được trong một
99
99
  ngày: năm thứ cần đều ĐÃ CÓ và vẫn bị dựng lại bằng thứ khác —
100
100
 
101
- | Cần | Đã có |
102
- | ----------------------------------------------- | ---------------------------------------------------------------------------------- |
103
- | đường kẻ chạm mép Card | `<CardContent flush>` |
104
- | header có kẻ khi thân là danh sách flush | `<CardHeader banded>` |
105
- | một hàng LÀ liên kết (thay cho nút rời) | `<ListRow asChild>` |
106
- | kẻ ô từng ngày trong lịch | `<Calendar bordered>` |
107
- | dải giữa hai vùng, tự kẻ theo VỊ TRÍ | `<CardBar>` (`border` để ép khi xếp chồng) |
108
- | chip "điều kiện đang bật" có dấu × để bỏ | `<Badge onRemove>` — KHÔNG phải `TagInput` |
109
- | dải tab nằm TRONG đầu Card | `<Card tabList activeTabKey onTabChange>` |
110
- | tiêu đề / đoạn văn / liên kết trong văn bản | `<Title>` · `<Paragraph>` · `<Link>` (`Text` 33 prop) |
111
- | nút nổi góc màn (quay lên đầu, hành động nhanh) | `<FloatButton>` |
112
- | khung xương của một FORM khi đang tải | `<SkeletonForm>` |
113
- | ô màu chỉ để xem, màu do NGƯỜI DÙNG chọn | `<Swatch>` |
114
- | ảnh đại diện của một tệp / bản xem trước | `<Thumbnail>` |
115
- | danh sách "có gì trong gói này" | `<FeatureList>` |
116
- | panel kéo giãn được (chia đôi màn) | `<DraggablePanel>` |
117
- | màn hội thoại AI | `<Welcome>` · `<Conversations>` · `<Attachments>` · `<ThoughtChain>` · `<Actions>` |
101
+ | Cần | Đã có |
102
+ | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
103
+ | đường kẻ chạm mép Card | `<CardContent flush>` |
104
+ | header có kẻ khi thân là danh sách flush | `<CardHeader banded>` |
105
+ | một hàng LÀ liên kết (thay cho nút rời) | `<ListRow asChild>` |
106
+ | kẻ ô từng ngày trong lịch | `<Calendar bordered>` |
107
+ | dải giữa hai vùng, tự kẻ theo VỊ TRÍ | `<CardBar>` (`border` để ép khi xếp chồng) |
108
+ | chip "điều kiện đang bật" có dấu × để bỏ | `<Badge onRemove>` — KHÔNG phải `TagInput` |
109
+ | dải tab nằm TRONG đầu Card | `<Card tabList activeTabKey onTabChange>` |
110
+ | tiêu đề / đoạn văn / liên kết trong văn bản | `<Title>` · `<Paragraph>` · `<Link>` (`Text` 33 prop) |
111
+ | nút nổi góc màn (quay lên đầu, hành động nhanh) | `<FloatButton>` |
112
+ | khung xương của một FORM khi đang tải | `<SkeletonForm>` |
113
+ | ô màu chỉ để xem, màu do NGƯỜI DÙNG chọn | `<Swatch>` |
114
+ | ảnh đại diện của một tệp / bản xem trước | `<Thumbnail>` |
115
+ | danh sách "có gì trong gói này" | `<FeatureList>` |
116
+ | panel kéo giãn được (chia đôi màn) | `<DraggablePanel>` |
117
+ | màn hội thoại AI | `<Welcome>` · `<Conversations>` · `<Attachments>` · `<ThoughtChain>` · `<Actions>` |
118
+ | một glyph lucide đứng một mình (trong `Text`, ô bảng, thẻ `<a>`) | `<Icon as={Lock} size="sm">` — KHÔNG phải `size-4` hay `size={16}` |
118
119
 
119
120
  Lỗi không phải "đoán sai tên prop" mà là **cho rằng nó không tồn tại nên không
120
121
  hỏi**.
@@ -590,6 +590,68 @@ const RULES = [
590
590
  message:
591
591
  "No em-dash (—) in product copy. Use a middot `·` for JP/EN label pairs, or restructure into two calm sentences. Keep copy factual and quiet.",
592
592
  },
593
+ {
594
+ id: "lucide-icon-needs-size",
595
+ replacement: '<Icon as={Glyph} size="sm" />',
596
+ // CONSUMER scope, like the other rules about what an app may write: inside this package a bare
597
+ // glyph is normal — Tree, Pagination, Attachments and a dozen others size their own svg from
598
+ // their own stylesheet, which is exactly the capability a consumer does not have. Measured:
599
+ // 90 findings here with the scope off, every one of them a component sizing its own glyph.
600
+ scope: "consumer",
601
+ matches: lucideGlyphMatches,
602
+ spansElement: true,
603
+ severity: "warn",
604
+ /*
605
+ * A lucide element rendered where NOTHING will size it.
606
+ *
607
+ * A lucide component ships `width="24" height="24"`, and exactly four rules in this library
608
+ * override that: `.ui-button svg`, `.ui-dropdown-menu-item > svg`, `.ui-topbar-item svg` and
609
+ * `[data-slot="list-row-leading"] > svg`. Outside them — in a `Text`, a table cell, an `<a>`,
610
+ * a `Flex` — the glyph draws at 24px beside 14px type. It is the one defect class that is
611
+ * INVISIBLE in review: the JSX is correct, the import is correct, and only the screen is
612
+ * wrong. A consumer swept one app and found 38 (gh#712).
613
+ *
614
+ * `warn`, not `error`, and deliberately so: the exemptions below are lexical, so a glyph
615
+ * inside a consumer's own wrapper component that happens to size it would be reported. The
616
+ * fix is cheap (`<Icon as={Lock} size="sm" />`) and the finding is worth reading even then.
617
+ */
618
+ standard: "WCAG 2.2 SC 1.4.4 · @godxjp/ui icon scale (docs/TOKENS.md, --icon-size-*)",
619
+ message:
620
+ 'A lucide glyph with nothing to size it renders at its intrinsic 24px — 1.7× a 14px label. Put it on the scale with the primitive: <Icon as={Lock} size="sm" tone="muted" />, which is aria-hidden by default. A size-4 / w-[16px] utility is what docs/CONSUMER-RULES.md §3/§8 forbid, and size={16} hard-codes a number the theme owns.',
621
+ },
622
+ {
623
+ id: "no-hand-rolled-list",
624
+ replacement: 'Flex as="ul" marker="none" + ListRow as="li"',
625
+ scope: "consumer",
626
+ severity: "warn",
627
+ spansElement: true,
628
+ /*
629
+ * A list built by hand instead of by the primitives, in the three shapes a consumer reaches for:
630
+ *
631
+ * 1. a raw `<ul>`/`<ol>` — it cannot carry the gap token, so the spacing goes back into
632
+ * utilities, and until `marker="none"` (gh#714) it was the only way to get a list without
633
+ * a bullet and a --space-5 indent. It no longer is.
634
+ * 2. `role="list"` / `role="listitem"` on a div — ARIA re-describing markup that HTML already
635
+ * has a element for (WAI-ARIA 1.2 §Using ARIA in HTML: prefer the native element).
636
+ * 3. a `<li>` (or any wrapper) around a library ROW instead of BEING it. This is the one that
637
+ * cost real money: `[data-slot="list-row"]:not(:last-child)` draws the divider, so a row
638
+ * alone in a wrapper of its own is always `:last-child` and the rule never matches — a
639
+ * consumer's settings menu and dashboard lost EVERY divider, silently, and code review saw
640
+ * nothing. `ListRow` has `as="li"` (and `as` + `asChild` for a row of links) precisely so
641
+ * the rows stay siblings.
642
+ *
643
+ * WARN, not error, and the measurement says why: of 15 `role="list"` wrappers one consumer
644
+ * audited, 3 survived as deliberate exceptions (a drag-and-drop Kanban column of Cards, two
645
+ * evidence lists inside a TableCell). A rule wrong one time in five is a rule that gets
646
+ * silenced wholesale at `error`; at `warn` the three exceptions take an
647
+ * `ui-audit-disable-line` that states the reason and the other twelve still get fixed.
648
+ */
649
+ test: /<(?:ul|ol)(?=[\s/>])[^\n]*|\brole=["'](?:list|listitem)["']|<li(?=[\s>])(?:(?!<\/li>)[\s\S]){0,400}?<(?:ListRow|Card)(?=[\s/>])/g,
650
+ standard:
651
+ "WAI-ARIA 1.2 (list / listitem) · HTML Living Standard (ul/ol/li) · WCAG 2.2 SC 1.3.1",
652
+ message:
653
+ 'Hand-rolled list — a raw <ul>/<ol>, an ARIA role="list"/"listitem", or a <li> wrapped around a library row. Build it from <Flex as="ul" marker="none"> (keeps the element, the <li> semantics and the gap token; no bullet, no indent) with <ListRow as="li"> as the rows. A row inside a wrapper of its own is an only child, so its :not(:last-child) divider never matches and every divider disappears silently (docs/CONSUMER-RULES.md §4, gh#714).',
654
+ },
593
655
  ];
594
656
 
595
657
  /**
@@ -946,6 +1008,72 @@ function insideStringLiteral(source, index) {
946
1008
  return quote !== "";
947
1009
  }
948
1010
 
1011
+ /**
1012
+ * The LOCAL names bound to `lucide-react` icons in this file — `import { Lock, Pencil as Edit }`
1013
+ * gives `Lock` and `Edit`. Reading the import rather than matching "any PascalCase tag" is what
1014
+ * keeps the rule from reporting every component in the file.
1015
+ */
1016
+ function lucideLocalNames(source) {
1017
+ const names = new Set();
1018
+ for (const imp of source.matchAll(
1019
+ /import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*["']lucide-react["']/g,
1020
+ )) {
1021
+ for (const spec of imp[1].split(",")) {
1022
+ const local =
1023
+ spec
1024
+ .trim()
1025
+ .split(/\s+as\s+/)
1026
+ .pop()
1027
+ ?.trim() ?? "";
1028
+ if (/^[A-Z][A-Za-z0-9]*$/.test(local)) names.add(local);
1029
+ }
1030
+ }
1031
+ return names;
1032
+ }
1033
+
1034
+ /**
1035
+ * Character ranges in which a glyph is SOMEBODY ELSE'S to size, so a glyph inside one is quiet:
1036
+ *
1037
+ * 1. the body of a component whose CSS sizes its own `svg` — the four rules named on the rule
1038
+ * below, which are the only four that exist;
1039
+ * 2. the value of a slot PROP (`icon={<Lock />}`, `leading={<Lock />}`). A component handed a
1040
+ * glyph through a named slot owns the metric for that slot — that is what a slot IS — and
1041
+ * reporting the call site would push consumers away from the slot and toward a bare glyph.
1042
+ */
1043
+ function iconSizingRanges(source) {
1044
+ const ranges = [];
1045
+ for (const m of source.matchAll(
1046
+ /<(Button|DropdownMenuItem|DropdownMenuCheckboxItem|DropdownMenuRadioItem|TopbarItem)\b[\s\S]*?<\/\1>/g,
1047
+ )) {
1048
+ ranges.push([m.index, m.index + m[0].length]);
1049
+ }
1050
+ for (const m of source.matchAll(
1051
+ /\b(?:icon|leading|trailing|mark|indicator|avatar|prefix|suffix|addonBefore|addonAfter)\s*=\s*\{/g,
1052
+ )) {
1053
+ const open = m.index + m[0].length - 1;
1054
+ const close = matchBracket(source, open);
1055
+ ranges.push([open, close < 0 ? source.length : close + 1]);
1056
+ }
1057
+ return ranges;
1058
+ }
1059
+
1060
+ /** A lucide element that no rule, no slot and no author-supplied size will ever measure. */
1061
+ function* lucideGlyphMatches(source) {
1062
+ const names = lucideLocalNames(source);
1063
+ if (names.size === 0) return;
1064
+ const ranges = iconSizingRanges(source);
1065
+ for (const m of source.matchAll(new RegExp(`<(?:${[...names].join("|")})(?=[\\s/>])`, "g"))) {
1066
+ if (insideStringLiteral(source, m.index)) continue;
1067
+ if (ranges.some(([from, to]) => m.index > from && m.index < to)) continue;
1068
+ const end = jsxOpeningEnd(source, m.index);
1069
+ if (end >= source.length) continue;
1070
+ // An explicit size, or ANY className — a class list is the author saying something about this
1071
+ // element's box, and second-guessing which utility sizes it is how a warning becomes noise.
1072
+ if (/\s(?:size|width|height|className|style)\s*=/.test(source.slice(m.index, end))) continue;
1073
+ yield { 0: source.slice(m.index, end + 1), index: m.index };
1074
+ }
1075
+ }
1076
+
949
1077
  /** Match real JSX text after a balanced opening tag, including props with comparisons. */
950
1078
  function* currencyMatches(source) {
951
1079
  for (const opening of source.matchAll(/<(?:[A-Za-z][\w.:]*\b|(?=>))/g)) {