diff --git a/packages/extension/package.json b/packages/extension/package.json index 91aee926f..8d4446f78 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -59,6 +59,7 @@ "sonner": "git+https://github.com/ujiro99/sonner.git", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", + "tldts": "^7.4.13", "uuid": "^14.0.0", "zod": "^3.24.1" }, diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index 10e205480..1c2e8699e 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -719,6 +719,24 @@ "Option_userStyles_desc_font_color": { "message": "Geben Sie die Schriftfarbe an. Standard: #0F172A" }, + "Option_userStyles_option_icon_color": { + "message": "Icon Color" + }, + "Option_userStyles_desc_icon_color": { + "message": "Specify icon color. Default: #0F172A" + }, + "Option_excludeFromGlobalIconColor": { + "message": "Exclude from global icon color" + }, + "Option_excludeFromGlobalIconColor_desc": { + "message": "Keep this icon's original color even when a global icon color is set." + }, + "Option_excludeFromGlobalIconColor_favicon_desc": { + "message": "Favicons are automatically excluded from the global icon color and keep their original colors." + }, + "Option_excludeFromGlobalIconColor_automatic": { + "message": "Automatic" + }, "Option_userStyles_option_font_scale": { "message": "Schriftgrößen-Skalierung" }, diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 77b80e6d8..43e9d6b6b 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -723,6 +723,24 @@ "Option_userStyles_desc_font_color": { "message": "Specify font color. Default: #0F172A" }, + "Option_userStyles_option_icon_color": { + "message": "Icon Color" + }, + "Option_userStyles_desc_icon_color": { + "message": "Specify icon color. Default: #0F172A" + }, + "Option_excludeFromGlobalIconColor": { + "message": "Exclude from global icon color" + }, + "Option_excludeFromGlobalIconColor_desc": { + "message": "Keep this icon's original color even when a global icon color is set." + }, + "Option_excludeFromGlobalIconColor_favicon_desc": { + "message": "Favicons are automatically excluded from the global icon color and keep their original colors." + }, + "Option_excludeFromGlobalIconColor_automatic": { + "message": "Automatic" + }, "Option_userStyles_option_font_scale": { "message": "Font size scaling" }, diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index 4dc78aefc..4a68ec177 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -719,6 +719,24 @@ "Option_userStyles_desc_font_color": { "message": "Especificar el color de fuente. Predeterminado: #0F172A" }, + "Option_userStyles_option_icon_color": { + "message": "Icon Color" + }, + "Option_userStyles_desc_icon_color": { + "message": "Specify icon color. Default: #0F172A" + }, + "Option_excludeFromGlobalIconColor": { + "message": "Exclude from global icon color" + }, + "Option_excludeFromGlobalIconColor_desc": { + "message": "Keep this icon's original color even when a global icon color is set." + }, + "Option_excludeFromGlobalIconColor_favicon_desc": { + "message": "Favicons are automatically excluded from the global icon color and keep their original colors." + }, + "Option_excludeFromGlobalIconColor_automatic": { + "message": "Automatic" + }, "Option_userStyles_option_font_scale": { "message": "Escala de tamaño de fuente" }, diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index cd7168273..6fa8e3227 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -719,6 +719,24 @@ "Option_userStyles_desc_font_color": { "message": "Spécifier la couleur de police. Par défaut: #0F172A" }, + "Option_userStyles_option_icon_color": { + "message": "Icon Color" + }, + "Option_userStyles_desc_icon_color": { + "message": "Specify icon color. Default: #0F172A" + }, + "Option_excludeFromGlobalIconColor": { + "message": "Exclude from global icon color" + }, + "Option_excludeFromGlobalIconColor_desc": { + "message": "Keep this icon's original color even when a global icon color is set." + }, + "Option_excludeFromGlobalIconColor_favicon_desc": { + "message": "Favicons are automatically excluded from the global icon color and keep their original colors." + }, + "Option_excludeFromGlobalIconColor_automatic": { + "message": "Automatic" + }, "Option_userStyles_option_font_scale": { "message": "Échelle de taille de police" }, diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index 2ec54606d..ae3b4691a 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -719,6 +719,24 @@ "Option_userStyles_desc_font_color": { "message": "फ़ॉन्ट का रंग निर्दिष्ट करें। डिफ़ॉल्ट: #0F172A" }, + "Option_userStyles_option_icon_color": { + "message": "Icon Color" + }, + "Option_userStyles_desc_icon_color": { + "message": "Specify icon color. Default: #0F172A" + }, + "Option_excludeFromGlobalIconColor": { + "message": "Exclude from global icon color" + }, + "Option_excludeFromGlobalIconColor_desc": { + "message": "Keep this icon's original color even when a global icon color is set." + }, + "Option_excludeFromGlobalIconColor_favicon_desc": { + "message": "Favicons are automatically excluded from the global icon color and keep their original colors." + }, + "Option_excludeFromGlobalIconColor_automatic": { + "message": "Automatic" + }, "Option_userStyles_option_font_scale": { "message": "फ़ॉन्ट साइज़ स्केल" }, diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index 6cd360745..f37856386 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -719,6 +719,24 @@ "Option_userStyles_desc_font_color": { "message": "Tentukan warna font. Default: #0F172A" }, + "Option_userStyles_option_icon_color": { + "message": "Icon Color" + }, + "Option_userStyles_desc_icon_color": { + "message": "Specify icon color. Default: #0F172A" + }, + "Option_excludeFromGlobalIconColor": { + "message": "Exclude from global icon color" + }, + "Option_excludeFromGlobalIconColor_desc": { + "message": "Keep this icon's original color even when a global icon color is set." + }, + "Option_excludeFromGlobalIconColor_favicon_desc": { + "message": "Favicons are automatically excluded from the global icon color and keep their original colors." + }, + "Option_excludeFromGlobalIconColor_automatic": { + "message": "Automatic" + }, "Option_userStyles_option_font_scale": { "message": "Penskalaan ukuran font" }, diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index f5baeccad..6d2b8dced 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -719,6 +719,24 @@ "Option_userStyles_desc_font_color": { "message": "Specifica il colore del carattere. Predefinito: #0F172A" }, + "Option_userStyles_option_icon_color": { + "message": "Icon Color" + }, + "Option_userStyles_desc_icon_color": { + "message": "Specify icon color. Default: #0F172A" + }, + "Option_excludeFromGlobalIconColor": { + "message": "Exclude from global icon color" + }, + "Option_excludeFromGlobalIconColor_desc": { + "message": "Keep this icon's original color even when a global icon color is set." + }, + "Option_excludeFromGlobalIconColor_favicon_desc": { + "message": "Favicons are automatically excluded from the global icon color and keep their original colors." + }, + "Option_excludeFromGlobalIconColor_automatic": { + "message": "Automatic" + }, "Option_userStyles_option_font_scale": { "message": "Scala dimensione font" }, diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index ad47d4c21..700be7607 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -719,6 +719,24 @@ "Option_userStyles_desc_font_color": { "message": "文字の色を指定します。デフォルト: #0F172A" }, + "Option_userStyles_option_icon_color": { + "message": "アイコンの色" + }, + "Option_userStyles_desc_icon_color": { + "message": "アイコンの色を指定します。デフォルト: #0F172A" + }, + "Option_excludeFromGlobalIconColor": { + "message": "全体のアイコン色から除外する" + }, + "Option_excludeFromGlobalIconColor_desc": { + "message": "全体のアイコン色が設定されている場合でも、このアイコン本来の色を保持します。" + }, + "Option_excludeFromGlobalIconColor_favicon_desc": { + "message": "ファビコンは自動的に全体のアイコン色から除外され、本来の色を保持します。" + }, + "Option_excludeFromGlobalIconColor_automatic": { + "message": "自動" + }, "Option_userStyles_option_font_scale": { "message": "文字サイズの倍率" }, diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index bbd9cd83f..1a1ce967e 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -719,6 +719,24 @@ "Option_userStyles_desc_font_color": { "message": "글꼴 색상을 지정합니다. 기본값: #0F172A" }, + "Option_userStyles_option_icon_color": { + "message": "Icon Color" + }, + "Option_userStyles_desc_icon_color": { + "message": "Specify icon color. Default: #0F172A" + }, + "Option_excludeFromGlobalIconColor": { + "message": "Exclude from global icon color" + }, + "Option_excludeFromGlobalIconColor_desc": { + "message": "Keep this icon's original color even when a global icon color is set." + }, + "Option_excludeFromGlobalIconColor_favicon_desc": { + "message": "Favicons are automatically excluded from the global icon color and keep their original colors." + }, + "Option_excludeFromGlobalIconColor_automatic": { + "message": "Automatic" + }, "Option_userStyles_option_font_scale": { "message": "글꼴 크기 스케일" }, diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index fc1bc9224..1061b812c 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -719,6 +719,24 @@ "Option_userStyles_desc_font_color": { "message": "Tentukan warna fon. Lalai: #0F172A" }, + "Option_userStyles_option_icon_color": { + "message": "Icon Color" + }, + "Option_userStyles_desc_icon_color": { + "message": "Specify icon color. Default: #0F172A" + }, + "Option_excludeFromGlobalIconColor": { + "message": "Exclude from global icon color" + }, + "Option_excludeFromGlobalIconColor_desc": { + "message": "Keep this icon's original color even when a global icon color is set." + }, + "Option_excludeFromGlobalIconColor_favicon_desc": { + "message": "Favicons are automatically excluded from the global icon color and keep their original colors." + }, + "Option_excludeFromGlobalIconColor_automatic": { + "message": "Automatic" + }, "Option_userStyles_option_font_scale": { "message": "Penskalaan saiz fon" }, diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index 8768173a8..6aaefa85d 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -719,6 +719,24 @@ "Option_userStyles_desc_font_color": { "message": "Especificar a cor da fonte. Padrão: #0F172A" }, + "Option_userStyles_option_icon_color": { + "message": "Icon Color" + }, + "Option_userStyles_desc_icon_color": { + "message": "Specify icon color. Default: #0F172A" + }, + "Option_excludeFromGlobalIconColor": { + "message": "Exclude from global icon color" + }, + "Option_excludeFromGlobalIconColor_desc": { + "message": "Keep this icon's original color even when a global icon color is set." + }, + "Option_excludeFromGlobalIconColor_favicon_desc": { + "message": "Favicons are automatically excluded from the global icon color and keep their original colors." + }, + "Option_excludeFromGlobalIconColor_automatic": { + "message": "Automatic" + }, "Option_userStyles_option_font_scale": { "message": "Escala do tamanho da fonte" }, diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index 0f45b75f8..61db9ec76 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -719,6 +719,24 @@ "Option_userStyles_desc_font_color": { "message": "Especificar a cor do tipo de letra. Predefinido: #0F172A" }, + "Option_userStyles_option_icon_color": { + "message": "Icon Color" + }, + "Option_userStyles_desc_icon_color": { + "message": "Specify icon color. Default: #0F172A" + }, + "Option_excludeFromGlobalIconColor": { + "message": "Exclude from global icon color" + }, + "Option_excludeFromGlobalIconColor_desc": { + "message": "Keep this icon's original color even when a global icon color is set." + }, + "Option_excludeFromGlobalIconColor_favicon_desc": { + "message": "Favicons are automatically excluded from the global icon color and keep their original colors." + }, + "Option_excludeFromGlobalIconColor_automatic": { + "message": "Automatic" + }, "Option_userStyles_option_font_scale": { "message": "Escala do tamanho da fonte" }, diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index 742199b4c..d64e37726 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -719,6 +719,24 @@ "Option_userStyles_desc_font_color": { "message": "Укажите цвет шрифта. По умолчанию: #0F172A" }, + "Option_userStyles_option_icon_color": { + "message": "Icon Color" + }, + "Option_userStyles_desc_icon_color": { + "message": "Specify icon color. Default: #0F172A" + }, + "Option_excludeFromGlobalIconColor": { + "message": "Exclude from global icon color" + }, + "Option_excludeFromGlobalIconColor_desc": { + "message": "Keep this icon's original color even when a global icon color is set." + }, + "Option_excludeFromGlobalIconColor_favicon_desc": { + "message": "Favicons are automatically excluded from the global icon color and keep their original colors." + }, + "Option_excludeFromGlobalIconColor_automatic": { + "message": "Automatic" + }, "Option_userStyles_option_font_scale": { "message": "Масштаб размера шрифта" }, diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index b0a416613..5dab316b1 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -719,6 +719,24 @@ "Option_userStyles_desc_font_color": { "message": "指定字体颜色。默认值:#0F172A" }, + "Option_userStyles_option_icon_color": { + "message": "Icon Color" + }, + "Option_userStyles_desc_icon_color": { + "message": "Specify icon color. Default: #0F172A" + }, + "Option_excludeFromGlobalIconColor": { + "message": "Exclude from global icon color" + }, + "Option_excludeFromGlobalIconColor_desc": { + "message": "Keep this icon's original color even when a global icon color is set." + }, + "Option_excludeFromGlobalIconColor_favicon_desc": { + "message": "Favicons are automatically excluded from the global icon color and keep their original colors." + }, + "Option_excludeFromGlobalIconColor_automatic": { + "message": "Automatic" + }, "Option_userStyles_option_font_scale": { "message": "字体大小缩放" }, diff --git a/packages/extension/src/components/Popup.tsx b/packages/extension/src/components/Popup.tsx index 18ac857f3..6da23cb45 100644 --- a/packages/extension/src/components/Popup.tsx +++ b/packages/extension/src/components/Popup.tsx @@ -8,7 +8,7 @@ import { useSidePanelNavigation } from "@/hooks/useSidePanelNavigation" import { useSidePanelAutoClose } from "@/hooks/useSidePanelAutoClose" import { useSelectContext } from "@/hooks/useSelectContext" import { popupContext } from "@/hooks/usePopupContext" -import { hexToHsl, isMac, onHover, cn } from "@/lib/utils" +import { hexToHsl, isMac, onHover, cn, isEmpty } from "@/lib/utils" import { t } from "@/services/i18n" import { STYLE_VARIABLE, EXIT_DURATION, SIDE, ALIGN } from "@/const" @@ -110,6 +110,12 @@ export const Popup = forwardRef( setDetectSelectionEnabled(!hover) } + const hasIconColor = Boolean( + userSettings?.userStyles?.some( + (s: any) => s.name === STYLE_VARIABLE.ICON_COLOR && !isEmpty(s.value), + ), + ) + return ( ( inOnboarding: props.inOnboarding, side, align, + hasIconColor, }} > {isPreview && } diff --git a/packages/extension/src/components/menu/Menu.module.css b/packages/extension/src/components/menu/Menu.module.css index 826f57a32..659aaff1c 100644 --- a/packages/extension/src/components/menu/Menu.module.css +++ b/packages/extension/src/components/menu/Menu.module.css @@ -18,6 +18,7 @@ font-size: var(--sc-font-size); font-family: var(--font-family); transition: ease-out 0.15s background; + color: var(--sc-font-color); &:disabled { opacity: 0.4; @@ -36,10 +37,23 @@ height: var(--sc-image-size, 16px); } +.itemImgMasked { + display: inline-block; + vertical-align: middle; + flex-shrink: 0; + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; +} + .itemTitle { margin-left: calc(0.4em + var(--sc-padding) / 2); margin-right: calc(0.4em + var(--sc-padding) / 2); text-align: left; + color: inherit; } .itemHorizontal { @@ -82,6 +96,7 @@ cursor: default; padding-right: calc(var(--sc-padding) / 2); transition: ease-out 0.15s background; + color: var(--sc-font-color); } .title { diff --git a/packages/extension/src/components/menu/Menu.tsx b/packages/extension/src/components/menu/Menu.tsx index 85fa9a344..45cb5aec2 100644 --- a/packages/extension/src/components/menu/Menu.tsx +++ b/packages/extension/src/components/menu/Menu.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react" +import { createContext, useRef, useState } from "react" import { STYLE, SIDE } from "@/const" import { TEST_IDS } from "@/testIds" @@ -8,11 +8,13 @@ import { toCommandTree } from "@/services/option/commandTree" import css from "./Menu.module.css" import { MenuTreeNode } from "./MenuFolder" +export const IconUrlsContext = createContext>({}) + export function Menu(): JSX.Element { const menuRef = useRef(null) const [hoverTrigger, setHoverTrigger] = useState("") const [hoverContent, setHoverContent] = useState("") - const { commands, folders, userSettings } = useSettingsWithImageCache() + const { commands, folders, userSettings, iconUrls } = useSettingsWithImageCache() const isHorizontal = userSettings.style === STYLE.HORIZONTAL const side = userSettings.popupPlacement?.side ?? SIDE.top @@ -20,27 +22,29 @@ export function Menu(): JSX.Element { const activeFolder = hoverTrigger || hoverContent return ( -
- {commandTree.map((node) => ( - - ))} -
+ +
+ {commandTree.map((node) => ( + + ))} +
+
) } diff --git a/packages/extension/src/components/menu/MenuFolder.tsx b/packages/extension/src/components/menu/MenuFolder.tsx index e4665852d..78835809a 100644 --- a/packages/extension/src/components/menu/MenuFolder.tsx +++ b/packages/extension/src/components/menu/MenuFolder.tsx @@ -129,7 +129,8 @@ export const MenuFolder = (props: { [css.itemOnlyIcon]: folder.onlyIcon && isHorizontal, [css.folderHorizontal]: isHorizontal, "pointer-events-none": inTransition, - "bg-accent text-accent-foreground": isOpen, + "bg-accent": isOpen, + ["hover:bg-accent"]: !inTransition, })} ref={anchorRef} role="menuitem" @@ -143,6 +144,7 @@ export const MenuFolder = (props: { src={folder.iconUrl} svg={folder.iconSvg} alt={folder.title} + excludeFromGlobalIconColor={folder.excludeFromGlobalIconColor} /> {!(folder.onlyIcon && isHorizontal) && ( {folder.title} diff --git a/packages/extension/src/components/menu/MenuImage.test.tsx b/packages/extension/src/components/menu/MenuImage.test.tsx new file mode 100644 index 000000000..d99798f1a --- /dev/null +++ b/packages/extension/src/components/menu/MenuImage.test.tsx @@ -0,0 +1,274 @@ +import { describe, it, expect } from "vitest" +import { render } from "@testing-library/react" +import { MenuImage } from "./MenuImage" +import { popupContext, ContextType } from "@/hooks/usePopupContext" +import { SIDE, ALIGN } from "@/const" + +const renderWithContext = (ui: React.ReactElement, contextValue: Partial = {}) => { + const fullContext: ContextType = { + side: SIDE.top, + align: ALIGN.start, + ...contextValue, + } + return render( + + {ui} + , + ) +} + +describe("MenuImage - Global Icon Color Exclusion", () => { + const testUiUrl = "https://cdn3.iconfinder.com/data/icons/feather-5/24/search-1024.png" + const testFaviconUrl = "https://www.google.com/favicon.ico" + const testSvg = '' + + it("renders a standard when global icon color is disabled (hasIconColor is falsy)", () => { + const { container } = renderWithContext( + , + { hasIconColor: false }, + ) + const img = container.querySelector("img") + expect(img).not.toBeNull() + expect(img?.getAttribute("src")).toBe(testUiUrl) + expect(container.querySelector("span[role='img']")).toBeNull() + }) + + it("renders a masked with background-color when global icon color is enabled and icon is a UI icon", () => { + const { container } = renderWithContext( + , + { hasIconColor: true }, + ) + const span = container.querySelector("span[role='img']") + expect(span).not.toBeNull() + expect(span?.getAttribute("style")).toContain("var(--sc-icon-color)") + expect(span?.getAttribute("style")).toContain(testUiUrl) + expect(container.querySelector("img")).toBeNull() + }) + + it("keeps original for website favicons even when global icon color is enabled", () => { + const { container } = renderWithContext( + , + { hasIconColor: true }, + ) + const img = container.querySelector("img") + expect(img).not.toBeNull() + expect(img?.getAttribute("src")).toBe(testFaviconUrl) + expect(container.querySelector("span[role='img']")).toBeNull() + }) + + it("keeps original for detected favicons when manual excludeFromGlobalIconColor is absent", () => { + const { container } = renderWithContext( + , + { hasIconColor: true }, + ) + const img = container.querySelector("img") + expect(img).not.toBeNull() + expect(img?.getAttribute("src")).toBe(testFaviconUrl) + expect(container.querySelector("span[role='img']")).toBeNull() + }) + + it("keeps original for Google service icons detected as favicons even when excludeFromGlobalIconColor is false", () => { + const googleServiceIcon = "https://www.gstatic.com/images/branding/product/2x/calendar_2020q4_32dp.png" + const { container } = renderWithContext( + , + { hasIconColor: true }, + ) + const img = container.querySelector("img") + expect(img).not.toBeNull() + expect(img?.getAttribute("src")).toBe(googleServiceIcon) + expect(container.querySelector("span[role='img']")).toBeNull() + }) + + it("renders a masked for non-favicon UI icon when manual setting is absent", () => { + const { container } = renderWithContext( + , + { hasIconColor: true }, + ) + const span = container.querySelector("span[role='img']") + expect(span).not.toBeNull() + expect(span?.getAttribute("style")).toContain("var(--sc-icon-color)") + expect(container.querySelector("img")).toBeNull() + }) + + it("keeps original when isFavicon is explicitly passed as true", () => { + const { container } = renderWithContext( + , + { hasIconColor: true }, + ) + const img = container.querySelector("img") + expect(img).not.toBeNull() + expect(img?.getAttribute("src")).toBe(testUiUrl) + expect(container.querySelector("span[role='img']")).toBeNull() + }) + + it("renders a standard when global icon color is enabled but excludeFromGlobalIconColor is true", () => { + const { container } = renderWithContext( + , + { hasIconColor: true }, + ) + const img = container.querySelector("img") + expect(img).not.toBeNull() + expect(img?.getAttribute("src")).toBe(testUiUrl) + expect(container.querySelector("span[role='img']")).toBeNull() + }) + + it("enables recoloring for a custom AI Prompt icon when manual exclusion is false", () => { + const flaticonUrl = "https://cdn-icons-png.flaticon.com/512/11865/11865326.png" + const { container } = renderWithContext( + , + { hasIconColor: true }, + ) + const span = container.querySelector("span") + expect(span).not.toBeNull() + expect(span?.getAttribute("style")).toContain("var(--sc-icon-color)") + expect(span?.getAttribute("style")).toContain(flaticonUrl) + expect(container.querySelector("img")).toBeNull() + }) + + it("disables recoloring for a custom AI Prompt icon when excludeFromGlobalIconColor is true", () => { + const flaticonUrl = "https://cdn-icons-png.flaticon.com/512/11865/11865326.png" + const { container } = renderWithContext( + , + { hasIconColor: true }, + ) + const img = container.querySelector("img") + expect(img).not.toBeNull() + expect(img?.getAttribute("src")).toBe(flaticonUrl) + expect(container.querySelector("span")).toBeNull() + }) + + it("dynamically updates between recolorable custom icon and protected favicon on prop change", () => { + const customUrl = "https://cdn-icons-png.flaticon.com/512/11865/11865326.png" + const faviconUrl = "https://chatgpt.com/favicon.ico" + + // 1. Initial render with custom icon (isFavicon: false) + const { container, rerender } = renderWithContext( + , + { hasIconColor: true }, + ) + expect(container.querySelector("span")).not.toBeNull() + expect(container.querySelector("img")).toBeNull() + + // 2. Dynamically change to favicon (isFavicon: true) + rerender( + + + , + ) + expect(container.querySelector("img")).not.toBeNull() + expect(container.querySelector("span")).toBeNull() + + // 3. Dynamically switch back to custom icon + rerender( + + + , + ) + expect(container.querySelector("span")).not.toBeNull() + expect(container.querySelector("img")).toBeNull() + }) + + it("applies var(--sc-icon-color) to inline SVG when global icon color is enabled and exclusion is false", () => { + const { container } = renderWithContext( + , + { hasIconColor: true }, + ) + const div = container.querySelector("div") + expect(div).not.toBeNull() + expect(div?.getAttribute("style")).toContain("var(--sc-icon-color)") + }) + + it("keeps original foreground color on inline SVG when excludeFromGlobalIconColor is true", () => { + const { container } = renderWithContext( + , + { hasIconColor: true }, + ) + const div = container.querySelector("div") + expect(div).not.toBeNull() + expect(div?.getAttribute("style")).toContain("hsl(var(--foreground))") + }) + + it("keeps original foreground color on inline SVG when global icon color is disabled", () => { + const { container } = renderWithContext( + , + { hasIconColor: false }, + ) + const div = container.querySelector("div") + expect(div).not.toBeNull() + expect(div?.getAttribute("style")).toContain("hsl(var(--foreground))") + }) + + describe("Accessibility (alt / decorative handling)", () => { + it("sets aria-hidden='true' and omits role='img' on masked span when alt is empty or not provided", () => { + const { container } = renderWithContext( + , + { hasIconColor: true }, + ) + const span = container.querySelector("span") + expect(span).not.toBeNull() + expect(span?.getAttribute("aria-hidden")).toBe("true") + expect(span?.getAttribute("role")).toBeNull() + expect(span?.getAttribute("aria-label")).toBeNull() + }) + + it("sets role='img' and aria-label on masked span when meaningful alt is provided", () => { + const { container } = renderWithContext( + , + { hasIconColor: true }, + ) + const span = container.querySelector("span") + expect(span).not.toBeNull() + expect(span?.getAttribute("role")).toBe("img") + expect(span?.getAttribute("aria-label")).toBe("Search Icon") + expect(span?.getAttribute("aria-hidden")).toBeNull() + }) + + it("sets aria-hidden='true' on inline SVG div when alt is not provided", () => { + const { container } = renderWithContext( + , + { hasIconColor: false }, + ) + const div = container.querySelector("div") + expect(div).not.toBeNull() + expect(div?.getAttribute("aria-hidden")).toBe("true") + expect(div?.getAttribute("role")).toBeNull() + }) + + it("sets role='img' and aria-label on inline SVG div when meaningful alt is provided", () => { + const { container } = renderWithContext( + , + { hasIconColor: false }, + ) + const div = container.querySelector("div") + expect(div).not.toBeNull() + expect(div?.getAttribute("role")).toBe("img") + expect(div?.getAttribute("aria-label")).toBe("Folder Icon") + expect(div?.getAttribute("aria-hidden")).toBeNull() + }) + }) +}) diff --git a/packages/extension/src/components/menu/MenuImage.tsx b/packages/extension/src/components/menu/MenuImage.tsx index 9be541356..5ed261229 100644 --- a/packages/extension/src/components/menu/MenuImage.tsx +++ b/packages/extension/src/components/menu/MenuImage.tsx @@ -1,5 +1,7 @@ import { useState } from "react" import { isEmpty, cn } from "@/lib/utils" +import { isFaviconIcon } from "@/lib/favicon" +import { usePopupContext } from "@/hooks/usePopupContext" import css from "./Menu.module.css" type MenuImageProps = { @@ -7,24 +9,62 @@ type MenuImageProps = { svg?: string alt?: string className?: string + excludeFromGlobalIconColor?: boolean + isFavicon?: boolean } export function MenuImage(props: MenuImageProps): JSX.Element { const [svgElm, setSvgElm] = useState(null) + const { hasIconColor } = usePopupContext() const hasUrl = !isEmpty(props.src) const hasSvg = !isEmpty(props.svg) + const isFavicon = + props.isFavicon ?? (hasUrl ? isFaviconIcon({ url: props.src }) : false) + + const isRecolorActive = Boolean( + hasIconColor && !props.excludeFromGlobalIconColor && !isFavicon, + ) + if (svgElm && props.svg) { svgElm.innerHTML = props.svg } + const isDecorative = !props.alt || props.alt.trim() === "" + return hasUrl ? ( - {props.alt} + isRecolorActive ? ( + + ) : ( + {props.alt + ) ) : hasSvg ? (
) : ( <> diff --git a/packages/extension/src/components/menu/MenuItem.test.tsx b/packages/extension/src/components/menu/MenuItem.test.tsx new file mode 100644 index 000000000..eb94605d7 --- /dev/null +++ b/packages/extension/src/components/menu/MenuItem.test.tsx @@ -0,0 +1,149 @@ +import { describe, it, expect, vi } from "vitest" +import { render } from "@testing-library/react" +import { MenuItem } from "./MenuItem" +import { popupContext, ContextType } from "@/hooks/usePopupContext" +import { IconUrlsContext } from "./Menu" +import { SIDE, ALIGN, OPEN_MODE, ExecState } from "@/const" +import { ONBOARDING_AI_PROMPT_COMMAND_ID } from "@/components/onboarding/onboardingCommand" +import type { AiPromptCommand } from "@/types" + +vi.mock("@/hooks/useCommandExecutor", () => ({ + useCommandExecutor: () => ({ + itemState: { state: ExecState.NONE, message: "" }, + result: null, + executeCommand: vi.fn(), + clearResult: vi.fn(), + }), +})) + +vi.mock("@/hooks/useSelectContext", () => ({ + useSelectContext: () => ({ + selectionText: "test", + target: null, + }), +})) + +vi.mock("@/lib/commandEnabled", () => ({ + getCommandEnabled: () => ({ enabled: true, message: "" }), +})) + +const renderMenuItem = ( + command: AiPromptCommand, + iconUrls: Record = {}, + hasIconColor = true, +) => { + const fullContext: ContextType = { + side: SIDE.top, + align: ALIGN.start, + hasIconColor, + } + const menuRef = { current: document.createElement("div") } + + return render( + + + + + , + ) +} + +describe("MenuItem - AI Prompt Command Icon Recoloring", () => { + const flaticonUrl = "https://cdn-icons-png.flaticon.com/512/11865/11865326.png" + const cachedDataUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + + it("renders a masked for AI Prompt command with custom Flaticon URL when recoloring is enabled", () => { + const aiCommand: AiPromptCommand = { + id: ONBOARDING_AI_PROMPT_COMMAND_ID, + title: "Run with AI", + openMode: OPEN_MODE.AI_PROMPT, + iconUrl: cachedDataUrl, + excludeFromGlobalIconColor: false, + aiPromptOption: { + serviceId: "chatgpt", + prompt: "test", + openMode: OPEN_MODE.SIDE_PANEL, + }, + } as any + + const iconUrls = { + [ONBOARDING_AI_PROMPT_COMMAND_ID]: flaticonUrl, + } + + const { container } = renderMenuItem(aiCommand, iconUrls, true) + + // Button should match DevTools output + const button = container.querySelector("button[data-command-id='8b6f2e10-9a44-5c7d-8b3f-2e6a7c1d4f90']") + expect(button).not.toBeNull() + + const span = button?.querySelector("span._itemImgMasked_fcefc6, span[class*='itemImgMasked']") + expect(span).not.toBeNull() + expect(button?.querySelector("img")).toBeNull() + expect(span?.getAttribute("style")).toContain("var(--sc-icon-color)") + expect(span?.getAttribute("style")).toContain(cachedDataUrl) + }) + + it("renders an for AI Prompt command with custom Flaticon URL when excludeFromGlobalIconColor is true", () => { + const aiCommand: AiPromptCommand = { + id: ONBOARDING_AI_PROMPT_COMMAND_ID, + title: "Run with AI", + openMode: OPEN_MODE.AI_PROMPT, + iconUrl: cachedDataUrl, + excludeFromGlobalIconColor: true, + aiPromptOption: { + serviceId: "chatgpt", + prompt: "test", + openMode: OPEN_MODE.SIDE_PANEL, + }, + } as any + + const iconUrls = { + [ONBOARDING_AI_PROMPT_COMMAND_ID]: flaticonUrl, + } + + const { container } = renderMenuItem(aiCommand, iconUrls, true) + + const button = container.querySelector("button[data-command-id='8b6f2e10-9a44-5c7d-8b3f-2e6a7c1d4f90']") + expect(button).not.toBeNull() + + // Must render , NOT masked span + const img = button?.querySelector("img") + expect(img).not.toBeNull() + expect(button?.querySelector("span[class*='itemImgMasked']")).toBeNull() + expect(img?.getAttribute("src")).toBe(cachedDataUrl) + }) + + it("renders an for AI Prompt command when genuine ChatGPT favicon is used (automatic protection)", () => { + const chatGptFavicon = "https://chatgpt.com/favicon.ico" + const aiCommand: AiPromptCommand = { + id: ONBOARDING_AI_PROMPT_COMMAND_ID, + title: "Run with AI", + openMode: OPEN_MODE.AI_PROMPT, + iconUrl: cachedDataUrl, + excludeFromGlobalIconColor: false, + aiPromptOption: { + serviceId: "chatgpt", + prompt: "test", + openMode: OPEN_MODE.SIDE_PANEL, + }, + } as any + + const iconUrls = { + [ONBOARDING_AI_PROMPT_COMMAND_ID]: chatGptFavicon, + } + + const { container } = renderMenuItem(aiCommand, iconUrls, true) + + const button = container.querySelector("button[data-command-id='8b6f2e10-9a44-5c7d-8b3f-2e6a7c1d4f90']") + expect(button).not.toBeNull() + + // Must render because genuine favicon is protected + const img = button?.querySelector("img") + expect(img).not.toBeNull() + expect(button?.querySelector("span[class*='itemImgMasked']")).toBeNull() + }) +}) diff --git a/packages/extension/src/components/menu/MenuItem.tsx b/packages/extension/src/components/menu/MenuItem.tsx index b48e32882..97d0fbc5b 100644 --- a/packages/extension/src/components/menu/MenuItem.tsx +++ b/packages/extension/src/components/menu/MenuItem.tsx @@ -9,6 +9,9 @@ import { useCommandExecutor } from "@/hooks/useCommandExecutor" import { getCommandEnabled } from "@/lib/commandEnabled" import { PopupOption } from "@/services/option/defaultSettings" import { ExecState } from "@/const" +import { MenuImage } from "@/components/menu/MenuImage" +import { isFaviconIcon } from "@/lib/favicon" +import { IconUrlsContext } from "./Menu" import type { Command } from "@/types" import css from "./Menu.module.css" @@ -21,6 +24,8 @@ type MenuItemProps = { export function MenuItem(props: MenuItemProps): React.ReactNode { const buttonRef = useRef(null) + const iconUrls = React.useContext(IconUrlsContext) + const rawUrl = iconUrls?.[props.command.id] || props.command.iconUrl const { itemState, result, executeCommand, clearResult } = useCommandExecutor() const onlyIcon = props.onlyIcon @@ -83,7 +88,15 @@ export function MenuItem(props: MenuItemProps): React.ReactNode { onClick={handleClick} disabled={!enabled} > - + {!onlyIcon && {title}} {status === ExecState.NONE && ( - + )} {status === ExecState.EXECUTING && ( diff --git a/packages/extension/src/components/option/editor/CommandEditDialog.tsx b/packages/extension/src/components/option/editor/CommandEditDialog.tsx index 52d0d8b91..16fcbd842 100644 --- a/packages/extension/src/components/option/editor/CommandEditDialog.tsx +++ b/packages/extension/src/components/option/editor/CommandEditDialog.tsx @@ -122,10 +122,12 @@ const getDefault = ( ? { sourceType: base?.sourceType ?? COMMAND_SOURCE_TYPE.SELF_CREATED, sourceId: base?.sourceId ?? COMMAND_SOURCE_ID.SELF_CREATED, + excludeFromGlobalIconColor: base?.excludeFromGlobalIconColor ?? false, } : { sourceType: base?.sourceType, sourceId: base?.sourceId, + excludeFromGlobalIconColor: base?.excludeFromGlobalIconColor ?? false, } if (isSearchOpenMode(openMode)) { diff --git a/packages/extension/src/components/option/editor/FolderEditDialog.tsx b/packages/extension/src/components/option/editor/FolderEditDialog.tsx index 02fabd763..fd67fc82c 100644 --- a/packages/extension/src/components/option/editor/FolderEditDialog.tsx +++ b/packages/extension/src/components/option/editor/FolderEditDialog.tsx @@ -48,6 +48,7 @@ export const FolderEditDialog = ({ title: "", iconUrl: "https://cdn4.iconfinder.com/data/icons/basic-ui-2-line/32/folder-archive-document-archives-fold-1024.png", + excludeFromGlobalIconColor: false, onlyIcon: true, parentFolderId: ROOT_FOLDER, style: FOLDER_STYLE.INHERIT, @@ -60,7 +61,15 @@ export const FolderEditDialog = ({ }) useEffect(() => { - form.reset(folder ?? DefaultValue) + if (folder) { + form.reset({ + ...DefaultValue, + ...folder, + excludeFromGlobalIconColor: folder.excludeFromGlobalIconColor ?? false, + }) + } else { + form.reset(DefaultValue) + } }, [folder]) const isUpdate = folder != null diff --git a/packages/extension/src/components/option/editor/UserStyleList.tsx b/packages/extension/src/components/option/editor/UserStyleList.tsx index 2fd6d245f..2ea1c2c40 100644 --- a/packages/extension/src/components/option/editor/UserStyleList.tsx +++ b/packages/extension/src/components/option/editor/UserStyleList.tsx @@ -49,6 +49,7 @@ const Units: UnitMap = { [STYLE_VARIABLE.BORDER_COLOR]: undefined, [STYLE_VARIABLE.FONT_SCALE]: undefined, [STYLE_VARIABLE.FONT_COLOR]: undefined, + [STYLE_VARIABLE.ICON_COLOR]: undefined, [STYLE_VARIABLE.IMAGE_SCALE]: undefined, [STYLE_VARIABLE.PADDING_SCALE]: undefined, [STYLE_VARIABLE.POPUP_DELAY]: "ms", diff --git a/packages/extension/src/components/option/field/IconField.test.tsx b/packages/extension/src/components/option/field/IconField.test.tsx new file mode 100644 index 000000000..f76e4b2a1 --- /dev/null +++ b/packages/extension/src/components/option/field/IconField.test.tsx @@ -0,0 +1,254 @@ +import { describe, it, expect, vi } from "vitest" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { useForm, FormProvider } from "react-hook-form" +import { IconField } from "./IconField" + +// Mock useFavicon +vi.mock("@/hooks/option/useFavicon", () => ({ + useFavicon: () => ({ isLoading: false }), +})) + +// Mock i18n +vi.mock("@/services/i18n", () => ({ + t: (key: string) => key, +})) + +type TestWrapperProps = { + initialValues?: { + iconUrl?: string + iconSvg?: string + excludeFromGlobalIconColor?: boolean + customExcludeField?: boolean + } + nameExclude?: string + onSubmit?: (data: any) => void +} + +function TestWrapper({ + initialValues, + nameExclude, + onSubmit, +}: TestWrapperProps) { + const methods = useForm({ + defaultValues: { + iconUrl: initialValues?.iconUrl ?? "https://example.com/icon.png", + iconSvg: initialValues?.iconSvg ?? "", + excludeFromGlobalIconColor: + initialValues?.excludeFromGlobalIconColor ?? false, + customExcludeField: initialValues?.customExcludeField ?? false, + }, + }) + + return ( + +
onSubmit?.(data))}> + + + +
+ ) +} + +describe("IconField - Global Icon Color Exclusion", () => { + const faviconUrl = "https://www.google.com/favicon.ico" + const nonFaviconUrl = "https://cdn3.iconfinder.com/icon.png" + const googleServiceIcon = + "https://www.gstatic.com/images/branding/product/2x/calendar_2020q4_32dp.png" + + describe("Accessibility and basics", () => { + it("renders the exclude switch with id, htmlFor, and accessible name", () => { + render() + const toggle = screen.getByRole("switch") + expect(toggle).toBeDefined() + expect(toggle.getAttribute("id")).toBe("excludeFromGlobalIconColor") + expect(toggle.getAttribute("aria-label")).toBe( + "Option_excludeFromGlobalIconColor", + ) + + const label = document.querySelector( + 'label[for="excludeFromGlobalIconColor"]', + ) + expect(label).not.toBeNull() + }) + + it("supports custom nameExclude prop", () => { + render() + const toggle = screen.getByRole("switch") + expect(toggle.getAttribute("id")).toBe("customExcludeField") + + const label = document.querySelector('label[for="customExcludeField"]') + expect(label).not.toBeNull() + }) + }) + + describe("Automatic favicon exclusion", () => { + it("disables manual toggle and shows automatic status for a website favicon", () => { + render( + , + ) + + const toggle = screen.getByRole("switch") + expect(toggle).toBeDisabled() + expect(toggle).toBeChecked() + + // Automatic badge should be present + const badge = screen.getByTestId("favicon-automatic-badge") + expect(badge).toBeDefined() + expect(badge.textContent).toBe("Option_excludeFromGlobalIconColor_automatic") + + // Explanation for automatic favicon exclusion + expect( + screen.getByText("Option_excludeFromGlobalIconColor_favicon_desc"), + ).toBeDefined() + }) + + it("disables manual toggle and shows automatic status for a Google service icon", () => { + render( + , + ) + + const toggle = screen.getByRole("switch") + expect(toggle).toBeDisabled() + expect(toggle).toBeChecked() + + expect(screen.getByTestId("favicon-automatic-badge")).toBeDefined() + expect( + screen.getByText("Option_excludeFromGlobalIconColor_favicon_desc"), + ).toBeDefined() + }) + + it("enables manual toggle and shows default description for non-favicon icon", () => { + render( + , + ) + + const toggle = screen.getByRole("switch") + expect(toggle).toBeEnabled() + expect(toggle).not.toBeChecked() + + expect(screen.queryByTestId("favicon-automatic-badge")).toBeNull() + expect( + screen.getByText("Option_excludeFromGlobalIconColor_desc"), + ).toBeDefined() + }) + }) + + describe("Dynamic URL changes and state preservation", () => { + it("updates automatic/manual control availability when URL changes between favicon and non-favicon", () => { + render( + , + ) + + const urlInput = screen.getByRole("textbox") + const toggle = screen.getByRole("switch") + + // 1. Initial state with favicon: disabled & checked + expect(toggle).toBeDisabled() + expect(toggle).toBeChecked() + expect(screen.getByTestId("favicon-automatic-badge")).toBeDefined() + + // 2. Change URL to non-favicon -> toggle becomes available, restores saved false + fireEvent.change(urlInput, { target: { value: nonFaviconUrl } }) + expect(toggle).toBeEnabled() + expect(toggle).not.toBeChecked() + expect(screen.queryByTestId("favicon-automatic-badge")).toBeNull() + + // 3. Manually toggle to true + fireEvent.click(toggle) + expect(toggle).toBeChecked() + + // 4. Change URL back to favicon -> toggle becomes disabled, shows automatic + fireEvent.change(urlInput, { target: { value: faviconUrl } }) + expect(toggle).toBeDisabled() + expect(toggle).toBeChecked() + expect(screen.getByTestId("favicon-automatic-badge")).toBeDefined() + + // 5. Change URL back to non-favicon -> toggle becomes enabled, previous manual true is retained + fireEvent.change(urlInput, { target: { value: nonFaviconUrl } }) + expect(toggle).toBeEnabled() + expect(toggle).toBeChecked() + }) + + it("does not overwrite saved manual preference when icon is a favicon and submitted", async () => { + const handleSubmit = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByTestId("submit-btn")) + + // The submitted data must have excludeFromGlobalIconColor: false + // Automatic favicon exclusion must NOT permanently mutate the stored field + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + iconUrl: faviconUrl, + excludeFromGlobalIconColor: false, + }), + ) + }) + }) + + it("restores correct state when reopening with favicon vs non-favicon saved values", () => { + // Scenario A: Saved command with favicon URL and false manual setting + const { unmount } = render( + , + ) + expect(screen.getByRole("switch")).toBeDisabled() + expect(screen.getByRole("switch")).toBeChecked() + unmount() + + // Scenario B: Saved command with non-favicon URL and true manual setting + render( + , + ) + expect(screen.getByRole("switch")).toBeEnabled() + expect(screen.getByRole("switch")).toBeChecked() + }) + }) +}) + diff --git a/packages/extension/src/components/option/field/IconField.tsx b/packages/extension/src/components/option/field/IconField.tsx index 9da01f42c..d4a0e7628 100644 --- a/packages/extension/src/components/option/field/IconField.tsx +++ b/packages/extension/src/components/option/field/IconField.tsx @@ -3,14 +3,18 @@ import { useController } from "react-hook-form" import { FormLabel, FormMessage, FormDescription } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { MenuImage } from "@/components/menu/MenuImage" -import { isEmpty, isValidSVG } from "@/lib/utils" +import { isEmpty, isValidSVG, cn } from "@/lib/utils" +import { isFaviconIcon } from "@/lib/favicon" import { t as _t } from "@/services/i18n" const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) +import { Switch } from "@/components/ui/switch" + type IconField = { control: any nameUrl: string nameSvg: string + nameExclude?: string formLabel: string placeholder?: string description?: string @@ -22,6 +26,7 @@ export const IconField = ({ control, nameUrl, nameSvg, + nameExclude = "excludeFromGlobalIconColor", formLabel, description, placeholder, @@ -34,16 +39,24 @@ export const IconField = ({ name: nameSvg, control, }) + const { field: fieldExclude } = useController({ + name: nameExclude, + control, + }) const errUrl = stateUrl.errors[nameUrl] const errSvg = stateSvg.errors[nameSvg] + const isFavicon = Boolean( + !isEmpty(fieldUrl?.value) && isFaviconIcon({ url: fieldUrl.value }), + ) + return ( -
-
+
+
{formLabel} {description && {description}}
-
+
{`${errSvg.message}`}}

)} +
+
+
+ + {t("excludeFromGlobalIconColor")} + + {isFavicon && ( + + {t("excludeFromGlobalIconColor_automatic")} + + )} +
+ + {isFavicon + ? t("excludeFromGlobalIconColor_favicon_desc") + : t("excludeFromGlobalIconColor_desc")} + +
+ +
) diff --git a/packages/extension/src/const.ts b/packages/extension/src/const.ts index 799bdba40..aa448ea83 100644 --- a/packages/extension/src/const.ts +++ b/packages/extension/src/const.ts @@ -259,6 +259,7 @@ export enum STYLE_VARIABLE { BORDER_COLOR = "border-color", FONT_SCALE = "font-scale", FONT_COLOR = "font-color", + ICON_COLOR = "icon-color", IMAGE_SCALE = "image-scale", PADDING_SCALE = "padding-scale", POPUP_DELAY = "popup-delay", diff --git a/packages/extension/src/hooks/usePopupContext.tsx b/packages/extension/src/hooks/usePopupContext.tsx index d531f7f15..0b8fb364d 100644 --- a/packages/extension/src/hooks/usePopupContext.tsx +++ b/packages/extension/src/hooks/usePopupContext.tsx @@ -7,6 +7,7 @@ export type ContextType = { inOnboarding?: boolean side: SIDE align: ALIGN + hasIconColor?: boolean } export const popupContext = createContext({} as ContextType) diff --git a/packages/extension/src/lib/favicon.test.ts b/packages/extension/src/lib/favicon.test.ts new file mode 100644 index 000000000..ef19c551f --- /dev/null +++ b/packages/extension/src/lib/favicon.test.ts @@ -0,0 +1,324 @@ +import { describe, it, expect } from "vitest" +import { isFaviconIcon, getRegistrableDomain } from "./favicon" +import { OPEN_MODE } from "@shared/constants/open-mode" +import type { Command } from "@/types" + +describe("isFaviconIcon", () => { + it("recognizes standard favicon.ico URLs", () => { + expect(isFaviconIcon({ url: "https://www.google.com/favicon.ico" })).toBe(true) + expect(isFaviconIcon({ url: "https://chatgpt.com/favicon.ico" })).toBe(true) + expect(isFaviconIcon({ url: "https://www.amazon.com/favicon.ico" })).toBe(true) + }) + + it("recognizes URLs containing favicon in path or filename", () => { + expect( + isFaviconIcon({ + url: "https://www.youtube.com/s/desktop/f574e7a2/img/favicon_32x32.png", + }), + ).toBe(true) + expect( + isFaviconIcon({ + url: "https://s.pinimg.com/webapp/favicon-22eb868c.png", + }), + ).toBe(true) + }) + + it("recognizes .ico extensions and apple-touch-icon", () => { + expect( + isFaviconIcon({ + url: "https://assets.nflxext.com/ffe/siteui/common/icons/nficon2016.ico", + }), + ).toBe(true) + expect( + isFaviconIcon({ + url: "https://example.com/assets/apple-touch-icon-180x180.png", + }), + ).toBe(true) + }) + + it("recognizes favicon proxy services like Google S2 and favicon.im", () => { + expect( + isFaviconIcon({ + url: "https://www.google.com/s2/favicons?sz=64&domain_url=https://github.com", + }), + ).toBe(true) + expect(isFaviconIcon({ url: "https://favicon.im/claude.ai" })).toBe(true) + expect(isFaviconIcon({ url: "https://favicon.im/perplexity.ai" })).toBe(true) + }) + + it("recognizes known brand icons and first-party service asset domains (Step 3)", () => { + expect( + isFaviconIcon({ + url: "https://www.gstatic.com/lamda/images/gemini_sparkle_aurora_33f86dc0c0257da337c63.svg", + }), + ).toBe(true) + expect( + isFaviconIcon({ + url: "https://ssl.gstatic.com/docs/doclist/images/drive_2022q3_32dp.png", + }), + ).toBe(true) + expect( + isFaviconIcon({ + url: "https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg", + }), + ).toBe(true) + }) + + it("recognizes AI prompt commands with genuine AI-service favicon URLs as favicons", () => { + const chatGptCmd = { + id: "ai-chatgpt", + title: "ChatGPT", + openMode: OPEN_MODE.AI_PROMPT, + iconUrl: "https://chatgpt.com/favicon.ico", + } as Command + expect(isFaviconIcon({ url: chatGptCmd.iconUrl, command: chatGptCmd })).toBe(true) + + const geminiCmd = { + id: "ai-gemini", + title: "Gemini", + openMode: OPEN_MODE.AI_PROMPT, + iconUrl: "https://www.gstatic.com/lamda/images/gemini_sparkle_aurora_33f86dc0c0257da337c63.svg", + } as Command + expect(isFaviconIcon({ url: geminiCmd.iconUrl, command: geminiCmd })).toBe(true) + + const claudeCmd = { + id: "ai-claude", + title: "Claude", + openMode: OPEN_MODE.AI_PROMPT, + iconUrl: "https://favicon.im/claude.ai", + } as Command + expect(isFaviconIcon({ url: claudeCmd.iconUrl, command: claudeCmd })).toBe(true) + + const perplexityCmd = { + id: "ai-perplexity", + title: "Perplexity", + openMode: OPEN_MODE.AI_PROMPT, + iconUrl: "https://favicon.im/perplexity.ai", + } as Command + expect(isFaviconIcon({ url: perplexityCmd.iconUrl, command: perplexityCmd })).toBe(true) + }) + + it("does NOT recognize AI prompt commands with custom Flaticon or Iconfinder URLs as favicons", () => { + const flaticonCmd = { + id: "ai-custom-flaticon", + title: "Run with AI", + openMode: OPEN_MODE.AI_PROMPT, + iconUrl: "https://cdn-icons-png.flaticon.com/512/11865/11865326.png", + } as Command + expect(isFaviconIcon({ url: flaticonCmd.iconUrl, command: flaticonCmd })).toBe(false) + + const iconfinderCmd = { + id: "ai-custom-iconfinder", + title: "Run with AI", + openMode: OPEN_MODE.AI_PROMPT, + iconUrl: "https://cdn4.iconfinder.com/data/icons/basic-ui-2-line/32/folder-archive-document-archives-fold-1024.png", + } as Command + expect(isFaviconIcon({ url: iconfinderCmd.iconUrl, command: iconfinderCmd })).toBe(false) + }) + + it("handles normal commands with genuine favicons vs custom icons appropriately", () => { + const normalWithFavicon = { + id: "norm-1", + title: "Google Search", + openMode: OPEN_MODE.TAB, + searchUrl: "https://www.google.com/search?q=%s", + iconUrl: "https://www.google.com/favicon.ico", + } as Command + expect(isFaviconIcon({ url: normalWithFavicon.iconUrl, command: normalWithFavicon })).toBe(true) + + const normalWithCustom = { + id: "norm-2", + title: "Custom Command", + openMode: OPEN_MODE.TAB, + searchUrl: "https://www.google.com/search?q=%s", + iconUrl: "https://cdn-icons-png.flaticon.com/512/11865/11865326.png", + } as Command + expect(isFaviconIcon({ url: normalWithCustom.iconUrl, command: normalWithCustom })).toBe(false) + }) + + it("recognizes site-hosted icons matching searchUrl domain generically (Step 4)", () => { + const genericCmd = { + id: "site-1", + title: "Example Site", + openMode: OPEN_MODE.TAB, + searchUrl: "https://example.com/search?q=%s", + iconUrl: "https://cdn.example.com/images/logo.png", + } as Command + expect(isFaviconIcon({ url: genericCmd.iconUrl, command: genericCmd })).toBe(true) + + const driveCmd = { + id: "drive-1", + title: "Drive", + openMode: OPEN_MODE.TAB, + searchUrl: "https://drive.google.com/drive/search?q=%s", + iconUrl: "https://ssl.gstatic.com/docs/doclist/images/drive_2022q3_32dp.png", + } as Command + expect(isFaviconIcon({ url: driveCmd.iconUrl, command: driveCmd })).toBe(true) + }) + + it("does NOT recognize iconfinder or UI icon assets as favicons", () => { + expect( + isFaviconIcon({ + url: "https://cdn3.iconfinder.com/data/icons/feather-5/24/search-1024.png", + }), + ).toBe(false) + expect( + isFaviconIcon({ + url: "https://cdn4.iconfinder.com/data/icons/basic-ui-2-line/32/folder-archive-document-archives-fold-1024.png", + }), + ).toBe(false) + expect( + isFaviconIcon({ + url: "chrome-extension://abc/images/search_command.png", + }), + ).toBe(false) + expect( + isFaviconIcon({ + url: "images/search_command.png", + }), + ).toBe(false) + }) + + it("excludes icon libraries even when domain would otherwise match (generic isIconLibrary check)", () => { + const iconfinderCmd = { + id: "if-1", + title: "IconFinder", + openMode: OPEN_MODE.TAB, + searchUrl: "https://iconfinder.com/search?q=%s", + iconUrl: "https://cdn3.iconfinder.com/data/icons/feather-5/24/search-1024.png", + } as Command + expect(isFaviconIcon({ url: iconfinderCmd.iconUrl, command: iconfinderCmd })).toBe(false) + }) + + it("handles empty or data URLs safely", () => { + expect(isFaviconIcon({})).toBe(false) + expect(isFaviconIcon({ url: "" })).toBe(false) + expect(isFaviconIcon({ url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA" })).toBe( + false, + ) + }) + + it("does NOT match unrelated domains that share the same multi-part suffix (e.g. .co.uk)", () => { + const cmd = { + id: "cmd-uk-1", + title: "Foo UK", + openMode: OPEN_MODE.TAB, + searchUrl: "https://foo.co.uk/search?q=%s", + } as Command + + // An icon hosted on bar.co.uk should NOT match foo.co.uk + expect( + isFaviconIcon({ + url: "https://bar.co.uk/assets/icon.png", + command: cmd, + }), + ).toBe(false) + + // An icon hosted on subdomains of foo.co.uk SHOULD match + expect( + isFaviconIcon({ + url: "https://cdn.foo.co.uk/assets/logo.png", + command: cmd, + }), + ).toBe(true) + }) + + it("handles other multi-part suffixes like .co.jp and .com.au properly", () => { + const jpCmd = { + id: "cmd-jp-1", + title: "Store JP", + openMode: OPEN_MODE.TAB, + searchUrl: "https://store.company.co.jp/search?q=%s", + } as Command + + expect( + isFaviconIcon({ + url: "https://static.company.co.jp/images/logo.png", + command: jpCmd, + }), + ).toBe(true) + expect( + isFaviconIcon({ + url: "https://other-company.co.jp/images/logo.png", + command: jpCmd, + }), + ).toBe(false) + }) + + it("correlates localhost target with localhost icon", () => { + const localCmd = { + id: "local-1", + title: "Local App", + openMode: OPEN_MODE.TAB, + searchUrl: "http://localhost:3000/search?q=%s", + iconUrl: "http://localhost:3000/app-icon.png", + } as Command + expect(isFaviconIcon({ url: localCmd.iconUrl, command: localCmd })).toBe(true) + + const otherLocalCmd = { + id: "local-2", + title: "Local App", + openMode: OPEN_MODE.TAB, + searchUrl: "http://localhost:3000/search?q=%s", + iconUrl: "https://example.com/app-icon.png", + } as Command + expect(isFaviconIcon({ url: otherLocalCmd.iconUrl, command: otherLocalCmd })).toBe(false) + }) + + it("correlates IP address target with matching IP icon", () => { + const ipCmd = { + id: "ip-1", + title: "Router", + openMode: OPEN_MODE.TAB, + searchUrl: "http://192.168.1.1/search?q=%s", + iconUrl: "http://192.168.1.1/icon.png", + } as Command + expect(isFaviconIcon({ url: ipCmd.iconUrl, command: ipCmd })).toBe(true) + + const diffIpCmd = { + id: "ip-2", + title: "Router", + openMode: OPEN_MODE.TAB, + searchUrl: "http://192.168.1.1/search?q=%s", + iconUrl: "http://192.168.1.2/icon.png", + } as Command + expect(isFaviconIcon({ url: diffIpCmd.iconUrl, command: diffIpCmd })).toBe(false) + }) +}) + +describe("getRegistrableDomain", () => { + it("extracts registrable domain for standard single TLDs", () => { + expect(getRegistrableDomain("example.com")).toBe("example.com") + expect(getRegistrableDomain("www.example.com")).toBe("example.com") + expect(getRegistrableDomain("sub.nested.example.com")).toBe("example.com") + expect(getRegistrableDomain("github.io")).toBe("github.io") + }) + + it("extracts registrable domain for multi-part public suffixes", () => { + expect(getRegistrableDomain("foo.co.uk")).toBe("foo.co.uk") + expect(getRegistrableDomain("bar.co.uk")).toBe("bar.co.uk") + expect(getRegistrableDomain("assets.foo.co.uk")).toBe("foo.co.uk") + expect(getRegistrableDomain("www.store.co.jp")).toBe("store.co.jp") + expect(getRegistrableDomain("sub.domain.com.au")).toBe("domain.com.au") + expect(getRegistrableDomain("org.org.uk")).toBe("org.org.uk") + }) + + it("handles localhost correctly", () => { + expect(getRegistrableDomain("localhost")).toBe("localhost") + expect(getRegistrableDomain("http://localhost:3000")).toBe("localhost") + }) + + it("handles IP addresses correctly", () => { + expect(getRegistrableDomain("127.0.0.1")).toBe("127.0.0.1") + expect(getRegistrableDomain("192.168.1.1")).toBe("192.168.1.1") + expect(getRegistrableDomain("http://192.168.1.1:8080")).toBe("192.168.1.1") + expect(getRegistrableDomain("::1")).toBe("::1") + }) + + it("handles empty, whitespace, and invalid values safely", () => { + expect(getRegistrableDomain("")).toBe("") + expect(getRegistrableDomain(" ")).toBe("") + expect(getRegistrableDomain("invalid")).toBe("") + expect(getRegistrableDomain("com")).toBe("") + }) +}) diff --git a/packages/extension/src/lib/favicon.ts b/packages/extension/src/lib/favicon.ts new file mode 100644 index 000000000..c10639941 --- /dev/null +++ b/packages/extension/src/lib/favicon.ts @@ -0,0 +1,110 @@ +import { parse } from "tldts" +import { isEmpty } from "@/lib/utils" +import type { Command } from "@/types" + +/** + * Extracts the registrable domain (eTLD+1) from a hostname or URL using tldts. + * Handles normal domains, subdomains, multi-part public suffixes (e.g. .co.uk, .co.jp), + * localhost, IP addresses, and invalid/empty inputs consistently. + */ +export function getRegistrableDomain(hostname: string): string { + if (!hostname || isEmpty(hostname.trim())) { + return "" + } + + const cleanHost = hostname.trim().toLowerCase().replace(/^www\./, "") + const parsed = parse(cleanHost) + + if (parsed.domain) { + return parsed.domain + } + + // Handle IP addresses (e.g. 127.0.0.1, 192.168.1.1, ::1) + if (parsed.isIp && parsed.hostname) { + return parsed.hostname + } + + // Handle localhost + if (parsed.hostname === "localhost") { + return "localhost" + } + + return "" +} + +/** + * Checks if a URL or command icon is a recognized website/brand favicon. + */ +export function isFaviconIcon(options: { + url?: string + command?: Command +}): boolean { + const { url, command } = options + + if (!url || isEmpty(url)) return false + + // Base64 data URLs cannot be recognized by URL pattern directly. + if (url.startsWith("data:")) { + return false + } + + const lower = url.toLowerCase() + + // 1. Favicon service providers & endpoints + if ( + lower.includes("/s2/favicons") || + lower.includes("favicon.im") || + lower.includes("icon.horse") || + lower.includes("duckduckgo.com/ip3") + ) { + return true + } + + // 2. Standard favicon naming conventions + if ( + lower.includes("favicon") || + lower.includes("apple-touch-icon") || + /\.ico($|\?)/i.test(lower) + ) { + return true + } + + // 3. Known brand icons & first-party service asset domains (e.g. Google service icons on gstatic.com, Google Gemini aurora SVG) + if ( + lower.includes("gemini_sparkle") || + lower.includes("gstatic.com") + ) { + return true + } + + // 4. Correlate icon host with command's target website (searchUrl or startUrl) + if (command) { + const targetUrl = + (command as any).searchUrl || (command as any).pageActionOption?.startUrl + if (targetUrl) { + try { + const targetHost = new URL(targetUrl).hostname.replace(/^www\./, "") + const iconHost = new URL(url).hostname.replace(/^www\./, "") + + // Do not treat icon repositories or CDN libraries as website-specific favicons + const isIconLibrary = + iconHost.includes("iconfinder.com") || + iconHost.includes("fontawesome") || + iconHost.includes("flaticon") || + iconHost.includes("icons8") + + if (!isIconLibrary) { + const targetRoot = getRegistrableDomain(targetHost) + const iconRoot = getRegistrableDomain(iconHost) + if (targetRoot && iconRoot && targetRoot === iconRoot) { + return true + } + } + } catch { + // Invalid URL format, ignore + } + } + } + + return false +} diff --git a/packages/extension/src/services/option/userStyles.ts b/packages/extension/src/services/option/userStyles.ts index 9fa11bb67..fc61db9b0 100644 --- a/packages/extension/src/services/option/userStyles.ts +++ b/packages/extension/src/services/option/userStyles.ts @@ -30,6 +30,10 @@ export const Attributes: AttributeMap = { type: "color", default: "#0F172A", }, + [STYLE_VARIABLE.ICON_COLOR]: { + type: "color", + default: "#0F172A", + }, [STYLE_VARIABLE.IMAGE_SCALE]: { type: "number", default: 1, diff --git a/packages/extension/src/types/index.ts b/packages/extension/src/types/index.ts index d325766a9..4500a7b1b 100644 --- a/packages/extension/src/types/index.ts +++ b/packages/extension/src/types/index.ts @@ -49,6 +49,8 @@ export type SearchCommand = { title: string revision?: number iconUrl: string + /** When true, excludes this command's icon from being recolored by the global icon color setting. */ + excludeFromGlobalIconColor?: boolean sourceType?: COMMAND_SOURCE_TYPE sourceId?: string openMode: OPEN_MODE @@ -121,6 +123,8 @@ export type CommandFolder = { title: string iconUrl?: string iconSvg?: string + /** When true, excludes this folder's icon from being recolored by the global icon color setting. */ + excludeFromGlobalIconColor?: boolean onlyIcon?: boolean parentFolderId?: string style?: FOLDER_STYLE diff --git a/packages/extension/src/types/schema.test.ts b/packages/extension/src/types/schema.test.ts index 62ab6d1a9..291f0bdc3 100644 --- a/packages/extension/src/types/schema.test.ts +++ b/packages/extension/src/types/schema.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from "vitest" -import { AiPromptOptionSchema } from "./schema" +import { AiPromptOptionSchema, commandSchema, folderSchema } from "./schema" import { INSERT, toInsertTemplate } from "@/services/pageAction" import { OPEN_MODE } from "@shared/constants/open-mode" +import { SPACE_ENCODING } from "@/const" const baseOption = { serviceId: "chatgpt", @@ -55,3 +56,44 @@ describe("AiPromptOptionSchema", () => { } }) }) + +describe("excludeFromGlobalIconColor in schemas", () => { + it("SC-06: accepts command with excludeFromGlobalIconColor true and false", () => { + const validSearchCmd = { + id: "test-cmd", + openMode: OPEN_MODE.POPUP, + title: "Test Search", + iconUrl: "https://example.com/icon.png", + searchUrl: "https://example.com/search?q=%s", + openModeSecondary: OPEN_MODE.TAB, + spaceEncoding: SPACE_ENCODING.PLUS, + excludeFromGlobalIconColor: true, + } + const res = commandSchema.safeParse(validSearchCmd) + expect(res.success).toBe(true) + if (res.success && res.data.openMode === OPEN_MODE.POPUP) { + expect(res.data.excludeFromGlobalIconColor).toBe(true) + } + + const withoutOverride = { + ...validSearchCmd, + excludeFromGlobalIconColor: undefined, + } + const res2 = commandSchema.safeParse(withoutOverride) + expect(res2.success).toBe(true) + }) + + it("SC-07: accepts folder with excludeFromGlobalIconColor", () => { + const validFolder = { + id: "test-folder", + title: "Test Folder", + iconUrl: "https://example.com/folder.png", + excludeFromGlobalIconColor: true, + } + const res = folderSchema.safeParse(validFolder) + expect(res.success).toBe(true) + if (res.success) { + expect(res.data.excludeFromGlobalIconColor).toBe(true) + } + }) +}) diff --git a/packages/extension/src/types/schema.ts b/packages/extension/src/types/schema.ts index 5ca981696..c4b5f1ff6 100644 --- a/packages/extension/src/types/schema.ts +++ b/packages/extension/src/types/schema.ts @@ -28,6 +28,7 @@ import type { AiPromptCommand } from "@/types" const commandSourceSchema = { sourceType: z.nativeEnum(COMMAND_SOURCE_TYPE).optional(), sourceId: z.string().optional(), + excludeFromGlobalIconColor: z.boolean().optional(), } const searchSchema = z.object({ @@ -373,6 +374,7 @@ export const folderSchema = z title: z.string().min(1, { message: t("Option_zod_string_min", ["1"]) }), iconUrl: z.string().optional(), iconSvg: z.string().optional(), + excludeFromGlobalIconColor: z.boolean().optional(), onlyIcon: z.boolean().optional(), parentFolderId: z.string().optional(), style: z.nativeEnum(FOLDER_STYLE).optional(), diff --git a/packages/shared/src/types/command.ts b/packages/shared/src/types/command.ts index 951d7b5a1..74c3708a6 100644 --- a/packages/shared/src/types/command.ts +++ b/packages/shared/src/types/command.ts @@ -11,6 +11,11 @@ export interface BaseCommand { addedAt: string; openMode: OPEN_MODE; iconUrl: string; + /** + * When true, excludes this command's icon from being recolored by the global icon color setting. + */ + excludeFromGlobalIconColor?: boolean; + originalIconUrl?: string; } /** diff --git a/yarn.lock b/yarn.lock index 80e060297..7e7535a36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6381,7 +6381,7 @@ stop-iteration-iterator@^1.1.0: es-errors "^1.3.0" internal-slot "^1.1.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6399,6 +6399,15 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -6484,7 +6493,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -6498,6 +6507,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.2.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" @@ -6690,6 +6706,11 @@ tldts-core@^6.1.86: resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-6.1.86.tgz#a93e6ed9d505cb54c542ce43feb14c73913265d8" integrity sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA== +tldts-core@^7.4.13: + version "7.4.13" + resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-7.4.13.tgz#5dd4ed4730b34bb12cdc1f8a86c80f07c9780670" + integrity sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg== + tldts@^6.1.32: version "6.1.86" resolved "https://registry.yarnpkg.com/tldts/-/tldts-6.1.86.tgz#087e0555b31b9725ee48ca7e77edc56115cd82f7" @@ -6697,6 +6718,13 @@ tldts@^6.1.32: dependencies: tldts-core "^6.1.86" +tldts@^7.4.13: + version "7.4.13" + resolved "https://registry.yarnpkg.com/tldts/-/tldts-7.4.13.tgz#50e9a884162e6091844dadf0696df9353eb8b2be" + integrity sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA== + dependencies: + tldts-core "^7.4.13" + to-buffer@^1.1.1: version "1.2.2" resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.2.2.tgz#ffe59ef7522ada0a2d1cb5dfe03bb8abc3cdc133" @@ -7213,7 +7241,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -7231,6 +7259,15 @@ wrap-ansi@^5.1.0: string-width "^3.0.0" strip-ansi "^5.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"