only holds text, and mixed children would be
+// stringified with commas during SSR ("#,channel").
+export default function MySiteTitle(props: { children: string }) {
+ return {`${props.children} — ChatBloom`};
}
diff --git a/src/components/SetupHero.module.css b/src/components/SetupHero.module.css
new file mode 100644
index 0000000..6a6e26e
--- /dev/null
+++ b/src/components/SetupHero.module.css
@@ -0,0 +1,39 @@
+.hero {
+ position: relative;
+ overflow: hidden;
+ padding: clamp(40px, 8vh, 88px) 16px clamp(64px, 11vh, 120px);
+ text-align: center;
+}
+
+.bloom {
+ width: clamp(200px, 34vw, 340px);
+ aspect-ratio: 1;
+ margin: 0 auto clamp(32px, 6vh, 56px);
+}
+
+/* Bloom Red text only from 24px up. */
+.red {
+ color: var(--bloom);
+}
+
+/* Vertical marginalia carries the open call. */
+.vertical {
+ position: absolute;
+ top: 50%;
+ right: clamp(8px, 2vw, 24px);
+ margin: 0;
+ transform: translateY(-50%);
+ writing-mode: vertical-rl;
+ color: var(--ink-60);
+ font-family: var(--font-mono);
+ font-size: 0.65625rem;
+ letter-spacing: 0.22em;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+@media (max-width: 1100px) {
+ .vertical {
+ display: none;
+ }
+}
diff --git a/src/components/SetupHero.tsx b/src/components/SetupHero.tsx
new file mode 100644
index 0000000..96b4976
--- /dev/null
+++ b/src/components/SetupHero.tsx
@@ -0,0 +1,28 @@
+import Typography from "@suid/material/Typography";
+
+import Bloom from "~/components/brand/Bloom";
+import Sprinkles from "~/components/brand/Sprinkles";
+import styles from "./SetupHero.module.css";
+
+// One page, one flower, one promise: the flower owns the center, the statement
+// orbits it, and the sky stays in the margins.
+export default function SetupHero() {
+ return (
+
+
+
+
+ ChatBloom — setup
+
+
+ Put your chat on screen.
+
+
+ ChatBloom is a Twitch chat overlay for streamers.
+
+
+ An open call for contributors, volunteers and curious coders
+
+
+ );
+}
diff --git a/src/components/SevenTVNamepaint.tsx b/src/components/SevenTVNamepaint.tsx
index cf659e7..29fe1a1 100644
--- a/src/components/SevenTVNamepaint.tsx
+++ b/src/components/SevenTVNamepaint.tsx
@@ -1,21 +1,31 @@
+import type { JSX, ParentProps } from "solid-js";
+type SevenTVNamepaintProps = ParentProps<{
+ /** Fallback text color, shown where the paint's background is transparent. */
+ paintColor?: string;
+ paintBackground: string;
+ paintFilter?: string;
+ style?: JSX.CSSProperties;
+}>;
-export default function SevenTVNamepaint(props) {
- // TODO: Fetch 7TV cosmetics from `https://7tv.io/v2/cosmetics?user_identifier=login` for example,
- // construct styles from that, and memoize the result (`useMemo()`).
- // Apply those styles to the `span` below based on the `props.login` prop or `props.paint` prop.
-
+// TODO: Fetch 7TV cosmetics for a user (by `login`) with `createResource`
+// and derive the paint props from the result instead of passing them in.
+export default function SevenTVNamepaint(props: SevenTVNamepaintProps) {
return (
- {props.children}
+
+ {props.children}
+
);
}
diff --git a/src/components/SiteFooter.module.css b/src/components/SiteFooter.module.css
new file mode 100644
index 0000000..1f40b1d
--- /dev/null
+++ b/src/components/SiteFooter.module.css
@@ -0,0 +1,48 @@
+.footer {
+ padding: clamp(48px, 8vh, 88px) 0 28px;
+ border-top: 1px solid var(--hairline);
+}
+
+.wordmark {
+ display: inline-block;
+ font-size: 1.75rem;
+ text-decoration: none;
+}
+
+.lede {
+ max-width: 52ch;
+ margin: 18px 0 28px;
+ font-size: 1rem;
+ line-height: 1.65;
+}
+
+/* Small red text wears Root Red: Bloom Red is for 24px and up. */
+.credit {
+ color: var(--root);
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+ text-underline-offset: 3px;
+}
+
+.links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px 36px;
+ margin-bottom: clamp(48px, 8vh, 80px);
+ font-weight: 500;
+}
+
+/* Marginalia: small because it is true, not because it is unimportant. */
+.margins {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 8px 24px;
+ padding-top: 20px;
+ border-top: 1px solid var(--hairline);
+ color: var(--ink-60);
+ font-family: var(--font-mono);
+ font-size: 0.6875rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
diff --git a/src/components/SiteFooter.tsx b/src/components/SiteFooter.tsx
new file mode 100644
index 0000000..f144946
--- /dev/null
+++ b/src/components/SiteFooter.tsx
@@ -0,0 +1,45 @@
+import Container from "@suid/material/Container";
+import { For } from "solid-js";
+
+import Wordmark from "~/components/brand/Wordmark";
+import styles from "./SiteFooter.module.css";
+
+const links = [
+ { label: "Source on GitHub", href: "https://github.com/shiftbloom-studio/ChatBloom" },
+ { label: "shiftbloom.studio", href: "https://shiftbloom.studio" },
+ { label: "Open Collective", href: "https://opencollective.com/shiftbloom-studio" },
+ { label: "hello@shiftbloom.studio", href: "mailto:hello@shiftbloom.studio" },
+];
+
+export default function SiteFooter() {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/SiteHeader.module.css b/src/components/SiteHeader.module.css
new file mode 100644
index 0000000..dd98435
--- /dev/null
+++ b/src/components/SiteHeader.module.css
@@ -0,0 +1,35 @@
+.bar {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 22px 0;
+ border-bottom: 1px solid var(--hairline);
+}
+
+.product {
+ color: var(--ink);
+ font-family: var(--font-display);
+ font-size: 1.375rem;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ text-decoration: none;
+}
+
+.seed {
+ color: var(--bloom);
+}
+
+.studio {
+ font-size: 0.9375rem;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.by {
+ color: var(--ink-60);
+ font-family: var(--font-mono);
+ font-size: 0.6875rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx
new file mode 100644
index 0000000..3950b6f
--- /dev/null
+++ b/src/components/SiteHeader.tsx
@@ -0,0 +1,19 @@
+import Container from "@suid/material/Container";
+
+import Wordmark from "~/components/brand/Wordmark";
+import styles from "./SiteHeader.module.css";
+
+export default function SiteHeader() {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/brand/Bloom.module.css b/src/components/brand/Bloom.module.css
new file mode 100644
index 0000000..b40960a
--- /dev/null
+++ b/src/components/brand/Bloom.module.css
@@ -0,0 +1,51 @@
+.bloom {
+ display: block;
+ overflow: visible;
+}
+
+/* Unfold, don't slide: the flower opens outward from its center on the Bloom
+ Ease, then breathes between 1.00 and 1.02 in six-second cycles. */
+.alive {
+ animation:
+ unfurl 600ms var(--ease-bloom) both,
+ breathe 3s ease-in-out 600ms infinite alternate;
+}
+
+/* Its rings counter-rotate, slowly, like it has time. The view box is centered
+ on the seed, so its origin is the flower's center. */
+.alive .ring {
+ transform-box: view-box;
+ transform-origin: 0 0;
+ animation: turn 480s linear infinite;
+}
+
+.alive .ring:nth-of-type(even) {
+ animation-duration: 600s;
+ animation-direction: reverse;
+}
+
+@keyframes unfurl {
+ from {
+ opacity: 0;
+ transform: scale(0.6);
+ }
+}
+
+@keyframes breathe {
+ to {
+ transform: scale(1.02);
+ }
+}
+
+@keyframes turn {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .alive,
+ .alive .ring {
+ animation: none;
+ }
+}
diff --git a/src/components/brand/Bloom.tsx b/src/components/brand/Bloom.tsx
new file mode 100644
index 0000000..9928b55
--- /dev/null
+++ b/src/components/brand/Bloom.tsx
@@ -0,0 +1,105 @@
+import { createUniqueId, For, Show } from "solid-js";
+
+import styles from "./Bloom.module.css";
+
+// The Bloom, drawn from the construction spec in Brand Book v2.0 §04: four
+// petal rings and a seed, symmetry 36°. Same geometry as the studio's
+// bloom.svg master. When in doubt, rebuild from the numbers.
+type Ring = {
+ /** Distance from the center to the petal tip (half the ring diameter). */
+ tip: number;
+ petals: number;
+ /** Half-width of the petal's control polygon. */
+ width: number;
+ /** Root to tip. */
+ length: number;
+ /** Stem to tip: darker at the center, lighter at the edge. Flip it and the flower wilts. */
+ from: string;
+ to: string;
+ /** Rotation in degrees, so neighbouring rings interleave. */
+ offset: number;
+};
+
+// biome-ignore format: one ring per row, so it reads like the spec table
+const RINGS: Ring[] = [
+ { tip: 100, petals: 10, width: 32, length: 90, from: "#D8163F", to: "#FF2E52", offset: 0 },
+ { tip: 74, petals: 10, width: 25.16, length: 66.6, from: "#FF2E52", to: "#FF7E9E", offset: 18 },
+ { tip: 50, petals: 8, width: 19, length: 45, from: "#FF7E9E", to: "#FFC2D3", offset: 40.5 },
+ { tip: 27, petals: 6, width: 12.96, length: 24.3, from: "#FFB4CA", to: "#FFDCE7", offset: 70.5 },
+];
+
+const BLOOM_RED = "#FF2E52";
+const ROOT_RED = "#C81040";
+
+/** One cubic pair, mirrored: root at the origin, tip at (0, -length). */
+function petal({ width: w, length: l }: Ring) {
+ const c = Math.round(55 * l) / 100;
+ return `M 0,0 C ${w},0 ${w},${-c} 0,${-l} C ${-w},${-c} ${-w},0 0,0 Z`;
+}
+
+function angles(ring: Ring) {
+ return Array.from({ length: ring.petals }, (_, i) => ring.offset + (360 / ring.petals) * i);
+}
+
+type BloomProps = {
+ /** Full gradient from 32px up. Smaller than that, only the solid bloom reads. */
+ variant?: "gradient" | "solid";
+ /** Unfurl on load, then breathe while idle as the rings counter-rotate. */
+ alive?: boolean;
+ /** Accessible name. Without one the mark is decorative. */
+ title?: string;
+ class?: string;
+};
+
+export default function Bloom(props: BloomProps) {
+ const id = `bloom-${createUniqueId()}`;
+ const solid = () => props.variant === "solid";
+
+ return (
+
+ );
+}
diff --git a/src/components/brand/Sprinkles.module.css b/src/components/brand/Sprinkles.module.css
new file mode 100644
index 0000000..df35243
--- /dev/null
+++ b/src/components/brand/Sprinkles.module.css
@@ -0,0 +1,67 @@
+.sky {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+
+/* They twinkle at 4–5s intervals, staggered. A sprinkle that blinks in sync
+ with another is a crowd, not a sky. */
+.sprinkle {
+ position: absolute;
+ display: block;
+ overflow: visible;
+ animation: twinkle 4.5s ease-in-out infinite;
+}
+
+.sprinkle:nth-child(2) {
+ animation-duration: 4s;
+ animation-delay: -1.3s;
+}
+
+.sprinkle:nth-child(3) {
+ animation-duration: 5s;
+ animation-delay: -2.6s;
+}
+
+.sprinkle:nth-child(4) {
+ animation-duration: 4.2s;
+ animation-delay: -0.7s;
+}
+
+.sprinkle:nth-child(5) {
+ animation-duration: 4.7s;
+ animation-delay: -3.4s;
+}
+
+.sprinkle:nth-child(6) {
+ animation-duration: 4.4s;
+ animation-delay: -1.9s;
+}
+
+.bloom {
+ fill: var(--bloom);
+}
+
+.ink {
+ fill: var(--ink);
+}
+
+/* Dimmed, never so far that red reads as blush: invisible means absent. */
+@keyframes twinkle {
+ 50% {
+ opacity: 0.45;
+ }
+}
+
+/* On narrow screens the margins are too tight to hold a sky. */
+@media (max-width: 760px) {
+ .sky {
+ display: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .sprinkle {
+ animation: none;
+ }
+}
diff --git a/src/components/brand/Sprinkles.tsx b/src/components/brand/Sprinkles.tsx
new file mode 100644
index 0000000..2d11d84
--- /dev/null
+++ b/src/components/brand/Sprinkles.tsx
@@ -0,0 +1,51 @@
+import { For, type JSX } from "solid-js";
+
+import styles from "./Sprinkles.module.css";
+
+// The sky around the flower (Brand Book v2.0 §07): a spark, a cross and a dot.
+// At most seven per composition, red or ink only on white, margins only, and
+// never inside the mark's clearspace (a quarter of its height on every side).
+const glyphs = {
+ spark: () => ,
+ cross: () => ,
+ dot: () => ,
+};
+
+type Sprinkle = {
+ glyph: keyof typeof glyphs;
+ color: "bloom" | "ink";
+ /** Spark 8–24px, cross 8–16px, dot 6–10px. */
+ size: number;
+ /** Where it sits in the margin, as CSS insets. */
+ at: JSX.CSSProperties;
+};
+
+const sky: Sprinkle[] = [
+ { glyph: "spark", color: "bloom", size: 24, at: { top: "14%", left: "15%" } },
+ { glyph: "dot", color: "bloom", size: 7, at: { top: "34%", left: "6%" } },
+ { glyph: "cross", color: "ink", size: 12, at: { top: "11%", right: "20%" } },
+ { glyph: "dot", color: "ink", size: 6, at: { top: "37%", right: "12%" } },
+ { glyph: "spark", color: "ink", size: 15, at: { top: "57%", right: "19%" } },
+ { glyph: "cross", color: "bloom", size: 10, at: { top: "61%", left: "11%" } },
+];
+
+export default function Sprinkles() {
+ return (
+
+
+ {(sprinkle) => (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/brand/Wordmark.module.css b/src/components/brand/Wordmark.module.css
new file mode 100644
index 0000000..ee03779
--- /dev/null
+++ b/src/components/brand/Wordmark.module.css
@@ -0,0 +1,16 @@
+.wordmark {
+ font-family: var(--font-display);
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ white-space: nowrap;
+ color: var(--ink);
+}
+
+.studio {
+ font-weight: 400;
+ color: var(--ink-60);
+}
+
+.seed {
+ color: var(--bloom);
+}
diff --git a/src/components/brand/Wordmark.tsx b/src/components/brand/Wordmark.tsx
new file mode 100644
index 0000000..af99d64
--- /dev/null
+++ b/src/components/brand/Wordmark.tsx
@@ -0,0 +1,13 @@
+import styles from "./Wordmark.module.css";
+
+// "shiftbloom" display semibold, "studio" display regular at 60% ink, and the
+// seed, which is always Bloom Red. Never capitalize, hyphenate, split across
+// lines or drop the seed.
+export default function Wordmark(props: { class?: string }) {
+ return (
+
+ shiftbloom studio
+ .
+
+ );
+}
diff --git a/src/components/chat/Chat.module.css b/src/components/chat/Chat.module.css
new file mode 100644
index 0000000..81c660f
--- /dev/null
+++ b/src/components/chat/Chat.module.css
@@ -0,0 +1,148 @@
+.overlay {
+ /* --emote-height and --badge-height come from ChatOverlay.tsx, which also needs them. */
+ --chat-font-size: 20px;
+
+ position: fixed;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ overflow: hidden;
+ padding: 12px;
+ color: #fff;
+ font: 600 var(--chat-font-size) / 1.5 var(--chat-font, "Roboto", system-ui, sans-serif);
+ text-shadow:
+ 0 0 2px #000,
+ 0 0 2px #000,
+ 1px 1px 2px #000;
+}
+
+.line {
+ padding: 2px 0;
+ overflow-wrap: anywhere;
+ animation: line-in 150ms ease-out;
+}
+
+@keyframes line-in {
+ from {
+ opacity: 0;
+ translate: 0 6px;
+ }
+}
+
+.system {
+ color: #bfb3ff;
+ font-style: italic;
+}
+
+.action {
+ font-style: italic;
+}
+
+.badges {
+ display: inline;
+}
+
+.badge {
+ height: var(--badge-height);
+ margin-right: 4px;
+ vertical-align: -4px;
+ border-radius: 3px;
+}
+
+.name {
+ font-weight: 700;
+}
+
+.mention {
+ font-weight: 700;
+}
+
+.emote {
+ display: inline-grid;
+ place-items: center;
+ vertical-align: middle;
+ /* Emotes may be taller than a line of text; don't let them push lines apart. */
+ margin: -8px 0;
+}
+
+.emote > * {
+ grid-area: 1 / 1;
+ height: var(--emote-height);
+}
+
+.slide {
+ display: inline-block;
+ background-repeat: repeat-x;
+ background-size: auto 100%;
+}
+
+/* 7TV paints: every layer repeats the name, clipped to its text, stacked in one grid cell. */
+.paint {
+ display: inline-grid;
+ text-shadow: none;
+}
+
+.paintLayer {
+ grid-area: 1 / 1;
+ background-color: currentColor;
+ background-size: 100% 100%;
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+
+/* BTTV Pro username effects. */
+.glow {
+ text-shadow: 0 0 20px color-mix(in srgb, currentColor 80%, transparent);
+}
+
+@keyframes flare-sweep {
+ 0% {
+ background-position:
+ 200% center,
+ center;
+ }
+ 50%,
+ 100% {
+ background-position:
+ -200% center,
+ center;
+ }
+}
+
+.flare {
+ background-image:
+ linear-gradient(
+ 75deg,
+ transparent 40%,
+ color-mix(in srgb, currentColor 20%, white) 50%,
+ transparent 60%
+ ),
+ linear-gradient(currentColor, currentColor);
+ background-size:
+ 200% 100%,
+ 100% 100%;
+ background-repeat: no-repeat;
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ text-shadow: none;
+ animation: flare-sweep 8s linear infinite;
+}
+
+@keyframes texture-sweep {
+ to {
+ background-position: -96px -96px;
+ }
+}
+
+.texture {
+ background-size: 96px 96px;
+ background-repeat: repeat;
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ text-shadow: none;
+ animation: texture-sweep 16s linear infinite;
+}
diff --git a/src/components/chat/ChatLine.tsx b/src/components/chat/ChatLine.tsx
new file mode 100644
index 0000000..4133143
--- /dev/null
+++ b/src/components/chat/ChatLine.tsx
@@ -0,0 +1,63 @@
+import { createMemo, For, Show } from "solid-js";
+
+import type { ChatMessage, ChatSession } from "~/lib/chat/session";
+import type { Badge } from "~/lib/chat/types";
+
+import styles from "./Chat.module.css";
+import EmoteView, { sizedSrcset } from "./EmoteView";
+import Username from "./Username";
+
+export const BADGE_HEIGHT = 22;
+
+function BadgeImage(props: { badge: Badge }) {
+ return (
+
+ );
+}
+
+export default function ChatLine(props: { session: ChatSession; message: ChatMessage }) {
+ const parts = createMemo(() => props.session.parts(props.message));
+ const badges = createMemo(() => props.session.badges(props.message));
+ const user = () => props.session.user(props.message);
+
+ return (
+
+
+ {props.message.system}
+
+
+
+ {(badge) => }
+
+
+ {props.message.action ? " " : ": "}
+
+
+ {(part) => {
+ if (part.type === "text") return part.text;
+ if (part.type === "mention") {
+ return {part.text};
+ }
+ return ;
+ }}
+
+
+
+
+ );
+}
diff --git a/src/components/chat/ChatOverlay.tsx b/src/components/chat/ChatOverlay.tsx
new file mode 100644
index 0000000..f052a00
--- /dev/null
+++ b/src/components/chat/ChatOverlay.tsx
@@ -0,0 +1,25 @@
+import "./overlay.css";
+
+import { For } from "solid-js";
+
+import type { ChatSession } from "~/lib/chat/session";
+
+import styles from "./Chat.module.css";
+import ChatLine, { BADGE_HEIGHT } from "./ChatLine";
+import { EMOTE_HEIGHT } from "./EmoteView";
+import { BTTVEffectFilters } from "./Username";
+
+export default function ChatOverlay(props: { session: ChatSession }) {
+ return (
+
+
+
+ {(message) => }
+
+
+ );
+}
diff --git a/src/components/chat/EmoteView.tsx b/src/components/chat/EmoteView.tsx
new file mode 100644
index 0000000..395645a
--- /dev/null
+++ b/src/components/chat/EmoteView.tsx
@@ -0,0 +1,103 @@
+import { createSignal, For, type JSX, Show } from "solid-js";
+
+import type { EmotePart } from "~/lib/chat/tokenize";
+import type { Emote, ImageSet } from "~/lib/chat/types";
+
+import styles from "./Chat.module.css";
+
+export const EMOTE_HEIGHT = 32;
+
+/**
+ * Width descriptors scaled from a base height, so the browser picks an image for the rendered
+ * size rather than only for the device pixel ratio. Aspect ratio doesn't matter here: it is the
+ * same at every density.
+ */
+export function sizedSrcset(images: ImageSet, baseHeight: number): string {
+ return Object.entries(images)
+ .map(([density, url]) => `${url} ${Math.round(baseHeight * Number(density))}w`)
+ .join(", ");
+}
+
+function EmoteImage(props: {
+ emote: Emote;
+ style?: JSX.CSSProperties;
+ onAspect?: (a: number) => void;
+}) {
+ return (
+
{
+ const img = event.currentTarget;
+ props.onAspect?.(img.naturalWidth / img.naturalHeight);
+ }}
+ />
+ );
+}
+
+export default function EmoteView(props: { part: EmotePart }) {
+ const [loadedAspect, setLoadedAspect] = createSignal();
+ const effects = () => props.part.effects;
+
+ const aspect = () => {
+ const { width, height } = props.part.emote;
+ return width && height ? width / height : loadedAspect();
+ };
+ /** Explicit width in px, only when an effect changes it. */
+ const width = () => {
+ const fx = effects();
+ if (fx.aspectRatio) return EMOTE_HEIGHT * fx.aspectRatio;
+ if (fx.widthScale === 1 && !fx.slide) return undefined;
+ // A sliding emote is a background with nothing to measure, so assume square if unknown.
+ const ratio = aspect() ?? (fx.slide ? 1 : undefined);
+ return ratio ? EMOTE_HEIGHT * ratio * fx.widthScale : undefined;
+ };
+
+ const style = (): JSX.CSSProperties => {
+ const fx = effects();
+ const filter = fx.filters.join(" ") || undefined;
+ const w = width();
+ const animations = [...fx.animations];
+ if (fx.slide && w)
+ animations.push(`cb-fx-slide ${(w / EMOTE_HEIGHT) * 1.5}s linear infinite`);
+ return {
+ width: w === undefined ? undefined : `${w}px`,
+ "object-fit": w === undefined ? undefined : "fill",
+ transform: fx.transforms.join(" ") || undefined,
+ "transform-origin": fx.transformOrigin,
+ filter,
+ "--cb-fx-f": filter,
+ "--cb-slide-width": fx.slide && w ? `${w}px` : undefined,
+ animation: animations.join(", ") || undefined,
+ };
+ };
+
+ return (
+
+
+ }
+ >
+
+
+ {(overlay) => }
+
+ );
+}
diff --git a/src/components/chat/Username.tsx b/src/components/chat/Username.tsx
new file mode 100644
index 0000000..d70e156
--- /dev/null
+++ b/src/components/chat/Username.tsx
@@ -0,0 +1,135 @@
+import { For, Match, Show, Switch } from "solid-js";
+
+import type { BTTVUsernameEffect } from "~/lib/chat/providers/bttv";
+import type { Paint } from "~/lib/chat/providers/seventv/paint";
+
+import styles from "./Chat.module.css";
+
+/** BTTV's texture effects, with the outline color each one's SVG stroke filter uses. */
+export const BTTV_TEXTURES: Partial> = {
+ iridescence: "#2f395f",
+ supernova: "#702838",
+ glacier: "#166258",
+ intergalactic: "#4e316c",
+ midas: "#674911",
+};
+
+function PaintedText(props: { paint: Paint; text: string }) {
+ return (
+ 0}
+ fallback={
+
+ {props.text}
+
+ }
+ >
+
+
+ {(layer, index) => (
+ 0}
+ style={{
+ opacity: layer.opacity,
+ "background-image": layer.image,
+ "background-color": layer.color,
+ // Shadows go on the first layer only, like 7TV renders them.
+ filter: index() === 0 ? props.paint.filter : undefined,
+ }}
+ >
+ {props.text}
+
+ )}
+
+
+
+ );
+}
+
+function BTTVEffectText(props: { effect: BTTVUsernameEffect; text: string }) {
+ const texture = () => BTTV_TEXTURES[props.effect] !== undefined;
+ return (
+
+ {props.text}
+
+ );
+}
+
+/** A chatter's name in their color, wearing their 7TV paint or else their BTTV effect. */
+export default function Username(props: {
+ name: string;
+ color: string;
+ paint?: Paint;
+ effect?: BTTVUsernameEffect;
+}) {
+ return (
+
+
+
+ {(paint) => }
+
+
+ {(effect) => }
+
+
+
+ );
+}
+
+/** SVG outline filters the BTTV texture effects reference; rendered once per overlay. */
+export function BTTVEffectFilters() {
+ return (
+
+ );
+}
diff --git a/src/components/chat/overlay.css b/src/components/chat/overlay.css
new file mode 100644
index 0000000..a3fac95
--- /dev/null
+++ b/src/components/chat/overlay.css
@@ -0,0 +1,164 @@
+/* Global on purpose: emote effects reference these keyframes from inline styles, and OBS needs
+ the page itself transparent. The chat route sets `data-chat-overlay` while it is mounted;
+ the selector outranks SUID's CssBaseline `body` rule, which is injected later at runtime. */
+html[data-chat-overlay],
+html[data-chat-overlay] body {
+ /* biome-ignore lint/complexity/noImportantStyles: must beat the runtime-injected theme background */
+ background: transparent !important;
+}
+
+/* Animations use the individual `translate` / `rotate` / `scale` properties so they compose with
+ the static `transform` (flips, rotations) instead of replacing it. Filter animations prepend
+ `--cb-fx-f`, the emote's static filters. */
+
+@keyframes cb-fx-rainbow {
+ from {
+ filter: var(--cb-fx-f,) hue-rotate(0deg);
+ }
+ to {
+ filter: var(--cb-fx-f,) hue-rotate(360deg);
+ }
+}
+
+@keyframes cb-fx-party {
+ from {
+ filter: var(--cb-fx-f,) sepia(0.5) hue-rotate(0deg) saturate(2.5);
+ }
+ to {
+ filter: var(--cb-fx-f,) sepia(0.5) hue-rotate(360deg) saturate(2.5);
+ }
+}
+
+@keyframes cb-fx-shake {
+ 0% {
+ translate: 1px 1px;
+ }
+ 25% {
+ translate: -2px -1px;
+ }
+ 50% {
+ translate: 2px 2px;
+ }
+ 75% {
+ translate: -1px 2px;
+ }
+ 100% {
+ translate: 1px -1px;
+ }
+}
+
+@keyframes cb-fx-bttv-shake {
+ 0% {
+ translate: 0 1px;
+ }
+ 20% {
+ translate: 2px 0;
+ }
+ 40% {
+ translate: 1px -2px;
+ }
+ 60% {
+ translate: -2px 1px;
+ }
+ 80% {
+ translate: 0 -1px;
+ }
+}
+
+@keyframes cb-fx-jam {
+ 0%,
+ 100% {
+ translate: -2px -2px;
+ rotate: -6deg;
+ }
+ 30% {
+ translate: 3px 2px;
+ rotate: -5deg;
+ }
+ 50% {
+ translate: 2px 4px;
+ rotate: 3deg;
+ }
+ 75% {
+ translate: -1px 2px;
+ rotate: 1deg;
+ }
+}
+
+@keyframes cb-fx-bounce {
+ 0%,
+ 100% {
+ scale: 0.8 1;
+ }
+ 25% {
+ scale: 1.2 0.4;
+ }
+ 50% {
+ scale: 0.9 1.15;
+ }
+ 75% {
+ scale: 1 0.9;
+ }
+}
+
+@keyframes cb-fx-spin {
+ from {
+ rotate: 0deg;
+ }
+ to {
+ rotate: 360deg;
+ }
+}
+
+@keyframes cb-fx-appear {
+ 0%,
+ 20% {
+ translate: -18px 0;
+ scale: 0;
+ }
+ 65%,
+ 100% {
+ translate: 0 0;
+ scale: 1;
+ }
+}
+
+@keyframes cb-fx-leave {
+ 0%,
+ 40% {
+ translate: 0 0;
+ scale: 1;
+ }
+ 85%,
+ 100% {
+ translate: -18px 0;
+ scale: 0;
+ }
+}
+
+@keyframes cb-fx-in-out {
+ 0%,
+ 10% {
+ translate: -18px 0;
+ scale: 0;
+ }
+ 32%,
+ 70% {
+ translate: 0 0;
+ scale: 1;
+ }
+ 92%,
+ 100% {
+ translate: -18px 0;
+ scale: 0;
+ }
+}
+
+@keyframes cb-fx-slide {
+ from {
+ background-position-x: 0;
+ }
+ to {
+ background-position-x: calc(-1 * var(--cb-slide-width));
+ }
+}
diff --git a/src/components/icon/BTTVBadIcon.tsx b/src/components/icon/BTTVBadIcon.tsx
deleted file mode 100644
index b8daecd..0000000
--- a/src/components/icon/BTTVBadIcon.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { SvgIcon } from "@suid/material";
-
-export default function BTTVBadIcon(props) {
- return (
-
-
-
- );
-}
diff --git a/src/components/icon/BTTVIcon.tsx b/src/components/icon/BTTVIcon.tsx
index 4ea5e96..fb5029e 100644
--- a/src/components/icon/BTTVIcon.tsx
+++ b/src/components/icon/BTTVIcon.tsx
@@ -1,14 +1,9 @@
-import { SvgIcon } from "@suid/material";
+import SvgIcon, { type SvgIconProps } from "@suid/material/SvgIcon";
-export default function BTTVBadIcon(props) {
+export default function BTTVIcon(props: SvgIconProps) {
return (
-
-
+
+
);
}
diff --git a/src/components/icon/ChatterinoIcon.tsx b/src/components/icon/ChatterinoIcon.tsx
index ddba191..0409098 100644
--- a/src/components/icon/ChatterinoIcon.tsx
+++ b/src/components/icon/ChatterinoIcon.tsx
@@ -1,21 +1,16 @@
-import { SvgIcon } from "@suid/material";
+import SvgIcon, { type SvgIconProps } from "@suid/material/SvgIcon";
-// The SVG was generated from the original Chatterino icon using https://www.vectorizer.io/
-// Hand-edited to make it look better
-
-export default function ChatterinoIcon(props) {
+// Vectorized from the original Chatterino icon with https://www.vectorizer.io/,
+// then hand-edited into a single stroked outline.
+export default function ChatterinoIcon(props: SvgIconProps) {
return (
-
- {/*