From 8a6750d3732fbaa6cf4e162577bbbcfc01ebba81 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Thu, 24 Sep 2026 04:21:39 +0000
Subject: [PATCH] OpenEmoji catalog: every emoji in the reference set, with
search and filters
/openemoji/catalog reads the set's openemoji.json (every Unicode emoji,
drawn or not) and filters by name and CLDR keyword, group, subgroup,
skin tone (keyboard-style swap), Emoji version and status, with sort,
tile size, URL-synced filters, paged rendering and a detail panel
(codepoints, tone family, copy, PNG/SVG downloads, keyword chips).
Spec: an entry without files is listed, not drawn.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../app/openemoji/catalog/catalog.module.css | 449 +++++++++++++
.../src/app/openemoji/catalog/catalog.tsx | 597 ++++++++++++++++++
.../src/app/openemoji/catalog/page.tsx | 37 ++
.../src/app/openemoji/catalog/set.ts | 11 +
apps/logicsrc-web/src/app/openemoji/page.tsx | 12 +-
docs/openemoji.md | 4 +-
6 files changed, 1108 insertions(+), 2 deletions(-)
create mode 100644 apps/logicsrc-web/src/app/openemoji/catalog/catalog.module.css
create mode 100644 apps/logicsrc-web/src/app/openemoji/catalog/catalog.tsx
create mode 100644 apps/logicsrc-web/src/app/openemoji/catalog/page.tsx
create mode 100644 apps/logicsrc-web/src/app/openemoji/catalog/set.ts
diff --git a/apps/logicsrc-web/src/app/openemoji/catalog/catalog.module.css b/apps/logicsrc-web/src/app/openemoji/catalog/catalog.module.css
new file mode 100644
index 0000000..5e4445b
--- /dev/null
+++ b/apps/logicsrc-web/src/app/openemoji/catalog/catalog.module.css
@@ -0,0 +1,449 @@
+.catalog {
+ --tile: 56px;
+ --line: #e3e6e0;
+ --muted: #5b6b7a;
+ --accent: #0a7d59;
+ position: relative;
+}
+
+.notice {
+ color: var(--muted);
+ padding: 2rem 0;
+}
+
+.progress {
+ position: relative;
+ height: 1.9rem;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: #fff;
+ overflow: hidden;
+ margin-bottom: 1rem;
+}
+
+.progressBar {
+ position: absolute;
+ inset: 0 auto 0 0;
+ background: linear-gradient(90deg, #d8f0e5, #b6e3cf);
+ transition: width 0.4s ease;
+}
+
+.progress span {
+ position: relative;
+ display: block;
+ padding: 0.35rem 0.9rem;
+ font-size: 0.82rem;
+ color: #2c3a45;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.toolbar {
+ position: sticky;
+ top: 0;
+ z-index: 3;
+ display: grid;
+ gap: 0.6rem;
+ padding: 0.75rem 0 0.8rem;
+ background: #f6f7f4;
+ border-bottom: 1px solid var(--line);
+ margin-bottom: 1rem;
+}
+
+.search {
+ width: 100%;
+ font: inherit;
+ font-size: 1rem;
+ padding: 0.7rem 0.95rem;
+ border: 1px solid #cfd5cc;
+ border-radius: 0.6rem;
+ background: #fff;
+}
+
+.search:focus,
+.controls select:focus,
+.chip:focus-visible,
+.chipOn:focus-visible,
+.tile:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+}
+
+.controls label {
+ display: grid;
+ gap: 0.2rem;
+ font-size: 0.72rem;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.controls select {
+ font: inherit;
+ font-size: 0.88rem;
+ text-transform: none;
+ letter-spacing: 0;
+ color: #101418;
+ padding: 0.4rem 0.55rem;
+ border: 1px solid #cfd5cc;
+ border-radius: 0.45rem;
+ background: #fff;
+ min-width: 9rem;
+}
+
+.sizeControl input {
+ width: 9rem;
+ accent-color: var(--accent);
+}
+
+.row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.rowLabel {
+ font-size: 0.72rem;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-right: 0.25rem;
+}
+
+.chip,
+.chipOn {
+ padding: 0.3rem 0.7rem;
+ border-radius: 999px;
+ font-size: 0.84rem;
+ border: 1px solid #cfd5cc;
+ background: #fff;
+ color: #101418;
+}
+
+.chip small,
+.chipOn small {
+ color: var(--muted);
+ margin-left: 0.2rem;
+}
+
+.chip:hover {
+ border-color: var(--accent);
+}
+
+.chipOn {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+
+.chipOn small {
+ color: #d7f3e8;
+}
+
+.count {
+ margin-left: auto;
+ font-size: 0.85rem;
+ color: var(--muted);
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.reset {
+ background: none;
+ color: var(--accent);
+ padding: 0;
+ font-size: 0.85rem;
+}
+
+.grid {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(calc(var(--tile) + 2.4rem), 1fr));
+ gap: 0.45rem;
+}
+
+.tile {
+ width: 100%;
+ display: grid;
+ justify-items: center;
+ gap: 0.3rem;
+ padding: 0.6rem 0.3rem 0.45rem;
+ border: 1px solid var(--line);
+ border-radius: 0.7rem;
+ background: #fff;
+ color: inherit;
+ transition:
+ transform 0.12s ease,
+ border-color 0.12s ease,
+ box-shadow 0.12s ease;
+ content-visibility: auto;
+ contain-intrinsic-size: calc(var(--tile) + 2.6rem);
+}
+
+.tile:hover {
+ transform: translateY(-2px);
+ border-color: #b9c9bf;
+ box-shadow: 0 6px 16px rgb(16 20 24 / 8%);
+}
+
+.tile img {
+ width: var(--tile);
+ height: var(--tile);
+ object-fit: contain;
+}
+
+.tileOn {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent);
+}
+
+.todo {
+ background: #fafbf8;
+ border-style: dashed;
+}
+
+.fallback {
+ display: grid;
+ place-items: center;
+ width: var(--tile);
+ height: var(--tile);
+ font-size: calc(var(--tile) * 0.72);
+ line-height: 1;
+ opacity: 0.45;
+ filter: grayscale(0.4);
+}
+
+.label {
+ max-width: 100%;
+ font-size: 0.68rem;
+ color: var(--muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sentinel {
+ height: 1px;
+}
+
+.detail {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 20;
+ width: min(24rem, 100vw);
+ overflow-y: auto;
+ padding: 1.4rem 1.3rem 2rem;
+ background: #fff;
+ border-left: 1px solid var(--line);
+ box-shadow: -18px 0 40px rgb(16 20 24 / 12%);
+ animation: slide 0.18s ease;
+}
+
+@keyframes slide {
+ from {
+ transform: translateX(24px);
+ opacity: 0;
+ }
+}
+
+.close {
+ position: absolute;
+ top: 0.6rem;
+ right: 0.8rem;
+ width: 2rem;
+ height: 2rem;
+ border-radius: 999px;
+ background: #f1f3ef;
+ font-size: 1.3rem;
+ line-height: 1;
+}
+
+.hero {
+ display: flex;
+ align-items: flex-end;
+ gap: 1rem;
+ padding: 1rem;
+ border-radius: 0.9rem;
+ background: radial-gradient(circle at 30% 20%, #ffffff, #eef1ec);
+}
+
+.heroFallback {
+ font-size: 7rem;
+ line-height: 1;
+ opacity: 0.5;
+}
+
+.native {
+ font-size: 1.6rem;
+ opacity: 0.8;
+}
+
+.detail h3 {
+ margin: 1rem 0 0.3rem;
+ font-size: 1.25rem;
+}
+
+.detail h3::first-letter {
+ text-transform: uppercase;
+}
+
+.todoNote {
+ margin: 0 0 0.6rem;
+ font-size: 0.85rem;
+ color: #8a5a00;
+}
+
+.facts {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0.3rem 0.9rem;
+ margin: 0.8rem 0;
+ font-size: 0.86rem;
+}
+
+.facts dt {
+ color: var(--muted);
+}
+
+.facts dd {
+ margin: 0;
+ word-break: break-all;
+}
+
+.family {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+ margin: 0.4rem 0 0.9rem;
+}
+
+.familyItem,
+.familyOn {
+ width: 2.6rem;
+ height: 2.6rem;
+ display: grid;
+ place-items: center;
+ border-radius: 0.5rem;
+ border: 1px solid var(--line);
+ background: #fff;
+ font-size: 1.4rem;
+}
+
+.familyOn {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent);
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin-bottom: 0.9rem;
+}
+
+.downloads {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.35rem 0.8rem;
+ font-size: 0.86rem;
+ margin-bottom: 1rem;
+}
+
+.downloads span {
+ color: var(--muted);
+}
+
+.keywords {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+}
+
+.keywords button {
+ padding: 0.2rem 0.55rem;
+ border-radius: 999px;
+ background: #f1f3ef;
+ font-size: 0.78rem;
+ color: #2c3a45;
+}
+
+.keywords button:hover {
+ background: #e2ece6;
+}
+
+@media (max-width: 640px) {
+ .toolbar {
+ position: static;
+ }
+ .row[role="group"][aria-label="Group"] {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ padding-bottom: 0.2rem;
+ }
+ .row[role="group"][aria-label="Group"] > * {
+ flex: none;
+ }
+ .controls select {
+ min-width: 0;
+ }
+ .controls label {
+ flex: 1 1 45%;
+ }
+ .count {
+ margin-left: 0;
+ width: 100%;
+ }
+}
+
+.swatch {
+ display: inline-block;
+ width: 0.95rem;
+ height: 0.95rem;
+ border-radius: 999px;
+ vertical-align: -0.15rem;
+ box-shadow: inset 0 0 0 1px rgb(0 0 0 / 15%);
+}
+
+.intro {
+ max-width: 52rem;
+ margin-bottom: 1.2rem;
+}
+
+.intro h2 {
+ margin: 0.2rem 0 0.5rem;
+ font-size: 1.9rem;
+}
+
+.intro p {
+ margin: 0;
+ color: #41505d;
+ line-height: 1.6;
+}
+
+.copy {
+ padding: 0.45rem 0.8rem;
+ border-radius: 0.5rem;
+ border: 1px solid #cfd5cc;
+ background: #fff;
+ color: #101418;
+ font-size: 0.86rem;
+}
+
+.copy:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
diff --git a/apps/logicsrc-web/src/app/openemoji/catalog/catalog.tsx b/apps/logicsrc-web/src/app/openemoji/catalog/catalog.tsx
new file mode 100644
index 0000000..c03ed7c
--- /dev/null
+++ b/apps/logicsrc-web/src/app/openemoji/catalog/catalog.tsx
@@ -0,0 +1,597 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { ReactNode } from "react";
+import styles from "./catalog.module.css";
+import { REPO_RAW, SET_URL } from "./set";
+
+/**
+ * The whole OpenEmoji set, filterable.
+ *
+ * Reads the set's own openemoji.json, which lists every emoji Unicode has,
+ * drawn or not (an entry without files is listed, not drawn), so this page is
+ * complete from the first glyph and fills in as the rest are drawn.
+ *
+ * Filters live in the query string, so a filtered view is a link.
+ */
+
+
+type Emoji = {
+ key: string;
+ char: string;
+ name: string;
+ group: string;
+ subgroup: string;
+ unicode: string;
+ keywords?: string[];
+ base?: string;
+ png?: string;
+ svg?: string;
+ webp?: string;
+};
+
+type Manifest = {
+ name: string;
+ version: string;
+ unicode: string;
+ license: string;
+ ai_model?: string;
+ sizes: number[];
+ webp_sizes?: number[];
+ coverage: { total: number; drawn: number };
+ emoji: Emoji[];
+};
+
+/** Key, swatch colour, label. Colour dots, not 🏻: a box with no emoji font is no swatch at all. */
+const TONES: Array<[string, string, string]> = [
+ ["1f3fb", "#f7d7c4", "light"],
+ ["1f3fc", "#dfb896", "medium-light"],
+ ["1f3fd", "#bb8e67", "medium"],
+ ["1f3fe", "#8d5e3f", "medium-dark"],
+ ["1f3ff", "#5b3a2c", "dark"]
+];
+const TONE_KEYS = new Set(TONES.map(([key]) => key));
+const tonesOf = (e: Emoji): string[] => e.key.split("-").filter((cp) => TONE_KEYS.has(cp));
+
+type Filters = {
+ q: string;
+ group: string;
+ sub: string;
+ /** "" = default (no tone), a tone key, or "all" for every variant. */
+ tone: string;
+ version: string;
+ status: "" | "drawn" | "todo";
+ sort: "unicode" | "name" | "newest";
+ size: number;
+};
+
+const DEFAULTS: Filters = { q: "", group: "", sub: "", tone: "", version: "", status: "", sort: "unicode", size: 56 };
+const PAGE = 360;
+
+function readFilters(): Filters {
+ const params = new URLSearchParams(window.location.search);
+ const size = Number(params.get("size"));
+ const status = params.get("status");
+ const sort = params.get("sort");
+ return {
+ q: params.get("q") ?? "",
+ group: params.get("group") ?? "",
+ sub: params.get("sub") ?? "",
+ tone: params.get("tone") ?? "",
+ version: params.get("v") ?? "",
+ status: status === "drawn" || status === "todo" ? status : "",
+ sort: sort === "name" || sort === "newest" ? sort : "unicode",
+ size: size >= 32 && size <= 128 ? size : DEFAULTS.size
+ };
+}
+
+function writeFilters(f: Filters, selected: string | null): void {
+ const params = new URLSearchParams();
+ if (f.q) params.set("q", f.q);
+ if (f.group) params.set("group", f.group);
+ if (f.sub) params.set("sub", f.sub);
+ if (f.tone) params.set("tone", f.tone);
+ if (f.version) params.set("v", f.version);
+ if (f.status) params.set("status", f.status);
+ if (f.sort !== "unicode") params.set("sort", f.sort);
+ if (f.size !== DEFAULTS.size) params.set("size", String(f.size));
+ if (selected) params.set("e", selected);
+ const query = params.toString();
+ window.history.replaceState(null, "", query ? `?${query}` : window.location.pathname);
+}
+
+const compareVersions = (a: string, b: string): number => {
+ const [a1 = 0, a2 = 0] = a.split(".").map(Number);
+ const [b1 = 0, b2 = 0] = b.split(".").map(Number);
+ return a1 - b1 || a2 - b2;
+};
+
+const codepoints = (key: string): string =>
+ key
+ .split("-")
+ .map((cp) => `U+${cp.toUpperCase()}`)
+ .join(" ");
+
+/** Lowercased haystack per entry, built once. */
+const haystackOf = (e: Emoji): string =>
+ [e.name, e.char, e.key, codepoints(e.key), e.group, e.subgroup, ...(e.keywords ?? [])].join(" ").toLowerCase();
+
+const fill = (template: string, size: number): string => template.replace("{size}", String(size));
+
+export function Catalog(): ReactNode {
+ const [manifest, setManifest] = useState(null);
+ const [error, setError] = useState(null);
+ const [filters, setFilters] = useState(DEFAULTS);
+ const [query, setQuery] = useState("");
+ const [shown, setShown] = useState(PAGE);
+ const [selected, setSelected] = useState(null);
+ const [ready, setReady] = useState(false);
+ const sentinel = useRef(null);
+
+ useEffect(() => {
+ const initial = readFilters();
+ setFilters(initial);
+ setQuery(initial.q);
+ setSelected(new URLSearchParams(window.location.search).get("e"));
+ setReady(true);
+ fetch(`${SET_URL}/openemoji.json`)
+ .then((response) => {
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return response.json() as Promise;
+ })
+ .then(setManifest)
+ .catch((reason: Error) => setError(reason.message));
+ }, []);
+
+ // The search box types freely; the filter follows a beat behind.
+ useEffect(() => {
+ const timer = window.setTimeout(() => setFilters((f) => (f.q === query ? f : { ...f, q: query })), 140);
+ return () => window.clearTimeout(timer);
+ }, [query]);
+
+ useEffect(() => {
+ if (ready) writeFilters(filters, selected);
+ }, [filters, selected, ready]);
+
+ useEffect(() => setShown(PAGE), [filters]);
+
+ const index = useMemo(() => {
+ if (!manifest) return null;
+ const byKey = new Map(manifest.emoji.map((e) => [e.key, e]));
+ const variants = new Map();
+ for (const e of manifest.emoji) if (e.base) variants.set(e.base, [...(variants.get(e.base) ?? []), e]);
+ const hay = new Map(manifest.emoji.map((e) => [e.key, haystackOf(e)]));
+ const order = new Map(manifest.emoji.map((e, i) => [e.key, i]));
+ const groups = [...new Set(manifest.emoji.map((e) => e.group))];
+ const versions = [...new Set(manifest.emoji.map((e) => e.unicode))].sort(compareVersions).reverse();
+ return { byKey, variants, hay, order, groups, versions };
+ }, [manifest]);
+
+ const set = useCallback((key: K, value: Filters[K]) => {
+ setFilters((f) => {
+ const next = { ...f, [key]: value };
+ if (key === "group") next.sub = "";
+ return next;
+ });
+ }, []);
+
+ /** Everything but the tone view: what the group and status counts are taken over. */
+ const matchesBase = useCallback(
+ (e: Emoji, f: Filters, skip: Partial> = {}): boolean => {
+ if (!index) return false;
+ if (!skip.group && f.group && e.group !== f.group) return false;
+ if (f.sub && e.subgroup !== f.sub) return false;
+ if (f.version && e.unicode !== f.version) return false;
+ if (!skip.status && f.status === "drawn" && !e.png) return false;
+ if (!skip.status && f.status === "todo" && e.png) return false;
+ if (f.q) {
+ const hay = index.hay.get(e.key) ?? "";
+ for (const word of f.q.toLowerCase().split(/\s+/).filter(Boolean)) if (!hay.includes(word)) return false;
+ }
+ return true;
+ },
+ [index]
+ );
+
+ /**
+ * The tone view, the way a keyboard does it: default shows the untoned
+ * glyphs; a tone shows each glyph in that tone where it has one (and as it
+ * is where it has none); "all" shows every variant.
+ */
+ const toneView = useCallback(
+ (list: Emoji[], tone: string): Emoji[] => {
+ if (!index || tone === "all") return list;
+ if (!tone) return list.filter((e) => tonesOf(e).length === 0);
+ const out: Emoji[] = [];
+ for (const e of list) {
+ const tones = tonesOf(e);
+ if (tones.length > 0) continue;
+ const swap = (index.variants.get(e.key) ?? []).find((v) => {
+ const t = tonesOf(v);
+ return t.length > 0 && t.every((x) => x === tone);
+ });
+ out.push(swap ?? e);
+ }
+ return out;
+ },
+ [index]
+ );
+
+ const results = useMemo(() => {
+ if (!manifest || !index) return [];
+ // Tone swaps come from variants the other filters may have excluded
+ // (a search for "thumbs" still wants 👍🏽), so filter the toneless view
+ // first and swap after, then re-check the status filter on the swap.
+ const base = manifest.emoji.filter((e) => matchesBase(e, { ...filters, status: "" }));
+ let list = toneView(base, filters.tone);
+ if (filters.status === "drawn") list = list.filter((e) => e.png);
+ if (filters.status === "todo") list = list.filter((e) => !e.png);
+ if (filters.sort === "name") list = [...list].sort((a, b) => a.name.localeCompare(b.name));
+ if (filters.sort === "newest")
+ list = [...list].sort(
+ (a, b) => compareVersions(b.unicode, a.unicode) || (index.order.get(a.key) ?? 0) - (index.order.get(b.key) ?? 0)
+ );
+ return list;
+ }, [manifest, index, filters, matchesBase, toneView]);
+
+ const groupCounts = useMemo(() => {
+ const counts = new Map();
+ if (!manifest) return counts;
+ const list = toneView(
+ manifest.emoji.filter((e) => matchesBase(e, filters, { group: true })),
+ filters.tone
+ );
+ for (const e of list) counts.set(e.group, (counts.get(e.group) ?? 0) + 1);
+ return counts;
+ }, [manifest, filters, matchesBase, toneView]);
+
+ const subgroups = useMemo(() => {
+ if (!manifest || !filters.group) return [];
+ return [...new Set(manifest.emoji.filter((e) => e.group === filters.group).map((e) => e.subgroup))];
+ }, [manifest, filters.group]);
+
+ // Render in pages as the reader scrolls: 4,000 tiles at once is a slow first paint.
+ useEffect(() => {
+ const node = sentinel.current;
+ if (!node) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) setShown((n) => n + PAGE);
+ },
+ { rootMargin: "800px" }
+ );
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [results.length]);
+
+ const current = selected && index ? (index.byKey.get(selected) ?? null) : null;
+
+ // Arrow keys walk the results while the detail panel is open; Escape closes it.
+ useEffect(() => {
+ if (!current) return;
+ const onKey = (event: KeyboardEvent): void => {
+ if (event.key === "Escape") setSelected(null);
+ if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
+ const at = results.findIndex((e) => e.key === current.key);
+ if (at === -1) return;
+ const next = results[at + (event.key === "ArrowRight" ? 1 : -1)];
+ if (next) {
+ event.preventDefault();
+ setSelected(next.key);
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [current, results]);
+
+ if (error) {
+ return The set could not be loaded ({error}).
;
+ }
+ if (!manifest || !index) {
+ return Loading the set…
;
+ }
+
+ const drawnPct = Math.round((manifest.coverage.drawn / manifest.coverage.total) * 100);
+ const active =
+ filters.q || filters.group || filters.sub || filters.tone || filters.version || filters.status || filters.sort !== "unicode";
+
+ return (
+
+
+
+
+ {manifest.coverage.drawn.toLocaleString()} of {manifest.coverage.total.toLocaleString()} drawn · Unicode{" "}
+ {manifest.unicode} · {manifest.license} · set {manifest.version}
+
+
+
+
+
setQuery(event.target.value)}
+ aria-label="Search emoji"
+ />
+
+
+ Subgroup
+ set("sub", e.target.value)} disabled={!filters.group}>
+ {filters.group ? "All subgroups" : "Pick a group"}
+ {subgroups.map((s) => (
+
+ {s.replace(/-/g, " ")}
+
+ ))}
+
+
+
+ Emoji version
+ set("version", e.target.value)}>
+ Any
+ {index.versions.map((v) => (
+
+ New in {v}
+
+ ))}
+
+
+
+ Status
+ set("status", e.target.value as Filters["status"])}>
+ Drawn and not yet
+ Drawn
+ Not drawn yet
+
+
+
+ Sort
+ set("sort", e.target.value as Filters["sort"])}>
+ Unicode order
+ Name A–Z
+ Newest first
+
+
+
+ Size {filters.size}px
+ set("size", Number(e.target.value))}
+ />
+
+
+
+
+ set("group", "")}
+ >
+ All groups
+
+ {index.groups.map((g) => (
+ set("group", filters.group === g ? "" : g)}
+ >
+ {g} {(groupCounts.get(g) ?? 0).toLocaleString()}
+
+ ))}
+
+
+
+ Skin tone
+ set("tone", "")}>
+ Default
+
+ {TONES.map(([key, swatch, label]) => (
+ set("tone", filters.tone === key ? "" : key)}
+ >
+
+
+ ))}
+ set("tone", filters.tone === "all" ? "" : "all")}
+ >
+ Every variant
+
+
+ {results.length.toLocaleString()} {results.length === 1 ? "emoji" : "emoji"}
+ {active ? (
+ {
+ setQuery("");
+ setFilters({ ...DEFAULTS, size: filters.size });
+ }}
+ >
+ Reset
+
+ ) : null}
+
+
+
+
+ {results.length === 0 ? (
+
Nothing matches. Try fewer words, or reset the filters.
+ ) : (
+
+ {results.slice(0, shown).map((e) => (
+
+ setSelected(e.key)}
+ title={e.png ? e.name : `${e.name} (not drawn yet)`}
+ >
+ {e.webp ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ {e.char}
+
+ )}
+ {filters.size >= 48 ? {e.name} : null}
+
+
+ ))}
+
+ )}
+ {shown < results.length ?
: null}
+
+ {current ? (
+
{
+ setQuery(word);
+ setSelected(null);
+ }}
+ onClose={() => setSelected(null)}
+ />
+ ) : null}
+
+ );
+}
+
+function Detail({
+ emoji,
+ variants,
+ base,
+ onSelect,
+ onKeyword,
+ onClose
+}: {
+ emoji: Emoji;
+ variants: Emoji[];
+ base: Emoji | null;
+ onSelect: (key: string) => void;
+ onKeyword: (word: string) => void;
+ onClose: () => void;
+}): ReactNode {
+ const family = base ? [base, ...variants] : variants.length ? [emoji, ...variants] : [];
+ const imgTag = ` `;
+ return (
+
+
+ ×
+
+
+ {emoji.png ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
{emoji.char}
+ )}
+
+ {emoji.char}
+
+
+ {emoji.name}
+ {!emoji.png ? Not drawn yet. The system emoji stands in until it is.
: null}
+
+ Key
+
+ {emoji.key}
+
+ Codepoints
+
+ {codepoints(emoji.key)}
+
+ Group
+
+ {emoji.group} › {emoji.subgroup.replace(/-/g, " ")}
+
+ Emoji version
+ {emoji.unicode}
+
+
+ {family.length > 1 ? (
+
+ {family.map((v) => (
+
onSelect(v.key)}
+ title={v.name}
+ >
+ {v.webp ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+ v.char
+ )}
+
+ ))}
+
+ ) : null}
+
+
+
+ Copy emoji
+
+
+ Copy key
+
+ {emoji.png ? (
+
+ Copy <img>
+
+ ) : null}
+
+
+ {emoji.png ? (
+
+
Download
+ {[512, 256, 128, 64].map((size) => (
+
+ PNG {size}
+
+ ))}
+ {emoji.svg ? (
+
+ SVG
+
+ ) : null}
+
+ ) : null}
+
+ {emoji.keywords?.length ? (
+
+ {emoji.keywords.map((word) => (
+ onKeyword(word)}>
+ {word}
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/logicsrc-web/src/app/openemoji/catalog/page.tsx b/apps/logicsrc-web/src/app/openemoji/catalog/page.tsx
new file mode 100644
index 0000000..e9a20ca
--- /dev/null
+++ b/apps/logicsrc-web/src/app/openemoji/catalog/page.tsx
@@ -0,0 +1,37 @@
+import Link from "next/link";
+import type { ReactNode } from "react";
+import type { Metadata } from "next";
+import { SiteShell } from "@/components/site-shell";
+import { Catalog } from "./catalog";
+import { REPO_URL } from "./set";
+import styles from "./catalog.module.css";
+
+export const metadata: Metadata = {
+ title: "OpenEmoji catalog · LogicSRC",
+ description:
+ "Every standard emoji in the OpenEmoji set, 3,963 in Emoji 18.0, drawn by gpt-image-2 as one family. Search by name or CLDR keyword, filter by group, skin tone, Emoji version and status, copy the character or download PNG and SVG.",
+ alternates: { canonical: "/openemoji/catalog" }
+};
+
+export default function OpenEmojiCatalogPage(): ReactNode {
+ return (
+
+
+
+
+ OpenEmoji reference set
+
+
The catalog
+
+ Every emoji Unicode lists, drawn as one set. Search by name or by the words people use
+ (“lol” finds 😂), narrow by group, skin tone or Emoji version, and open one for
+ its codepoints, its tone family and the files. Anything not drawn yet shows your
+ system's emoji in its place. The set, its fonts and every size are in{" "}
+ profullstack/openemoji .
+
+
+
+
+
+ );
+}
diff --git a/apps/logicsrc-web/src/app/openemoji/catalog/set.ts b/apps/logicsrc-web/src/app/openemoji/catalog/set.ts
new file mode 100644
index 0000000..cfb0b5e
--- /dev/null
+++ b/apps/logicsrc-web/src/app/openemoji/catalog/set.ts
@@ -0,0 +1,11 @@
+/**
+ * Where the OpenEmoji reference set lives. A plain module, not the client
+ * one: a server component importing a constant from a "use client" file gets
+ * a client reference, not the string.
+ */
+
+/** Previews and openemoji.json, served by this site. */
+export const SET_URL = "/openemoji/set";
+/** Full-size files and the fonts live in the set's repository. */
+export const REPO_RAW = "https://raw.githubusercontent.com/profullstack/openemoji/main";
+export const REPO_URL = "https://github.com/profullstack/openemoji";
diff --git a/apps/logicsrc-web/src/app/openemoji/page.tsx b/apps/logicsrc-web/src/app/openemoji/page.tsx
index 16a865b..89fdc2e 100644
--- a/apps/logicsrc-web/src/app/openemoji/page.tsx
+++ b/apps/logicsrc-web/src/app/openemoji/page.tsx
@@ -105,6 +105,11 @@ export default function OpenEmojiPage(): ReactNode {
Status: 0.1. Files first and made_by on the work, like{" "}
OpenWiki.
+
+
+ Browse the whole set: 3,963 emoji
+
+
@@ -113,7 +118,8 @@ export default function OpenEmojiPage(): ReactNode {
From the reference implementation: every glyph drawn by gpt-image-2 under one art
direction. The skin tones are edits of their base, which is why the two thumbs are the
- same thumb.
+ same thumb. The catalog has all of them, with
+ search and filters.
Where everything lives
+
+ Catalog: every emoji in the reference set, with
+ search, group, skin tone, version and status filters
+
Specification: the shape, eight rules, discovery, the
mapping from Mastodon, Slack, Discord and CLDR
diff --git a/docs/openemoji.md b/docs/openemoji.md
index eddc261..fda5d58 100644
--- a/docs/openemoji.md
+++ b/docs/openemoji.md
@@ -91,7 +91,7 @@ There are eight, and every one of them degrades rather than fails.
**3. A custom emoji's key starts with `x-`.** `x-` followed by lowercase letters, digits, `_` or `-`. It has no `char`, one or more `shortcodes` (without colons), a `name`, and `group: "Custom"` unless the set groups its own. A shortcode belongs to its set: two sets may both define `:shipit:`, and an app that loads both decides which wins. No `x-` key may be used for something Unicode has encoded; when Unicode encodes it, the set adds the standard key and may keep the custom one as an alias.
-**4. Files are named by key, and the descriptor says where.** An entry's `png` is a path that may contain `{size}`, filled in from the set's `sizes`; `svg` is a path; a set may add `webp`, `avif` or `apng` the same way. A reader picks the smallest size at least as large as the rendered size times the device pixel ratio, and the largest when none is. Fonts are listed once for the set with their colour format (`cbdt`, `sbix`, `colrv1`, `ot-svg`), because a browser picks among them by what it supports, not per emoji.
+**4. Files are named by key, and the descriptor says where.** An entry's `png` is a path that may contain `{size}`, filled in from the set's `sizes`; `svg` is a path; a set may add `webp`, `avif` or `apng` the same way. A reader picks the smallest size at least as large as the rendered size times the device pixel ratio, and the largest when none is. An entry with no file paths is listed but not drawn: a set may list every emoji Unicode has while it is still being drawn, and a reader treats such an entry as missing (rule 5). Fonts are listed once for the set with their colour format (`cbdt`, `sbix`, `colrv1`, `ot-svg`), because a browser picks among them by what it supports, not per emoji.
**5. Coverage is stated, not implied.** `coverage.total` is the number of standard emoji in the Unicode version the set names, `coverage.drawn` how many of them it has, and `coverage.missing` their keys. A reader that meets an emoji the set does not have falls back to the platform's own, never to a blank or a box. A set that draws only faces is a valid set; it only has to say so.
@@ -134,6 +134,8 @@ Importing from any of them is a fetch and a rename: shortcode to `x-` key, image
## Reference implementation
+The set it drew is browsable at [/openemoji/catalog](/openemoji/catalog): every emoji, searchable by name and CLDR keyword, filterable by group, skin tone, Emoji version and status. Its files are in [profullstack/openemoji](https://github.com/profullstack/openemoji).
+
`emoji` in [profullstack/cli-tools](https://github.com/profullstack/cli-tools) draws a complete set with an image model and packs it as above:
```sh