Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/components/announcements/DiscordIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { forwardRef } from 'react';
import type { LucideProps } from 'lucide-react';

/**
* Discord's brand mark, drawn here because lucide-react carries no brand
* glyphs at all (they were split out of the icon set upstream) and an
* announcement that arrived from a Discord channel has nothing else that
* says so at a glance.
*
* Deliberately shaped to lucide's own component contract — same props, same
* ref target, `currentColor` by default — so it can sit in
* `ANNOUNCEMENT_ICONS` beside real lucide icons and be rendered by call
* sites that know nothing about it (`<Icon className="w-4 h-4" style={{
* color }} />`).
*
* Filled rather than stroked, unlike every glyph around it: a brand mark
* redrawn as outlines stops reading as that brand. `fill={color}` keeps it
* inheriting colour exactly the way the stroked icons do, so it still
* follows `--text-muted` / `--accent` with no special casing.
*/
export const DiscordIcon = forwardRef<SVGSVGElement, LucideProps>(function DiscordIcon(
{ size = 24, color = 'currentColor', className, style, ...rest },
ref,
) {
return (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={size}
height={size}
fill={color}
stroke="none"
className={className}
style={style}
aria-hidden="true"
{...rest}
>
<path d="M20.317 4.3698a19.7913 19.7913 0 0 0-4.8851-1.5152.0741.0741 0 0 0-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 0 0-.0785-.037 19.7363 19.7363 0 0 0-4.8852 1.515.0699.0699 0 0 0-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 0 0 .0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 0 0 .0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 0 0-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 0 1-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 0 1 .0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 0 1 .0785.0095c.1202.099.246.198.3728.2924a.077.077 0 0 1-.0066.1276 12.2986 12.2986 0 0 1-1.873.8914.0766.0766 0 0 0-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 0 0 .0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 0 0 .0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 0 0-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
);
});
10 changes: 10 additions & 0 deletions src/components/announcements/__tests__/icons.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { ANNOUNCEMENT_ICON_KEYS, ANNOUNCEMENT_ICONS, iconForAnnouncement } from '../icons.js';
import { DiscordIcon } from '../DiscordIcon.js';
import type { AnnouncementType } from '../types.js';

// types.ts exports `AnnouncementType` as a union only, no runtime array — this
Expand All @@ -12,9 +13,18 @@ describe('ANNOUNCEMENT_ICON_KEYS / ANNOUNCEMENT_ICONS', () => {
expect([...ANNOUNCEMENT_ICON_KEYS]).toEqual([
'release', 'update', 'event', 'maintenance', 'security',
'megaphone', 'gift', 'sparkles', 'alert-triangle', 'info', 'key', 'coins', 'party',
'discord',
]);
});

// The only key whose glyph is not a lucide icon — it is drawn in this
// package (lucide ships no brand marks). Pinned so a refactor that loses
// the local component and silently leaves the key mapped to nothing gets
// caught here rather than as an empty square in three portals.
it('maps the discord key to the locally drawn brand mark', () => {
expect(ANNOUNCEMENT_ICONS.discord).toBe(DiscordIcon);
});

it('has exactly one glyph per key, with no gaps', () => {
for (const key of ANNOUNCEMENT_ICON_KEYS) {
expect(ANNOUNCEMENT_ICONS[key]).toBeTruthy();
Expand Down
9 changes: 8 additions & 1 deletion src/components/announcements/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Megaphone, Gift, Sparkles, AlertTriangle, Info, Key, Coins, PartyPopper,
} from 'lucide-react';
import type { AnnouncementType } from './types.js';
import { DiscordIcon } from './DiscordIcon.js';

/**
* The closed set of icon keys an announcement can be tagged with. A stored
Expand All @@ -25,14 +26,19 @@ import type { AnnouncementType } from './types.js';
export const ANNOUNCEMENT_ICON_KEYS = [
'release', 'update', 'event', 'maintenance', 'security',
'megaphone', 'gift', 'sparkles', 'alert-triangle', 'info', 'key', 'coins', 'party',
'discord',
] as const;

export type AnnouncementIconKey = typeof ANNOUNCEMENT_ICON_KEYS[number];

/**
* One lucide-react component per key. Every consumer — this row, and
* One icon component per key. Every consumer — this row, and
* sphere-backoffice's icon picker — reads a glyph from this single map, so
* there is exactly one place that decides what each key looks like.
*
* All lucide-react except `discord`: lucide carries no brand marks, so that
* one is drawn locally against the same component contract (see
* DiscordIcon.tsx) and is otherwise indistinguishable to every call site.
*/
export const ANNOUNCEMENT_ICONS: Record<AnnouncementIconKey, typeof Rocket> = {
release: Rocket,
Expand All @@ -48,6 +54,7 @@ export const ANNOUNCEMENT_ICONS: Record<AnnouncementIconKey, typeof Rocket> = {
key: Key,
coins: Coins,
party: PartyPopper,
discord: DiscordIcon,
};

function isAnnouncementIconKey(value: string): value is AnnouncementIconKey {
Expand Down
Loading