diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json
index 8f3f77f..50eb1ba 100644
--- a/apps/web/package-lock.json
+++ b/apps/web/package-lock.json
@@ -10,7 +10,8 @@
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7",
- "react-router-dom": "^7.18.1"
+ "react-router-dom": "^7.18.1",
+ "react-useanimations": "^2.10.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
@@ -2617,6 +2618,12 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/lottie-web": {
+ "version": "5.13.0",
+ "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
+ "integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
+ "license": "MIT"
+ },
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -2916,6 +2923,19 @@
"react-dom": ">=18"
}
},
+ "node_modules/react-useanimations": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/react-useanimations/-/react-useanimations-2.10.0.tgz",
+ "integrity": "sha512-MzGNv8vkvb6qEvMBCj+O6nUloUHSJRubMAH3uE7J4M+pjt5ud5xDaXBrQgv5GbvBg29XzviKWHTfvvkofDEu+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "lottie-web": "^5.5.7"
+ },
+ "peerDependencies": {
+ "react": ">=16",
+ "react-dom": ">=16"
+ }
+ },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
diff --git a/apps/web/package.json b/apps/web/package.json
index 2c35ed0..8ec5477 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -15,7 +15,8 @@
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7",
- "react-router-dom": "^7.18.1"
+ "react-router-dom": "^7.18.1",
+ "react-useanimations": "^2.10.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
diff --git a/apps/web/src/components/layout/AccountMenu.tsx b/apps/web/src/components/layout/AccountMenu.tsx
index 8542705..9dab25a 100644
--- a/apps/web/src/components/layout/AccountMenu.tsx
+++ b/apps/web/src/components/layout/AccountMenu.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../../auth/AuthContext";
import { AccountAvatar } from "../ui/AccountAvatar";
+import { AppIcon } from "../ui/AppIcons";
import { useI18n } from "../../i18n/I18nContext";
import { memberRoleSummary } from "../../lib/memberRole";
import { can, Permission } from "../../lib/permissions";
@@ -49,7 +50,7 @@ export function AccountMenu() {
{name}
- ▾
+
diff --git a/apps/web/src/components/layout/BellMenu.tsx b/apps/web/src/components/layout/BellMenu.tsx
index 04510b6..4e61c6a 100644
--- a/apps/web/src/components/layout/BellMenu.tsx
+++ b/apps/web/src/components/layout/BellMenu.tsx
@@ -6,7 +6,7 @@ import type { AppNotification } from "../../domain/types";
import { useI18n } from "../../i18n/I18nContext";
import { pathForNotification } from "../../lib/nav";
import { formatLocalDateTime, formatTimeAgo } from "../../lib/time";
-import { Button } from "../ui";
+import { AppIcon, Button } from "../ui";
const PREVIEW = 5;
@@ -117,21 +117,7 @@ export function BellMenu() {
title={t("topbar.notifications")}
>
- {/* 簡化鈴鐺:幾何,不用 emoji */}
-
+
{unread > 0 ? (
{unread > 9 ? "9+" : unread}
diff --git a/apps/web/src/components/layout/MobileDock.tsx b/apps/web/src/components/layout/MobileDock.tsx
index 05c3d6b..44da632 100644
--- a/apps/web/src/components/layout/MobileDock.tsx
+++ b/apps/web/src/components/layout/MobileDock.tsx
@@ -103,7 +103,11 @@ export function MobileDock() {
{t(item.labelKey)}
- {active ? ✓ : null}
+ {active ? (
+
+
+
+ ) : null}
);
diff --git a/apps/web/src/components/layout/PageHelp.tsx b/apps/web/src/components/layout/PageHelp.tsx
index ebb4fe3..b84cfaf 100644
--- a/apps/web/src/components/layout/PageHelp.tsx
+++ b/apps/web/src/components/layout/PageHelp.tsx
@@ -18,6 +18,7 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { Link, useLocation } from "react-router-dom";
+import { AppIcon } from "../ui/AppIcons";
import { useI18n } from "../../i18n/I18nContext";
import {
PAGE_HELP_RELATED,
@@ -150,7 +151,7 @@ export function PageHelpTrigger({ className = "" }: { className?: string }) {
aria-haspopup="dialog"
title={`${t("help.open")} (? )`}
>
- ?
+
);
}
diff --git a/apps/web/src/components/radar/ExplorePanel.tsx b/apps/web/src/components/radar/ExplorePanel.tsx
index 53c4f89..5310f4e 100644
--- a/apps/web/src/components/radar/ExplorePanel.tsx
+++ b/apps/web/src/components/radar/ExplorePanel.tsx
@@ -4,7 +4,7 @@ import type { Brand, BrandProduct, CostPreview, ExploreResult, WatchTermSuggesti
import { useI18n } from "../../i18n/I18nContext";
import { useFormatApiError } from "../../lib/apiErrors";
import { checkThreadsTerm, isThreadsSearchable, normalizeSearchTerm } from "../../lib/threadsTerm";
-import { Badge, Button, Input } from "../ui";
+import { AppIcon, Badge, Button, Input } from "../ui";
import { CostPreviewDialog } from "./CostPreviewDialog";
const MAX_TERMS = 6;
@@ -188,7 +188,7 @@ export function ExplorePanel({ onExplored }: { onExplored: () => void }) {
>
{term}
- ×
+
))
diff --git a/apps/web/src/components/studio/ImageAttach.tsx b/apps/web/src/components/studio/ImageAttach.tsx
index 3e6dab0..032b57d 100644
--- a/apps/web/src/components/studio/ImageAttach.tsx
+++ b/apps/web/src/components/studio/ImageAttach.tsx
@@ -1,5 +1,5 @@
import { useRef, useState } from "react";
-import { Button } from "../ui";
+import { AppIcon, Button } from "../ui";
import { useI18n } from "../../i18n/I18nContext";
import {
compressDataUrlForUpload,
@@ -149,7 +149,7 @@ export function ImageAttach({
disabled={disabled}
onClick={() => remove(img.id)}
>
- ×
+
))}
diff --git a/apps/web/src/components/ui/AppIcons.test.tsx b/apps/web/src/components/ui/AppIcons.test.tsx
new file mode 100644
index 0000000..3d8cf40
--- /dev/null
+++ b/apps/web/src/components/ui/AppIcons.test.tsx
@@ -0,0 +1,33 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { AppIcon } from "./AppIcons";
+
+describe("AppIcon", () => {
+ it("injects an svg from useanimations data", () => {
+ const { container } = render(
+ ,
+ );
+ const host = container.querySelector(".hb-app-icon");
+ expect(host).toBeTruthy();
+ expect(host?.querySelector("svg")).toBeTruthy();
+ });
+
+ it("starts animating when the parent is hovered", async () => {
+ const { container } = render(
+ ,
+ );
+ const host = container.querySelector(".hb-app-icon");
+ const before = host?.innerHTML ?? "";
+ host?.closest("button")?.dispatchEvent(new Event("pointerenter", { bubbles: true }));
+ await new Promise((resolve) => {
+ window.setTimeout(resolve, 80);
+ });
+ const after = host?.innerHTML ?? "";
+ expect(after.length).toBeGreaterThan(0);
+ expect(after !== before || after.includes("transform")).toBe(true);
+ });
+});
diff --git a/apps/web/src/components/ui/AppIcons.tsx b/apps/web/src/components/ui/AppIcons.tsx
index efc0d35..2fc4b00 100644
--- a/apps/web/src/components/ui/AppIcons.tsx
+++ b/apps/web/src/components/ui/AppIcons.tsx
@@ -1,210 +1,197 @@
-import type { ReactNode, SVGProps } from "react";
+import { useEffect, useRef } from "react";
+import lottieMod from "lottie-web";
+import archive from "react-useanimations/lib/archive";
+import arrowDownCircle from "react-useanimations/lib/arrowDownCircle";
+import bookmark from "react-useanimations/lib/bookmark";
+import calendar from "react-useanimations/lib/calendar";
+import checkmark from "react-useanimations/lib/checkmark";
+import copy from "react-useanimations/lib/copy";
+import edit from "react-useanimations/lib/edit";
+import explore from "react-useanimations/lib/explore";
+import folder from "react-useanimations/lib/folder";
+import help from "react-useanimations/lib/help";
+import lock from "react-useanimations/lib/lock";
+import mail from "react-useanimations/lib/mail";
+import menu2 from "react-useanimations/lib/menu2";
+import notification2 from "react-useanimations/lib/notification2";
+import plusToX from "react-useanimations/lib/plusToX";
+import searchToX from "react-useanimations/lib/searchToX";
+import share from "react-useanimations/lib/share";
+import skipForward from "react-useanimations/lib/skipForward";
+import star from "react-useanimations/lib/star";
+import thumbUp from "react-useanimations/lib/thumbUp";
+import userPlus from "react-useanimations/lib/userPlus";
+import visibility from "react-useanimations/lib/visibility";
import type { NavKey } from "../../lib/nav";
-export type AppIconName = NavKey | "more" | "spark" | "send";
+type LottieItem = {
+ play: () => void;
+ destroy: () => void;
+ setDirection: (direction: number) => void;
+ goToAndStop: (value: number, isFrame?: boolean) => void;
+ totalFrames: number;
+};
+
+type LottiePlayer = {
+ loadAnimation: (opts: {
+ container: Element;
+ renderer: "svg";
+ loop: boolean;
+ autoplay: boolean;
+ animationData: unknown;
+ }) => LottieItem;
+};
+
+type Glyph = { animationData: unknown; animationKey: string };
+
+/** CJS 套件在 Vite 下 default 可能包一層 `{ default: x }`。 */
+function unwrapDefault(mod: T | { default: T }): T {
+ if (typeof mod === "function") return mod;
+ if (mod && typeof mod === "object" && "default" in mod) {
+ return (mod as { default: T }).default;
+ }
+ return mod as T;
+}
+
+function asGlyph(mod: unknown): Glyph {
+ const inner = unwrapDefault(mod) as Glyph;
+ return {
+ animationKey: inner.animationKey,
+ animationData: unwrapDefault(inner.animationData),
+ };
+}
+
+const lottie = unwrapDefault(lottieMod) as LottiePlayer;
+
+export type AppIconName =
+ | NavKey
+ | "more"
+ | "spark"
+ | "send"
+ | "bell"
+ | "visibility"
+ | "chevron"
+ | "help"
+ | "close"
+ | "check";
type Props = {
name: AppIconName;
size?: number;
className?: string;
-} & Omit, "name">;
+ /** 密碼眼睛等:點一下才播,不要 hover 就劃掉 */
+ play?: "hover" | "click";
+ /** 一開始停在最後一格(例如 close 顯示 X) */
+ reverse?: boolean;
+};
+
+const glyphs: Record = {
+ today: asGlyph(calendar),
+ crew: asGlyph(userPlus),
+ studio: asGlyph(edit),
+ scout: asGlyph(searchToX),
+ radar: asGlyph(explore),
+ crm: asGlyph(archive),
+ outbox: asGlyph(mail),
+ jobs: asGlyph(folder),
+ brands: asGlyph(bookmark),
+ policy: asGlyph(lock),
+ playbooks: asGlyph(copy),
+ insights: asGlyph(thumbUp),
+ benchmark: asGlyph(star),
+ utm: asGlyph(share),
+ more: asGlyph(menu2),
+ spark: asGlyph(star),
+ send: asGlyph(skipForward),
+ bell: asGlyph(notification2),
+ visibility: asGlyph(visibility),
+ chevron: asGlyph(arrowDownCircle),
+ help: asGlyph(help),
+ close: asGlyph(plusToX),
+ check: asGlyph(checkmark),
+};
/**
- * 精緻線稿 icon:圓潤端點、雙層深度、細微星芒
- * 主線 currentColor;星芒用主題 teal / coral,適配 light / dark。
+ * 用 react-useanimations 的 Lottie 檔,但自己播。
+ * 原套件事件只綁在 18px 方塊上,hover 側欄整列不會動;cleanup 也抓不到 instance。
*/
-export function AppIcon({ name, size = 22, className = "", ...rest }: Props) {
+export function AppIcon({
+ name,
+ size = 22,
+ className = "",
+ play = "hover",
+ reverse = false,
+}: Props) {
+ const hostRef = useRef(null);
+
+ useEffect(() => {
+ const host = hostRef.current;
+ const glyph = glyphs[name];
+ if (!host || !glyph?.animationData || typeof lottie?.loadAnimation !== "function") {
+ return;
+ }
+
+ host.replaceChildren();
+ const anim = lottie.loadAnimation({
+ container: host,
+ renderer: "svg",
+ loop: false,
+ autoplay: false,
+ animationData: glyph.animationData,
+ });
+
+ if (reverse && anim.totalFrames > 0) {
+ anim.goToAndStop(anim.totalFrames - 1, true);
+ }
+
+ const target = host.closest("a, button, [data-icon-host]") ?? host;
+ let direction = reverse ? -1 : 1;
+
+ const playForward = () => {
+ if (reverse) {
+ anim.goToAndStop(0, true);
+ }
+ anim.setDirection(1);
+ anim.play();
+ };
+ const playBack = () => {
+ if (reverse) {
+ anim.goToAndStop(Math.max(0, anim.totalFrames - 1), true);
+ return;
+ }
+ anim.setDirection(-1);
+ anim.play();
+ };
+ const playToggle = () => {
+ anim.setDirection(direction);
+ anim.play();
+ direction *= -1;
+ };
+
+ if (play === "click") {
+ target.addEventListener("click", playToggle);
+ } else {
+ target.addEventListener("pointerenter", playForward);
+ target.addEventListener("pointerleave", playBack);
+ }
+
+ return () => {
+ if (play === "click") {
+ target.removeEventListener("click", playToggle);
+ } else {
+ target.removeEventListener("pointerenter", playForward);
+ target.removeEventListener("pointerleave", playBack);
+ }
+ anim.destroy();
+ };
+ }, [name, play, reverse]);
+
return (
-
+ />
);
}
-
-const stroke = {
- stroke: "currentColor",
- strokeWidth: 1.5,
- strokeLinecap: "round" as const,
- strokeLinejoin: "round" as const,
-};
-
-/** 小星芒:點綴色走主題 magic / accent */
-function Spark({
- x = 18,
- y = 5,
- s = 1,
- tone = "magic",
-}: {
- x?: number;
- y?: number;
- s?: number;
- tone?: "magic" | "accent";
-}) {
- // magic 不用 --hb-magic:那是次色奶油黃,當線條放在淺底上幾乎看不見。
- const color = tone === "accent" ? "var(--hb-accent-warm)" : "var(--hb-brand-deep)";
- return (
-
-
-
- );
-}
-
-const glyphs: Record = {
- // 今日:圓角日曆 + 星
- today: (
- <>
-
-
-
-
- >
- ),
- // 帳號:雙圓 + 柔弧(島民)
- crew: (
- <>
-
-
-
-
-
- >
- ),
- // 創作:羽毛筆 + 星
- studio: (
- <>
-
-
-
-
- >
- ),
- // 海巡:水晶球 / 羅盤弧 + 十字光
- scout: (
- <>
-
-
-
-
-
- >
- ),
- // 雷達:同心圓掃描
- radar: (
- <>
-
-
-
-
-
- >
- ),
- // 名單:看板卡片
- crm: (
- <>
-
-
-
-
- >
- ),
- // 發送:紙飛機 + 軌跡星
- outbox: (
- <>
-
-
-
- >
- ),
- // 任務:魔法卷軸/手提袋簡化
- jobs: (
- <>
-
-
-
-
- >
- ),
- // 品牌:旗幟堡壘簡化 + 星
- brands: (
- <>
-
-
-
-
- >
- ),
- // 商機政策:盾牌 + 核取,代表全域回覆與篩選規則
- policy: (
- <>
-
-
- >
- ),
- playbooks: (
- <>
-
-
- >
- ),
- insights: (
- <>
-
-
-
- >
- ),
- benchmark: (
- <>
-
-
-
- >
- ),
- utm: (
- <>
-
-
-
- >
- ),
- more: (
- <>
-
-
-
- >
- ),
- spark: (
- <>
-
-
- >
- ),
- // 送出:置中紙飛機(小圓鈕專用,無星芒避免破圖感)
- send: (
- <>
-
-
- >
- ),
-};
diff --git a/apps/web/src/components/ui/Input.tsx b/apps/web/src/components/ui/Input.tsx
index 54974cf..567b05c 100644
--- a/apps/web/src/components/ui/Input.tsx
+++ b/apps/web/src/components/ui/Input.tsx
@@ -1,4 +1,5 @@
import { useState, type InputHTMLAttributes } from "react";
+import { AppIcon } from "./AppIcons";
type Props = InputHTMLAttributes & {
label?: string;
@@ -54,7 +55,7 @@ export function Input({
aria-pressed={visible}
onClick={() => setVisible((v) => !v)}
>
-
+
) : null}
@@ -68,26 +69,3 @@ export function Input({
);
}
-
-function PasswordEyeIcon({ hidden }: { hidden: boolean }) {
- return (
-
- );
-}
diff --git a/apps/web/src/pages/studio/InspirePanel.tsx b/apps/web/src/pages/studio/InspirePanel.tsx
index 429cdfa..fa28452 100644
--- a/apps/web/src/pages/studio/InspirePanel.tsx
+++ b/apps/web/src/pages/studio/InspirePanel.tsx
@@ -1052,7 +1052,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
title={t("inspire.unpinTitle")}
aria-label={t("inspire.unpinTitle")}
>
- ×
+
))}
@@ -1317,7 +1317,7 @@ function ElementRow({
aria-label={t("inspire.deleteAria")}
onClick={onRemove}
>
- ×
+
);
diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css
index 244027f..6c989c4 100644
--- a/apps/web/src/styles/global.css
+++ b/apps/web/src/styles/global.css
@@ -2301,8 +2301,6 @@ svg {
.hb-inspire-send-btn .hb-app-icon {
display: block;
flex-shrink: 0;
- /* 紙飛機視覺重心略偏左,微調置中 */
- transform: translate(1px, 0.5px);
}
.hb-inspire-send-btn__dots {
diff --git a/apps/web/src/styles/layout.css b/apps/web/src/styles/layout.css
index f05d206..72ee1b0 100644
--- a/apps/web/src/styles/layout.css
+++ b/apps/web/src/styles/layout.css
@@ -1211,10 +1211,28 @@ a.hb-topbar__chip:hover {
}
.hb-dock__icon svg,
-.hb-app-icon {
+.hb-app-icon,
+.hb-app-icon svg {
display: block;
}
+.hb-app-icon {
+ flex-shrink: 0;
+ line-height: 0;
+ overflow: hidden;
+}
+
+.hb-app-icon svg {
+ width: 100%;
+ height: 100%;
+}
+
+.hb-app-icon path[stroke],
+.hb-app-icon circle[stroke],
+.hb-app-icon rect[stroke] {
+ stroke: currentColor !important;
+}
+
.hb-dock-more__ico {
display: grid;
place-items: center;
diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts
index e262193..d4c5118 100644
--- a/apps/web/src/test/setup.ts
+++ b/apps/web/src/test/setup.ts
@@ -2,6 +2,19 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
+/** lottie-web 載入時會摸 canvas;jsdom 的 getContext 會丟 Not implemented。 */
+HTMLCanvasElement.prototype.getContext = function getContext(this: HTMLCanvasElement) {
+ return {
+ fillStyle: "",
+ fillRect() {},
+ drawImage() {},
+ getImageData() {
+ return { data: new Uint8ClampedArray(4) };
+ },
+ canvas: this,
+ };
+} as unknown as typeof HTMLCanvasElement.prototype.getContext;
+
afterEach(() => {
cleanup();
});