@stateglyph/cli 0.1.0 → 0.2.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 (3) hide show
  1. package/README.md +5 -0
  2. package/dist/index.js +38 -67
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -21,4 +21,9 @@ The generated components are TypeScript React files. They are self-contained
21
21
  and depend only on `react` and `lucide-react`, so you can edit them freely in
22
22
  your own project.
23
23
 
24
+ Each copied component includes animated outgoing/incoming glyph transitions,
25
+ continuous rotation where defined, and live reduced-motion support. Customize
26
+ `animated`, `duration`, `spinDuration`, and `transition` without installing a
27
+ StateGlyph runtime package.
28
+
24
29
  MIT licensed. The source icons from Lucide use the ISC License.
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import { readFileSync } from "fs";
4
5
  import { Command } from "commander";
5
6
 
6
7
  // src/add.ts
@@ -8,6 +9,9 @@ import { access, mkdir, writeFile } from "fs/promises";
8
9
  import path from "path";
9
10
  import { stateIconCatalog } from "@stateglyph/core";
10
11
 
12
+ // src/animation-source.ts
13
+ var animationSource = '"use client";\n\nimport {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type SVGProps,\n} from "react";\nimport type { LucideIcon } from "lucide-react";\n\ntype TransitionName = "crossfade" | "scale-fade" | "rotate" | "slide" | "morph";\n\ntype AnimatedIconProps = Omit<\n SVGProps<SVGSVGElement>,\n "children" | "dangerouslySetInnerHTML"\n> & {\n icon: LucideIcon;\n iconName: string;\n iconId: string;\n state: string;\n transition?: TransitionName;\n duration?: number;\n spinDuration?: number;\n animated?: boolean;\n continuous?: boolean;\n decorative?: boolean;\n label?: string;\n size?: number | string;\n strokeWidth?: number;\n};\n\ntype Snapshot = Pick<\n AnimatedIconProps,\n "icon" | "iconName" | "iconId" | "state" | "continuous"\n>;\ntype Frame = { current: Snapshot; previous: Snapshot | null; revision: number };\n\nconst defaultDurations = {\n crossfade: 180,\n "scale-fade": 200,\n rotate: 240,\n slide: 220,\n morph: 240,\n};\n\nfunction motionPreference() {\n return (\n typeof window !== "undefined" &&\n !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches\n );\n}\n\nfunction subscribeToMotion(callback: () => void) {\n if (typeof window === "undefined" || !window.matchMedia) return () => {};\n const query = window.matchMedia("(prefers-reduced-motion: reduce)");\n query.addEventListener("change", callback);\n return () => query.removeEventListener("change", callback);\n}\n\nfunction milliseconds(value: string, fallback: number) {\n const parsed = Number.parseFloat(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(\n 0,\n value.trim().endsWith("ms")\n ? parsed\n : value.trim().endsWith("s")\n ? parsed * 1000\n : parsed,\n );\n}\n\nfunction transitionFrames(transition: TransitionName): {\n incoming: Keyframe[];\n outgoing: Keyframe[];\n} {\n const rest = {\n opacity: 1,\n transform: "translateY(0) scale(1) rotate(0deg)",\n filter: "blur(0px)",\n };\n const poses = {\n crossfade: [{ opacity: 0 }, { opacity: 0 }],\n "scale-fade": [\n { opacity: 0, transform: "scale(0.65)" },\n { opacity: 0, transform: "scale(1.2)" },\n ],\n rotate: [\n { opacity: 0, transform: "rotate(-60deg) scale(0.8)" },\n { opacity: 0, transform: "rotate(60deg) scale(0.8)" },\n ],\n slide: [\n { opacity: 0, transform: "translateY(6px)" },\n { opacity: 0, transform: "translateY(-6px)" },\n ],\n morph: [\n {\n opacity: 0,\n transform: "scale(0.75) rotate(-18deg)",\n filter: "blur(2px)",\n },\n {\n opacity: 0,\n transform: "scale(0.75) rotate(18deg)",\n filter: "blur(2px)",\n },\n ],\n } satisfies Record<TransitionName, [Keyframe, Keyframe]>;\n const [start, end] = poses[transition];\n return { incoming: [start, rest], outgoing: [rest, end] };\n}\n\nfunction AnimatedIcon({\n icon,\n iconName,\n iconId,\n state,\n continuous = false,\n transition = "crossfade",\n duration,\n spinDuration,\n animated = true,\n decorative = true,\n label,\n size = 24,\n strokeWidth = 2,\n className,\n style,\n ...svgProps\n}: AnimatedIconProps) {\n const reducedMotion = useSyncExternalStore(\n subscribeToMotion,\n motionPreference,\n () => false,\n );\n const rootRef = useRef<SVGSVGElement>(null);\n const incomingRef = useRef<SVGGElement>(null);\n const outgoingRef = useRef<SVGGElement>(null);\n const spinRef = useRef<SVGGElement>(null);\n const [frame, setFrame] = useState<Frame>(() => ({\n current: { icon, iconName, iconId, state, continuous },\n previous: null,\n revision: 0,\n }));\n\n // Keep the outgoing glyph until its exit completes. Updating a guarded snapshot\n // during render avoids an intermediate paint with the new state and old glyph.\n if (\n frame.current.state !== state ||\n frame.current.iconId !== iconId ||\n frame.current.icon !== icon ||\n frame.current.continuous !== continuous\n ) {\n setFrame({\n current: { icon, iconName, iconId, state, continuous },\n previous: frame.current.iconId === iconId ? frame.current : null,\n revision: frame.revision + 1,\n });\n }\n\n useEffect(() => {\n let active = true;\n const animations: Animation[] = [];\n const root = rootRef.current;\n const incoming = incomingRef.current;\n const outgoing = outgoingRef.current;\n const finish = () => {\n if (!active) return;\n setFrame((current) =>\n current.revision === frame.revision && current.previous\n ? { ...current, previous: null }\n : current,\n );\n };\n const computed = root ? getComputedStyle(root) : null;\n const timing = Number.isFinite(duration)\n ? Math.max(0, duration!)\n : milliseconds(\n (computed?.getPropertyValue("--stateglyph-duration") ||\n computed?.getPropertyValue("--stateglyph-preset-duration")) ??\n "",\n defaultDurations[transition],\n );\n const easing =\n computed?.getPropertyValue("--stateglyph-easing").trim() ||\n computed?.getPropertyValue("--stateglyph-preset-easing").trim() ||\n "cubic-bezier(0.22, 1, 0.36, 1)";\n const canAnimate =\n animated && !reducedMotion && typeof incoming?.animate === "function";\n\n if (frame.previous && outgoing && incoming && canAnimate && timing > 0) {\n const frames = transitionFrames(transition);\n try {\n const exit = outgoing.animate(frames.outgoing, {\n duration: timing,\n easing,\n fill: "both",\n });\n animations.push(exit);\n const enter = incoming.animate(frames.incoming, {\n duration: timing,\n easing,\n fill: "both",\n });\n animations.push(enter);\n enter.onfinish = finish;\n } catch {\n // Invalid consumer-provided CSS timing must never hide an icon.\n for (const animation of animations) animation.cancel();\n queueMicrotask(finish);\n }\n } else if (frame.previous) {\n queueMicrotask(finish);\n }\n\n return () => {\n active = false;\n for (const animation of animations) {\n animation.onfinish = null;\n animation.cancel();\n }\n };\n }, [frame, animated, reducedMotion, duration, transition]);\n\n // Continuous motion has a separate lifecycle so finishing an entrance does\n // not reset the spinner\'s rotation phase.\n useEffect(() => {\n const spin = spinRef.current;\n if (\n !animated ||\n reducedMotion ||\n !frame.current.continuous ||\n !spin ||\n typeof spin.animate !== "function"\n )\n return;\n const computed = rootRef.current ? getComputedStyle(rootRef.current) : null;\n const timing = Number.isFinite(spinDuration)\n ? Math.max(0, spinDuration!)\n : milliseconds(\n (computed?.getPropertyValue("--stateglyph-spin-duration") ||\n computed?.getPropertyValue("--stateglyph-preset-spin-duration")) ??\n "",\n 900,\n );\n if (timing <= 0) return;\n const animation = spin.animate(\n [{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],\n { duration: timing, iterations: Infinity, easing: "linear" },\n );\n return () => animation.cancel();\n }, [\n frame.revision,\n frame.current.continuous,\n animated,\n reducedMotion,\n spinDuration,\n ]);\n\n const CurrentIcon = frame.current.icon;\n const PreviousIcon = frame.previous?.icon;\n const layerStyle = {\n transformBox: "view-box",\n transformOrigin: "12px 12px",\n } as const;\n\n return (\n <svg\n xmlns="http://www.w3.org/2000/svg"\n width={size}\n height={size}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={strokeWidth}\n strokeLinecap="round"\n strokeLinejoin="round"\n {...svgProps}\n ref={rootRef}\n className={`lucide lucide-${iconName}${className ? ` ${className}` : ""}`}\n style={{\n display: "inline-block",\n verticalAlign: "middle",\n flexShrink: 0,\n overflow: "visible",\n ...style,\n }}\n data-state-icon={iconId}\n data-state={state}\n data-transition={transition}\n data-state-continuous={continuous ? "true" : undefined}\n aria-hidden={decorative ? true : undefined}\n aria-label={decorative ? undefined : label}\n role={decorative ? undefined : "img"}\n focusable="false"\n >\n {PreviousIcon && animated && !reducedMotion && duration !== 0 ? (\n <g\n key={`outgoing-${frame.revision}`}\n ref={outgoingRef}\n data-state-layer="outgoing"\n aria-hidden="true"\n style={layerStyle}\n >\n <PreviousIcon\n size={24}\n strokeWidth={strokeWidth}\n stroke="inherit"\n fill="inherit"\n aria-hidden="true"\n focusable="false"\n />\n </g>\n ) : null}\n <g\n key={`incoming-${frame.revision}`}\n ref={incomingRef}\n data-state-layer="incoming"\n style={{\n ...layerStyle,\n opacity:\n PreviousIcon && animated && !reducedMotion && duration !== 0\n ? 0\n : 1,\n }}\n >\n <g ref={spinRef} data-state-layer="glyph" style={layerStyle}>\n <CurrentIcon\n size={24}\n strokeWidth={strokeWidth}\n stroke="inherit"\n fill="inherit"\n aria-hidden="true"\n focusable="false"\n />\n </g>\n </g>\n </svg>\n );\n}\n';
14
+
11
15
  // src/template.ts
12
16
  function toPascalCase(value) {
13
17
  return value.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
@@ -17,85 +21,42 @@ function renderIconComponent(definition) {
17
21
  const stateTypeName = `${toPascalCase(definition.id)}State`;
18
22
  const propsTypeName = `${componentName}Props`;
19
23
  const states = Object.entries(definition.states);
20
- const lucideComponents = [
21
- ...new Set(states.map(([, stateDefinition]) => stateDefinition.icon))
22
- ].map(toPascalCase).sort();
23
- const continuousStates = states.filter(([, stateDefinition]) => stateDefinition.continuous).map(([name]) => name);
24
- const animationName = `stateglyph-${definition.id}-spin`;
24
+ const glyphs = [...new Set(states.map(([, value]) => value.icon))];
25
+ const imports = glyphs.map(toPascalCase).sort();
26
+ const runtime = animationSource.replace(
27
+ 'import type { LucideIcon } from "lucide-react";',
28
+ `import { ${imports.join(", ")}, type LucideIcon } from "lucide-react";`
29
+ );
25
30
  const stateUnion = states.map(([name]) => JSON.stringify(name)).join(" | ");
26
31
  const iconRows = states.map(
27
- ([name, stateDefinition]) => ` ${JSON.stringify(name)}: ${toPascalCase(stateDefinition.icon)},`
32
+ ([name, value]) => ` ${JSON.stringify(name)}: ${toPascalCase(value.icon)},`
33
+ ).join("\n");
34
+ const nameRows = states.map(
35
+ ([name, value]) => ` ${JSON.stringify(name)}: ${JSON.stringify(value.icon)},`
28
36
  ).join("\n");
29
37
  const labelRows = states.map(
30
- ([name, stateDefinition]) => ` ${JSON.stringify(name)}: ${JSON.stringify(stateDefinition.label)},`
38
+ ([name, value]) => ` ${JSON.stringify(name)}: ${JSON.stringify(value.label)},`
31
39
  ).join("\n");
32
- return `import type { SVGProps } from "react";
33
- import {
34
- ${lucideComponents.map((name) => ` ${name},`).join("\n")}
35
- type LucideIcon,
36
- } from "lucide-react";
37
-
40
+ const continuous = states.filter(([, value]) => value.continuous).map(([name]) => name);
41
+ return `${runtime}
38
42
  export type ${stateTypeName} = ${stateUnion};
39
-
40
- export type ${propsTypeName} = Omit<SVGProps<SVGSVGElement>, "children"> & {
41
- state: ${stateTypeName};
42
- size?: number | string;
43
- strokeWidth?: number;
44
- duration?: number;
45
- decorative?: boolean;
46
- label?: string;
47
- };
48
-
43
+ export type ${propsTypeName} = Omit<AnimatedIconProps, "icon" | "iconName" | "iconId" | "state" | "continuous"> & { state: ${stateTypeName}; };
49
44
  const iconByState = {
50
45
  ${iconRows}
51
46
  } satisfies Record<${stateTypeName}, LucideIcon>;
52
-
47
+ const nameByState = {
48
+ ${nameRows}
49
+ } satisfies Record<${stateTypeName}, string>;
53
50
  const labelByState = {
54
51
  ${labelRows}
55
52
  } satisfies Record<${stateTypeName}, string>;
53
+ const continuousStates = new Set<${stateTypeName}>(${JSON.stringify(continuous)});
56
54
 
57
- const continuousStates = new Set<${stateTypeName}>(${JSON.stringify(continuousStates)});
58
-
59
- export function ${componentName}({
60
- state,
61
- size = 24,
62
- strokeWidth = 2,
63
- duration = 900,
64
- decorative = true,
65
- label,
66
- style,
67
- ...svgProps
68
- }: ${propsTypeName}) {
55
+ export function ${componentName}({ state, label, transition = ${JSON.stringify(definition.transition ?? "crossfade")}, ...props }: ${propsTypeName}) {
69
56
  const Icon = iconByState[state];
70
- const isContinuous = continuousStates.has(state);
71
- const accessibleLabel = label ?? labelByState[state];
72
-
73
- return (
74
- <>
75
- {isContinuous ? (
76
- <style>{\`@keyframes ${animationName} { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { [data-state-icon="${definition.id}"] { animation: none !important; } }\`}</style>
77
- ) : null}
78
- <Icon
79
- {...svgProps}
80
- size={size}
81
- strokeWidth={strokeWidth}
82
- data-state-icon="${definition.id}"
83
- data-state={state}
84
- aria-hidden={decorative ? true : undefined}
85
- aria-label={decorative ? undefined : accessibleLabel}
86
- role={decorative ? undefined : "img"}
87
- focusable="false"
88
- style={{
89
- ...style,
90
- animation:
91
- style?.animation ??
92
- (isContinuous
93
- ? \`${animationName} \${duration}ms linear infinite\`
94
- : undefined),
95
- }}
96
- />
97
- </>
98
- );
57
+ if (!Icon) return null;
58
+ return <AnimatedIcon {...props} icon={Icon} iconName={nameByState[state]} iconId=${JSON.stringify(definition.id)}
59
+ state={state} label={label ?? labelByState[state]} transition={transition} continuous={continuousStates.has(state)} />;
99
60
  }
100
61
  `;
101
62
  }
@@ -153,7 +114,14 @@ var categoryLabels = {
153
114
  device: "Device and connection",
154
115
  form: "Form states",
155
116
  commerce: "Commerce",
156
- notification: "Notifications"
117
+ notification: "Notifications",
118
+ connectivity: "Connectivity",
119
+ security: "Security and privacy",
120
+ appearance: "Appearance",
121
+ editing: "Editing tools",
122
+ files: "Files and folders",
123
+ weather: "Weather and time",
124
+ productivity: "Productivity"
157
125
  };
158
126
  function formatIconList() {
159
127
  const sections = Object.entries(categoryLabels).flatMap(
@@ -180,8 +148,11 @@ function formatIconList() {
180
148
  }
181
149
 
182
150
  // src/index.ts
151
+ var { version } = JSON.parse(
152
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
153
+ );
183
154
  var program = new Command();
184
- program.name("stateglyph").description("Browse and add StateGlyph components to a React project.").version("0.1.0").showHelpAfterError();
155
+ program.name("stateglyph").description("Browse and add StateGlyph components to a React project.").version(version).showHelpAfterError();
185
156
  program.command("list").description("List every available icon, grouped by category.").action(() => {
186
157
  process.stdout.write(`${formatIconList()}
187
158
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stateglyph/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Copy StateGlyph components into React projects.",
5
5
  "license": "MIT",
6
6
  "author": "Kamalveer Singh",
@@ -41,7 +41,7 @@
41
41
  "prepublishOnly": "npm run build"
42
42
  },
43
43
  "dependencies": {
44
- "@stateglyph/core": "0.1.0",
44
+ "@stateglyph/core": "0.2.0",
45
45
  "commander": "^15.0.0"
46
46
  },
47
47
  "engines": {