From 3a088981922236dd1398de77b8867dee95017c0a Mon Sep 17 00:00:00 2001
From: Evan Hu
Date: Sat, 19 Sep 2026 00:02:05 +0900
Subject: [PATCH 1/9] refactor(ui): drop the duplicate Card and point its call
sites at @pace/propel
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
@pace/propel is the library the repo standardises on, and Card is the one place where the two copies were a byte-level duplicate rather than a redesign: packages/propel/src/card/helper.tsx and packages/ui/src/card/helper.tsx were identical, and the two card.tsx files differed on exactly one line — whether `cn` comes from `../utils/classname` or `../utils`. Both are clsx + tailwind-merge, so there is no behaviour to preserve and nothing to decide.
Two of the six consumers (profile/overview/workload.tsx and state-distribution.tsx) imported nothing else from @pace/ui and now import no @pace/ui at all. The rest keep the symbols that have no propel twin, so the specifier is split rather than the whole statement moved.
packages/ui survives as the app-composites layer; only the components that have a propel equivalent leave it.
---
.../components/modules/module-card-item.tsx | 3 +-
.../components/profile/overview/activity.tsx | 3 +-
.../overview/priority-distribution.tsx | 3 +-
.../profile/overview/state-distribution.tsx | 2 +-
.../components/profile/overview/stats.tsx | 3 +-
.../components/profile/overview/workload.tsx | 2 +-
packages/ui/src/card/card.tsx | 40 ------------------
packages/ui/src/card/helper.tsx | 41 -------------------
packages/ui/src/card/index.ts | 7 ----
packages/ui/src/index.ts | 1 -
10 files changed, 10 insertions(+), 95 deletions(-)
delete mode 100644 packages/ui/src/card/card.tsx
delete mode 100644 packages/ui/src/card/helper.tsx
delete mode 100644 packages/ui/src/card/index.ts
diff --git a/apps/web/core/components/modules/module-card-item.tsx b/apps/web/core/components/modules/module-card-item.tsx
index b90b27be..87baa8e5 100644
--- a/apps/web/core/components/modules/module-card-item.tsx
+++ b/apps/web/core/components/modules/module-card-item.tsx
@@ -15,10 +15,11 @@ import { InfoOutline, UserAltOutline, WorkItemsOutline } from "@makeplane/propel
import { MODULE_STATUS, EUserPermissions, EUserPermissionsLevel, IS_FAVORITE_MENU_OPEN } from "@pace/constants";
import { useLocalStorage } from "@pace/hooks";
import { LinearProgress } from "@makeplane/propel/components/linear-progress";
+import { Card } from "@pace/propel/card";
import { TOAST_TYPE, setPromiseToast, setToast } from "@pace/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { IModule } from "@pace/types";
-import { Card, FavoriteStar } from "@pace/ui";
+import { FavoriteStar } from "@pace/ui";
import { getDate, renderFormattedPayloadDate, generateQueryParams } from "@pace/utils";
// components
import { DateRangeDropdown } from "@/components/dropdowns/date-range";
diff --git a/apps/web/core/components/profile/overview/activity.tsx b/apps/web/core/components/profile/overview/activity.tsx
index de804cc4..72516a81 100644
--- a/apps/web/core/components/profile/overview/activity.tsx
+++ b/apps/web/core/components/profile/overview/activity.tsx
@@ -10,8 +10,9 @@ import useSWR from "swr";
// ui
import { useTranslation } from "@pace/i18n";
import { Avatar } from "@pace/propel/avatar";
+import { Card } from "@pace/propel/card";
import { EmptyStateCompact } from "@pace/propel/empty-state";
-import { Loader, Card } from "@pace/ui";
+import { Loader } from "@pace/ui";
import { calculateTimeAgo, getFileURL } from "@pace/utils";
// components
import { ActivityMessage, IssueLink } from "@/components/core/activity";
diff --git a/apps/web/core/components/profile/overview/priority-distribution.tsx b/apps/web/core/components/profile/overview/priority-distribution.tsx
index ec418b8d..4273a294 100644
--- a/apps/web/core/components/profile/overview/priority-distribution.tsx
+++ b/apps/web/core/components/profile/overview/priority-distribution.tsx
@@ -6,10 +6,11 @@
// pace imports
import { useTranslation } from "@pace/i18n";
+import { Card } from "@pace/propel/card";
import { BarChart } from "@pace/propel/charts/bar-chart";
import { EmptyStateCompact } from "@pace/propel/empty-state";
import type { IUserProfileData } from "@pace/types";
-import { Loader, Card } from "@pace/ui";
+import { Loader } from "@pace/ui";
import { capitalizeFirstLetter } from "@pace/utils";
type Props = {
diff --git a/apps/web/core/components/profile/overview/state-distribution.tsx b/apps/web/core/components/profile/overview/state-distribution.tsx
index ddf1770d..ac3d5bd0 100644
--- a/apps/web/core/components/profile/overview/state-distribution.tsx
+++ b/apps/web/core/components/profile/overview/state-distribution.tsx
@@ -7,10 +7,10 @@
// pace imports
import { STATE_GROUPS } from "@pace/constants";
import { useTranslation } from "@pace/i18n";
+import { Card } from "@pace/propel/card";
import { PieChart } from "@pace/propel/charts/pie-chart";
import { EmptyStateCompact } from "@pace/propel/empty-state";
import type { IUserProfileData, IUserStateDistribution } from "@pace/types";
-import { Card } from "@pace/ui";
import { capitalizeFirstLetter } from "@pace/utils";
type Props = {
diff --git a/apps/web/core/components/profile/overview/stats.tsx b/apps/web/core/components/profile/overview/stats.tsx
index 39703e7a..1af9340b 100644
--- a/apps/web/core/components/profile/overview/stats.tsx
+++ b/apps/web/core/components/profile/overview/stats.tsx
@@ -9,10 +9,11 @@ import { useParams } from "@/app/hooks/navigation";
// ui
import { useTranslation } from "@pace/i18n";
+import { Card, ECardDirection, ECardSpacing } from "@pace/propel/card";
import { LayerStackIcon } from "@pace/propel/icons";
import { AddOutline, UserOutline } from "@makeplane/propel/icons";
import type { IUserProfileData } from "@pace/types";
-import { Loader, Card, ECardSpacing, ECardDirection } from "@pace/ui";
+import { Loader } from "@pace/ui";
// types
type Props = {
diff --git a/apps/web/core/components/profile/overview/workload.tsx b/apps/web/core/components/profile/overview/workload.tsx
index 89a8d7e0..8c48fda3 100644
--- a/apps/web/core/components/profile/overview/workload.tsx
+++ b/apps/web/core/components/profile/overview/workload.tsx
@@ -8,8 +8,8 @@
import { STATE_GROUPS } from "@pace/constants";
// types
import { useTranslation } from "@pace/i18n";
+import { Card, ECardDirection, ECardSpacing } from "@pace/propel/card";
import type { IUserStateDistribution } from "@pace/types";
-import { Card, ECardDirection, ECardSpacing } from "@pace/ui";
// constants
type Props = {
diff --git a/packages/ui/src/card/card.tsx b/packages/ui/src/card/card.tsx
deleted file mode 100644
index 4ee7f7e7..00000000
--- a/packages/ui/src/card/card.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * Copyright (c) 2023-present Plane Software, Inc. and contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- * See the LICENSE file for details.
- */
-
-import * as React from "react";
-import { cn } from "../utils";
-import type { TCardDirection, TCardSpacing, TCardVariant } from "./helper";
-import { ECardDirection, ECardSpacing, ECardVariant, getCardStyle } from "./helper";
-
-export interface CardProps extends React.HTMLAttributes {
- variant?: TCardVariant;
- spacing?: TCardSpacing;
- direction?: TCardDirection;
- className?: string;
- children: React.ReactNode;
-}
-
-const Card = React.forwardRef(function Card(props: CardProps, ref: React.ForwardedRef) {
- const {
- variant = ECardVariant.WITH_SHADOW,
- direction = ECardDirection.COLUMN,
- className = "",
- spacing = ECardSpacing.LG,
- children,
- ...rest
- } = props;
-
- const style = getCardStyle(variant, spacing, direction);
- return (
-
- {children}
-
- );
-});
-
-Card.displayName = "plane-ui-card";
-
-export { Card, ECardVariant, ECardSpacing, ECardDirection };
diff --git a/packages/ui/src/card/helper.tsx b/packages/ui/src/card/helper.tsx
deleted file mode 100644
index cc914c5d..00000000
--- a/packages/ui/src/card/helper.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * Copyright (c) 2023-present Plane Software, Inc. and contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- * See the LICENSE file for details.
- */
-
-export enum ECardVariant {
- WITHOUT_SHADOW = "without-shadow",
- WITH_SHADOW = "with-shadow",
-}
-export enum ECardDirection {
- ROW = "row",
- COLUMN = "column",
-}
-export enum ECardSpacing {
- SM = "sm",
- LG = "lg",
-}
-export type TCardVariant = ECardVariant.WITHOUT_SHADOW | ECardVariant.WITH_SHADOW;
-export type TCardDirection = ECardDirection.ROW | ECardDirection.COLUMN;
-export type TCardSpacing = ECardSpacing.SM | ECardSpacing.LG;
-
-export interface ICardProperties {
- [key: string]: string;
-}
-
-const DEFAULT_STYLE = "bg-surface-1 rounded-lg border-[0.5px] border-subtle w-full flex flex-col";
-export const containerStyle: ICardProperties = {
- [ECardVariant.WITHOUT_SHADOW]: "",
- [ECardVariant.WITH_SHADOW]: "hover:shadow-raised-200 duration-300",
-};
-export const spacings = {
- [ECardSpacing.SM]: "p-4",
- [ECardSpacing.LG]: "p-6",
-};
-export const directions = {
- [ECardDirection.ROW]: "flex-row space-x-3",
- [ECardDirection.COLUMN]: "flex-col space-y-3",
-};
-export const getCardStyle = (variant: TCardVariant, spacing: TCardSpacing, direction: TCardDirection) =>
- DEFAULT_STYLE + " " + directions[direction] + " " + containerStyle[variant] + " " + spacings[spacing];
diff --git a/packages/ui/src/card/index.ts b/packages/ui/src/card/index.ts
deleted file mode 100644
index 4260f850..00000000
--- a/packages/ui/src/card/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * Copyright (c) 2023-present Plane Software, Inc. and contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- * See the LICENSE file for details.
- */
-
-export * from "./card";
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index e00a1604..c13ced21 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -7,7 +7,6 @@
export * from "./app-progress-bar";
export * from "./avatar";
export * from "./breadcrumbs";
-export * from "./card";
export * from "./collapsible";
export * from "./content-wrapper";
export * from "./control-link";
From d7bffc84de03d35a0b92192ed241d9ca7f4e285e Mon Sep 17 00:00:00 2001
From: Evan Hu
Date: Sat, 19 Sep 2026 00:09:00 +0900
Subject: [PATCH 2/9] chore(codemods): add the @pace/ui Loader to @pace/propel
Skeleton rename
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The sweep this drives touches 80 files, so the reviewable artefact is the transform and its tests rather than the hunks: the transform has to be provably incapable of changing anything but the import lines and the identifier at its use sites.
It keys off the import source and never off the name. `Loader` is a name app code also defines for itself, and @pace/propel's own emoji picker re-exports a lucide `Loader`; a transform matching `` in JSX would rewrite first-party components in files that import nothing from @pace/ui. The headlessui-v2-default-tags transform already makes that guard and its comment explains why.
It uses the AST only to locate character offsets and splices the replacement into the original text instead of returning `root.toSource()`. An earlier sweep in this effort did go through recast, and recast rewrote unrelated JSX in 7 files — it reprints a whole subtree whenever it cannot reconcile comment attachment, and several of these files carry `// oxlint-disable-next-line` comments inside their JSX. The spec pins that by asserting full output equality on exactly that shape.
Two things were easy to get wrong and are pinned by tests. JSXIdentifier is declared as a subtype of Identifier in ast-types, so searching both types splices every JSX element name twice. And the scope hangs off Program, not off the File node the root path points at — `root.get().scope` is null, so comparing a lookup against it matches every unresolved identifier rather than none, which is the difference between leaving a function-scoped `const Loader` alone and renaming it.
---
packages/codemods/package.json | 1 +
.../ui-loader-to-propel-skeleton.spec.ts | 183 ++++++++++++++++++
.../codemods/ui-loader-to-propel-skeleton.ts | 152 +++++++++++++++
3 files changed, 336 insertions(+)
create mode 100644 packages/codemods/tests/ui-loader-to-propel-skeleton.spec.ts
create mode 100644 packages/codemods/ui-loader-to-propel-skeleton.ts
diff --git a/packages/codemods/package.json b/packages/codemods/package.json
index e0a2083a..bab2be12 100644
--- a/packages/codemods/package.json
+++ b/packages/codemods/package.json
@@ -10,6 +10,7 @@
"fix:lint": "oxlint --fix .",
"function-declaration": "jscodeshift -t ./function-declaration.ts --extensions=tsx --parser=tsx ../../apps/*/app ../../apps/*/core ../../apps/*/helpers ../../packages/*/src --ignore-pattern='**/node_modules/**' --ignore-pattern='**/dist/**' --ignore-pattern='**/build/**' --ignore-pattern='**/*.d.ts'",
"headlessui-v2-default-tags": "jscodeshift -t ./headlessui-v2-default-tags.ts --extensions=tsx --parser=tsx ../../apps/*/app ../../apps/*/core ../../apps/*/helpers ../../packages/*/src --ignore-pattern='**/node_modules/**' --ignore-pattern='**/dist/**' --ignore-pattern='**/build/**' --ignore-pattern='**/*.d.ts'",
+ "ui-loader-to-propel-skeleton": "jscodeshift -t ./ui-loader-to-propel-skeleton.ts --extensions=tsx --parser=tsx ../../apps/space/core ../../apps/web/app ../../apps/web/core ../../packages/editor/src --ignore-pattern='**/node_modules/**' --ignore-pattern='**/dist/**' --ignore-pattern='**/build/**' --ignore-pattern='**/*.d.ts'",
"remove-directives": "jscodeshift -t ./remove-directives.ts --extensions=ts,tsx --parser=tsx ../../apps/*/app ../../apps/*/core ../../apps/*/helpers ../../packages/*/src --ignore-pattern='**/node_modules/**' --ignore-pattern='**/dist/**' --ignore-pattern='**/build/**' --ignore-pattern='**/*.d.ts'",
"test": "vitest run"
},
diff --git a/packages/codemods/tests/ui-loader-to-propel-skeleton.spec.ts b/packages/codemods/tests/ui-loader-to-propel-skeleton.spec.ts
new file mode 100644
index 00000000..619e8985
--- /dev/null
+++ b/packages/codemods/tests/ui-loader-to-propel-skeleton.spec.ts
@@ -0,0 +1,183 @@
+/**
+ * Copyright (c) 2023-present Plane Software, Inc. and contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * See the LICENSE file for details.
+ */
+
+import { describe, it, expect } from "vitest";
+import { applyTransform } from "@hypermod/utils";
+import * as transformer from "../ui-loader-to-propel-skeleton";
+
+const apply = (source: string) =>
+ applyTransform(transformer, source, { parser: "tsx" });
+
+describe("ui-loader-to-propel-skeleton", () => {
+ it("moves a sole-specifier import and renames the element and its .Item", async () => {
+ const result = await apply(`
+ import { Loader } from "@pace/ui";
+
+ export const Placeholder = () => (
+
+
+
+ );
+ `);
+
+ expect(result).toContain(
+ `import { Skeleton } from "@pace/propel/skeleton";`
+ );
+ expect(result).not.toContain("@pace/ui");
+ expect(result).toContain(``);
+ expect(result).toContain(``);
+ expect(result).toContain("");
+ });
+
+ it("splits Loader out and leaves the other @pace/ui specifiers in place", async () => {
+ const result = await apply(`
+ import { Loader, CustomMenu, Row } from "@pace/ui";
+
+ export const Placeholder = () => ;
+ `);
+
+ expect(result).toContain(
+ `import { Skeleton } from "@pace/propel/skeleton";`
+ );
+ expect(result).toContain(`import { CustomMenu, Row } from "@pace/ui";`);
+ });
+
+ it("removes a trailing Loader specifier without leaving a dangling comma", async () => {
+ const result = await apply(`
+ import { CustomMenu, Loader } from "@pace/ui";
+
+ export const Placeholder = () => ;
+ `);
+
+ expect(result).toContain(`import { CustomMenu } from "@pace/ui";`);
+ expect(result).not.toMatch(/,\s*\}/);
+ });
+
+ it("keeps the leading comment above both statements", async () => {
+ const result = await apply(`
+ // ui
+ import { Loader, Row } from "@pace/ui";
+
+ export const Placeholder = () => ;
+ `);
+
+ expect(result).toMatch(
+ /\/\/ ui\n\s*import \{ Skeleton \} from "@pace\/propel\/skeleton";\n\s*import \{ Row \} from "@pace\/ui";/
+ );
+ });
+
+ it("handles a multi-line specifier list", async () => {
+ const result = await apply(`
+ import {
+ CustomMenu,
+ Loader,
+ Row,
+ } from "@pace/ui";
+
+ export const Placeholder = () => ;
+ `);
+
+ expect(result).toContain("CustomMenu,");
+ expect(result).toContain("Row,");
+ expect(result).not.toMatch(/\bLoader\b/);
+ });
+
+ it("leaves a same-named local component alone when nothing comes from @pace/ui", async () => {
+ const result = await apply(`
+ import { Row } from "@pace/ui";
+
+ function Loader() {
+ return ;
+ }
+
+ export const Placeholder = () => ;
+ `);
+
+ expect(result).not.toContain("@pace/propel/skeleton");
+ expect(result).toContain("function Loader()");
+ expect(result).toContain("");
+ });
+
+ it("leaves a file that imports no UI library untouched", async () => {
+ const result = await apply(`
+ import { Loader } from "@/components/loader";
+
+ export const Placeholder = () => ;
+ `);
+
+ expect(result).not.toContain("@pace/propel/skeleton");
+ expect(result).toContain(`import { Loader } from "@/components/loader";`);
+ });
+
+ it("does not rename a reference shadowed inside a function", async () => {
+ const result = await apply(`
+ import { Loader } from "@pace/ui";
+
+ export function render() {
+ const Loader = () => ;
+ return ;
+ }
+ `);
+
+ expect(result).toContain(
+ `import { Skeleton } from "@pace/propel/skeleton";`
+ );
+ expect(result).toContain("const Loader = () => ;");
+ expect(result).toContain("return ;");
+ });
+
+ it("keeps an aliased local name and renames nothing", async () => {
+ const result = await apply(`
+ import { Loader as UiLoader } from "@pace/ui";
+
+ export const Placeholder = () => ;
+ `);
+
+ expect(result).toContain(
+ `import { Skeleton as UiLoader } from "@pace/propel/skeleton";`
+ );
+ expect(result).toContain("");
+ });
+
+ it("preserves the type-only modifier", async () => {
+ const result = await apply(`
+ import type { Loader } from "@pace/ui";
+
+ export type Props = { loader: typeof Loader };
+ `);
+
+ expect(result).toContain(
+ `import type { Skeleton } from "@pace/propel/skeleton";`
+ );
+ expect(result).toContain("typeof Skeleton");
+ });
+
+ it("reprints nothing but the lines it edits, JSX comments included", async () => {
+ // This is the shape recast mangled on an earlier sweep: an oxlint directive inside the JSX, a
+ // self-closing element next to a text node, and blank lines between siblings.
+ const source = `import { Loader } from "@pace/ui";
+
+export const Placeholder = () => (
+
+ {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions */}
+
+ label
+
+
+
+
+);
+`;
+
+ // applyTransform trims what it returns, hence the trim on the expectation rather than on the input.
+ expect(await apply(source)).toBe(
+ source
+ .replaceAll("Loader", "Skeleton")
+ .replace(`from "@pace/ui"`, `from "@pace/propel/skeleton"`)
+ .trim()
+ );
+ });
+});
diff --git a/packages/codemods/ui-loader-to-propel-skeleton.ts b/packages/codemods/ui-loader-to-propel-skeleton.ts
new file mode 100644
index 00000000..ccc903fa
--- /dev/null
+++ b/packages/codemods/ui-loader-to-propel-skeleton.ts
@@ -0,0 +1,152 @@
+/**
+ * Copyright (c) 2023-present Plane Software, Inc. and contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * See the LICENSE file for details.
+ */
+
+import type {
+ API,
+ ASTPath,
+ FileInfo,
+ ImportDeclaration,
+ ImportSpecifier,
+} from "jscodeshift";
+
+/**
+ * `Loader` from `@pace/ui` -> `Skeleton` from `@pace/propel/skeleton`.
+ *
+ * The two are the same component under two names: both render `animate-pulse` + `role="status"` around their children and expose an `.Item` taking `height`/`width`/`className` with an `"auto"` default, and the class expressions are identical. Skeleton additionally sets `data-slot` and an `aria-label`, which is additive — no selector in the repo targets the old markup (`plane-ui-loader` appears only on the deleted component's own `displayName`).
+ *
+ * Everything keys off the import source, never off the name. `Loader` is a name several app files define for themselves, and one of them (`packages/editor/src/components/editors/document/loader.tsx`) both defines a `DocumentContentLoader` and imports the @pace/ui `Loader`. A transform that matched `` in JSX would rewrite first-party components in the files that never imported anything. The headlessui-v2-default-tags transform makes the same guard for the same reason.
+ *
+ * The AST is used only to locate character offsets; the replacement is spliced into the original text and the source string is returned instead of `root.toSource()`. Reprinting a mutated AST through recast is not byte-preserving — on a 531-file sweep in this repo it rewrote unrelated JSX in 7 files, because recast reprints a whole subtree when it cannot reconcile comment attachment and several files carry `// oxlint-disable-next-line` comments inside their JSX. Splicing keeps every hunk in the 80 files to the import lines and the bare identifier renames.
+ *
+ * Deliberately not done:
+ *
+ * - No merging into an existing `@pace/propel/skeleton` import, and no reordering. A file keeping other @pace/ui symbols gains a second import statement above the one it had; that is what the icons sweep does too, and both oxlint and oxfmt accept it.
+ * - An aliased import (`Loader as X`) keeps its local name — the new import becomes `Skeleton as X` and no reference is touched. There is none in the repo today, but renaming references would otherwise have to prove the alias is free at every use site.
+ * - A reference whose scope resolves to something other than the module-level import is left alone, which is what makes a shadowing `const Loader` inside a function safe.
+ */
+const FROM_SOURCE = "@pace/ui";
+const TO_SOURCE = "@pace/propel/skeleton";
+const FROM_NAME = "Loader";
+const TO_NAME = "Skeleton";
+
+type Splice = { start: number; end: number; text: string };
+
+/** Babel records these; ast-types does not declare them on the node types. */
+type Ranged = { start?: number; end?: number };
+
+const rangeOf = (node: unknown): [number, number] | undefined => {
+ const { start, end } = (node ?? {}) as Ranged;
+ return typeof start === "number" && typeof end === "number"
+ ? [start, end]
+ : undefined;
+};
+
+export default function transform(file: FileInfo, api: API) {
+ const j = api.jscodeshift;
+ const root = j(file.source);
+
+ const splices: Splice[] = [];
+ /** Local names bound to @pace/ui's `Loader` that are safe to rename at their use sites. */
+ const renamedLocals = new Set();
+
+ root
+ .find(j.ImportDeclaration, { source: { value: FROM_SOURCE } })
+ .forEach((path: ASTPath) => {
+ const specifiers = path.node.specifiers ?? [];
+ const index = specifiers.findIndex(
+ (specifier) =>
+ specifier.type === "ImportSpecifier" &&
+ // ast-types types both names as `string | IdentifierKind`; only the string form can be compared.
+ typeof specifier.imported?.name === "string" &&
+ specifier.imported.name === FROM_NAME
+ );
+ if (index === -1) return;
+
+ const specifier = specifiers[index] as ImportSpecifier;
+ const specifierRange = rangeOf(specifier);
+ const declarationRange = rangeOf(path.node);
+ if (!specifierRange || !declarationRange) return;
+
+ const localName =
+ typeof specifier.local?.name === "string"
+ ? specifier.local.name
+ : FROM_NAME;
+ // An alias only has to survive when it differs from both names; `Loader as Skeleton` already binds the target name.
+ const binding =
+ localName === FROM_NAME || localName === TO_NAME
+ ? TO_NAME
+ : `${TO_NAME} as ${localName}`;
+ // `import type { Loader }` binds no runtime value; the replacement has to keep the modifier or the emitted import stops type-checking.
+ const keyword =
+ path.node.importKind === "type" ? "import type" : "import";
+ const declaration = `${keyword} { ${binding} } from "${TO_SOURCE}";`;
+
+ if (specifiers.length === 1) {
+ // Nothing else came from @pace/ui, so the statement itself moves and the file loses the dependency.
+ splices.push({
+ start: declarationRange[0],
+ end: declarationRange[1],
+ text: declaration,
+ });
+ } else {
+ // Cutting to the next specifier's start (or back from the previous one's end when this is the last) takes the separating comma and whitespace with it, whatever the formatting.
+ const neighbour =
+ index + 1 < specifiers.length
+ ? rangeOf(specifiers[index + 1])
+ : rangeOf(specifiers[index - 1]);
+ if (!neighbour) return;
+ const [cutStart, cutEnd] =
+ index + 1 < specifiers.length
+ ? [specifierRange[0], neighbour[0]]
+ : [neighbour[1], specifierRange[1]];
+ splices.push({ start: cutStart, end: cutEnd, text: "" });
+ // Inserted at the `import` keyword, so a leading `// ui` style comment stays above both statements.
+ splices.push({
+ start: declarationRange[0],
+ end: declarationRange[0],
+ text: `${declaration}\n`,
+ });
+ }
+
+ if (localName === FROM_NAME) renamedLocals.add(localName);
+ });
+
+ if (splices.length === 0) return file.source;
+
+ // The scope hangs off Program, not off the File node the root path points at — `root.get().scope` is null, and comparing against null would have matched every unresolved identifier instead of none.
+ const moduleScope = root.find(j.Program).paths()[0]?.scope;
+
+ if (renamedLocals.size > 0 && moduleScope) {
+ const rename = (path: ASTPath<{ name?: unknown }>) => {
+ const name = path.node.name;
+ if (typeof name !== "string" || !renamedLocals.has(name)) return;
+ // The import specifier's own identifiers sit inside a range already being replaced.
+ if (path.parent?.node?.type === "ImportSpecifier") return;
+ // A JSX member expression's property (`Loader.Item` -> the `Item`) must not be touched.
+ if (
+ path.parent?.node?.type === "JSXMemberExpression" &&
+ path.parent.node.property === path.node
+ )
+ return;
+ if (path.scope?.lookup(name) !== moduleScope) return;
+ const range = rangeOf(path.node);
+ if (range)
+ splices.push({ start: range[0], end: range[1], text: TO_NAME });
+ };
+
+ // One pass, not two: ast-types declares JSXIdentifier as a subtype of Identifier, so this collection already contains the JSX element names. Searching both types would splice every one of them twice.
+ root.find(j.Identifier).forEach(rename);
+ }
+
+ // Back to front, so an earlier splice never invalidates a later offset.
+ let output = file.source;
+ for (const splice of splices.toSorted((a, b) => b.start - a.start)) {
+ output =
+ output.slice(0, splice.start) + splice.text + output.slice(splice.end);
+ }
+
+ return output;
+}
From 7d7de97dd246c612c1afce677462a5b03ed3c035 Mon Sep 17 00:00:00 2001
From: Evan Hu
Date: Sat, 19 Sep 2026 00:09:05 +0900
Subject: [PATCH 3/9] refactor(ui): retire Loader in favour of @pace/propel's
Skeleton across 80 files
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Loader and Skeleton are the same component under two names: both wrap their children in `animate-pulse` with `role="status"`, both expose an `.Item` taking `height`/`width`/`className` defaulting to `"auto"`, and the class expressions are identical. Skeleton is the one that lives in the library everything standardises on, and it had zero consumers until now.
Skeleton additionally emits `data-slot` attributes and an `aria-label="Loading content"`, so the rendered markup gains an accessible name it did not have. Nothing styles the old markup — `plane-ui-loader` appeared only on the deleted component's own `displayName`, and the call sites pass only `height`, `width` and `className`, all of which Skeleton supports.
63 of the 80 files imported nothing else from @pace/ui and now import no @pace/ui at all. The rest keep the composites that have no propel twin, so the specifier is split out rather than the statement moved.
Produced by `pnpm --filter @pace/codemods run ui-loader-to-propel-skeleton`; every hunk is an import line or a bare identifier.
---
.../core/components/issues/filters/labels.tsx | 12 +--
.../core/components/issues/filters/state.tsx | 12 +--
.../peek-overview/full-screen-peek-view.tsx | 26 +++---
.../issues/peek-overview/side-peek-view.tsx | 14 ++--
.../(projects)/browse/[workItem]/page.tsx | 22 ++---
.../(detail)/[archivedIssueId]/page.tsx | 22 ++---
.../components/analytics/insight-card.tsx | 4 +-
.../analytics/insight-table/loader.tsx | 4 +-
.../web/core/components/analytics/loaders.tsx | 26 +++---
.../analytics/overview/active-projects.tsx | 4 +-
.../automation/auto-archive-automation.tsx | 9 ++-
.../automation/auto-close-automation.tsx | 9 ++-
.../components/base-layouts/gantt/sidebar.tsx | 15 ++--
.../components/common/filters/created-by.tsx | 12 +--
.../core/description-versions/modal.tsx | 19 ++---
.../components/core/image-picker-popover.tsx | 22 ++---
.../core/modals/bulk-delete-issues-modal.tsx | 15 ++--
.../modals/existing-issues-list-modal.tsx | 15 ++--
.../cycles/active-cycle/cycle-stats.tsx | 18 ++---
.../cycles/active-cycle/productivity.tsx | 8 +-
.../cycles/active-cycle/progress.tsx | 8 +-
.../cycles/analytics-sidebar/root.tsx | 16 ++--
.../analytics-sidebar/sidebar-chart.tsx | 8 +-
.../rich-text/description-input/loader.tsx | 24 +++---
.../components/estimates/loader-screen.tsx | 14 ++--
.../gantt-chart/sidebar/issues/sidebar.tsx | 14 ++--
.../gantt-chart/sidebar/modules/sidebar.tsx | 14 ++--
.../global/product-updates/changelog.tsx | 8 +-
.../home/widgets/loaders/home-loader.tsx | 10 +--
.../home/widgets/loaders/quick-links.tsx | 8 +-
.../home/widgets/loaders/recent-activity.tsx | 10 +--
.../inbox/inbox-filter/filters/labels.tsx | 12 +--
.../inbox/inbox-filter/filters/members.tsx | 12 +--
.../inbox/inbox-filter/filters/state.tsx | 12 +--
.../modals/create-modal/issue-description.tsx | 8 +-
.../inbox/modals/select-duplicate.tsx | 15 ++--
.../core/components/inbox/sidebar/root.tsx | 11 +--
.../integration/single-integration-card.tsx | 8 +-
.../integration/slack/select-channel.tsx | 8 +-
.../issue-detail/issue-activity/loader.tsx | 30 +++----
.../issues/issue-detail/subscription.tsx | 8 +-
.../filters/header/filters/assignee.tsx | 12 +--
.../filters/header/filters/created-by.tsx | 12 +--
.../filters/header/filters/cycle.tsx | 12 +--
.../filters/header/filters/labels.tsx | 12 +--
.../filters/header/filters/mentions.tsx | 12 +--
.../filters/header/filters/module.tsx | 12 +--
.../filters/header/filters/project.tsx | 12 +--
.../filters/header/filters/state.tsx | 12 +--
.../components/description-editor.tsx | 24 +++---
.../issues/parent-issues-list-modal.tsx | 15 ++--
.../issues/peek-overview/loader.tsx | 72 ++++++++---------
.../labels/project-setting-label-list.tsx | 14 ++--
.../license/modal/card/checkout-button.tsx | 14 ++--
.../license/modal/card/talk-to-sales.tsx | 14 ++--
.../modules/analytics-sidebar/root.tsx | 17 ++--
.../modules/dropdowns/filters/lead.tsx | 12 +--
.../modules/dropdowns/filters/members.tsx | 12 +--
.../pages/loaders/page-content-loader.tsx | 80 +++++++++----------
.../components/pages/loaders/page-loader.tsx | 26 +++---
.../core/components/pages/version/editor.tsx | 38 ++++-----
.../components/profile/overview/activity.tsx | 16 ++--
.../overview/priority-distribution.tsx | 16 ++--
.../components/profile/overview/stats.tsx | 12 +--
apps/web/core/components/profile/sidebar.tsx | 18 ++---
.../core/components/project-states/loader.tsx | 14 ++--
.../project/dropdowns/filters/lead.tsx | 12 +--
.../project/dropdowns/filters/members.tsx | 12 +--
.../core/components/project/form-loader.tsx | 50 ++++++------
.../project-settings-member-defaults.tsx | 14 ++--
.../project/publish-project/modal.tsx | 15 ++--
apps/web/core/components/readonly/state.tsx | 10 +--
.../rich-filters/filter-item/loader.tsx | 8 +-
.../components/rich-filters/filters-row.tsx | 9 ++-
.../stickies/layout/stickies-infinite.tsx | 9 ++-
.../stickies/layout/stickies-loader.tsx | 38 ++++-----
.../project-inbox/inbox-layout-loader.tsx | 16 ++--
.../workspace/sidebar/projects-list.tsx | 8 +-
.../workspace/sidebar/workspace-menu-root.tsx | 10 +--
.../components/editors/document/loader.tsx | 36 ++++-----
packages/ui/src/index.ts | 1 -
packages/ui/src/loader.tsx | 38 ---------
82 files changed, 658 insertions(+), 684 deletions(-)
delete mode 100644 packages/ui/src/loader.tsx
diff --git a/apps/space/core/components/issues/filters/labels.tsx b/apps/space/core/components/issues/filters/labels.tsx
index 8c8ed7d6..7e6751ca 100644
--- a/apps/space/core/components/issues/filters/labels.tsx
+++ b/apps/space/core/components/issues/filters/labels.tsx
@@ -6,7 +6,7 @@
import React, { useState } from "react";
// pace imports
-import { Loader } from "@pace/ui";
+import { Skeleton } from "@pace/propel/skeleton";
// types
import type { IIssueLabel } from "@/types/issue";
// local imports
@@ -76,11 +76,11 @@ export function FilterLabels(props: Props) {
);
diff --git a/apps/web/core/components/ui/loader/layouts/project-inbox/inbox-layout-loader.tsx b/apps/web/core/components/ui/loader/layouts/project-inbox/inbox-layout-loader.tsx
index cdca18b8..c38a06b6 100644
--- a/apps/web/core/components/ui/loader/layouts/project-inbox/inbox-layout-loader.tsx
+++ b/apps/web/core/components/ui/loader/layouts/project-inbox/inbox-layout-loader.tsx
@@ -6,7 +6,7 @@
import React from "react";
// ui
-import { Loader } from "@pace/ui";
+import { Skeleton } from "@pace/propel/skeleton";
import { InboxSidebarLoader } from "./inbox-sidebar-loader";
export function InboxLayoutLoader() {
@@ -16,15 +16,15 @@ export function InboxLayoutLoader() {
-
+
-
-
-
-
+
+
+
+
-
-
+
+
);
diff --git a/apps/web/core/components/workspace/sidebar/projects-list.tsx b/apps/web/core/components/workspace/sidebar/projects-list.tsx
index 5680a2eb..8dac3486 100644
--- a/apps/web/core/components/workspace/sidebar/projects-list.tsx
+++ b/apps/web/core/components/workspace/sidebar/projects-list.tsx
@@ -18,7 +18,7 @@ import { useTranslation } from "@pace/i18n";
import { IconButton } from "@pace/propel/icon-button";
import { TOAST_TYPE, setToast } from "@pace/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
-import { Loader } from "@pace/ui";
+import { Skeleton } from "@pace/propel/skeleton";
import { copyUrlToClipboard, cn, orderJoinedProjects } from "@pace/utils";
// components
import { CreateProjectModal } from "@/components/project/create-project-modal";
@@ -228,11 +228,11 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
leaveTo="transform scale-95 opacity-0"
>
{loader === "init-loader" && (
-
+
{Array.from({ length: 4 }).map((_, index) => (
-
+
))}
-
+
)}
{isAllProjectsListOpen && (
diff --git a/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx b/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx
index d3214d6b..ea9c67e9 100644
--- a/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx
+++ b/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx
@@ -15,7 +15,7 @@ import { Menu, Transition } from "@headlessui/react";
import { useTranslation } from "@pace/i18n";
import { TOAST_TYPE, setToast } from "@pace/propel/toast";
import type { IWorkspace } from "@pace/types";
-import { Loader } from "@pace/ui";
+import { Skeleton } from "@pace/propel/skeleton";
import { orderWorkspacesList, cn } from "@pace/utils";
// helpers
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
@@ -178,10 +178,10 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
) : (
-
-
-
-
+
+
+
+
)}
diff --git a/packages/editor/src/components/editors/document/loader.tsx b/packages/editor/src/components/editors/document/loader.tsx
index a37dddf8..61461027 100644
--- a/packages/editor/src/components/editors/document/loader.tsx
+++ b/packages/editor/src/components/editors/document/loader.tsx
@@ -5,7 +5,7 @@
*/
// pace imports
-import { Loader } from "@pace/ui";
+import { Skeleton } from "@pace/propel/skeleton";
import { cn } from "@pace/utils";
type Props = {
@@ -17,41 +17,41 @@ export function DocumentContentLoader(props: Props) {
return (
-
+
-
+
-
+
-
-
+
+
-
+
-
-
+
+
-
-
+
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
);
}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index c13ced21..aa1b7aac 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -18,7 +18,6 @@ export * from "./favorite-star";
export * from "./form-fields";
export * from "./header";
export * from "./link";
-export * from "./loader";
export * from "./modals";
export * from "./popovers";
export * from "./row";
diff --git a/packages/ui/src/loader.tsx b/packages/ui/src/loader.tsx
deleted file mode 100644
index 836a412b..00000000
--- a/packages/ui/src/loader.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * Copyright (c) 2023-present Plane Software, Inc. and contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- * See the LICENSE file for details.
- */
-
-import React from "react";
-// helpers
-import { cn } from "./utils";
-
-type Props = {
- children: React.ReactNode;
- className?: string;
-};
-
-function Loader({ children, className = "" }: Props) {
- return (
-
- {children}
-
- );
-}
-
-type ItemProps = {
- height?: string;
- width?: string;
- className?: string;
-};
-
-function Item({ height = "auto", width = "auto", className = "" }: ItemProps) {
- return ;
-}
-
-Loader.Item = Item;
-
-Loader.displayName = "plane-ui-loader";
-
-export { Loader };
From eff82c4eb6a008990b9d780d6fb1a7f46cdebc85 Mon Sep 17 00:00:00 2001
From: Evan Hu
Date: Sat, 19 Sep 2026 00:12:20 +0900
Subject: [PATCH 4/9] refactor(ui): retire the duplicate Spinner onto
@pace/propel/spinners
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both packages exported a `Spinner` with the same name, the same `ISpinner { height = "32px", width = "32px", className }` and the same two SVG paths verbatim. They differed in two places, so propel's copy is corrected first and the 33 call sites move second — that ordering is what makes the swap a no-op rather than a visual change.
The accent fill moves from the arc `` onto the `
Loading...
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index aa1b7aac..87f56dca 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -22,7 +22,6 @@ export * from "./modals";
export * from "./popovers";
export * from "./row";
export * from "./sortable";
-export * from "./spinners";
export * from "./tables";
export * from "./tag";
export * from "./utils";
diff --git a/packages/ui/src/spinners/circular-spinner.tsx b/packages/ui/src/spinners/circular-spinner.tsx
deleted file mode 100644
index 59aacd6c..00000000
--- a/packages/ui/src/spinners/circular-spinner.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * Copyright (c) 2023-present Plane Software, Inc. and contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- * See the LICENSE file for details.
- */
-
-import * as React from "react";
-// helpers
-import { cn } from "../utils";
-
-export interface ISpinner extends React.SVGAttributes {
- height?: string;
- width?: string;
- className?: string | undefined;
-}
-
-export function Spinner({ height = "32px", width = "32px", className = "" }: ISpinner) {
- return (
-
-
- Loading...
-
- );
-}
diff --git a/packages/ui/src/spinners/index.ts b/packages/ui/src/spinners/index.ts
deleted file mode 100644
index 6f87ad95..00000000
--- a/packages/ui/src/spinners/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * Copyright (c) 2023-present Plane Software, Inc. and contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- * See the LICENSE file for details.
- */
-
-export * from "./circular-spinner";
From e70f9f1b9ee3b6e981c74a46c7901234ae66ef03 Mon Sep 17 00:00:00 2001
From: Evan Hu
Date: Sat, 19 Sep 2026 00:17:16 +0900
Subject: [PATCH 5/9] refactor(space): point the auth forms at @pace/propel's
Input
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
packages/propel/src/input/input.tsx and packages/ui/src/form-fields/input.tsx are the same component: the same `mode`/`inputSize`/`hasError` props with the same defaults and a character-for-character identical class expression. propel's renders Base UI's `Input` instead of a bare `` and adds `aria-invalid` when `hasError` is set, which is the only difference the three auth forms can see — none of them passes `hasError`, and Base UI's input is a `Field.Control` that forwards every native prop and composes its own change/focus handlers with the caller's.
@pace/ui stops re-exporting its copy, so no app can reach it any more, but the file itself stays: input-color-picker still composes it and that composite has no propel counterpart. Deleting the file would mean migrating a screen that is not part of this work.
---
apps/space/core/components/account/auth-forms/email.tsx | 2 +-
apps/space/core/components/account/auth-forms/password.tsx | 3 ++-
apps/space/core/components/account/auth-forms/unique-code.tsx | 2 +-
packages/ui/src/form-fields/index.ts | 2 +-
4 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/apps/space/core/components/account/auth-forms/email.tsx b/apps/space/core/components/account/auth-forms/email.tsx
index bce0596d..6fc88853 100644
--- a/apps/space/core/components/account/auth-forms/email.tsx
+++ b/apps/space/core/components/account/auth-forms/email.tsx
@@ -14,7 +14,7 @@ import { Button } from "@pace/propel/button";
import type { IEmailCheckData } from "@pace/types";
// ui
import { Spinner } from "@pace/propel/spinners";
-import { Input } from "@pace/ui";
+import { Input } from "@pace/propel/input";
// helpers
import { cn } from "@pace/utils";
import { checkEmailValidity } from "@/helpers/string.helper";
diff --git a/apps/space/core/components/account/auth-forms/password.tsx b/apps/space/core/components/account/auth-forms/password.tsx
index 0d58a05c..388aa4d4 100644
--- a/apps/space/core/components/account/auth-forms/password.tsx
+++ b/apps/space/core/components/account/auth-forms/password.tsx
@@ -12,7 +12,8 @@ import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@pace/constants";
import { Button } from "@pace/propel/button";
import { AuthService } from "@pace/services";
import { Spinner } from "@pace/propel/spinners";
-import { Input, PasswordStrengthIndicator } from "@pace/ui";
+import { Input } from "@pace/propel/input";
+import { PasswordStrengthIndicator } from "@pace/ui";
import { getPasswordStrength } from "@pace/utils";
// types
import { EAuthModes, EAuthSteps } from "@/types/auth";
diff --git a/apps/space/core/components/account/auth-forms/unique-code.tsx b/apps/space/core/components/account/auth-forms/unique-code.tsx
index 1387631d..1ec7f63c 100644
--- a/apps/space/core/components/account/auth-forms/unique-code.tsx
+++ b/apps/space/core/components/account/auth-forms/unique-code.tsx
@@ -11,7 +11,7 @@ import { API_BASE_URL } from "@pace/constants";
import { Button } from "@pace/propel/button";
import { AuthService } from "@pace/services";
import { Spinner } from "@pace/propel/spinners";
-import { Input } from "@pace/ui";
+import { Input } from "@pace/propel/input";
// hooks
import { useTimer } from "@pace/hooks";
// types
diff --git a/packages/ui/src/form-fields/index.ts b/packages/ui/src/form-fields/index.ts
index 1ffa30fd..675fd3bd 100644
--- a/packages/ui/src/form-fields/index.ts
+++ b/packages/ui/src/form-fields/index.ts
@@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
-export * from "./input";
+// `./input` is deliberately not re-exported: @pace/propel/input is the same component and is what apps import now. The file stays because input-color-picker still composes it, and that one is a @pace/ui composite with no propel counterpart.
export * from "./textarea";
export * from "./input-color-picker";
export * from "./password";
From 05a7319b86181bb4f2937d853b7513ac358d4072 Mon Sep 17 00:00:00 2001
From: Evan Hu
Date: Sat, 19 Sep 2026 00:20:15 +0900
Subject: [PATCH 6/9] feat(propel): honour showTooltip on Avatar and take over
AvatarGroup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`showTooltip` was already declared in propel Avatar's prop type and never destructured, so passing it did nothing — the same class of dead prop as the ones in propel's menu. It now does what @pace/ui's Avatar did: wraps the avatar in propel's Tooltip labelled `fallbackText ?? name ?? "?"`, and passes `disabled` rather than dropping the wrapper, so the DOM is the same shape whether the tooltip is on or off.
The default is `true` because that is what the call sites being migrated were written against. apps/space's group-by column headers (`issue-layouts/utils.tsx`) render `` with no `showTooltip` and rely on the name appearing on hover; `properties/member.tsx` and `navbar/user-avatar.tsx` pass it explicitly and are unaffected by the choice. The one pre-existing propel Avatar call site, apps/web's profile activity feed, therefore gains a tooltip carrying the actor's display name where it had none.
AvatarGroup moves across as-is (`max`, `showTooltip`, `size`, the `+N` overflow chip) with only its import paths rewritten; it was already reaching into @pace/propel for the Tooltip, so the move shortens that edge rather than creating one. @pace/ui's avatar/helper.tsx duplicated propel's `getSizeInfo`/`getBorderRadius`/`isAValidNumber` verbatim and goes with it.
Two visible consequences for the three apps/space sites, both wanted: the fallback initial's background stops being the hardcoded `#028375`/`#ffffff` and reads `--background-color-accent-primary`/`--text-color-on-color`, which is what makes it correct in dark mode; and the avatar image regains its `alt`, which Base UI's Avatar.Image does not set on its own. Neither is verifiable without a browser — nothing here renders one.
---
.../issue-layouts/properties/member.tsx | 3 +-
.../components/issues/issue-layouts/utils.tsx | 2 +-
.../components/issues/navbar/user-avatar.tsx | 2 +-
.../src/avatar/avatar-group.tsx | 6 +-
packages/propel/src/avatar/avatar.tsx | 67 ++++++-----
packages/propel/src/avatar/index.ts | 1 +
packages/ui/src/avatar/avatar.stories.tsx | 24 ----
packages/ui/src/avatar/avatar.tsx | 111 ------------------
packages/ui/src/avatar/helper.tsx | 70 -----------
packages/ui/src/avatar/index.ts | 8 --
packages/ui/src/index.ts | 1 -
11 files changed, 46 insertions(+), 249 deletions(-)
rename packages/{ui => propel}/src/avatar/avatar-group.tsx (94%)
delete mode 100644 packages/ui/src/avatar/avatar.stories.tsx
delete mode 100644 packages/ui/src/avatar/avatar.tsx
delete mode 100644 packages/ui/src/avatar/helper.tsx
delete mode 100644 packages/ui/src/avatar/index.ts
diff --git a/apps/space/core/components/issues/issue-layouts/properties/member.tsx b/apps/space/core/components/issues/issue-layouts/properties/member.tsx
index 0f31d34b..f20d88d6 100644
--- a/apps/space/core/components/issues/issue-layouts/properties/member.tsx
+++ b/apps/space/core/components/issues/issue-layouts/properties/member.tsx
@@ -8,8 +8,7 @@ import { observer } from "mobx-react";
// icons
import type { LucideIcon } from "lucide-react";
import { MembersOutline } from "@makeplane/propel/icons";
-// pace ui
-import { Avatar, AvatarGroup } from "@pace/ui";
+import { Avatar, AvatarGroup } from "@pace/propel/avatar";
// pace utils
import { cn } from "@pace/utils";
// hooks
diff --git a/apps/space/core/components/issues/issue-layouts/utils.tsx b/apps/space/core/components/issues/issue-layouts/utils.tsx
index fd28cec7..4eed7600 100644
--- a/apps/space/core/components/issues/issue-layouts/utils.tsx
+++ b/apps/space/core/components/issues/issue-layouts/utils.tsx
@@ -7,6 +7,7 @@
import { isNil } from "lodash-es";
// types
import { EIconSize, ISSUE_PRIORITIES } from "@pace/constants";
+import { Avatar } from "@pace/propel/avatar";
import { CycleGroupIcon, PriorityIcon, StateGroupIcon } from "@pace/propel/icons";
import { CyclesOutline, ModuleOutline } from "@makeplane/propel/icons";
import type {
@@ -17,7 +18,6 @@ import type {
TGroupedIssues,
} from "@pace/types";
// ui
-import { Avatar } from "@pace/ui";
// components
// constants
// stores
diff --git a/apps/space/core/components/issues/navbar/user-avatar.tsx b/apps/space/core/components/issues/navbar/user-avatar.tsx
index 17cc2a61..a1c809b3 100644
--- a/apps/space/core/components/issues/navbar/user-avatar.tsx
+++ b/apps/space/core/components/issues/navbar/user-avatar.tsx
@@ -13,9 +13,9 @@ import { LogOutOutline } from "@makeplane/propel/icons";
import { Popover, Transition } from "@headlessui/react";
// pace imports
import { API_BASE_URL } from "@pace/constants";
+import { Avatar } from "@pace/propel/avatar";
import { Button } from "@pace/propel/button";
import { AuthService } from "@pace/services";
-import { Avatar } from "@pace/ui";
import { getFileURL } from "@pace/utils";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
diff --git a/packages/ui/src/avatar/avatar-group.tsx b/packages/propel/src/avatar/avatar-group.tsx
similarity index 94%
rename from packages/ui/src/avatar/avatar-group.tsx
rename to packages/propel/src/avatar/avatar-group.tsx
index 2b8162b5..edd416b5 100644
--- a/packages/ui/src/avatar/avatar-group.tsx
+++ b/packages/propel/src/avatar/avatar-group.tsx
@@ -6,12 +6,12 @@
import React from "react";
// ui
-import { Tooltip } from "@pace/propel/tooltip";
+import { Tooltip } from "../tooltip";
// helpers
import { cn } from "../utils";
// types
-import type { TAvatarSize } from "./helper";
-import { getSizeInfo, isAValidNumber } from "./helper";
+import type { TAvatarSize } from "./avatar";
+import { getSizeInfo, isAValidNumber } from "./avatar";
type Props = {
/**
diff --git a/packages/propel/src/avatar/avatar.tsx b/packages/propel/src/avatar/avatar.tsx
index e4ce720f..dc81d690 100644
--- a/packages/propel/src/avatar/avatar.tsx
+++ b/packages/propel/src/avatar/avatar.tsx
@@ -6,6 +6,7 @@
import React from "react";
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
+import { Tooltip } from "../tooltip";
import { cn } from "../utils/classname";
export type TAvatarSize = "sm" | "md" | "base" | "lg" | number;
@@ -15,7 +16,7 @@ type Props = {
fallbackBackgroundColor?: string; //The background color if the avatar image fails to load
fallbackText?: string;
fallbackTextColor?: string; //The text color if the avatar image fails to load
- showTooltip?: boolean;
+ showTooltip?: boolean; //Whether the name is surfaced as a hover tooltip. @default true
size?: TAvatarSize; //The size of the avatars
shape?: "circle" | "square";
src?: string; //The source of the avatar image
@@ -91,6 +92,7 @@ export function Avatar(props: Props) {
fallbackBackgroundColor,
fallbackText,
fallbackTextColor,
+ showTooltip = true,
size = "md",
shape = "circle",
src,
@@ -102,32 +104,41 @@ export function Avatar(props: Props) {
const fallbackLetter = name?.[0]?.toUpperCase() ?? fallbackText ?? "?";
return (
-
-
-
-
- {fallbackLetter}
-
-
-
+ // The tooltip is how an avatar says whose it is: the label is the only text the component renders beyond a single initial. `disabled` rather than a conditional wrapper keeps the DOM identical either way, so turning the tooltip off cannot reflow a row of avatars.
+
+
+
+ {/* `alt` carries the name for anyone not hovering: the tooltip is pointer-only, and an avatar image with no alt text reads as its URL. */}
+
+
+ {fallbackLetter}
+
+
+
+
);
}
diff --git a/packages/propel/src/avatar/index.ts b/packages/propel/src/avatar/index.ts
index 522264e6..af722d71 100644
--- a/packages/propel/src/avatar/index.ts
+++ b/packages/propel/src/avatar/index.ts
@@ -5,3 +5,4 @@
*/
export * from "./avatar";
+export * from "./avatar-group";
diff --git a/packages/ui/src/avatar/avatar.stories.tsx b/packages/ui/src/avatar/avatar.stories.tsx
deleted file mode 100644
index 9f96059f..00000000
--- a/packages/ui/src/avatar/avatar.stories.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-/**
- * Copyright (c) 2023-present Plane Software, Inc. and contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- * See the LICENSE file for details.
- */
-
-import type { Meta, StoryObj } from "@storybook/react";
-import { Avatar } from "./avatar";
-
-const meta: Meta = {
- title: "Avatar",
- component: Avatar,
-};
-
-export default meta;
-type Story = StoryObj;
-
-export const Default: Story = {
- args: { name: "John Doe" },
-};
-
-export const Large: Story = {
- args: { name: "John Doe" },
-};
diff --git a/packages/ui/src/avatar/avatar.tsx b/packages/ui/src/avatar/avatar.tsx
deleted file mode 100644
index 1d29b047..00000000
--- a/packages/ui/src/avatar/avatar.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * Copyright (c) 2023-present Plane Software, Inc. and contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- * See the LICENSE file for details.
- */
-
-// ui
-import { Tooltip } from "@pace/propel/tooltip";
-// helpers
-import { cn } from "../utils";
-import type { TAvatarSize } from "./helper";
-import { getBorderRadius, getSizeInfo, isAValidNumber } from "./helper";
-
-type Props = {
- /**
- * The name of the avatar which will be displayed on the tooltip
- */
- name?: string;
- /**
- * The background color if the avatar image fails to load
- */
- fallbackBackgroundColor?: string;
- /**
- * The text to display if the avatar image fails to load
- */
- fallbackText?: string;
- /**
- * The text color if the avatar image fails to load
- */
- fallbackTextColor?: string;
- /**
- * Whether to show the tooltip or not
- * @default true
- */
- showTooltip?: boolean;
- /**
- * The size of the avatars
- * Possible values: "sm", "md", "base", "lg"
- * @default "md"
- */
- size?: TAvatarSize;
- /**
- * The shape of the avatar
- * Possible values: "circle", "square"
- * @default "circle"
- */
- shape?: "circle" | "square";
- /**
- * The source of the avatar image
- */
- src?: string;
- /**
- * The custom CSS class name to apply to the component
- */
- className?: string;
-};
-
-export function Avatar(props: Props) {
- const {
- name,
- fallbackBackgroundColor,
- fallbackText,
- fallbackTextColor,
- showTooltip = true,
- size = "md",
- shape = "circle",
- src,
- className = "",
- } = props;
-
- // get size details based on the size prop
- const sizeInfo = getSizeInfo(size);
-
- return (
-
-