feat(decorations): 530 new avatar decorations, thumbnail-first picker
CI / Build & Quality Checks (push) Successful in 3m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 15s
CI / Playwright smoke (e2e) (push) Successful in 10m56s

Adds 530 decorations in 19 new categories (631 total). Decorations that
contain Discord branding (the Clyde-visor helmets) are excluded.

The decorations are ~1 MB animated PNGs, so a picker that rendered every
one would pull ~590 MB while scrolling. The picker now:
- shows static 144px WebP thumbnails (~9.5 KB each, `thumbs/` on the CDN)
  and loads the animated file only on hover, focus or selection, with a
  fallback to the full file if a thumbnail is missing;
- mounts one category at a time behind tabs, plus a name search across
  all categories.

scripts/makeDecorationThumbs.py builds the thumbnails from the busiest
frame of each animation (many start on an empty frame).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-25 23:50:12 -04:00
co-authored by Claude Opus 5.5
parent 4323babfb9
commit 78b1a2ff6f
3 changed files with 799 additions and 35 deletions
@@ -1,10 +1,15 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Text, Spinner, color } from 'folds';
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
import { Method } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SettingTile } from '../../../components/setting-tile';
import { DECORATION_CATEGORIES, decorationUrl } from '../../lotus/avatarDecorations';
import {
ALL_DECORATIONS,
DECORATION_CATEGORIES,
decorationThumbUrl,
decorationUrl,
} from '../../lotus/avatarDecorations';
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
@@ -21,6 +26,12 @@ function DecorationPreviewCell({
selected: boolean;
onSelect: (slug: string) => void;
}) {
// The full decorations are ~1 MB animated PNGs. Show the small static
// thumbnail and only pull the animated file while the cell is hovered,
// focused or selected. A missing thumbnail falls back to the full file.
const [active, setActive] = useState(false);
const [thumbFailed, setThumbFailed] = useState(false);
const animate = active || selected || thumbFailed;
return (
<button
type="button"
@@ -28,6 +39,10 @@ function DecorationPreviewCell({
aria-label={name}
aria-pressed={selected}
onClick={() => onSelect(slug)}
onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)}
onFocus={() => setActive(true)}
onBlur={() => setActive(false)}
style={{
position: 'relative',
width: CELL_SIZE,
@@ -44,10 +59,11 @@ function DecorationPreviewCell({
}}
>
<img
src={decorationUrl(slug)}
src={animate ? decorationUrl(slug) : decorationThumbUrl(slug)}
alt={name}
loading="lazy"
decoding="async"
onError={() => setThumbFailed(true)}
style={{
position: 'absolute',
inset: 0,
@@ -61,6 +77,12 @@ function DecorationPreviewCell({
);
}
function categoryOf(slug: string | null): string {
const found =
slug && DECORATION_CATEGORIES.find((c) => c.decorations.some((d) => d.slug === slug));
return found ? found.id : DECORATION_CATEGORIES[0].id;
}
export function ProfileDecoration() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
@@ -75,6 +97,16 @@ export function ProfileDecoration() {
// mount-time fetch below clobbering a fresh selection if it resolves late
// (mirrors ProfileStatus's statusDirtyRef in Profile.tsx).
const dirtyRef = useRef(false);
const [activeCategory, setActiveCategory] = useState(DECORATION_CATEGORIES[0].id);
const [search, setSearch] = useState('');
const query = search.trim().toLowerCase();
const visible = useMemo(
() =>
query
? ALL_DECORATIONS.filter((d) => d.name.toLowerCase().includes(query))
: (DECORATION_CATEGORIES.find((c) => c.id === activeCategory)?.decorations ?? []),
[query, activeCategory],
);
const fetchDecoration = useCallback(() => {
let cancelled = false;
@@ -92,6 +124,7 @@ export function ProfileDecoration() {
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
setCurrent(val);
setSelected(val);
setActiveCategory(categoryOf(val));
})
.catch(() => {
if (cancelled) return;
@@ -196,9 +229,7 @@ export function ProfileDecoration() {
{loadError
? 'Failed to load'
: selected
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
(d) => d.slug === selected,
)?.name ?? selected)
? (ALL_DECORATIONS.find((d) => d.slug === selected)?.name ?? selected)
: 'None'}
</Text>
{selected && !loadError && (
@@ -256,42 +287,72 @@ export function ProfileDecoration() {
</Text>
)}
{/* Category grid */}
{/* Search + category tabs. Only one category (or the search results)
is mounted at a time so the picker never loads the whole catalog. */}
<Input
aria-label="Search decorations"
placeholder="Search decorations"
value={search}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
variant="Secondary"
size="400"
radii="300"
before={<Icon size="100" src={Icons.Search} />}
/>
{!query && (
<Box gap="100" wrap="Wrap" role="tablist" aria-label="Decoration categories">
{DECORATION_CATEGORIES.map((category) => (
<Chip
key={category.id}
type="button"
role="tab"
aria-selected={category.id === activeCategory}
variant={category.id === activeCategory ? 'Primary' : 'Secondary'}
fill="Soft"
outlined={category.id === activeCategory}
radii="Pill"
onClick={() => setActiveCategory(category.id)}
>
<Text as="span" size="B300">
{category.label}
</Text>
</Chip>
))}
</Box>
)}
<div
role={query ? undefined : 'tabpanel'}
style={{
maxHeight: 480,
maxHeight: 360,
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 4,
display: 'flex',
flexDirection: 'column',
gap: 24,
}}
>
{DECORATION_CATEGORIES.map((category) => (
<div key={category.id} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Text size="L400" priority="400">
{category.label}
</Text>
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
gap: 20,
}}
>
{category.decorations.map((d) => (
<DecorationPreviewCell
key={d.slug}
slug={d.slug}
name={d.name}
selected={selected === d.slug}
onSelect={handleSelect}
/>
))}
</div>
{visible.length === 0 ? (
<Text size="T300" priority="300">
No decorations match “{search.trim()}”.
</Text>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
gap: 20,
padding: 2,
}}
>
{visible.map((d) => (
<DecorationPreviewCell
key={d.slug}
slug={d.slug}
name={d.name}
selected={selected === d.slug}
onSelect={handleSelect}
/>
))}
</div>
))}
)}
</div>
</Box>
</SettingTile>