diff --git a/docs/superpowers/specs/2026-08-16-custom-svg-tags-design.md b/docs/superpowers/specs/2026-08-16-custom-svg-tags-design.md
new file mode 100644
index 0000000..31f294d
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-16-custom-svg-tags-design.md
@@ -0,0 +1,121 @@
+# Custom SVG tags + icon-only mode (staff-tags, custom-user-tags)
+
+Date: 2026-08-16
+Repos/plugins: `staff-tags`, `custom-user-tags` (classic, `/root/revenge-plugins`)
+
+## Problem
+
+Both plugins currently support only a fixed set of ~50 preset monochrome icons (`lib/icons.ts`,
+rendered as a single SVG `path` via `ui/Icon.tsx`). Users want to supply their own SVG artwork for
+a tag's icon, and want an easy way to show only the icon with no text label at all — today a tag
+always falls back to `defaultText`/its stored text and never renders truly empty.
+
+## Feasibility (confirmed live against a connected 340.13 client)
+
+`findByProps("Svg")` — the module both plugins already use for `Svg`/`Path` — also exports
+`SvgXml`/`SvgFromXml`/`SvgUri`/`SvgCss` and the full `react-native-svg` primitive set (`Circle`,
+`Rect`, `G`, gradients, etc.). Discord bundles the complete library, not just the two primitives
+currently used. Rendering arbitrary user-supplied SVG markup via `SvgXml` requires no new
+dependency.
+
+## Decisions (from user)
+
+- **Input method:** paste raw SVG markup into a text field. No native file/document picker — avoids
+ depending on an RN module that isn't confirmed available in Revenge's plugin sandbox.
+- **Coloring:** a custom SVG keeps its own authored colors. The tag's color/gradient settings only
+ affect the text label and background chip, not the SVG's internal fills. (Preset icons keep their
+ existing behavior: force-tinted to the tag's icon color.)
+- **Icon-only toggle:** one general "Icon only (hide text)" switch that works for both preset icons
+ and custom SVGs, not a custom-SVG-specific feature.
+
+## Data model changes
+
+`staff-tags/src/lib/getTag.ts` — `TagOverride` interface gains:
+```ts
+customSvg?: string; // raw SVG markup, mutually exclusive with `icon` (preset id)
+customSvgFallback?: string; // short text shown in chat, where icons can't render at all
+iconOnly?: boolean;
+```
+`custom-user-tags/src/lib/tags.ts` — `UserTag` interface gains the same three fields.
+
+Setting `customSvg` in the editor UI clears `icon` (and vice versa) — a tag has at most one icon
+source at a time. Nothing in storage besides these two fields changes.
+
+## Icon representation and rendering
+
+Extend `IconDef` (`lib/icons.ts`, both plugins) to represent either a preset or a custom SVG:
+```ts
+export interface IconDef {
+ id: string;
+ name: string;
+ fallback: string;
+ path?: string; // preset: path data
+ viewBox?: string; // preset: defaults to "0 0 24 24"
+ svg?: string; // custom: full raw markup
+}
+```
+`getIcon(id)` is unchanged (still resolves preset ids from `ICONS`). A new path in
+`getTag.ts`/`resolveTag.ts`: if `settings.customSvg`/`tag.customSvg` is set, build the resolved
+tag's `icon` as `{ id: "custom", name: "Custom", fallback: settings.customSvgFallback ?? "",
+svg: settings.customSvg }` instead of calling `getIcon(settings.icon)`.
+
+`ui/Icon.tsx` branches on which field is present:
+- `icon.svg` set → ` `, no `fill`
+ prop (preserves the SVG's own colors). Wrapped in try/catch — a malformed paste renders nothing
+ instead of throwing, consistent with how the rest of these patches degrade on bad input.
+- `icon.path` set → existing ` ` behavior, unchanged, still tinted
+ via the `color` prop.
+
+Every existing call site (`patches/tag.tsx`'s chat-tag styling, `ui/GradientTag.tsx`) already just
+passes `icon` and `color` through to `Icon` generically — no changes needed there.
+
+## Icon-only text suppression
+
+`getTag.ts` currently resolves text as `settings.text?.trim() || def.defaultText` — never actually
+empty. When `settings.iconOnly`/`tag.iconOnly` is true, resolve `text: ""` instead, skipping the
+`defaultText` fallback. This requires an icon (preset or custom) to be set — the editor UI disables
+the toggle when no icon is selected, since an empty-text/no-icon tag would render nothing.
+
+This already flows correctly with no further changes:
+- Name row / member list / profile (JSX surfaces): render the icon with an empty label.
+- Chat (`patches/chat.ts`, flat-data surface, can never render a real icon — pre-existing platform
+ limit both plugins already document): existing ternary
+ `tag.icon ? (tag.text ? icon+text : icon.fallback) : tag.text` already falls through to
+ `icon.fallback` when text is empty. Preset icons already have a `fallback` glyph. Custom SVGs
+ don't, so the editor gets an optional short "fallback text for chat" input next to the paste box
+ (stored as `customSvgFallback?: string`) — if left blank, chat shows an empty-but-colored chip.
+ Acceptable degraded case; not worth blocking on.
+
+## Input validation
+
+On save, before writing `customSvg` to storage: trim whitespace, reject (inline error, don't save)
+if empty or doesn't start with ` | undefined;
+
+export const MAX_CUSTOM_SVG_LENGTH = 20000;
+
+export function isValidCustomSvg(markup: string | undefined): boolean {
+ const trimmed = markup?.trim();
+ if (!trimmed || trimmed.length > MAX_CUSTOM_SVG_LENGTH || !/^ {
diff --git a/plugins/custom-user-tags/src/patches/chat.ts b/plugins/custom-user-tags/src/patches/chat.ts
index eb5ba8a..c271434 100644
--- a/plugins/custom-user-tags/src/patches/chat.ts
+++ b/plugins/custom-user-tags/src/patches/chat.ts
@@ -12,7 +12,7 @@ export default () => {
if (!getTagProperties) return () => {};
return after("default", getTagProperties, ([{ message }], ret) => {
- if (ret?.tagType) return;
+ if (ret?.tagType || ret?.__revengeCustomTag) return;
const tag = resolveTag(message?.author?.id);
if (!tag) return;
@@ -29,7 +29,8 @@ export default () => {
tagTextColor: tag.textColor ? ReactNative.processColor(chroma(tag.textColor).hex()) : undefined,
tagBackgroundColor: ReactNative.processColor(chroma(tag.backgroundColor).hex()),
tagVerified: false,
- tagType: undefined
+ tagType: undefined,
+ __revengeCustomTag: true
};
});
};
diff --git a/plugins/custom-user-tags/src/patches/details.tsx b/plugins/custom-user-tags/src/patches/details.tsx
index f5a3b59..99463e1 100644
--- a/plugins/custom-user-tags/src/patches/details.tsx
+++ b/plugins/custom-user-tags/src/patches/details.tsx
@@ -29,6 +29,8 @@ const rowPatch = ([{ user }]: any[], res: any) => {
);
if (!nameContainer) return;
+ if (findInReactTree(nameContainer, (c) => c?.props?.__revengeCustomTag)) return;
+
const existingTag = findInReactTree(nameContainer, (c) => c?.type?.Types);
if (existingTag && existingTag.props?.type !== 0) return;
@@ -43,7 +45,8 @@ const rowPatch = ([{ user }]: any[], res: any) => {
backgroundColor: tag.backgroundColor,
icon: tag.icon,
iconColor: tag.iconColor,
- verified: false
+ verified: false,
+ __revengeCustomTag: true
});
return;
}
@@ -62,6 +65,7 @@ const rowPatch = ([{ user }]: any[], res: any) => {
icon={tag.icon}
iconColor={tag.iconColor}
verified={false}
+ __revengeCustomTag={true}
/>
);
}
diff --git a/plugins/custom-user-tags/src/patches/name.tsx b/plugins/custom-user-tags/src/patches/name.tsx
index 6a1079b..d9e5bb4 100644
--- a/plugins/custom-user-tags/src/patches/name.tsx
+++ b/plugins/custom-user-tags/src/patches/name.tsx
@@ -14,6 +14,8 @@ export default () => {
return after("DisplayName", DisplayNameModule, ([{ user }]: any[], ret: any) => {
try {
+ if (findInReactTree(ret, (c) => c?.props?.__revengeCustomTag)) return;
+
const tagComponent = findInReactTree(ret, (c) => c?.type?.Types);
if (tagComponent && tagComponent.props?.type !== 0) return;
@@ -28,7 +30,8 @@ export default () => {
backgroundColor: tag.backgroundColor,
icon: tag.icon,
iconColor: tag.iconColor,
- verified: false
+ verified: false,
+ __revengeCustomTag: true
};
return;
}
@@ -46,6 +49,7 @@ export default () => {
icon={tag.icon}
iconColor={tag.iconColor}
verified={false}
+ __revengeCustomTag={true}
/>
);
} catch {
diff --git a/plugins/custom-user-tags/src/patches/tag.tsx b/plugins/custom-user-tags/src/patches/tag.tsx
index 3dc1b84..1e36b9e 100644
--- a/plugins/custom-user-tags/src/patches/tag.tsx
+++ b/plugins/custom-user-tags/src/patches/tag.tsx
@@ -20,7 +20,7 @@ export default () => {
if (textColor) label.props.style?.push?.({ color: textColor });
if (backgroundColor) ret?.props?.style?.push?.({ backgroundColor });
- if (icon?.path) {
+ if (icon?.path || icon?.svg) {
const style = label.props.style ?? {};
ret.props.children = (
diff --git a/plugins/custom-user-tags/src/ui/Icon.tsx b/plugins/custom-user-tags/src/ui/Icon.tsx
index 4a616da..07b2c96 100644
--- a/plugins/custom-user-tags/src/ui/Icon.tsx
+++ b/plugins/custom-user-tags/src/ui/Icon.tsx
@@ -5,6 +5,7 @@ import { IconDef } from "../lib/icons";
const svgModule = findByProps("Svg") as Record | undefined;
const Svg = svgModule?.Svg ?? (() => null);
const Path = svgModule?.Path ?? (() => null);
+const SvgXml = svgModule?.SvgXml ?? (() => null);
interface IconProps {
icon: IconDef;
@@ -14,6 +15,9 @@ interface IconProps {
}
export default function Icon({ icon, size = 12, color = "#fff", style }: IconProps) {
+ // Custom SVGs keep their own authored colors - no fill override, unlike preset icons below.
+ if (icon.svg) return ;
+
if (!icon.path) return null;
const viewBox = icon.viewBox ?? "0 0 24 24";
return (
diff --git a/plugins/custom-user-tags/src/ui/Settings.tsx b/plugins/custom-user-tags/src/ui/Settings.tsx
index c3d9cf6..6668f0e 100644
--- a/plugins/custom-user-tags/src/ui/Settings.tsx
+++ b/plugins/custom-user-tags/src/ui/Settings.tsx
@@ -2,24 +2,20 @@ import { React, ReactNative } from "@vendetta/metro/common";
import { useProxy } from "@vendetta/storage";
import { storage } from "@vendetta/plugin";
import { TableRowGroup, TextInput } from "@shared/ui/table";
-import { showToast } from "@vendetta/ui/toasts";
import SettingsScaffold from "@shared/ui/SettingsScaffold";
import ListSection from "@shared/ui/ListSection";
import PrimaryButton from "@shared/ui/PrimaryButton";
import NoteBox from "@shared/ui/NoteBox";
-import { allTags, setUserTag, removeUserTag } from "../lib/tags";
+import { allTags } from "../lib/tags";
import { getIcon } from "../lib/icons";
-import ColorInput from "./ColorInput";
-import IconPicker from "./IconPicker";
+import openTagEditor from "./TagEditorAlert";
const { View } = ReactNative;
+// Opens the same rich editor used for long-press tagging (full SVG/icon-only support) instead of
+// keeping a second, simpler add-tag form here that would drift out of sync with it.
function AddTagForm() {
const [userId, setUserId] = React.useState("");
- const [text, setText] = React.useState("");
- const [color, setColor] = React.useState("#5865F2");
- const [icon, setIcon] = React.useState("none");
- const canSave = !!userId.trim() && (!!text.trim() || icon !== "none");
return (
@@ -29,20 +25,11 @@ function AddTagForm() {
value={userId}
onChange={setUserId}
/>
-
-
-
{
- setUserTag(userId.trim(), { text: text.trim(), color, icon: icon === "none" ? undefined : icon });
- showToast(`Tagged user ${userId.trim()}`, undefined);
- setUserId("");
- setText("");
- setIcon("none");
- }}
+ onPress={() => openTagEditor(userId.trim(), userId.trim())}
/>
);
@@ -68,16 +55,13 @@ export default function Settings() {
title="Tagged users"
emptyText="No one's tagged yet."
items={userIds.map((id) => {
- const icon = getIcon(tags[id].icon)?.fallback;
- const label = icon ? `${icon} ${tags[id].text}`.trim() : tags[id].text;
+ const icon = getIcon(tags[id].icon)?.fallback || tags[id].customSvgFallback;
+ const label = (icon ? `${icon} ${tags[id].text}` : tags[id].text).trim() || id;
return {
key: id,
label,
- subLabel: `${id} • Tap to remove`,
- onPress: () => {
- removeUserTag(id);
- showToast("Removed tag", undefined);
- }
+ subLabel: `${id} • Tap to edit`,
+ onPress: () => openTagEditor(id, tags[id].text || id)
};
})}
/>
diff --git a/plugins/custom-user-tags/src/ui/Tag.tsx b/plugins/custom-user-tags/src/ui/Tag.tsx
index b686e94..0f6d0d8 100644
--- a/plugins/custom-user-tags/src/ui/Tag.tsx
+++ b/plugins/custom-user-tags/src/ui/Tag.tsx
@@ -22,7 +22,7 @@ interface TagProps {
export default function CustomTag({ tag, style }: TagProps) {
return (
- {tag.icon && tag.icon.path && }
+ {tag.icon && (tag.icon.path || tag.icon.svg) && }
{!!tag.text && {tag.text} }
);
diff --git a/plugins/custom-user-tags/src/ui/TagEditorAlert.tsx b/plugins/custom-user-tags/src/ui/TagEditorAlert.tsx
index 299b87a..e0128d3 100644
--- a/plugins/custom-user-tags/src/ui/TagEditorAlert.tsx
+++ b/plugins/custom-user-tags/src/ui/TagEditorAlert.tsx
@@ -1,10 +1,11 @@
import { React, ReactNative } from "@vendetta/metro/common";
import { findByProps } from "@vendetta/metro";
-import { TextInput } from "@shared/ui/table";
+import { TableSwitchRow, TextInput } from "@shared/ui/table";
import { showToast } from "@vendetta/ui/toasts";
import ColorInput from "./ColorInput";
import IconPicker from "./IconPicker";
import { getUserTag, setUserTag, removeUserTag } from "../lib/tags";
+import { isValidCustomSvg, MAX_CUSTOM_SVG_LENGTH } from "../lib/icons";
const { View } = ReactNative;
@@ -30,16 +31,36 @@ function TagEditor({ userId, username }: { userId: string; username: string }) {
const [text, setText] = React.useState(existing?.text ?? "");
const [color, setColor] = React.useState(existing?.color ?? "#5865F2");
const [icon, setIcon] = React.useState(existing?.icon ?? "none");
+ const [svg, setSvg] = React.useState(existing?.customSvg ?? "");
+ const [svgFallback, setSvgFallback] = React.useState(existing?.customSvgFallback ?? "");
+ const [iconOnly, setIconOnly] = React.useState(!!existing?.iconOnly);
const { dismissAlert, AlertModal, AlertActions, AlertActionButton } = alertParts();
if (!AlertModal || !AlertActions || !AlertActionButton) return null;
+ const hasIcon = icon !== "none" || !!svg.trim();
+
const save = () => {
- if (!text.trim() && icon === "none") {
+ if (svg.trim() && !isValidCustomSvg(svg)) {
+ showToast("Invalid SVG markup", undefined);
+ return;
+ }
+ if (!text.trim() && !hasIcon) {
showToast("Enter some tag text or choose an icon first", undefined);
return;
}
- setUserTag(userId, { text: text.trim(), color, icon: icon === "none" ? undefined : icon });
+ if (iconOnly && !hasIcon) {
+ showToast("Select an icon before enabling icon-only", undefined);
+ return;
+ }
+ setUserTag(userId, {
+ text: text.trim(),
+ color,
+ icon: svg.trim() ? undefined : icon === "none" ? undefined : icon,
+ customSvg: svg.trim() || undefined,
+ customSvgFallback: svgFallback.trim() || undefined,
+ iconOnly
+ });
showToast(`Tagged ${username}`, undefined);
dismissAlert?.(ALERT_KEY);
};
@@ -56,7 +77,41 @@ function TagEditor({ userId, username }: { userId: string; username: string }) {
content={
-
+ {
+ setIcon(v);
+ if (v !== "none") setSvg("");
+ }}
+ color={color}
+ />
+ {
+ setSvg(v);
+ if (v) setIcon("none");
+ }}
+ />
+ {!!svg && (
+
+ )}
+
}
diff --git a/plugins/staff-tags/src/lib/getTag.ts b/plugins/staff-tags/src/lib/getTag.ts
index fc2eaa3..34cb2ae 100644
--- a/plugins/staff-tags/src/lib/getTag.ts
+++ b/plugins/staff-tags/src/lib/getTag.ts
@@ -3,7 +3,7 @@ import { chroma, constants } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin";
import { rawColors } from "@vendetta/ui";
import { isValidHex } from "@shared/lib/color";
-import { getIcon, IconDef } from "./icons";
+import { getIcon, isValidCustomSvg, IconDef } from "./icons";
const { Permissions } = constants;
const permissionsModule = findByProps("computePermissions", "canEveryoneRole");
@@ -31,6 +31,9 @@ export interface TagOverride {
enabled?: boolean;
text?: string;
icon?: string;
+ customSvg?: string;
+ customSvgFallback?: string;
+ iconOnly?: boolean;
useCustomColor?: boolean;
color?: string;
useGradient?: boolean;
@@ -55,6 +58,13 @@ export function tagSettings(id: string): TagOverride {
return storage.tags[id];
}
+function resolveIcon(settings: TagOverride): IconDef | undefined {
+ if (settings.customSvg && isValidCustomSvg(settings.customSvg)) {
+ return { id: "custom", name: "Custom", fallback: settings.customSvgFallback?.trim() || "", svg: settings.customSvg.trim() };
+ }
+ return getIcon(settings.icon);
+}
+
function resolveBackgroundColor(def: TagDefinition, settings: TagOverride, guild: any, user: any): string {
if (settings.useCustomColor && isValidHex(settings.color)) {
return settings.color;
@@ -100,7 +110,7 @@ export default function getTag(guild: any, channel: any, user: any): ResolvedTag
const backgroundColor = resolveBackgroundColor(def, settings, guild, user);
const textColor = chroma(backgroundColor).get("lab.l") < 70 ? rawColors.WHITE_500 : rawColors.BLACK_500;
- const icon = getIcon(settings.icon);
+ const icon = resolveIcon(settings);
let gradientColor: string | undefined;
if (settings.useGradient) {
@@ -111,7 +121,7 @@ export default function getTag(guild: any, channel: any, user: any): ResolvedTag
return {
id: def.id,
- text: settings.text?.trim() || def.defaultText,
+ text: settings.iconOnly && icon ? "" : settings.text?.trim() || def.defaultText,
textColor,
backgroundColor,
gradientColor,
diff --git a/plugins/staff-tags/src/lib/icons.ts b/plugins/staff-tags/src/lib/icons.ts
index a248aab..5adf92a 100644
--- a/plugins/staff-tags/src/lib/icons.ts
+++ b/plugins/staff-tags/src/lib/icons.ts
@@ -1,9 +1,27 @@
+import { findByProps } from "@vendetta/metro";
+
export interface IconDef {
id: string;
name: string;
- path: string;
fallback: string;
+ path?: string;
viewBox?: string;
+ svg?: string;
+}
+
+const svgModule = findByProps("Svg") as Record | undefined;
+
+export const MAX_CUSTOM_SVG_LENGTH = 20000;
+
+export function isValidCustomSvg(markup: string | undefined): boolean {
+ const trimmed = markup?.trim();
+ if (!trimmed || trimmed.length > MAX_CUSTOM_SVG_LENGTH || !/^ {
if (!getTagProperties) return () => {};
return after("default", getTagProperties, ([{ message }], ret) => {
- if (ret?.tagType) return;
+ if (ret?.tagType || ret?.__revengeCustomTag) return;
const channel = ChannelStore?.getChannel(message?.channel_id);
const guild = GuildStore?.getGuild(channel?.guild_id);
@@ -32,7 +32,8 @@ export default () => {
tagTextColor: tag.textColor ? ReactNative.processColor(chroma(tag.textColor).hex()) : undefined,
tagBackgroundColor: ReactNative.processColor(chroma(tag.backgroundColor).hex()),
tagVerified: tag.verified,
- tagType: undefined
+ tagType: undefined,
+ __revengeCustomTag: true
};
});
};
diff --git a/plugins/staff-tags/src/patches/details.tsx b/plugins/staff-tags/src/patches/details.tsx
index a270b71..26f7815 100644
--- a/plugins/staff-tags/src/patches/details.tsx
+++ b/plugins/staff-tags/src/patches/details.tsx
@@ -21,6 +21,8 @@ const rowPatch = ([{ guildId, user }]: any[], res: any) => {
);
if (!nameContainer) return;
+ if (findInReactTree(nameContainer, (c) => c?.props?.__revengeCustomTag)) return;
+
const existingTag = findInReactTree(nameContainer, (c) => c?.type?.Types);
if (existingTag && existingTag.props?.type !== 0) return;
@@ -36,7 +38,8 @@ const rowPatch = ([{ guildId, user }]: any[], res: any) => {
backgroundColor: tag.backgroundColor,
icon: tag.icon,
iconColor: tag.iconColor,
- verified: tag.verified
+ verified: tag.verified,
+ __revengeCustomTag: true
});
return;
}
@@ -54,6 +57,7 @@ const rowPatch = ([{ guildId, user }]: any[], res: any) => {
gradientColor={tag.gradientColor}
icon={tag.icon}
iconColor={tag.iconColor}
+ __revengeCustomTag={true}
/>
);
} else if (TagModule) {
@@ -66,6 +70,7 @@ const rowPatch = ([{ guildId, user }]: any[], res: any) => {
icon={tag.icon}
iconColor={tag.iconColor}
verified={tag.verified}
+ __revengeCustomTag={true}
/>
);
}
diff --git a/plugins/staff-tags/src/patches/name.tsx b/plugins/staff-tags/src/patches/name.tsx
index 1d46494..ff4c28e 100644
--- a/plugins/staff-tags/src/patches/name.tsx
+++ b/plugins/staff-tags/src/patches/name.tsx
@@ -26,6 +26,8 @@ export default () => {
if (DisplayNameModule?.DisplayName) {
patches.push(after("DisplayName", DisplayNameModule, ([{ guildId, channelId, user }], ret) => {
try {
+ if (findInReactTree(ret, (c) => c?.props?.__revengeCustomTag)) return;
+
const tagComponent = findInReactTree(ret, (c) => c?.type?.Types);
// A real built-in tag (bot/system/etc.) is already present - don't touch it.
@@ -44,7 +46,8 @@ export default () => {
backgroundColor: tag.backgroundColor,
icon: tag.icon,
iconColor: tag.iconColor,
- verified: tag.verified
+ verified: tag.verified,
+ __revengeCustomTag: true
};
return;
}
@@ -62,6 +65,7 @@ export default () => {
gradientColor={tag.gradientColor}
icon={tag.icon}
iconColor={tag.iconColor}
+ __revengeCustomTag={true}
/>
);
} else if (TagModule) {
@@ -75,6 +79,7 @@ export default () => {
icon={tag.icon}
iconColor={tag.iconColor}
verified={tag.verified}
+ __revengeCustomTag={true}
/>
);
}
diff --git a/plugins/staff-tags/src/patches/tag.tsx b/plugins/staff-tags/src/patches/tag.tsx
index 3dc1b84..1e36b9e 100644
--- a/plugins/staff-tags/src/patches/tag.tsx
+++ b/plugins/staff-tags/src/patches/tag.tsx
@@ -20,7 +20,7 @@ export default () => {
if (textColor) label.props.style?.push?.({ color: textColor });
if (backgroundColor) ret?.props?.style?.push?.({ backgroundColor });
- if (icon?.path) {
+ if (icon?.path || icon?.svg) {
const style = label.props.style ?? {};
ret.props.children = (
diff --git a/plugins/staff-tags/src/ui/GradientTag.tsx b/plugins/staff-tags/src/ui/GradientTag.tsx
index e7db5fc..bad4535 100644
--- a/plugins/staff-tags/src/ui/GradientTag.tsx
+++ b/plugins/staff-tags/src/ui/GradientTag.tsx
@@ -30,7 +30,7 @@ export default function GradientTag({ text, textColor, backgroundColor, gradient
const Gradient = gradientColor ? getGradientComponent() : null;
const tagContent = (
<>
- {icon?.path && }
+ {(icon?.path || icon?.svg) && }
{text}
>
);
diff --git a/plugins/staff-tags/src/ui/Icon.tsx b/plugins/staff-tags/src/ui/Icon.tsx
index 4a616da..07b2c96 100644
--- a/plugins/staff-tags/src/ui/Icon.tsx
+++ b/plugins/staff-tags/src/ui/Icon.tsx
@@ -5,6 +5,7 @@ import { IconDef } from "../lib/icons";
const svgModule = findByProps("Svg") as Record | undefined;
const Svg = svgModule?.Svg ?? (() => null);
const Path = svgModule?.Path ?? (() => null);
+const SvgXml = svgModule?.SvgXml ?? (() => null);
interface IconProps {
icon: IconDef;
@@ -14,6 +15,9 @@ interface IconProps {
}
export default function Icon({ icon, size = 12, color = "#fff", style }: IconProps) {
+ // Custom SVGs keep their own authored colors - no fill override, unlike preset icons below.
+ if (icon.svg) return ;
+
if (!icon.path) return null;
const viewBox = icon.viewBox ?? "0 0 24 24";
return (
diff --git a/plugins/staff-tags/src/ui/pages/Settings.tsx b/plugins/staff-tags/src/ui/pages/Settings.tsx
index 70fd718..de4f230 100644
--- a/plugins/staff-tags/src/ui/pages/Settings.tsx
+++ b/plugins/staff-tags/src/ui/pages/Settings.tsx
@@ -1,10 +1,11 @@
-import { React } from "@vendetta/metro/common";
+import { NavigationNative, React } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
-import { TableRowGroup, TableSwitchRow, TextInput } from "@shared/ui/table";
+import { TableRow, TableRowArrow, TableRowGroup, TableSwitchRow, TextInput } from "@shared/ui/table";
import SettingsScaffold from "@shared/ui/SettingsScaffold";
+import NoteBox from "@shared/ui/NoteBox";
import { TAG_DEFINITIONS, tagSettings } from "../../lib/getTag";
-import { getIcon } from "../../lib/icons";
+import { getIcon, isValidCustomSvg, MAX_CUSTOM_SVG_LENGTH } from "../../lib/icons";
import ColorInput from "../ColorInput";
import IconPicker from "../IconPicker";
@@ -35,9 +36,43 @@ function TagSettingsSection({ id, defaultText, defaultColor }: { id: string; def
{ settings.icon = v === "none" ? undefined : v; }}
+ onChange={(v: string) => {
+ settings.icon = v === "none" ? undefined : v;
+ if (v !== "none") settings.customSvg = undefined;
+ }}
color={activeColor}
/>
+ {
+ settings.customSvg = v || undefined;
+ if (v) settings.icon = undefined;
+ }}
+ />
+ {!!settings.customSvg && !isValidCustomSvg(settings.customSvg) && (
+ Invalid SVG markup - this tag will show without an icon until it's fixed.
+ )}
+ {!!settings.customSvg && (
+ { settings.customSvgFallback = v; }}
+ />
+ )}
+ { settings.iconOnly = v; }}
+ />
@@ -84,9 +120,30 @@ export default function Settings() {
onValueChange={(v: boolean) => { storage.useRoleColor = v; }}
/>
- {TAG_DEFINITIONS.map((def) => (
-
- ))}
+
+ {TAG_DEFINITIONS.map((def) => {
+ const settings = tagSettings(def.id);
+ const iconFallback = getIcon(settings.icon)?.fallback;
+ const label = iconFallback ? `${iconFallback} ${def.defaultText}` : def.defaultText;
+
+ return (
+ }
+ onPress={() => navigation.navigate("VendettaCustomPage", {
+ title: def.defaultText,
+ render: () => (
+
+
+
+ )
+ })}
+ />
+ );
+ })}
+
);
}