@godxjp/ui 27.3.1 → 27.4.1

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.
@@ -0,0 +1,31 @@
1
+ import * as React from "react";
2
+ export type { IconGlyphProp, IconGlyphProp as IconProps, } from "../../props/components/general.prop.js";
3
+ /**
4
+ * Icon — a glyph on the `--icon-size-*` scale, and the only supported way to draw a standalone one.
5
+ *
6
+ * WHY THIS EXISTS (gh#712). A lucide component ships `width="24" height="24"`, and exactly four
7
+ * rules in this library ever override that: `.ui-button svg`, `.ui-dropdown-menu-item > svg`,
8
+ * `.ui-topbar-item > svg` and `[data-slot="list-row-leading"] > svg`. Anywhere else — in a `Text`,
9
+ * in a table cell, in an `<a>` — the glyph draws at 24px beside 14px type, 1.71× the text it
10
+ * annotates. A consumer cannot fix that themselves: `size-4` and `w-[16px]` are what
11
+ * docs/CONSUMER-RULES.md §3/§8 forbid, and `size={16}` hard-codes a number the theme owns. One
12
+ * consumer measured 38 such glyphs in a single app.
13
+ *
14
+ * RENDERS ONTO THE GLYPH, NOT AROUND IT. `as={Lock}` is invoked with the class, the size and the
15
+ * a11y wiring, so the element that carries the metric IS the `<svg>`. A wrapper `<span>` would add
16
+ * a box to every flex row that holds an icon, and would break `Button`'s own `svg` rule by putting
17
+ * an element between the two.
18
+ *
19
+ * DECORATIVE BY DEFAULT. Without `label` the glyph is `aria-hidden` — a glyph beside a visible
20
+ * label is decoration, and announcing it twice is the usual defect (WCAG 2.2 SC 1.1.1). Pass
21
+ * `label` only when the glyph is the ONLY thing saying what it says; it then renders as
22
+ * `role="img"` + that name, which is how a graphic gets a name in WAI-ARIA 1.2. The string is the
23
+ * consumer's to localize — the library ships no copy for it.
24
+ */
25
+ export declare const Icon: React.ForwardRefExoticComponent<Omit<React.SVGProps<SVGSVGElement>, "children" | "ref"> & {
26
+ as: React.ComponentType<React.SVGProps<SVGSVGElement> & React.RefAttributes<SVGSVGElement>>;
27
+ size?: import("../../props/index.js").IconSizeProp;
28
+ tone?: import("../../props/index.js").TextToneProp;
29
+ label?: string;
30
+ className?: import("../../props/index.js").ClassNameProp;
31
+ } & React.RefAttributes<SVGSVGElement>>;
@@ -0,0 +1,23 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "../../lib/utils.js";
4
+ const Icon = React.forwardRef(function Icon2({ as: Glyph, size = "md", tone, label, className, ...props }, ref) {
5
+ const named = label !== void 0 && label !== "";
6
+ return /* @__PURE__ */ jsx(
7
+ Glyph,
8
+ {
9
+ ref,
10
+ "data-slot": "icon",
11
+ "data-size": size,
12
+ "data-tone": tone,
13
+ className: cn("ui-icon", className),
14
+ role: named ? "img" : void 0,
15
+ "aria-label": named ? label : void 0,
16
+ "aria-hidden": named ? void 0 : true,
17
+ ...props
18
+ }
19
+ );
20
+ });
21
+ export {
22
+ Icon
23
+ };
@@ -2,6 +2,8 @@ export { Button, buttonVariants } from "./button.js";
2
2
  export type { ButtonProps } from "./button.js";
3
3
  export { Text, Heading, Typography, Title, Paragraph, Link } from "./typography.js";
4
4
  export type { TextProps, HeadingProps, TextProp, HeadingProp, TypographyProp, TypographyProps, TypographyBlockProp, TypographyTitleProp, TitleProps, ParagraphProp, ParagraphProps, LinkProp, LinkProps, } from "./typography.js";
5
+ export { Icon } from "./icon.js";
6
+ export type { IconGlyphProp, IconProps } from "./icon.js";
5
7
  export { Logo } from "./logo.js";
6
8
  export type { LogoMark, LogoProps, LogoSize, LogoTone } from "./logo.js";
7
9
  export { Reveal } from "./reveal.js";
@@ -1,5 +1,6 @@
1
1
  import { Button, buttonVariants } from "./button.js";
2
2
  import { Text, Heading, Typography, Title, Paragraph, Link } from "./typography.js";
3
+ import { Icon } from "./icon.js";
3
4
  import { Logo } from "./logo.js";
4
5
  import { Reveal } from "./reveal.js";
5
6
  import { FloatButton } from "./float-button.js";
@@ -15,6 +16,7 @@ export {
15
16
  Button,
16
17
  FloatButton,
17
18
  Heading,
19
+ Icon,
18
20
  Link,
19
21
  Logo,
20
22
  Paragraph,
@@ -1,3 +1,3 @@
1
1
  import type { FlexProp } from "../../props/components/layout.prop.js";
2
- export type { FlexAlignProp, FlexDirectionProp, FlexJustifyProp, FlexProp, FlexProp as FlexProps, } from "../../props/components/layout.prop.js";
3
- export declare function Flex({ as: Element, direction, grow, shrink, surface, bleed, reveal, gap, gapRaw, pad, padRaw, align, justify, wrap, hideBelow, hideFrom, hideBelowRaw, hideFromRaw, fill, width, className, style, children, ...props }: FlexProp): import("react").JSX.Element;
2
+ export type { FlexAlignProp, FlexDirectionProp, FlexJustifyProp, FlexMarkerProp, FlexProp, FlexProp as FlexProps, } from "../../props/components/layout.prop.js";
3
+ export declare function Flex({ as: Element, direction, marker, grow, shrink, surface, bleed, reveal, gap, gapRaw, pad, padRaw, align, justify, wrap, hideBelow, hideFrom, hideBelowRaw, hideFromRaw, fill, width, className, style, children, ...props }: FlexProp): import("react").JSX.Element;
@@ -6,9 +6,15 @@ import { flexGapClass, padStyle, padStepToken } from "../../lib/variants.js";
6
6
  const toCssLength = (value) => typeof value === "number" ? `${value}px` : value;
7
7
  const rawBreakpoint = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
8
8
  const rawBreakpointRule = (axis, px) => `@media (width ${axis === "below" ? "<" : ">="} ${px}px){.ui-flex[data-hide-${axis}-raw="${px}"]{display:none}}`;
9
+ const listMarker = (element, marker) => {
10
+ if (element !== "ul" && element !== "ol") return void 0;
11
+ const resolved = marker ?? (element === "ul" ? "disc" : "decimal");
12
+ return resolved === "none" ? void 0 : resolved;
13
+ };
9
14
  function Flex({
10
15
  as: Element = "div",
11
16
  direction = "row",
17
+ marker,
12
18
  grow,
13
19
  shrink,
14
20
  surface,
@@ -65,7 +71,7 @@ function Flex({
65
71
  "data-shrink": shrink === false ? "false" : void 0,
66
72
  "data-surface": surface,
67
73
  "data-reveal": reveal,
68
- "data-list": Element === "ul" ? "disc" : Element === "ol" ? "decimal" : void 0,
74
+ "data-list": listMarker(Element, marker),
69
75
  "data-align": align,
70
76
  "data-justify": justify,
71
77
  "data-wrap": wrap ? "true" : void 0,
@@ -12,6 +12,7 @@ export type { TopbarItemProp, TopbarItemProp as TopbarItemProps, } from "../../p
12
12
  */
13
13
  export declare const TopbarItem: React.ForwardRefExoticComponent<Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
14
14
  hideBelow?: import("../../props/index.js").BreakpointProp;
15
+ icon?: React.ReactNode;
15
16
  badge?: React.ReactNode;
16
17
  badgeTone?: import("../../props/components/layout.prop.js").SidebarBadgeToneProp;
17
18
  asChild?: boolean;
@@ -2,9 +2,10 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { Slot } from "../../lib/slot.js";
4
4
  import { cn } from "../../lib/utils.js";
5
- const TopbarItem = React.forwardRef(function TopbarItem2({ asChild = false, className, type, hideBelow, badge, badgeTone, children, ...props }, ref) {
5
+ const TopbarItem = React.forwardRef(function TopbarItem2({ asChild = false, className, type, hideBelow, icon, badge, badgeTone, children, ...props }, ref) {
6
6
  const Comp = asChild ? Slot : "button";
7
7
  const showBadge = !asChild && badge !== void 0 && badge !== "";
8
+ const iconNode = !asChild && icon !== void 0 && icon !== null && icon !== false ? /* @__PURE__ */ jsx("span", { "data-slot": "topbar-item-icon", className: "ui-topbar-item-icon", children: icon }) : null;
8
9
  const badgeNode = showBadge ? /* @__PURE__ */ jsx(
9
10
  "span",
10
11
  {
@@ -24,6 +25,7 @@ const TopbarItem = React.forwardRef(function TopbarItem2({ asChild = false, clas
24
25
  type: asChild ? void 0 : type ?? "button",
25
26
  ...props,
26
27
  children: asChild ? children : /* @__PURE__ */ jsxs(Fragment, { children: [
28
+ iconNode,
27
29
  children,
28
30
  badgeNode
29
31
  ] })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "AUTO-GENERATED by scripts/gen-measurement-contract.mjs — do not edit. Read this instead of guessing: docs/MEASUREMENT-CONTRACT.md.",
3
- "version": "27.3.1",
3
+ "version": "27.4.1",
4
4
  "targetSize": {
5
5
  "standard": "WCAG 2.2 SC 2.5.8 Target Size (Minimum), level AA — 24×24 CSS px",
6
6
  "min": 24,
@@ -1,6 +1,6 @@
1
1
  /** Foundation component prop types — @see docs/COMPONENTS.md#foundation */
2
2
  import type * as React from "react";
3
- import type { ActivityAnnounceProp, ActivityVariantProp, AsChildProp, ButtonSizeProp, ButtonVariantProp, ChildrenProp, ClassNameProp, DescriptionProp, DisabledProp, FontWeightProp, HeadingLevelProp, IdProp, LabelProp, OnClickProp, OnOpenChangeProp, OpenProp, PendingProp, RevealDelayProp, ShapeProp, SizeProp, TextAlignProp, TextSizeProp, TextToneProp, TextWhitespaceProp, TitleLevelProp, TypographyActionsConfigProp, TypographyCopyConfigProp, TypographyEditConfigProp, TypographyEllipsisConfigProp, TypographyTypeProp } from "../vocabulary/index.js";
3
+ import type { ActivityAnnounceProp, ActivityVariantProp, AsChildProp, ButtonSizeProp, ButtonVariantProp, ChildrenProp, ClassNameProp, DescriptionProp, DisabledProp, FontWeightProp, HeadingLevelProp, IconSizeProp, IdProp, LabelProp, OnClickProp, OnOpenChangeProp, OpenProp, PendingProp, RevealDelayProp, ShapeProp, SizeProp, TextAlignProp, TextSizeProp, TextToneProp, TextWhitespaceProp, TitleLevelProp, TypographyActionsConfigProp, TypographyCopyConfigProp, TypographyEditConfigProp, TypographyEllipsisConfigProp, TypographyTypeProp } from "../vocabulary/index.js";
4
4
  /**
5
5
  * @see Text — typographic primitive; replaces hand-rolled `<span className="text-[13px] …">`.
6
6
  *
@@ -336,6 +336,47 @@ export type ActivityProp = Omit<React.HTMLAttributes<HTMLSpanElement>, "color">
336
336
  announce?: ActivityAnnounceProp;
337
337
  className?: ClassNameProp;
338
338
  };
339
+ /**
340
+ * @see Icon — the ONE supported way to put a standalone glyph on the `--icon-size-*` scale.
341
+ *
342
+ * A lucide component carries `width="24" height="24"` of its own, and only four rules in this
343
+ * library ever re-size a glyph (`.ui-button svg`, the menu row, the topbar cell, the ListRow
344
+ * leading slot) — three of them direct-child rules. Outside those four a glyph renders at 24px
345
+ * beside 14px text, and a consumer has no supported way to fix it: `docs/CONSUMER-RULES.md` §8
346
+ * forbids `size-4`/`w-[16px]`, and `size={16}` re-derives a scale the theme owns. Measured in a
347
+ * consumer at 38 such glyphs (gh#712).
348
+ *
349
+ * Icon renders ONTO the glyph rather than wrapping it — the sized element IS the `<svg>` — so it
350
+ * composes anywhere a bare glyph does (inside `Text`, in a `Button`, in a table cell, in an `<a>`)
351
+ * and adds no box to the layout.
352
+ */
353
+ export type IconGlyphProp = Omit<React.SVGProps<SVGSVGElement>, "ref" | "children"> & {
354
+ /**
355
+ * The glyph COMPONENT — `as={Lock}`, not `<Lock />`. Any component that forwards SVG props and a
356
+ * ref qualifies (every `lucide-react` icon does); Icon hands it the class, the size attributes
357
+ * and the a11y wiring.
358
+ */
359
+ as: React.ComponentType<React.SVGProps<SVGSVGElement> & React.RefAttributes<SVGSVGElement>>;
360
+ /**
361
+ * Step of the nine-step icon scale. Default `md` (16px — "the default icon step" in
362
+ * docs/TOKENS.md). A glyph beside `Text size="sm"` wants `sm`; an empty-state mark wants `4xl`.
363
+ */
364
+ size?: IconSizeProp;
365
+ /**
366
+ * Semantic colour intent, the same vocabulary `Text` uses. Omitted by default, and that default
367
+ * is load-bearing: a glyph inherits `currentColor`, so an Icon inside a Button, a Badge or a
368
+ * toned `Text` paints in that surface's own ink without being told.
369
+ */
370
+ tone?: TextToneProp;
371
+ /**
372
+ * Accessible NAME, when the glyph carries meaning nothing else on screen says (a lock in a
373
+ * status cell with no text beside it). Localized by the consumer — the library ships no copy.
374
+ * Supplying it switches the glyph from `aria-hidden` to `role="img"` + that name; leave it off
375
+ * (the default) for a decorative glyph beside a visible label, which is the common case.
376
+ */
377
+ label?: string;
378
+ className?: ClassNameProp;
379
+ };
339
380
  /**
340
381
  * FloatButton `type` — the two fills Ant Design gives the corner mark.
341
382
  *
@@ -100,6 +100,11 @@ export type PageContainerProp = {
100
100
  className?: ClassNameProp;
101
101
  };
102
102
  export type FlexDirectionProp = "row" | "col";
103
+ /**
104
+ * List marker for `as="ul"`/`as="ol"` — the CSS `list-style-type` keywords the layer implements,
105
+ * plus `none` for a list that is SEMANTIC ONLY (gh#714).
106
+ */
107
+ export type FlexMarkerProp = "disc" | "decimal" | "none";
103
108
  export type FlexAlignProp = "start" | "center" | "end" | "stretch" | "baseline";
104
109
  export type FlexJustifyProp = "start" | "center" | "end" | "between" | "around" | "evenly";
105
110
  /** @see Flex */
@@ -112,6 +117,26 @@ export type FlexProp = React.HTMLAttributes<HTMLElement> & {
112
117
  * `.ui-flex` rules carry `display: flex`, so the box is identical either way.
113
118
  */
114
119
  as?: "div" | "span" | "ul" | "ol" | "li";
120
+ /**
121
+ * Marker for a LIST element (`as="ul"` → `disc`, `as="ol"` → `decimal` by default); ignored by
122
+ * every other tag.
123
+ *
124
+ * `marker="none"` emits no `data-list` at all, so the list keeps its element, its `<li>`
125
+ * semantics and its `gap`, and loses the bullet AND the `--space-5` indent that come with it.
126
+ *
127
+ * ## Why the axis exists
128
+ *
129
+ * The bullet was unconditional, and the catalog's own idiom — `<ListRow as="li">` inside a list
130
+ * — could not live with it: `.ui-flex[data-list] > li { display: list-item }` outranks
131
+ * `[data-slot="list-row"] { display: flex }`, so every row fell out of flex layout (measured on
132
+ * this page: row height 94.58px → 41.2px once the attribute is gone). The only move left was a
133
+ * RAW `<ul>`, which carries no gap token and which the consumer rules forbid in spirit — one
134
+ * consumer kept three `Flex role="list"` divs for exactly that reason (gh#714).
135
+ *
136
+ * `disc`/`decimal` stay available so an `<ol>` can be bulleted (or a `<ul>` numbered) without a
137
+ * `list-style-type` in a className.
138
+ */
139
+ marker?: FlexMarkerProp;
115
140
  /** Lightweight surfaces for rows and notices; no Card elevation by default. */
116
141
  surface?: "muted" | "popover" | "warning";
117
142
  /** Negative inline inset, using the same spacing scale as pad. */
@@ -1314,6 +1339,23 @@ export type TopbarItemProp = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>,
1314
1339
  */
1315
1340
  /** Hide below a shared responsive breakpoint, without changing cell height. */
1316
1341
  hideBelow?: BreakpointProp;
1342
+ /**
1343
+ * The cell's leading glyph, placed in a slot the CELL sizes (`--topbar-icon-size`).
1344
+ *
1345
+ * Passing the glyph as a child works too — but only while it stays a DIRECT child, because
1346
+ * `.ui-topbar-item > svg` is the rule that sizes it. The moment a cell wraps its glyph (a
1347
+ * `<Flex hideBelow>` so the icon drops at narrow widths) the rule can no longer reach it and the
1348
+ * glyph renders at lucide's intrinsic 24px: measured at 390px on an org-switcher cell, 1.5x the
1349
+ * cell's own step (gh#712). This slot survives that, because the wrapper is the one the cell
1350
+ * placed. The alternative — widening the rule to a descendant — was measured to shrink or grow
1351
+ * OTHER components' glyphs inside the cell (a Badge's 12px glyph became 16px), so it is not one.
1352
+ *
1353
+ * Decorative by convention: the cell's accessible name comes from its own `aria-label` or its
1354
+ * visible label, not from the glyph. For a glyph that must carry a name, or one that needs a
1355
+ * step other than the bar's, pass `<Icon as={…} size=… label=… />` — as this prop's value or as
1356
+ * a child — and it keeps its own metric and its own semantics.
1357
+ */
1358
+ icon?: ReactNode;
1317
1359
  /**
1318
1360
  * Unread count OVERLAID on the cell's glyph — the notification-bell affordance the cell's own use
1319
1361
  * cases name (gh#398). Pass the CONTENT ONLY, exactly like `SidebarItemProp.badge`: a number, a
@@ -434,6 +434,11 @@ export declare const VOCABULARY_REGISTRY: {
434
434
  readonly category: "interaction";
435
435
  readonly description: "Text size — golden-ratio type-scale steps (2xs…2xl), never an arbitrary px";
436
436
  };
437
+ readonly IconSizeProp: {
438
+ readonly file: "vocabulary/interaction.prop.ts";
439
+ readonly category: "interaction";
440
+ readonly description: "Icon size — the nine --icon-size-* scale steps (2xs…4xl), never an arbitrary px or a size- utility";
441
+ };
437
442
  readonly TextToneProp: {
438
443
  readonly file: "vocabulary/interaction.prop.ts";
439
444
  readonly category: "interaction";
@@ -746,6 +751,11 @@ export declare const COMPONENT_PROP_REGISTRY: {
746
751
  readonly file: "components/layout.prop.ts";
747
752
  readonly vocabulary: readonly [];
748
753
  };
754
+ readonly FlexMarkerProp: {
755
+ readonly group: "layout";
756
+ readonly file: "components/layout.prop.ts";
757
+ readonly vocabulary: readonly [];
758
+ };
749
759
  readonly FlexAlignProp: {
750
760
  readonly group: "layout";
751
761
  readonly file: "components/layout.prop.ts";
@@ -767,6 +777,10 @@ export declare const COMPONENT_PROP_REGISTRY: {
767
777
  readonly field: "direction";
768
778
  readonly local: true;
769
779
  readonly reason: "Flex-specific axis control.";
780
+ }, {
781
+ readonly field: "marker";
782
+ readonly local: true;
783
+ readonly reason: 'List-marker seam for as="ul"/"ol" (disc | decimal | none). marker="none" emits no data-list, so a list keeps its element, its <li> semantics and its gap token while losing the bullet and the --space-5 indent — the home <ListRow as="li"> had none of, which forced a raw <ul> (gh#714).';
770
784
  }, "GapProp", {
771
785
  readonly field: "align";
772
786
  readonly local: true;
@@ -1203,6 +1217,23 @@ export declare const COMPONENT_PROP_REGISTRY: {
1203
1217
  readonly reason: "Ambient live-region opt-in — ActivityAnnounceProp vocabulary; deliberately NOT a boolean so the value names the politeness level.";
1204
1218
  }];
1205
1219
  };
1220
+ readonly IconGlyphProp: {
1221
+ readonly group: "general";
1222
+ readonly file: "components/general.prop.ts";
1223
+ readonly vocabulary: readonly ["IconProp", "IconSizeProp", "TextToneProp", "LabelProp", "ClassNameProp", {
1224
+ readonly field: "label";
1225
+ readonly local: true;
1226
+ readonly reason: "LabelProp narrowed to string: this becomes the glyph's aria-label, and an accessible name is TEXT — a ReactNode cannot be one.";
1227
+ }, {
1228
+ readonly field: "as";
1229
+ readonly local: true;
1230
+ readonly reason: "The glyph COMPONENT to render — the IconProp concept, widened to a component that also forwards SVG props and a ref, because Icon renders ONTO the glyph (the sized element is the <svg> itself) rather than wrapping it.";
1231
+ }, {
1232
+ readonly field: "size";
1233
+ readonly local: true;
1234
+ readonly reason: "IconSizeProp, the nine-step icon scale — deliberately NOT the four-step control SizeProp: a glyph beside 2xs caption text and a 48px empty-state mark are the same primitive.";
1235
+ }];
1236
+ };
1206
1237
  readonly FloatButtonTypeProp: {
1207
1238
  readonly group: "general";
1208
1239
  readonly file: "components/general.prop.ts";
@@ -433,6 +433,11 @@ const VOCABULARY_REGISTRY = {
433
433
  category: "interaction",
434
434
  description: "Text size \u2014 golden-ratio type-scale steps (2xs\u20262xl), never an arbitrary px"
435
435
  },
436
+ IconSizeProp: {
437
+ file: "vocabulary/interaction.prop.ts",
438
+ category: "interaction",
439
+ description: "Icon size \u2014 the nine --icon-size-* scale steps (2xs\u20264xl), never an arbitrary px or a size- utility"
440
+ },
436
441
  TextToneProp: {
437
442
  file: "vocabulary/interaction.prop.ts",
438
443
  category: "interaction",
@@ -767,6 +772,7 @@ const COMPONENT_PROP_REGISTRY = {
767
772
  ]
768
773
  },
769
774
  FlexDirectionProp: { group: "layout", file: "components/layout.prop.ts", vocabulary: [] },
775
+ FlexMarkerProp: { group: "layout", file: "components/layout.prop.ts", vocabulary: [] },
770
776
  FlexAlignProp: { group: "layout", file: "components/layout.prop.ts", vocabulary: [] },
771
777
  FlexJustifyProp: { group: "layout", file: "components/layout.prop.ts", vocabulary: [] },
772
778
  FlexProp: {
@@ -779,6 +785,11 @@ const COMPONENT_PROP_REGISTRY = {
779
785
  reason: "Closed tag seam (div | span) so a Flex stays valid HTML inside a phrasing-only parent such as the <button> a TabsTrigger renders \u2014 same shape as ListRow's `as` (gh#354)."
780
786
  },
781
787
  { field: "direction", local: true, reason: "Flex-specific axis control." },
788
+ {
789
+ field: "marker",
790
+ local: true,
791
+ reason: 'List-marker seam for as="ul"/"ol" (disc | decimal | none). marker="none" emits no data-list, so a list keeps its element, its <li> semantics and its gap token while losing the bullet and the --space-5 indent \u2014 the home <ListRow as="li"> had none of, which forced a raw <ul> (gh#714).'
792
+ },
782
793
  "GapProp",
783
794
  { field: "align", local: true, reason: "Flex-specific align-items keyword subset." },
784
795
  { field: "justify", local: true, reason: "Flex-specific justify-content keyword subset." },
@@ -1328,6 +1339,32 @@ const COMPONENT_PROP_REGISTRY = {
1328
1339
  }
1329
1340
  ]
1330
1341
  },
1342
+ IconGlyphProp: {
1343
+ group: "general",
1344
+ file: "components/general.prop.ts",
1345
+ vocabulary: [
1346
+ "IconProp",
1347
+ "IconSizeProp",
1348
+ "TextToneProp",
1349
+ "LabelProp",
1350
+ "ClassNameProp",
1351
+ {
1352
+ field: "label",
1353
+ local: true,
1354
+ reason: "LabelProp narrowed to string: this becomes the glyph's aria-label, and an accessible name is TEXT \u2014 a ReactNode cannot be one."
1355
+ },
1356
+ {
1357
+ field: "as",
1358
+ local: true,
1359
+ reason: "The glyph COMPONENT to render \u2014 the IconProp concept, widened to a component that also forwards SVG props and a ref, because Icon renders ONTO the glyph (the sized element is the <svg> itself) rather than wrapping it."
1360
+ },
1361
+ {
1362
+ field: "size",
1363
+ local: true,
1364
+ reason: "IconSizeProp, the nine-step icon scale \u2014 deliberately NOT the four-step control SizeProp: a glyph beside 2xs caption text and a 48px empty-state mark are the same primitive."
1365
+ }
1366
+ ]
1367
+ },
1331
1368
  FloatButtonTypeProp: {
1332
1369
  group: "general",
1333
1370
  file: "components/general.prop.ts",
@@ -2,6 +2,6 @@
2
2
  export type { ClassNameProp, ChildrenProp, IdProp, OpenProp, DefaultOpenProp, OnOpenChangeProp, HandlerProp, PendingProp, RequiredProp, DisabledProp, LabelProp, HelperProp, ErrorProp, ErrorBagProp, PlaceholderProp, NameProp, ValueProp, DefaultValueProp, OnValueChangeProp, OnChangeProp, OnClickProp, AsChildProp, FlushProp, WidthProp, ControlWidthProp, AllowClearProp, MaxTagCountProp, MaxTagPlaceholderProp, NotFoundContentProp, PopupMatchWidthProp, } from "./shared.prop.js";
3
3
  export type { TitleProp, SubtitleProp, StatusProp, DescriptionProp, ExtraProp, FooterProp, ActionProp, IconProp, ConfirmLabelProp, CancelLabelProp, ActionsProp, EmptyMessageProp, } from "./content.prop.js";
4
4
  export type { PageDensityProp, PageContainerVariantProp, CenteredShellWidthProp, CenteredShellAlignProp, CenteredShellPresetProp, ErrorSurfaceModeProp, ErrorSurfaceStatusProp, AuthShellPresetProp, MobileShellHeightProp, MobileShellWidthProp, OrientationProp, TableDensityProp, DensityProp, GapNameProp, PadProp, PadRawProp, PadSides, GapProp, GapStepProp, } from "./layout.prop.js";
5
- export type { ButtonVariantProp, ButtonSizeProp, BadgeVariantProp, AppSettingPickerAppearanceProp, AppSettingToggleAppearanceProp, ShapeProp, AvatarShapeProp, TextSizeProp, TextToneProp, FontWeightProp, HeadingLevelProp, TextAlignProp, TextWhitespaceProp, TypographyTypeProp, TitleLevelProp, TypographyCopyConfigProp, TypographyEditConfigProp, TypographyEllipsisConfigProp, TypographyActionsConfigProp, SizeProp, ControlStatusProp, ControlVariantProp, FormLayoutProp, DescriptionsLayoutProp, BreakpointProp, ConfirmVariantProp, ToneProp, AlertVariantProp, SortDirectionProp, ColumnAlignProp, SortStateProp, RevealDelayProp, ActivityVariantProp, ActivityAnnounceProp, } from "./interaction.prop.js";
5
+ export type { ButtonVariantProp, ButtonSizeProp, BadgeVariantProp, AppSettingPickerAppearanceProp, AppSettingToggleAppearanceProp, ShapeProp, AvatarShapeProp, TextSizeProp, TextToneProp, FontWeightProp, HeadingLevelProp, TextAlignProp, TextWhitespaceProp, TypographyTypeProp, TitleLevelProp, TypographyCopyConfigProp, TypographyEditConfigProp, TypographyEllipsisConfigProp, TypographyActionsConfigProp, SizeProp, ControlStatusProp, ControlVariantProp, FormLayoutProp, DescriptionsLayoutProp, BreakpointProp, ConfirmVariantProp, ToneProp, AlertVariantProp, SortDirectionProp, ColumnAlignProp, SortStateProp, RevealDelayProp, ActivityVariantProp, ActivityAnnounceProp, IconSizeProp, } from "./interaction.prop.js";
6
6
  export type { BreadcrumbItemProp, BreadcrumbProp, BreadcrumbItemMenuProp, BreadcrumbItemMenuEntryProp, BreadcrumbSeparatorProp, BreadcrumbItemRenderProp, } from "./navigation.prop.js";
7
7
  export type { GetRowIdProp, GetRowLabelProp, OnRowClickProp, ColumnDefProp, TablePresetProp, TableColumnPriorityProp, TableCellIndentProp, SelectedIdsProp, OnSelectChangeProp, OnTableDensityChangeProp, TableRowToneProp, RowToneProp, OnSortChangeProp, OnSearchChangeProp, OnClearFiltersProp, HasActiveFiltersProp, StickyProp, ColumnFixedProp, ColumnFilterValueProp, ColumnFilterItemProp, ColumnFilterStateProp, OnColumnFilterChangeProp, ColumnCompareProp, ColumnSorterProp, TableSelectionItemProp, TableRowSelectionProp, TableExpandableProp, TableSummaryProp, TableScrollProp, TableStickyProp, OnRowProp, TablePaginationProp, TablePaginationPositionProp, DescriptionsColumnProp, DescriptionsSpanProp, DescriptionsItemsProp, } from "./data.prop.js";
@@ -31,6 +31,17 @@ export type ShapeProp = "default" | "pill" | "sharp";
31
31
  export type AvatarShapeProp = "circle" | "square";
32
32
  /** Text size — steps of the golden-ratio type scale (NEVER an arbitrary px). `sm` = base. */
33
33
  export type TextSizeProp = "2xs" | "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
34
+ /**
35
+ * Icon size — the NINE steps of the `--icon-size-*` scale (docs/TOKENS.md · gh#326), not the four
36
+ * of the control `SizeProp`.
37
+ *
38
+ * The icon axis already had a vocabulary before it had a name, and it is a FIXED LIST rather than
39
+ * a ratio: 14/16 = 0.875 but 20/16 = 1.25, because a 1px-stroke glyph cannot sit on half a pixel.
40
+ * Naming the whole list here — as `TextSizeProp` does for type — is what lets a consumer put a
41
+ * standalone glyph on the scale (`<Icon as={Lock} size="sm" />`) instead of reaching for a
42
+ * `className="size-4"` the consumer rules forbid.
43
+ */
44
+ export type IconSizeProp = "2xs" | "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
34
45
  /** Text colour intent — maps to semantic foreground tokens (no raw palette). */
35
46
  /**
36
47
  * `inherit` is the one that is NOT a colour: it takes whatever the surface already set (gh#643).
@@ -23,3 +23,5 @@
23
23
  @import "./navigation-layout.css";
24
24
  @import "./chart-layout.css";
25
25
  @import "./float-button-layout.css";
26
+
27
+ @import "./icon-layout.css";
@@ -0,0 +1,37 @@
1
+
2
+ @layer components {
3
+ svg.ui-icon[data-slot="icon"] {
4
+ inline-size: var(--icon-glyph-size);
5
+ block-size: var(--icon-glyph-size);
6
+
7
+ flex: 0 0 auto;
8
+ }
9
+
10
+ .ui-icon[data-size="2xs"] {
11
+ --icon-glyph-size: var(--icon-size-2xs);
12
+ }
13
+ .ui-icon[data-size="xs"] {
14
+ --icon-glyph-size: var(--icon-size-xs);
15
+ }
16
+ .ui-icon[data-size="sm"] {
17
+ --icon-glyph-size: var(--icon-size-sm);
18
+ }
19
+ .ui-icon[data-size="md"] {
20
+ --icon-glyph-size: var(--icon-size-md);
21
+ }
22
+ .ui-icon[data-size="lg"] {
23
+ --icon-glyph-size: var(--icon-size-lg);
24
+ }
25
+ .ui-icon[data-size="xl"] {
26
+ --icon-glyph-size: var(--icon-size-xl);
27
+ }
28
+ .ui-icon[data-size="2xl"] {
29
+ --icon-glyph-size: var(--icon-size-2xl);
30
+ }
31
+ .ui-icon[data-size="3xl"] {
32
+ --icon-glyph-size: var(--icon-size-3xl);
33
+ }
34
+ .ui-icon[data-size="4xl"] {
35
+ --icon-glyph-size: var(--icon-size-4xl);
36
+ }
37
+ }
@@ -25,3 +25,5 @@
25
25
  @import "./navigation-layout.css";
26
26
  @import "./chart-layout.css";
27
27
  @import "./float-button-layout.css";
28
+
29
+ @import "./icon-layout.css";
@@ -2083,6 +2083,16 @@
2083
2083
  flex: 0 0 auto;
2084
2084
  }
2085
2085
 
2086
+ .ui-topbar-item-icon {
2087
+ display: inline-flex;
2088
+ flex: 0 0 auto;
2089
+ align-items: center;
2090
+ }
2091
+ .ui-topbar-item-icon svg {
2092
+ inline-size: var(--topbar-icon-size);
2093
+ block-size: var(--topbar-icon-size);
2094
+ }
2095
+
2086
2096
  @media (width <= 68.75rem) {
2087
2097
  .ui-topbar-center {
2088
2098
  display: var(--topbar-center-compact-display);
@@ -51,37 +51,45 @@
51
51
  }
52
52
 
53
53
  [data-slot="text"][data-tone="default"],
54
- [data-slot="heading"][data-tone="default"] {
54
+ [data-slot="heading"][data-tone="default"],
55
+ [data-slot="icon"][data-tone="default"] {
55
56
  color: hsl(var(--foreground));
56
57
  }
57
58
  [data-slot="text"][data-tone="muted"],
58
- [data-slot="heading"][data-tone="muted"] {
59
+ [data-slot="heading"][data-tone="muted"],
60
+ [data-slot="icon"][data-tone="muted"] {
59
61
  color: hsl(var(--muted-foreground));
60
62
  }
61
63
 
62
64
  [data-slot="text"][data-tone="inherit"],
63
- [data-slot="heading"][data-tone="inherit"] {
65
+ [data-slot="heading"][data-tone="inherit"],
66
+ [data-slot="icon"][data-tone="inherit"] {
64
67
  color: inherit;
65
68
  }
66
69
  [data-slot="text"][data-tone="primary"],
67
- [data-slot="heading"][data-tone="primary"] {
70
+ [data-slot="heading"][data-tone="primary"],
71
+ [data-slot="icon"][data-tone="primary"] {
68
72
  color: hsl(var(--primary));
69
73
  }
70
74
 
71
75
  [data-slot="text"][data-tone="success"],
72
- [data-slot="heading"][data-tone="success"] {
76
+ [data-slot="heading"][data-tone="success"],
77
+ [data-slot="icon"][data-tone="success"] {
73
78
  color: hsl(var(--text-success));
74
79
  }
75
80
  [data-slot="text"][data-tone="warning"],
76
- [data-slot="heading"][data-tone="warning"] {
81
+ [data-slot="heading"][data-tone="warning"],
82
+ [data-slot="icon"][data-tone="warning"] {
77
83
  color: hsl(var(--text-warning));
78
84
  }
79
85
  [data-slot="text"][data-tone="destructive"],
80
- [data-slot="heading"][data-tone="destructive"] {
86
+ [data-slot="heading"][data-tone="destructive"],
87
+ [data-slot="icon"][data-tone="destructive"] {
81
88
  color: hsl(var(--text-error));
82
89
  }
83
90
  [data-slot="text"][data-tone="info"],
84
- [data-slot="heading"][data-tone="info"] {
91
+ [data-slot="heading"][data-tone="info"],
92
+ [data-slot="icon"][data-tone="info"] {
85
93
  color: hsl(var(--text-info));
86
94
  }
87
95
 
@@ -5,9 +5,9 @@ Read this once; the audit enforces it. Everything else in `docs/` is for contrib
5
5
  1. Load styles with `@import "@godxjp/ui/styles"` (fonts bundled, 729 sliced woff2 faces, ~11.7 MB), `@import "@godxjp/ui/styles/core"` (no `@font-face` at all), `@import "@godxjp/ui/styles/core-with-fallbacks"` (`core` + six `local()`-only metric-matched faces, still zero network bytes — for when you supply Noto Sans JP yourself) or `@import "@godxjp/ui/styles/core-with-jis-level1"` (`core-with-fallbacks` + Noto Sans JP merged to JIS X 0208 level 1: **3 requests, ~1.53 MB, once** instead of ~8 font requests on every navigation — for a Japanese app that wants the bundled face). Never cherry-pick `*-layout.css`.
6
6
  2. Every page is `<PageContainer title subtitle extra footer>`; its sections are spaced by the page. Group items inside a section with `<Flex direction="col" gap>` or `<ResponsiveGrid>`.
7
7
  3. No Tailwind layout on your own elements: no `flex`, `grid`, `gap-*`, `p-*`, `m-*`, `space-*`. Rows are `<Flex>` (default row), stacks are `<Flex direction="col">`, grids are `<ResponsiveGrid>`.
8
- 4. No hand-rolled surfaces: no `rounded-* border bg-*` divs. A box is `Card`, a pill is `Badge`, a person is `Avatar`, a row is `ListRow`, a label/value pair is `Descriptions`, an empty area is `EmptyState`, a read-only sample of a colour the USER chose is `Swatch`.
8
+ 4. No hand-rolled surfaces: no `rounded-* border bg-*` divs. A box is `Card`, a pill is `Badge`, a person is `Avatar`, a row is `ListRow`, a label/value pair is `Descriptions`, an empty area is `EmptyState`, a read-only sample of a colour the USER chose is `Swatch`. A LIST of those rows is `<Flex as="ul" marker="none" direction="col" gap="none">` with `<ListRow as="li">` children — `marker="none"` keeps the `<ul>`, the `<li>` semantics and the gap token while dropping the bullet and the indent. Never a raw `<ul>`/`<ol>` (it carries no gap token), never `<div role="list">` + `<div role="listitem">`, and never a wrapper around each row: the divider is `:not(:last-child)` among siblings, so a row alone in its own wrapper is always the last one and EVERY divider disappears silently. A bulleted prose list is the same `<Flex as="ul">` without `marker`.
9
9
  5. Real controls only: `Button`, `Input`, `Select`, `Textarea`, `Checkbox`… never raw `<button>`/`<input>`; a labelled control lives in `<FormField label>`. A Select outside a form takes `width="auto"`. **A disabled control's reason is visible text, never a tooltip** — see below.
10
- 6. Text is `<Text>` / `<Heading>` with `tone`, `size`, `weight`, `truncate`, `mono` — not `className="text-muted-foreground font-semibold"`.
10
+ 6. Text is `<Text>` / `<Heading>` with `tone`, `size`, `weight`, `truncate`, `mono` — not `className="text-muted-foreground font-semibold"`. A STANDALONE glyph is `<Icon as={Lock} size="sm" tone="muted" />` from `@godxjp/ui/general`: a bare lucide icon carries its own `width="24" height="24"` and only four places re-size it for you (`Button`, a `DropdownMenuItem`, a `TopbarItem`, a `ListRow` leading slot), so everywhere else — in a `Text`, a table cell, an `<a>` — it draws at 24px beside 14px type. `size-4` and `w-[16px]` are closed to you by rules 3 and 8, and `size={16}` hard-codes a number the theme owns; `Icon` puts the glyph on the `--icon-size-*` scale (`2xs…4xl`) instead, and is `aria-hidden` unless you give it a `label` (then it is `role="img"` with that name). The audit rule is `lucide-icon-needs-size`.
11
11
  7. Colours are semantic tokens (`tone="destructive"`, `bg-primary`), never palette names, hex, or `bg-black` / `text-white`.
12
12
  8. Sizes come from props (`size`, `width`, `columns`), never `w-[240px]` / `max-h-[420px]`.
13
13
  9. Logical directions (`ms-`, `me-`, `start-`, `end-`) when a utility is unavoidable; never `ml-` / `left-`.
@@ -3,8 +3,8 @@
3
3
  > Generated by `scripts/frame-coverage.mjs` (issue #163). Do not edit by hand — run `pnpm check:frame-coverage`.
4
4
  > Standard: [FRAME-COVERAGE-STANDARD.md](./FRAME-COVERAGE-STANDARD.md). A missing dimension is **UNTESTED**, never a pass.
5
5
 
6
- - Public components: **163**
7
- - With ≥1 frame: **142** · zero-frame: **21**
6
+ - Public components: **164**
7
+ - With ≥1 frame: **143** · zero-frame: **21**
8
8
  - Fully covered (every contract axis declared): **0**
9
9
 
10
10
  ## UNTESTED — no `/frame/**` route
@@ -74,6 +74,7 @@ Legend: ✓ covered · N/A reasoned skip · · UNTESTED (blank cell = UNTESTED).
74
74
  | Logo | ✓ | ✓ | N/A | N/A | · | · | · | · | · | · |
75
75
  | Reveal | ✓ | N/A | N/A | N/A | · | · | · | · | · | · |
76
76
  | Activity | ✓ | · | N/A | N/A | · | · | · | · | · | · |
77
+ | Icon | ✓ | · | N/A | N/A | · | · | · | · | · | · |
77
78
  | DataTable | ✓ | ✓ | · | · | · | · | · | · | · | · |
78
79
  | Card | ✓ | ✓ | N/A | N/A | · | · | · | · | · | · |
79
80
  | CardContent | ✓ | N/A | N/A | N/A | · | · | · | · | · | · |
@@ -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.4.1",
4
+ "godxUiMcp": "27.4.1",
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)) {