diff --git a/src/components/nostr/ColorPaletteDisplay.tsx b/src/components/nostr/ColorPaletteDisplay.tsx
new file mode 100644
index 0000000..240469f
--- /dev/null
+++ b/src/components/nostr/ColorPaletteDisplay.tsx
@@ -0,0 +1,216 @@
+import { memo, useMemo } from "react";
+import { cn } from "@/lib/utils";
+import { safeHex, type LayoutMode } from "@/lib/color-moment-helpers";
+
+interface ColorPaletteDisplayProps {
+ colors: string[];
+ layout?: LayoutMode;
+ emoji?: string;
+ emojiSize?: "md" | "lg";
+ className?: string;
+}
+
+/** Shared color palette renderer supporting all 6 layout modes */
+export const ColorPaletteDisplay = memo(function ColorPaletteDisplay({
+ colors,
+ layout = "horizontal",
+ emoji,
+ emojiSize = "md",
+ className,
+}: ColorPaletteDisplayProps) {
+ if (colors.length === 0) return null;
+
+ return (
+
+
+ {emoji && (
+
+
+ {emoji}
+
+
+ )}
+
+ );
+});
+
+function LayoutRenderer({
+ colors,
+ layout,
+}: {
+ colors: string[];
+ layout: LayoutMode;
+}) {
+ switch (layout) {
+ case "horizontal":
+ return (
+
+ {colors.map((color, i) => (
+
+ ))}
+
+ );
+
+ case "vertical":
+ return (
+
+ {colors.map((color, i) => (
+
+ ))}
+
+ );
+
+ case "grid":
+ return (
+
+ {colors.map((color, i) => (
+
+ ))}
+
+ );
+
+ case "star":
+ return ;
+
+ case "checkerboard":
+ return ;
+
+ case "diagonalStripes":
+ return ;
+
+ default:
+ return (
+
+ {colors.map((color, i) => (
+
+ ))}
+
+ );
+ }
+}
+
+/** Radial pie slices from center using clip-path */
+function StarLayout({ colors }: { colors: string[] }) {
+ const total = colors.length;
+
+ return (
+
+ {/* Background fill to cover center gap */}
+
+ {colors.map((color, index) => {
+ const angle = 360 / total;
+ const startAngle = index * angle - 90;
+ const scale = 1.5;
+ const overlap = 0.5;
+ const adjustedStartAngle = startAngle - overlap;
+ const adjustedAngle = angle + overlap * 2;
+
+ const points: string[] = ["50% 50%"];
+ const steps = 12;
+ for (let i = 0; i <= steps; i++) {
+ const currentAngle = adjustedStartAngle + (adjustedAngle * i) / steps;
+ const rad = (currentAngle * Math.PI) / 180;
+ const x = 50 + 50 * scale * Math.cos(rad);
+ const y = 50 + 50 * scale * Math.sin(rad);
+ points.push(`${x}% ${y}%`);
+ }
+
+ return (
+
+ );
+ })}
+
+ );
+}
+
+/** SVG data URI tiled checkerboard pattern */
+function CheckerboardLayout({ colors }: { colors: string[] }) {
+ const backgroundImage = useMemo(() => {
+ const n = colors.length;
+ const cellSize = 1;
+ const svgSize = n * cellSize;
+
+ let rects = "";
+ for (let row = 0; row < n; row++) {
+ for (let col = 0; col < n; col++) {
+ const color = safeHex(colors[(row + col) % n]);
+ rects += ``;
+ }
+ }
+
+ const svg = ``;
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
+ }, [colors]);
+
+ return (
+
+ );
+}
+
+/** Diagonal stripes via CSS linear-gradient */
+function DiagonalStripesLayout({ colors }: { colors: string[] }) {
+ const background = useMemo(() => {
+ const n = colors.length;
+ const stripePercent = 100 / n;
+
+ const stops = colors
+ .map((color, i) => {
+ const start = i * stripePercent;
+ const end = (i + 1) * stripePercent;
+ const hex = safeHex(color);
+ return `${hex} ${start}%, ${hex} ${end}%`;
+ })
+ .join(", ");
+
+ return `linear-gradient(135deg, ${stops})`;
+ }, [colors]);
+
+ return ;
+}
diff --git a/src/components/nostr/kinds/ColorMomentDetailRenderer.tsx b/src/components/nostr/kinds/ColorMomentDetailRenderer.tsx
new file mode 100644
index 0000000..5b0b797
--- /dev/null
+++ b/src/components/nostr/kinds/ColorMomentDetailRenderer.tsx
@@ -0,0 +1,66 @@
+import type { NostrEvent } from "@/types/nostr";
+import {
+ getColorMomentColors,
+ getColorMomentLayout,
+ getColorMomentName,
+ getColorMomentEmoji,
+ safeHex,
+} from "@/lib/color-moment-helpers";
+import { ColorPaletteDisplay } from "@/components/nostr/ColorPaletteDisplay";
+import { useCopy } from "@/hooks/useCopy";
+
+/**
+ * Kind 3367 Detail Renderer - Color Moment (Detail View)
+ * Full-size palette with copyable color swatches below
+ */
+export function ColorMomentDetailRenderer({ event }: { event: NostrEvent }) {
+ const colors = getColorMomentColors(event);
+ const layout = getColorMomentLayout(event) || "horizontal";
+ const name = getColorMomentName(event);
+ const emoji = getColorMomentEmoji(event);
+ const { copy } = useCopy();
+
+ if (colors.length < 3) {
+ return (
+
+ Invalid color moment (fewer than 3 colors)
+
+ );
+ }
+
+ return (
+
+ {name &&
{name}
}
+
+
+
+
+ {colors.map((color, i) => {
+ const hex = safeHex(color);
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/ColorMomentRenderer.tsx b/src/components/nostr/kinds/ColorMomentRenderer.tsx
new file mode 100644
index 0000000..2ea90b2
--- /dev/null
+++ b/src/components/nostr/kinds/ColorMomentRenderer.tsx
@@ -0,0 +1,63 @@
+import {
+ getColorMomentColors,
+ getColorMomentLayout,
+ getColorMomentName,
+ getColorMomentEmoji,
+} from "@/lib/color-moment-helpers";
+import { ColorPaletteDisplay } from "@/components/nostr/ColorPaletteDisplay";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { useAddWindow } from "@/core/state";
+
+/**
+ * Kind 3367 Renderer - Color Moment (Feed View)
+ * Shows a color palette with optional emoji overlay and name
+ */
+export function ColorMomentRenderer({ event }: BaseEventProps) {
+ const colors = getColorMomentColors(event);
+ const layout = getColorMomentLayout(event);
+ const name = getColorMomentName(event);
+ const emoji = getColorMomentEmoji(event);
+ const addWindow = useAddWindow();
+
+ if (colors.length < 3) {
+ return (
+
+
+ Invalid color moment (fewer than 3 colors)
+
+
+ );
+ }
+
+ const openDetail = () => {
+ addWindow("open", { pointer: { id: event.id } });
+ };
+
+ return (
+
+
+ {name && (
+
+ {name}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index 6b3668c..72465cd 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -195,6 +195,8 @@ import {
NsiteNamedDetailRenderer,
NsiteLegacyDetailRenderer,
} from "./NsiteDetailRenderer";
+import { ColorMomentRenderer } from "./ColorMomentRenderer";
+import { ColorMomentDetailRenderer } from "./ColorMomentDetailRenderer";
/**
* Registry of kind-specific renderers
@@ -222,6 +224,7 @@ const kindRenderers: Record> = {
1311: LiveChatMessageRenderer, // Live Chat Message (NIP-53)
1244: VoiceMessageRenderer, // Voice Message Reply (NIP-A0)
1337: Kind1337Renderer, // Code Snippet (NIP-C0)
+ 3367: ColorMomentRenderer, // Color Moment
1617: PatchRenderer, // Patch (NIP-34)
1618: PullRequestRenderer, // Pull Request (NIP-34)
1621: IssueRenderer, // Issue (NIP-34)
@@ -354,6 +357,7 @@ const detailRenderers: Record<
777: SpellDetailRenderer, // Spell Detail
1068: PollDetailRenderer, // Poll Detail (NIP-88)
1337: Kind1337DetailRenderer, // Code Snippet Detail (NIP-C0)
+ 3367: ColorMomentDetailRenderer, // Color Moment Detail
1617: PatchDetailRenderer, // Patch Detail (NIP-34)
1618: PullRequestDetailRenderer, // Pull Request Detail (NIP-34)
1621: IssueDetailRenderer, // Issue Detail (NIP-34)
diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts
index 4740b10..ed90554 100644
--- a/src/constants/kinds.ts
+++ b/src/constants/kinds.ts
@@ -71,6 +71,7 @@ import {
UserX,
Video,
Wallet,
+ Palette,
WandSparkles,
XCircle,
Zap,
@@ -614,6 +615,14 @@ export const EVENT_KINDS: Record = {
// icon: Coins,
// },
+ 3367: {
+ kind: 3367,
+ name: "Color Moment",
+ description: "Color Moment",
+ nip: "",
+ icon: Palette,
+ },
+
// Community
4550: {
kind: 4550,
diff --git a/src/index.css b/src/index.css
index ed0e975..03f2765 100644
--- a/src/index.css
+++ b/src/index.css
@@ -399,7 +399,9 @@ body.animating-layout
border-radius: 0 !important;
}
-.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-window * {
+.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
+ .mosaic-window
+ *:not(.color-palette-display) {
border-radius: 0 !important;
}
diff --git a/src/lib/color-moment-helpers.ts b/src/lib/color-moment-helpers.ts
new file mode 100644
index 0000000..8adc984
--- /dev/null
+++ b/src/lib/color-moment-helpers.ts
@@ -0,0 +1,62 @@
+import { getOrComputeCachedValue, getTagValue } from "applesauce-core/helpers";
+import type { NostrEvent } from "nostr-tools";
+
+export type LayoutMode =
+ | "horizontal"
+ | "vertical"
+ | "grid"
+ | "star"
+ | "checkerboard"
+ | "diagonalStripes";
+
+const HEX_RE = /^#[0-9a-fA-F]{6}$/;
+
+/** Validate hex color, fallback to black */
+export function safeHex(color: string): string {
+ return HEX_RE.test(color) ? color : "#000000";
+}
+
+const ColorMomentColorsSymbol = Symbol("colorMomentColors");
+
+/** Extract validated hex colors from `c` tags */
+export function getColorMomentColors(event: NostrEvent): string[] {
+ return getOrComputeCachedValue(event, ColorMomentColorsSymbol, () =>
+ event.tags
+ .filter((t) => t[0] === "c" && t[1] && HEX_RE.test(t[1]))
+ .map((t) => t[1]),
+ );
+}
+
+/** Get layout mode from `layout` tag */
+export function getColorMomentLayout(
+ event: NostrEvent,
+): LayoutMode | undefined {
+ const value = getTagValue(event, "layout");
+ if (!value) return undefined;
+ const valid: LayoutMode[] = [
+ "horizontal",
+ "vertical",
+ "grid",
+ "star",
+ "checkerboard",
+ "diagonalStripes",
+ ];
+ return valid.includes(value as LayoutMode)
+ ? (value as LayoutMode)
+ : undefined;
+}
+
+/** Get optional name from `name` tag */
+export function getColorMomentName(event: NostrEvent): string | undefined {
+ return getTagValue(event, "name") || undefined;
+}
+
+/** Get single emoji from content (if present) */
+export function getColorMomentEmoji(event: NostrEvent): string | undefined {
+ const content = event.content?.trim();
+ if (!content) return undefined;
+ // Match a single emoji (including compound emoji with ZWJ, skin tones, etc.)
+ const emojiRegex =
+ /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(\u200D(\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*$/u;
+ return emojiRegex.test(content) ? content : undefined;
+}