@helloimjolopez-pathway/pathway-components 0.0.0-stage → 0.1.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,155 @@
1
+ /**
2
+ * ActionIcon: the icon-only control Pathway puts in a header or a toolbar.
3
+ *
4
+ * SOURCE OF TRUTH: Figma set `Action Icon` 40006794:19891, on the
5
+ * `↳ ❇️ Action Icon` page. Nine variants on two axes, read and MEASURED
6
+ * 2026-10-07. Used 30 times inside the Widget set and twice inside KPI Tiles,
7
+ * which makes it the most-nested component in this part of the system and the
8
+ * one whose absence from Code Connect mattered most.
9
+ *
10
+ * MEASURED, per Size. The box is the target, `Container.Icon` is the state
11
+ * layer, and the glyph is the frame size an `<Icon>` takes:
12
+ *
13
+ * Size box Container.Icon glyph
14
+ * Small 36x36 24x24 r=8 12
15
+ * Base 44x44 28x28 r=8 14
16
+ * Large 48x48 32x32 r=8 16
17
+ *
18
+ * STATE TOKENS, read off all six state variants 2026-10-08.
19
+ *
20
+ * The state layer's fill:
21
+ * Default none
22
+ * Hover `Fill/Action/Primary/Subtle/Hover`
23
+ * Pressed `Fill/Action/Primary/Subtle/Pressed`
24
+ *
25
+ * The glyph's colour:
26
+ * Default `Foreground/Action/Secondary/Rest`
27
+ * Hover `Foreground/Action/Secondary/Hover`
28
+ * Pressed `Foreground/Action/Secondary/Pressed`
29
+ *
30
+ * `State` is Default / Hover / Pressed and is runtime, never a prop.
31
+ * `Icon` is an INSTANCE_SWAP, which in code is the `name` prop: any Material
32
+ * Symbols ligature.
33
+ *
34
+ * THE COMPONENT ITSELF BINDS NO FILL, in any of the nine variants, and the
35
+ * glyph's fill is unbound too so it inherits. That is why this takes its
36
+ * resting colour from `color` on the button and lets the glyph inherit it: the
37
+ * design expresses "whatever the surrounding context is" rather than a fixed
38
+ * icon colour, and a hard-coded one would break the Widget header, the KPI
39
+ * tile and a dark toolbar all differently.
40
+ *
41
+ * WHY IT LIVES HERE rather than in widget.jsx, where it used to. It was a
42
+ * private helper inside the Widget, so the Figma component had nothing
43
+ * importable to point at and the KPI Tile could not reuse it. The Widget now
44
+ * imports it.
45
+ */
46
+ import React, { useState } from "react";
47
+ import { Icon } from "./icon.jsx";
48
+ import { useReducedMotion } from "../_shared/use-reduced-motion.jsx";
49
+
50
+ const C = (n) => `var(--semantic-color-${n})`;
51
+ const U = (n) => `var(--semantic-layout-units-${n})`;
52
+ const M = (n) => `var(--motion-${n})`;
53
+
54
+ /* Read off the Action Icon set, every variant, 2026-10-12. `box` is the button,
55
+ `layer` is the state-layer frame the hover and pressed fills go on, `glyph` is
56
+ the nested Material Symbols instance.
57
+
58
+ THE GLYPHS WERE EVERY ONE 2px TOO SMALL. This read 12 / 14 / 16, which is the
59
+ sequence one step down from the real one, so every action icon in the system
60
+ rendered small inside a correctly sized target: the Widget header's 16px
61
+ ellipsis drew at 14. The three state-layer sizes were right, which is why it
62
+ survived a measurement pass that compared targets and layers and never looked
63
+ at the glyph. Figma's nested instance is 14 at Small, 16 at Base, 18 at Large. */
64
+ export const ACTION_ICON_SIZES = {
65
+ Small: { box: 36, layer: 24, glyph: 14 },
66
+ Base: { box: 44, layer: 28, glyph: 16 },
67
+ Large: { box: 48, layer: 32, glyph: 18 },
68
+ };
69
+
70
+ export const T = {
71
+ rest: C("foreground-action-secondary-rest"),
72
+ hover: C("foreground-action-secondary-hover"),
73
+ pressed: C("foreground-action-secondary-pressed"),
74
+ /* THE STATE LAYER IS ACTION/PRIMARY/SUBTLE, NOT ACTION/SECONDARY.
75
+ Read off the set's six state variants on 2026-10-08: Hover binds
76
+ Fill/Action/Primary/Subtle/Hover and Pressed binds /Pressed. This file used
77
+ the SECONDARY family, which is a neutral grey, where the design is a light
78
+ blue. That is the single most visible token error in the component, because
79
+ the hover state is the only thing the layer is for. */
80
+ layerHover: C("fill-action-primary-subtle-hover"),
81
+ layerPress: C("fill-action-primary-subtle-pressed"),
82
+ };
83
+
84
+ /**
85
+ * @param {string} name Material Symbols ligature, Figma's `Icon` swap.
86
+ * @param {"Small"|"Base"|"Large"} size Figma's Size axis.
87
+ * @param {string} label Required: an icon-only control with no accessible
88
+ * name is unusable, so this is not optional.
89
+ * @param {0|1} fill FILL axis. The Widget header is FILL 0.
90
+ * @param {string} color Override the resting colour. Omit on a light surface;
91
+ * pass one on a dark or on-brand surface, where Ghost
92
+ * is the right family.
93
+ */
94
+ export function ActionIcon({
95
+ name,
96
+ size = "Small",
97
+ label,
98
+ fill = 0,
99
+ color,
100
+ onClick,
101
+ disabled,
102
+ className = "",
103
+ style,
104
+ ...rest
105
+ }) {
106
+ const noMotion = useReducedMotion();
107
+ const [hov, setHov] = useState(false);
108
+ const [press, setPress] = useState(false);
109
+ const s = ACTION_ICON_SIZES[size] || ACTION_ICON_SIZES.Small;
110
+ const fg = disabled ? T.rest : press ? T.pressed : hov ? T.hover : (color || T.rest);
111
+
112
+ return (
113
+ <button
114
+ type="button"
115
+ aria-label={label}
116
+ title={label}
117
+ disabled={disabled}
118
+ onClick={onClick}
119
+ onMouseEnter={() => setHov(true)}
120
+ onMouseLeave={() => { setHov(false); setPress(false); }}
121
+ onMouseDown={() => setPress(true)}
122
+ onMouseUp={() => setPress(false)}
123
+ onBlur={() => setPress(false)}
124
+ className={`pw-action-icon ${className}`}
125
+ data-size={size}
126
+ style={{
127
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
128
+ width: s.box, height: s.box, flexShrink: 0, boxSizing: "border-box",
129
+ background: "transparent", border: "none", padding: 0,
130
+ cursor: disabled ? "default" : "pointer",
131
+ opacity: disabled ? 0.5 : 1,
132
+ color: fg,
133
+ ...style,
134
+ }}
135
+ {...rest}
136
+ >
137
+ {/* Container.Icon is the state layer, and it is SMALLER than the target.
138
+ The fill belongs on this box, not on the button: painting the target
139
+ gives a hover area visibly larger than the one in the design, which
140
+ is the bug the Widget header shipped with once. */}
141
+ <span style={{
142
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
143
+ width: s.layer, height: s.layer, borderRadius: U("cornerradius-base"),
144
+ background: press ? T.layerPress : hov ? T.layerHover : "transparent",
145
+ transition: noMotion ? "none" : `background ${M("duration-2")} ${M("easing-standard")}`,
146
+ }}>
147
+ <Icon name={name} size={s.glyph} fill={fill} style={{
148
+ transition: noMotion ? "none" : `color ${M("duration-2")} ${M("easing-standard")}`,
149
+ }} />
150
+ </span>
151
+ </button>
152
+ );
153
+ }
154
+
155
+ export default ActionIcon;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * DisplayIcon: a decorative icon in a tinted circle, used by alerts, empty
3
+ * states and the KPI tile's featured icon slot.
4
+ *
5
+ * SOURCE OF TRUTH: Figma set `DisplayIcon` 40006522:26547 on the
6
+ * `↳ ❇️ Display Icon` page. 21 variants on Size x Color, plus an
7
+ * INSTANCE_SWAP for the glyph, read and MEASURED 2026-10-07.
8
+ *
9
+ * MEASURED
10
+ * Size box glyph
11
+ * Small 24x24 12
12
+ * Medium 32x32 16
13
+ * Large 40x40 20
14
+ *
15
+ * Color Accent | Positive | Negative | Danger | Neutral | Info | Alert
16
+ *
17
+ * THE COLOUR AXIS MAPS ONTO THE MEANING-NAMED TONE GROUPS, not onto hue names,
18
+ * which is what CLAUDE.md §2.0 requires. `Danger` and `Negative` are both in
19
+ * the Figma axis and are different tones in the token set: Negative is Red and
20
+ * Severe is Orange, so Danger takes Severe. `Accent` has no single meaning, so
21
+ * it takes Brand rather than one of the four decorative accents, because a
22
+ * display icon on an alert is not decoration.
23
+ *
24
+ * Each tone draws its SUBTLE fill with its ON SUBTLE foreground, which is the
25
+ * pairing the token set guarantees and `check-fill-foreground-pairs` asserts.
26
+ * Reaching for a Strong fill here would put tone text on a tone block.
27
+ *
28
+ * THE TOKENS BELOW ARE READ BACK FROM FIGMA, not derived from the names.
29
+ *
30
+ * What was there before, and what it took to find it: the variant itself has no
31
+ * fill and the nested glyph instance has no fill, so a reader checking those two
32
+ * nodes concludes the Color axis is unbound. It is not: the bindings live on the
33
+ * `Container` frame and on the `_shape` vector INSIDE the glyph instance. A
34
+ * first pass reported "no tokens at all", which was wrong.
35
+ *
36
+ * The real defects, found by reading all 21 variants on 2026-10-08 and fixed in
37
+ * the file the same day:
38
+ *
39
+ * - Only 4 of 21 glyphs were bound. The other 17 were raw fills.
40
+ * - Two of those 4 used HUE-NAMED tokens, `Foreground/Static/Amethyst/Bold`
41
+ * and `Foreground/Static/Green/Bold`, which the semantic tier forbids and
42
+ * which are not even in the local variable set any more.
43
+ * - `Neutral` drew `Fill/Action/Secondary/Hover`: an ACTION token on a
44
+ * decorative element that has no states.
45
+ * - `Info` drew `Fill/Static/Brand/Faint`, and Faint has no paired On Subtle
46
+ * foreground, so the pairing the token set guarantees did not exist for it.
47
+ * - Every Container carried a paint STYLE as well as a variable, which is two
48
+ * sources of truth for one colour. The styles were cleared.
49
+ */
50
+ import React from "react";
51
+ import { Icon } from "./icon.jsx";
52
+
53
+ const C = (n) => `var(--semantic-color-${n})`;
54
+
55
+ const U = (n) => `var(--semantic-layout-units-${n})`;
56
+
57
+ export const L = {
58
+ // Figma: Container cornerRadius 8 on all 21 variants.
59
+ radius: U("cornerradius-base"),
60
+ };
61
+
62
+ export const DISPLAY_ICON_SIZES = {
63
+ Small: { box: 24, glyph: 12 },
64
+ Medium: { box: 32, glyph: 16 },
65
+ Large: { box: 40, glyph: 20 },
66
+ };
67
+
68
+ /**
69
+ * Figma's Color axis, read back from the file after binding on 2026-10-08.
70
+ *
71
+ * EACH VALUE KEEPS THE HUE IT ALREADY RENDERED. The names are not a clean map
72
+ * onto the tone groups and that is the file's own history, not something to
73
+ * tidy here: `Accent` resolves to INFO (amethyst) and `Info` resolves to BRAND.
74
+ * Repointing either would repaint a variant rather than fix a mapping, so the
75
+ * binding kept the hue and fixed only the tier and the pairing.
76
+ *
77
+ * An earlier version of this file guessed Accent to Brand and Info to Info,
78
+ * which is those two swapped, so a consumer asking for Accent got blue where
79
+ * the design is amethyst.
80
+ */
81
+ export const DISPLAY_ICON_COLORS = {
82
+ // Accent is amethyst, which is the Info tone in the token set.
83
+ Accent: { fill: C("fill-static-info-subtle"), fg: C("foreground-static-info-on-subtle") },
84
+ Positive: { fill: C("fill-static-positive-subtle"), fg: C("foreground-static-positive-on-subtle") },
85
+ Negative: { fill: C("fill-static-negative-subtle"), fg: C("foreground-static-negative-on-subtle") },
86
+ // Danger is Severe (orange), a different tone from Negative (red). Both are
87
+ // in the Figma axis, so they must stay different here too.
88
+ Danger: { fill: C("fill-static-severe-subtle"), fg: C("foreground-static-severe-on-subtle") },
89
+ Alert: { fill: C("fill-static-attention-subtle"), fg: C("foreground-static-attention-on-subtle") },
90
+ // Neutral has no On Subtle rung, so its foreground is Base.
91
+ Neutral: { fill: C("fill-static-neutral-subtle"), fg: C("foreground-static-neutral-base") },
92
+ // Info is the BRAND blue in this component, not the Info tone.
93
+ Info: { fill: C("fill-static-brand-subtle"), fg: C("foreground-static-brand-on-subtle") },
94
+ };
95
+
96
+ /**
97
+ * @param {string} name Material Symbols ligature, Figma's `IconInstance` swap.
98
+ * @param {"Small"|"Medium"|"Large"} size
99
+ * @param {keyof DISPLAY_ICON_COLORS} color Figma's Color axis.
100
+ * @param {string} label Omit for a decorative icon, which is the common case:
101
+ * a display icon almost always sits beside a heading
102
+ * that already says what it means.
103
+ */
104
+ export function DisplayIcon({
105
+ name,
106
+ size = "Small",
107
+ color = "Accent",
108
+ fill = 1,
109
+ label,
110
+ className = "",
111
+ style,
112
+ ...rest
113
+ }) {
114
+ const s = DISPLAY_ICON_SIZES[size] || DISPLAY_ICON_SIZES.Small;
115
+ const tone = DISPLAY_ICON_COLORS[color] || DISPLAY_ICON_COLORS.Accent;
116
+ return (
117
+ <span
118
+ className={`pw-display-icon ${className}`}
119
+ data-size={size}
120
+ data-color={color}
121
+ style={{
122
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
123
+ width: s.box, height: s.box, flexShrink: 0,
124
+ /* r=8 ON EVERY VARIANT, a rounded square. This drew a full circle, which
125
+ is a different shape: Figma's Container corner radius is 8 at all
126
+ three sizes, and only the Featured icon's Light outline type is round. */
127
+ borderRadius: L.radius,
128
+ background: tone.fill, color: tone.fg, ...style,
129
+ }}
130
+ {...rest}
131
+ >
132
+ <Icon name={name} size={s.glyph} fill={fill} label={label} />
133
+ </span>
134
+ );
135
+ }
136
+
137
+ export default DisplayIcon;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * FeaturedIcon: the large icon badge a KPI tile puts above its number.
3
+ *
4
+ * WHY THIS EXISTS. The KPI Tiles set nests a FOREIGN component here: `Featured
5
+ * icon` 40003806:3550, a remote library component from Untitled UI, the same
6
+ * source as the `↳ Select` page in CLAUDE.md §8.1. Eighty-four variants of it,
7
+ * ten instances inside a Pathway component, none of them ours and none of them
8
+ * mappable from this repo because a Code Connect mapping publishes into the
9
+ * file that owns the component.
10
+ *
11
+ * THE FOREIGN COMPONENT WAS ALREADY USING PATHWAY'S COLOURS, just not bound to
12
+ * them. Every one of the seven raw hexes in the four variants the KPI Tile uses
13
+ * matches an existing Pathway semantic token EXACTLY, distance 0 in RGB:
14
+ *
15
+ * #dff6e2 Fill/Static/Positive/Subtle
16
+ * #dcd9ef Fill/Static/Info/Subtle
17
+ * #353063 Foreground/Static/Info/On Subtle
18
+ * #ffffff Fill/Surface/Elevated
19
+ * #e6e2dc Stroke/Static/Neutral/Base
20
+ * #313131 Foreground/Static/Neutral/Base
21
+ * #001f00 Foreground/Static/Positive/On Strong
22
+ *
23
+ * So nothing here is a redesign. It is the same drawing on the tokens it was
24
+ * already the colour of.
25
+ *
26
+ * MEASURED, from the four variants the KPI Tile actually nests:
27
+ *
28
+ * Type=Light outline r=28, so a circle
29
+ * lg 48x48, glyph 24
30
+ * fill and stroke are THE SAME HEX at sw=8, so the ring is invisible and
31
+ * the shape is a flat tinted circle. Reproduced as a solid circle, because
32
+ * an 8px stroke in the fill's own colour is decoration that draws nothing.
33
+ *
34
+ * Type=Modern r=8, a rounded square. NOT tone-coloured:
35
+ * lg 48x48, glyph 24
36
+ * md 40x40, glyph 20
37
+ * Fill/Surface/Elevated, 1px Stroke/Static/Neutral/Base, inside align,
38
+ * plus a shadow. Its glyph is Foreground/Static/Neutral/Base.
39
+ *
40
+ * ONE DEPARTURE AND ONE GAP, both deliberate:
41
+ *
42
+ * - The Positive glyph takes `On Subtle` (#0d351b) rather than the `On
43
+ * Strong` (#001f00) the foreign component used. On Strong is the
44
+ * foreground for a SOLID fill and this glyph sits on a Subtle one, so the
45
+ * exact-hex match would have been a pairing violation. On Subtle measures
46
+ * 11.94:1 on that tint against On Strong's 15.4:1, so both clear AAA and
47
+ * the difference is invisible at 24px. This also makes Positive consistent
48
+ * with the Info variant, which was already an exact match AND the correct
49
+ * pairing.
50
+ *
51
+ * - Figma's Modern shadow is `0 1 2 rgba(10,13,18,0.05)` plus two inner
52
+ * shadows. Pathway's smallest elevation token is `--elevation-widget`,
53
+ * `0 2px 8px -2px`, so there is no exact match and the inner shadows have
54
+ * no token at all. Rather than invent one, this uses the nearest existing
55
+ * token and the gap is recorded in the spec. The inner shadows are dropped:
56
+ * they are an Untitled UI flourish, not a Pathway treatment.
57
+ */
58
+ import React from "react";
59
+ import { Icon } from "./icon.jsx";
60
+
61
+ const C = (n) => `var(--semantic-color-${n})`;
62
+ const U = (n) => `var(--semantic-layout-units-${n})`;
63
+
64
+ export const FEATURED_ICON_SIZES = {
65
+ md: { box: 40, glyph: 20 },
66
+ lg: { box: 48, glyph: 24 },
67
+ };
68
+
69
+ /**
70
+ * The tones, on the same pairing every Pathway tone group guarantees: a Subtle
71
+ * fill with its On Subtle foreground. Matched to DisplayIcon's table so the two
72
+ * cannot disagree, including that `Accent` is amethyst (Info) and `Info` is
73
+ * brand, which is the file's own history.
74
+ */
75
+ export const FEATURED_ICON_COLORS = {
76
+ Accent: { fill: C("fill-static-info-subtle"), fg: C("foreground-static-info-on-subtle") },
77
+ Positive: { fill: C("fill-static-positive-subtle"), fg: C("foreground-static-positive-on-subtle") },
78
+ Negative: { fill: C("fill-static-negative-subtle"), fg: C("foreground-static-negative-on-subtle") },
79
+ Danger: { fill: C("fill-static-severe-subtle"), fg: C("foreground-static-severe-on-subtle") },
80
+ Alert: { fill: C("fill-static-attention-subtle"), fg: C("foreground-static-attention-on-subtle") },
81
+ Neutral: { fill: C("fill-static-neutral-subtle"), fg: C("foreground-static-neutral-base") },
82
+ Info: { fill: C("fill-static-brand-subtle"), fg: C("foreground-static-brand-on-subtle") },
83
+ };
84
+
85
+ export const FEATURED_ICON_TYPES = ["outline", "modern"];
86
+
87
+ export const T = {
88
+ chipFill: C("fill-surface-elevated"),
89
+ chipBorder: C("stroke-static-neutral-base"),
90
+ chipGlyph: C("foreground-static-neutral-base"),
91
+ };
92
+
93
+ export const L = {
94
+ // Light outline is r=28 on a 48 box, which is past half the width, so it is a
95
+ // circle rather than a specific radius. CornerRadius/Full says that.
96
+ circle: U("cornerradius-full"),
97
+ // Modern is r=8 at both sizes.
98
+ chip: U("cornerradius-base"),
99
+ border: "var(--semantic-layout-units-borderwidth-base)",
100
+ };
101
+
102
+ /**
103
+ * @param {string} name Material Symbols ligature.
104
+ * @param {"md"|"lg"} size Figma's Size axis, the two values the design uses.
105
+ * @param {"outline"|"modern"} type Figma's Type axis. `outline` is the tinted
106
+ * circle and takes `color`; `modern` is the white chip
107
+ * and ignores it, because it is not tone-coloured.
108
+ * @param {keyof FEATURED_ICON_COLORS} color Only meaningful for `outline`.
109
+ * @param {string} glyphColor Override the glyph colour with a semantic token's
110
+ * var(). Needed because Figma is NOT consistent here: the
111
+ * KPI Tile's Icon 01 binds its glyph to
112
+ * Foreground/Static/Positive/On STRONG while Icon 02 binds
113
+ * Info/On Subtle, for the same Light outline type. Rather
114
+ * than silently normalise one of them, the component
115
+ * defaults to the correct On Subtle pairing and the call
116
+ * site can say when the file differs.
117
+ * @param {string} label Omit for a decorative badge, which is the common case:
118
+ * a featured icon sits above a heading that already says
119
+ * what it means.
120
+ */
121
+ export function FeaturedIcon({
122
+ name = "trending_up",
123
+ size = "lg",
124
+ type = "outline",
125
+ color = "Positive",
126
+ glyphColor,
127
+ fill = 1,
128
+ label,
129
+ className = "",
130
+ style,
131
+ ...rest
132
+ }) {
133
+ const s = FEATURED_ICON_SIZES[size] || FEATURED_ICON_SIZES.lg;
134
+ const modern = type === "modern";
135
+ const tone = FEATURED_ICON_COLORS[color] || FEATURED_ICON_COLORS.Positive;
136
+
137
+ return (
138
+ <span
139
+ className={`pw-featured-icon ${className}`}
140
+ data-size={size}
141
+ data-type={type}
142
+ data-color={modern ? "Neutral" : color}
143
+ style={{
144
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
145
+ width: s.box, height: s.box, flexShrink: 0, boxSizing: "border-box",
146
+ borderRadius: modern ? L.chip : L.circle,
147
+ background: modern ? T.chipFill : tone.fill,
148
+ color: glyphColor || (modern ? T.chipGlyph : tone.fg),
149
+ // Modern's 1px border is INSIDE-aligned in Figma, which border-box
150
+ // reproduces: the chip stays 48, the border eats into it.
151
+ border: modern ? `${L.border} solid ${T.chipBorder}` : "none",
152
+ boxShadow: modern ? "var(--elevation-widget)" : "none",
153
+ ...style,
154
+ }}
155
+ {...rest}
156
+ >
157
+ <Icon name={name} size={s.glyph} fill={fill} label={label} />
158
+ </span>
159
+ );
160
+ }
161
+
162
+ export default FeaturedIcon;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Icon: the one place Pathway renders an icon.
3
+ *
4
+ * WHY THIS FILE EXISTS. Every Figma icon component in this system had NO code
5
+ * to point at. Code Connect listed them with no path and no link, so a
6
+ * developer opening an icon in Figma was told nothing about how to render one.
7
+ * The repo was part of the reason: six components each carried their OWN
8
+ * private `Icon` or `Glyph` helper (top-nav, search, org-switcher, dashboard,
9
+ * kpi-tile, plus the sidenav's inline spans), all doing the same thing
10
+ * slightly differently, and none of them importable. There was nothing to
11
+ * link BECAUSE there was no shared module.
12
+ *
13
+ * WHAT A PATHWAY ICON ACTUALLY IS, since this is the question that keeps
14
+ * coming up. It is not an SVG file and there is no per-icon asset to fetch.
15
+ * It is one variable font, Material Symbols Rounded, and an icon is its NAME
16
+ * written as text inside an element that has the font applied. The browser's
17
+ * ligature substitution turns the text `arrow_forward` into the arrow glyph.
18
+ * So:
19
+ *
20
+ * the font Material Symbols Rounded, from Google Fonts
21
+ * the stylesheet https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded
22
+ * :opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200
23
+ * the catalogue https://fonts.google.com/icons (set Style = Rounded)
24
+ * the source https://github.com/google/material-design-icons
25
+ * one icon <span class="material-symbols-rounded">arrow_forward</span>
26
+ *
27
+ * The name you write is the folder name in that repo and the name shown in
28
+ * that catalogue. Nothing is imported per icon, which is the whole appeal: the
29
+ * font carries every glyph, so adding an icon costs nothing.
30
+ *
31
+ * THE FOUR AXES are variable-font axes, not separate fonts:
32
+ *
33
+ * FILL 0 outlined, 1 solid. READ IT FROM FIGMA PER COMPONENT, never assume.
34
+ * SideNav is FILL 1 throughout; the Widget header is FILL 0.
35
+ * wght 400 everywhere in Pathway.
36
+ * GRAD 0 everywhere in Pathway.
37
+ * opsz matched to the font size, which is what keeps the stroke weight even
38
+ * across sizes. A 16px icon at opsz 20 is visibly lighter than it
39
+ * should be, and that defect shipped once in the SideNav rail.
40
+ *
41
+ * SIZE IS THE FRAME, NEVER THE VECTOR. Figma nests a wrapper (24), an icon
42
+ * frame (16) and the drawn vector (~12), because the Material grid has about
43
+ * 2px of built-in padding. `size` here is the FRAME, so `size={16}` reproduces
44
+ * Figma's 16 and lands the visible glyph near 12. Passing the vector's 12
45
+ * ships an icon a third too small. See docs/design-system-spec.md §7.2.
46
+ */
47
+ import React from "react";
48
+
49
+ /** The stylesheet every surface that renders an icon must load. */
50
+ export const MATERIAL_SYMBOLS_HREF =
51
+ "https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded" +
52
+ ":opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200";
53
+
54
+ /** Where the names come from, for anyone looking for one. */
55
+ export const ICON_SOURCES = {
56
+ catalogue: "https://fonts.google.com/icons",
57
+ repo: "https://github.com/google/material-design-icons",
58
+ webSymbols: "https://github.com/google/material-design-icons/tree/master/symbols/web",
59
+ };
60
+
61
+ /** Pathway's fixed axis values. Only FILL and opsz ever vary. */
62
+ export const AXES = { wght: 400, GRAD: 0 };
63
+
64
+ /**
65
+ * The per-size table from docs/design-system-spec.md §7.2. `wrapper` is the
66
+ * container Figma draws around the frame, and is the touch or alignment box
67
+ * rather than anything the glyph itself occupies.
68
+ */
69
+ export const SIZES = {
70
+ L: { frame: 18, wrapper: 26 },
71
+ M: { frame: 16, wrapper: 24 },
72
+ S: { frame: 14, wrapper: 20 },
73
+ };
74
+
75
+ /**
76
+ * @param {string} name The Material Symbols ligature, e.g. "arrow_forward".
77
+ * Same string as the folder name in Google's repo.
78
+ * @param {number|"L"|"M"|"S"} size The FRAME size. A letter resolves through
79
+ * SIZES; a number is used as-is. Default M, 16.
80
+ * @param {0|1} fill FILL axis. Read it from Figma; never assume.
81
+ * @param {string} color Any CSS colour. Pass a semantic token's var(), never
82
+ * a hex. Omit to inherit, which is usually right: an
83
+ * icon beside a label should take the label's colour.
84
+ * @param {string} label An accessible name. Omit for a decorative icon and it
85
+ * is hidden from assistive tech, which is the default
86
+ * because most icons sit beside their own text.
87
+ */
88
+ export function Icon({
89
+ name,
90
+ size = "M",
91
+ fill = 0,
92
+ color,
93
+ label,
94
+ className = "",
95
+ style,
96
+ ...rest
97
+ }) {
98
+ const frame = typeof size === "number" ? size : (SIZES[size] || SIZES.M).frame;
99
+ return (
100
+ <span
101
+ className={`material-symbols-rounded ${className}`}
102
+ // Decorative by default. A label makes it an image with a name.
103
+ role={label ? "img" : undefined}
104
+ aria-label={label || undefined}
105
+ aria-hidden={label ? undefined : true}
106
+ style={{
107
+ fontSize: frame,
108
+ lineHeight: 1,
109
+ display: "block",
110
+ flexShrink: 0,
111
+ color,
112
+ // opsz TRACKS THE FONT SIZE. Pinning it to 20 while the glyph renders
113
+ // at 16 makes the stroke too light, which is what went wrong in the
114
+ // collapsed SideNav rail.
115
+ fontVariationSettings: `'FILL' ${fill}, 'wght' ${AXES.wght}, 'GRAD' ${AXES.GRAD}, 'opsz' ${frame}`,
116
+ ...style,
117
+ }}
118
+ {...rest}
119
+ >
120
+ {name}
121
+ </span>
122
+ );
123
+ }
124
+
125
+ /**
126
+ * The icon in its Figma wrapper: the alignment box, not the glyph.
127
+ *
128
+ * Use it when you are reproducing a Figma frame that nests
129
+ * `Container.LeadingIcon` around `Icon.Leading`. A bare `<Icon>` is the frame
130
+ * alone and is what most layouts want.
131
+ */
132
+ export function IconBox({ name, size = "M", fill = 0, color, label, style, ...rest }) {
133
+ const s = typeof size === "string" ? (SIZES[size] || SIZES.M) : { frame: size, wrapper: size + 8 };
134
+ return (
135
+ <span style={{
136
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
137
+ width: s.wrapper, height: s.wrapper, flexShrink: 0, ...style,
138
+ }} {...rest}>
139
+ <Icon name={name} size={s.frame} fill={fill} color={color} label={label} />
140
+ </span>
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Drop the stylesheet in. Storybook and the HTML demos load it in their own
146
+ * head; this is for a consumer who renders an Icon without having done that,
147
+ * because a missing font shows the LIGATURE NAME as text rather than failing,
148
+ * and "arrow_forward" in the middle of a button is a confusing bug to chase.
149
+ */
150
+ export const IconFont = () => (
151
+ <link rel="stylesheet" href={MATERIAL_SYMBOLS_HREF} />
152
+ );
153
+
154
+ export default Icon;