# Files changed · PR \#8 · screen/col

[View on GitCafe](https://git.cafe/screen/col/pull/8/diffs)

Repository: [screen/col](https://git.cafe/screen/col)

Visibility: public

[Improve library discovery and add CI checks](https://git.cafe/screen/col/pull/8.md)

Version: 1

Head commit: 7c12dfcceb664ade48fd286a06110bcb050b23da

Base commit: 99728b1779c85e8d232f7250c869698ef3e10be5

Comparison base: 5f031c35ef4692da6628c861bdbb59f00c7a1191

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## \.github/ISSUE_TEMPLATE/library-request.yml

Status: modified; +7; −0

```diff
@@ -48,6 +48,13 @@
       placeholder: React, Vue, TypeScript
     validations:
       required: true
+  - type: input
+    id: use-cases
+    attributes:
+      label: Use cases
+      placeholder: Dashboards, Rapid Prototyping
+    validations:
+      required: true
   - type: checkboxes
     id: checks
     attributes:
```

## \.github/workflows/ci.yml

Status: added; +17; −0

```diff
@@ -1,0 +1,17 @@
+name: Verify
+
+on:
+  push:
+  pull_request:
+
+jobs:
+  check:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-node@v4
+        with:
+          node-version: '20.9'
+          cache: npm
+      - run: npm ci
+      - run: npm run check
```

## CONTRIBUTING.md

Status: modified; +1; −1

````diff
@@ -18,7 +18,7 @@
 npm run dev
 ```
 
-Run `npm run build` before submitting changes.
+Run `npm run check` before submitting changes.
 
 ## Add a library
 
````

## PRODUCT.md

Status: modified; +2; −2

```diff
@@ -8,11 +8,11 @@
 
 ## Users
 
-Frontend developers and product designers comparing UI libraries for an active project, plus contributors maintaining the directory.
+Frontend developers and product designers finding UI libraries for an active project, plus contributors maintaining the directory.
 
 ## Product Purpose
 
-Col is a community-maintained directory for finding, filtering, and comparing UI libraries without opening many disconnected sources.
+Col is a community-maintained directory for finding, filtering, sorting, and saving UI libraries without opening many disconnected sources.
 
 ## Positioning
 
```

## README.md

Status: modified; +6; −4

````diff
@@ -31,7 +31,7 @@
 
 ## What Col does
 
-Col organizes UI libraries by category, stack, and use case. Search from the homepage, then compare matching libraries in the directory.
+Col organizes UI libraries by category, stack, and use case. Search from the homepage, then filter, sort, and save matching libraries in the directory.
 
 - Search by name, keyword, category, stack, or use case.
 - Filter libraries without leaving the directory.
@@ -57,7 +57,7 @@
 Before opening a pull request:
 
 ```bash
+npm run check
-npm run build
 ```
 
 ## Request something
@@ -74,7 +74,7 @@
 - what it provides and who it helps;
 - its supported stacks;
 - the closest Col category and use cases;
-- confirmation that it is maintained and publicly accessible.
+- confirmation that it is publicly accessible.
 
 Search [existing libraries](data/libraries.ts), [issues](https://github.com/screen-gd/Col/issues), and [pull requests](https://github.com/screen-gd/Col/pulls) first.
 
@@ -105,7 +105,7 @@
 3. Reuse the existing category, stack, and use-case values when possible.
 4. Keep the description factual and short.
 5. Confirm the URL points to the official project.
+6. Run `npm run check`.
-6. Run `npm run build`.
 7. Open a pull request using the provided template.
 
 ```ts
@@ -163,6 +163,8 @@
 app/                  Routes, layout, and global styles
 components/           Search, filters, cards, header, and shared UI
 data/libraries.ts     The curated library registry
+lib/                  Shared helpers and route metadata
+tests/                Registry and discovery checks
 public/brand/         Col brand assets
 public/hero-logos/    Library artwork used by the homepage
 .github/              Issue forms and pull request guidance
````

## app/docs/page.tsx

Status: modified; +1; −1

```diff
@@ -59,7 +59,7 @@
 
             <DocSection id="pull-requests" title="Open a pull request">
               <p>Fork the repository, create a focused branch, and keep unrelated changes out. Library additions belong in <code>data/libraries.ts</code> with a unique kebab-case slug, canonical URL, factual description, and existing taxonomy values where possible.</p>
-              <pre><code>{`npm install\nnpm run dev\nnpm run build`}</code></pre>
+              <pre><code>{`npm install\nnpm run dev\nnpm run check`}</code></pre>
               <p>Explain what changed, why it changed, and how you verified it. Include screenshots for visible interface changes.</p>
               <DocLink href="https://github.com/screen-gd/Col/blob/main/CONTRIBUTING.md">Read the contribution guide</DocLink>
             </DocSection>
```

## app/globals.css

Status: modified; +9; −92

```diff
@@ -179,26 +179,10 @@
   color: #111;
 }
 
-.gooey-surface {
-  background: #090909;
-  color: #fff;
-  box-shadow: inset 0 0 0 1px rgba(255,255,255,.14);
-}
-.hero-search-surface { box-shadow: none; }
-.gooey-input-text { color: #fff; }
-.gooey-key { border-color: rgba(255,255,255,.12); color: rgba(255,255,255,.75); }
-
-html.light .gooey-surface {
-  background: #fff;
-  color: #111;
-  box-shadow: inset 0 0 0 1px rgba(17,17,17,.16);
-}
-html.light .hero-search-surface { box-shadow: none; }
-html.light .gooey-input-text { color: #111; }
-html.light .gooey-key { border-color: rgba(17,17,17,.14); color: rgba(17,17,17,.65); }
 html.light .hero-stat { background: transparent !important; border-color: rgba(17,17,17,.14) !important; }
 
 .hero-search { border-color: rgba(255,255,255,.15); background: #090909; color: #fff; }
+.hero-search:focus-within { outline: 1px solid #fff; outline-offset: 2px; }
 .hero-search-icon { color: rgba(255,255,255,.72); transition: transform 220ms ease; }
 .hero-search:focus-within .hero-search-icon { transform: translateX(2px); }
 .hero-search-input { color: #fff; }
@@ -225,6 +209,7 @@
 }
 
 html.light .hero-search { border-color: rgba(17,17,17,.16); background: #fff; color: #111; }
+html.light .hero-search:focus-within { outline-color: #111; }
 html.light .hero-search-icon { color: rgba(17,17,17,.68); }
 html.light .hero-search-input { color: #111; }
 html.light .hero-search-hints { color: rgba(17,17,17,.48); }
@@ -402,28 +387,6 @@
   html.light .site-header-mobile-menu a:hover, html.light .site-header-mobile-menu a[aria-current="page"] { background: rgba(17,17,17,.06); }
 }
 
-html.light .gooey-filter-layer { filter: none !important; }
-html.light .gooey-orb { display: none !important; }
-
-html.light [data-slot="select-content"] {
-  border-color: rgba(17,17,17,.14);
-  background: #fff;
-  color: #111;
-  box-shadow: none;
-}
-
-html.dark [data-slot="select-content"] { box-shadow: none; }
-html.dark [data-slot="select-item"][data-highlighted],
-html.dark [data-slot="select-item"]:focus {
-  background: #191919;
-  color: #fff;
-}
-
-html.light [data-slot="select-item"][data-highlighted] {
-  background: #f0f0ed;
-  color: #111;
-}
-
 .popular-filter-button {
   border-color: rgba(255,255,255,.18);
   background: #050505;
@@ -497,9 +460,9 @@
 .coss-menu-item:focus,
 .coss-menu-item:focus-visible,
 .coss-menu-item[data-highlighted] {
-  background: transparent;
+  background: #303030;
   color: #fff;
+  outline: 1px solid #fff;
-  outline: none;
 }
 
 html.light .coss-input,
@@ -512,9 +475,9 @@
 html.light .coss-menu-item:focus,
 html.light .coss-menu-item:focus-visible,
 html.light .coss-menu-item[data-highlighted] {
-  background: transparent;
+  background: #e8e8e5;
   color: #111;
-  outline: none;
+  outline: 1px solid #111;
 }
 
 @keyframes coss-menu-in {
@@ -529,11 +492,11 @@
 
 .filter-tab { color: rgba(255,255,255,.72); }
 .filter-tab:hover { background: rgba(255,255,255,.06); color: #fff; }
+.filter-tab[aria-pressed="true"] { border-color: rgba(255,255,255,.24); background: #fff; color: #080808; }
-.filter-tab[data-state="active"] { border-color: rgba(255,255,255,.24); background: #fff; color: #080808; }
 
 html.light .filter-tab { color: rgba(17,17,17,.68); }
 html.light .filter-tab:hover { background: rgba(17,17,17,.05); color: #111; }
-html.light .filter-tab[data-state="active"] { border-color: #111; background: #111; color: #fff; }
+html.light .filter-tab[aria-pressed="true"] { border-color: #111; background: #111; color: #fff; }
 
 .library-card { border-color: rgba(255,255,255,.14); background: #050505; color: #fff; }
 .library-card:hover { border-color: rgba(255,255,255,.3); background: #090909; }
@@ -544,7 +507,7 @@
 html.light .library-card { border-color: rgba(17,17,17,.14); background: #fff; color: #111; }
 html.light .library-card:hover { border-color: rgba(17,17,17,.3); background: #fcfcfb; }
 html.light .library-muted { color: rgba(17,17,17,.7); }
-html.light .library-subtle { color: rgba(17,17,17,.52); }
+html.light .library-subtle { color: rgba(17,17,17,.65); }
 html.light .library-chip { border-color: rgba(17,17,17,.12); background: rgba(17,17,17,.045); color: rgba(17,17,17,.72); }
 
 html.light [class~="text-white"] { color: #111; }
@@ -588,26 +551,6 @@
   filter: blur(3px) brightness(.85) saturate(0);
 }
 
-.hero-background-card--mid {
-  opacity: 0.62;
-  filter: blur(1.5px) brightness(0.94) saturate(0);
-}
-
-.hero-background-card--far {
-  opacity: 0.4;
-  filter: blur(4px) brightness(0.82) saturate(0);
-}
-
-@keyframes star-border-movement-bottom {
-  0% { transform: translate(0%, 0%); opacity: 1; }
-  100% { transform: translate(-100%, 0%); opacity: 0; }
-}
-
-@keyframes star-border-movement-top {
-  0% { transform: translate(0%, 0%); opacity: 1; }
-  100% { transform: translate(100%, 0%); opacity: 0; }
-}
-
 /* Entrance */
 @keyframes fade-up {
   from {
@@ -628,23 +571,7 @@
 .delay-2 {
   animation-delay: 0.24s;
 }
-.delay-3 {
-  animation-delay: 0.36s;
-}
 
-/* Infinite marquee */
-@keyframes marquee {
-  from {
-    transform: translateX(0);
-  }
-  to {
-    transform: translateX(-50%);
-  }
-}
-.animate-marquee {
-  animation: marquee 60s linear infinite;
-}
-
 :focus-visible:not(input) {
   outline: 1px solid rgba(255, 255, 255, 0.7);
   outline-offset: 4px;
@@ -680,16 +607,6 @@
     scroll-behavior: auto !important;
   }
 }
-.marquee-mask {
-  mask-image: linear-gradient(
-    to right,
-    transparent,
-    black 12%,
-    black 88%,
-    transparent
-  );
-}
-
 @theme inline {
   --animate-rainbow: rainbow var(--speed, 2s) infinite linear;
   --color-color-5: var(--color-5);
```

## app/layout.tsx

Status: modified; +2; −2

```diff
@@ -19,7 +19,7 @@
 
 export const metadata: Metadata = {
   title: "Col — Collection of Libraries",
-  description: "We curate all the best libraries, so you don't have to.",
+  description: "Browse UI libraries by category, stack, and use case.",
   icons: { icon: "/brand/col-mark.png" },
   keywords: [
     "ui libraries",
@@ -39,7 +39,7 @@
   return (
     <html lang="en" className="dark" suppressHydrationWarning>
       <head>
-        <script dangerouslySetInnerHTML={{ __html: `try{const t=localStorage.getItem('col:theme');const l=t==='light'||(!t&&matchMedia('(prefers-color-scheme: light)').matches);document.documentElement.classList.toggle('light',l);document.documentElement.classList.toggle('dark',!l)}catch{}` }} />
+        <script dangerouslySetInnerHTML={{ __html: `let t;try{t=localStorage.getItem('col:theme')}catch{}const l=t==='light'||(t!=='dark'&&matchMedia('(prefers-color-scheme: light)').matches);document.documentElement.classList.toggle('light',l);document.documentElement.classList.toggle('dark',!l)` }} />
       </head>
       <body
         className={`${inter.variable} ${pixelifySans.variable} ${geistMono.variable} min-h-screen font-sans`}
```

## app/libraries/page.tsx

Status: modified; +7; −3

```diff
@@ -1,15 +1,19 @@
 import { DirectoryExplorer } from "@/components/DirectoryExplorer";
 import { Header } from "@/components/Header";
+import { SiteFooter } from "@/components/SiteFooter";
+import { firstQuery } from "@/lib/directory";
 
+export default async function LibrariesPage({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
-export default async function LibrariesPage({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
-  const { q = "" } = await searchParams;
+  const { q } = await searchParams;
+  const initialQuery = firstQuery(q);
 
   return (
     <>
       <Header />
       <main className="w-full max-w-full overflow-x-hidden">
+        <DirectoryExplorer initialQuery={initialQuery} />
-        <DirectoryExplorer initialQuery={q} />
       </main>
+      <SiteFooter />
     </>
   );
 }
```

## components/AsciiFluid.tsx

Status: deleted; +0; −126

```diff
@@ -1,126 +1,0 @@
-"use client";
-
-import { useEffect, useRef } from "react";
-
-const RAMP = " .·:;-~=+*x#%@";
-const CELL = 16;
-
-// Coolors palette: #45A29E (deep) -> #66FCF1 (bright)
-const DEEP: [number, number, number] = [69, 162, 158];
-const BRIGHT: [number, number, number] = [102, 252, 241];
-
-/**
- * ASCII fluid background (21st.dev style).
- * A monospace character field with an ambient wave; the cursor injects
- * energy that ripples and decays like fluid. Dependency-free canvas.
- */
-export function AsciiFluid({ className = "" }: { className?: string }) {
-  const canvasRef = useRef<HTMLCanvasElement>(null);
-
-  useEffect(() => {
-    const canvas = canvasRef.current;
-    const parent = canvas?.parentElement;
-    const ctx = canvas?.getContext("2d");
-    if (!canvas || !parent || !ctx) return;
-
-    let raf = 0;
-    let cols = 0;
-    let rows = 0;
-    let field = new Float32Array(0);
-    let t = 0;
-    const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
-    const mouse = { x: -9999, y: -9999 };
-
-    const resize = () => {
-      const rect = parent.getBoundingClientRect();
-      canvas.width = rect.width * dpr;
-      canvas.height = rect.height * dpr;
-      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
-      ctx.font = `${CELL * 0.85}px ui-monospace, SFMono-Regular, monospace`;
-      ctx.textAlign = "center";
-      ctx.textBaseline = "middle";
-      cols = Math.ceil(rect.width / CELL);
-      rows = Math.ceil(rect.height / CELL);
-      field = new Float32Array(cols * rows);
-    };
-
-    const onMove = (e: MouseEvent) => {
-      const rect = canvas.getBoundingClientRect();
-      mouse.x = e.clientX - rect.left;
-      mouse.y = e.clientY - rect.top;
-    };
-
-    const onLeave = () => {
-      mouse.x = -9999;
-      mouse.y = -9999;
-    };
-
-    const render = () => {
-      t += 0.02;
-      ctx.clearRect(0, 0, cols * CELL, rows * CELL);
-
-      // Inject energy around the cursor — a soft radial splat.
-      if (mouse.x > -999) {
-        const mc = mouse.x / CELL;
-        const mr = mouse.y / CELL;
-        const R = 6;
-        const y0 = Math.max(0, Math.floor(mr - R));
-        const y1 = Math.min(rows, Math.ceil(mr + R));
-        const x0 = Math.max(0, Math.floor(mc - R));
-        const x1 = Math.min(cols, Math.ceil(mc + R));
-        for (let y = y0; y < y1; y++) {
-          for (let x = x0; x < x1; x++) {
-            const d = Math.hypot(x - mc, y - mr);
-            if (d < R) {
-              const i = y * cols + x;
-              field[i] = Math.min(1, field[i] + (1 - d / R) * 0.4);
-            }
-          }
-        }
-      }
-
-      for (let y = 0; y < rows; y++) {
-        for (let x = 0; x < cols; x++) {
-          const i = y * cols + x;
-          field[i] *= 0.94; // fluid decay
-
-          // Ambient drifting wave keeps the field alive without input.
-          const ambient =
-            0.08 + 0.06 * Math.sin(x * 0.22 + t) * Math.cos(y * 0.28 - t * 0.8);
-          const v = Math.min(1, Math.max(0, ambient + field[i]));
-
-          const ch = RAMP[Math.min(RAMP.length - 1, (v * RAMP.length) | 0)];
-          if (ch === " ") continue;
-
-          const r = (DEEP[0] + (BRIGHT[0] - DEEP[0]) * v) | 0;
-          const g = (DEEP[1] + (BRIGHT[1] - DEEP[1]) * v) | 0;
-          const b = (DEEP[2] + (BRIGHT[2] - DEEP[2]) * v) | 0;
-          ctx.fillStyle = `rgba(${r},${g},${b},${(0.18 + v * 0.7).toFixed(3)})`;
-          ctx.fillText(ch, x * CELL + CELL / 2, y * CELL + CELL / 2);
-        }
-      }
-
-      raf = requestAnimationFrame(render);
-    };
-
-    resize();
-    render();
-    window.addEventListener("resize", resize);
-    window.addEventListener("mousemove", onMove);
-    document.addEventListener("mouseleave", onLeave);
-    return () => {
-      cancelAnimationFrame(raf);
-      window.removeEventListener("resize", resize);
-      window.removeEventListener("mousemove", onMove);
-      document.removeEventListener("mouseleave", onLeave);
-    };
-  }, []);
-
-  return (
-    <canvas
-      ref={canvasRef}
-      aria-hidden
-      className={`pointer-events-none absolute inset-0 h-full w-full ${className}`}
-    />
-  );
-}
```

## components/CategoryBento.tsx

Status: deleted; +0; −85

```diff
@@ -1,85 +1,0 @@
-"use client";
-
-import {
-  Activity,
-  ArrowUpRight,
-  BarChart3,
-  Box,
-  Cuboid,
-  ImageIcon,
-  PanelsTopLeft,
-  Palette,
-  type LucideIcon,
-} from "lucide-react";
-import { CATEGORIES, libraries, type Category } from "@/data/libraries";
-import GlareHover from "./GlareHover";
-
-const CATEGORY_ICONS: Record<Category, LucideIcon> = {
-  "Component Library": Cuboid,
-  "Animation & Motion": Activity,
-  "Templates & Blocks": PanelsTopLeft,
-  Icons: ImageIcon,
-  "Charts & Data Viz": BarChart3,
-  "3D & WebGL": Box,
-  "CSS Framework": Palette,
-};
-
-interface CategoryBentoProps {
-  active: Category | null;
-  onSelect: (category: Category | null) => void;
-}
-
-export function CategoryBento({ active, onSelect }: CategoryBentoProps) {
-  return (
-    <div className="overflow-x-auto pb-3">
-      <div className="grid min-w-[1240px] grid-cols-7 gap-4">
-        {CATEGORIES.map((category) => {
-          const matches = libraries.filter((library) => library.category === category);
-          const isActive = active === category;
-          const Icon = CATEGORY_ICONS[category];
-
-          return (
-            <GlareHover
-              key={category}
-              width="100%"
-              height="170px"
-              background={isActive ? "#111111" : "#050505"}
-              borderColor={isActive ? "rgba(255,255,255,.42)" : "rgba(255,255,255,.14)"}
-              borderRadius="12px"
-              glareColor="#ffffff"
-              glareOpacity={0.08}
-              glareSize={180}
-              className="group"
-            >
-              <button
-                type="button"
-                onClick={() => {
-                  onSelect(isActive ? null : category);
-                  document.getElementById("directory")?.scrollIntoView({
-                    behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
-                  });
-                }}
-                aria-pressed={isActive}
-                className="relative z-10 flex h-full w-full flex-col justify-between p-5 text-left"
-              >
-                <div className="flex items-start justify-between text-white/75">
-                  <Icon className="size-5" strokeWidth={1.5} aria-hidden />
-                  <ArrowUpRight className="size-4 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" aria-hidden />
-                </div>
-                <div>
-                  <strong className="block text-2xl tracking-[-0.04em] text-white tabular-nums">
-                    {matches.length.toString().padStart(2, "0")}
-                  </strong>
-                  <h3 className="mt-1 text-sm leading-tight font-semibold text-white">{category}</h3>
-                  <p className="mt-2 truncate text-[11px] text-white/50">
-                    {matches.slice(0, 3).map((library) => library.name).join(" · ") || "More soon"}
-                  </p>
-                </div>
-              </button>
-            </GlareHover>
-          );
-        })}
-      </div>
-    </div>
-  );
-}
```

## components/DirectoryExplorer.tsx

Status: modified; +35; −42

```diff
@@ -1,30 +1,20 @@
 "use client";
 
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
 import { Heart, SearchX } from "lucide-react";
 import {
   libraries,
   type Category,
-  type Library,
   type Stack,
   type UseCase,
 } from "@/data/libraries";
 import { Button } from "@/components/ui/button";
+import { filterLibraries, nextSaved, readSaved } from "@/lib/directory";
 import { FilterBar, FilterDropdown } from "./FilterBar";
 import { LibraryCard } from "./LibraryCard";
 
 const SAVED_LIBRARIES_KEY = "col:saved-libraries";
-
-function matchesQuery(library: Library, tokens: string[]): boolean {
-  return tokens.every((token) => [
-    library.name,
-    library.description,
-    library.category,
-    ...library.stacks,
-    ...library.useCases,
-    ...(library.tags ?? []),
-  ].join(" ").toLowerCase().includes(token));
-}
+const validSlugs = new Set(libraries.map(({ slug }) => slug));
 
 export function DirectoryExplorer({ initialQuery = "" }: { initialQuery?: string }) {
   const [query, setQuery] = useState(initialQuery);
@@ -34,41 +24,42 @@
   const [saved, setSaved] = useState<Set<string>>(new Set());
   const [showSaved, setShowSaved] = useState(false);
   const [sort, setSort] = useState<"curated" | "name">("curated");
+  const searchRef = useRef<HTMLInputElement>(null);
 
   useEffect(() => {
+    try { setSaved(readSaved(localStorage.getItem(SAVED_LIBRARIES_KEY), validSlugs)); } catch { /* Session state remains available. */ }
+    const sync = (event: StorageEvent) => {
+      if (event.key === SAVED_LIBRARIES_KEY || event.key === null) setSaved(readSaved(event.key === null ? null : event.newValue, validSlugs));
+    };
+    window.addEventListener("storage", sync);
+    return () => window.removeEventListener("storage", sync);
+  }, []);
+
+  useEffect(() => {
+    const shortcut = (event: KeyboardEvent) => {
+      if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
+        event.preventDefault();
+        searchRef.current?.focus();
+      }
+    };
+    window.addEventListener("keydown", shortcut);
-    try {
+    return () => window.removeEventListener("keydown", shortcut);
-      const stored: unknown = JSON.parse(localStorage.getItem(SAVED_LIBRARIES_KEY) ?? "[]");
-      if (Array.isArray(stored)) setSaved(new Set(stored.filter((slug): slug is string => typeof slug === "string")));
-    } catch {
-      localStorage.removeItem(SAVED_LIBRARIES_KEY);
-    }
   }, []);
 
-  const results = useMemo(() => {
-    const tokens = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
-    return libraries.filter((library) =>
-      (tokens.length === 0 || matchesQuery(library, tokens)) &&
-      (category === null || library.category === category) &&
+  const results = useMemo(() => filterLibraries(libraries, query, category, stacks, useCases, sort), [query, category, stacks, useCases, sort]);
-      (stacks.length === 0 || stacks.every((stack) => library.stacks.includes(stack))) &&
-      (useCases.length === 0 || useCases.every((useCase) => library.useCases.includes(useCase))),
-    );
-  }, [query, category, stacks, useCases]);
 
+  const visibleResults = showSaved ? results.filter(({ slug }) => saved.has(slug)) : results;
-  const visibleResults = (showSaved ? results.filter(({ slug }) => saved.has(slug)) : results)
-    .toSorted((a, b) => sort === "name" ? a.name.localeCompare(b.name) : 0);
 
   const toggleSaved = (slug: string) => {
-    setSaved((current) => {
-      const next = new Set(current);
-      if (next.has(slug)) next.delete(slug);
-      else next.add(slug);
-      try {
+    let stored: string | null | undefined;
+    try { stored = localStorage.getItem(SAVED_LIBRARIES_KEY); } catch { /* Use session state. */ }
+    const next = nextSaved(saved, stored, slug, validSlugs);
-        localStorage.setItem(SAVED_LIBRARIES_KEY, JSON.stringify([...next]));
+    try {
+      localStorage.setItem(SAVED_LIBRARIES_KEY, JSON.stringify([...next]));
+    } catch {
+      // Keep the selection for this session when storage is unavailable.
+    }
+    setSaved(next);
-      } catch {
-        // Keep the selection for this session when storage is unavailable.
-      }
-      return next;
-    });
   };
 
   const clearFilters = () => {
@@ -76,6 +67,7 @@
     setCategory(null);
     setStacks([]);
     setUseCases([]);
+    searchRef.current?.focus();
   };
 
   return (
@@ -86,7 +78,7 @@
           <p className="theme-muted mt-2 text-sm">Search and filter the complete Col library directory.</p>
         </div>
         <div className="flex items-center gap-3">
-          <span className="theme-muted text-sm tabular-nums">{visibleResults.length} libraries</span>
+          <span role="status" aria-live="polite" className="theme-muted text-sm tabular-nums">{visibleResults.length} {visibleResults.length === 1 ? "library" : "libraries"}{visibleResults.length === 0 ? " found" : ""}</span>
           <FilterDropdown
             label={sort === "curated" ? "Curated order" : "Name A–Z"}
             value={sort}
@@ -102,6 +94,7 @@
 
       <FilterBar
         query={query}
+        searchRef={searchRef}
         onQueryChange={setQuery}
         activeCategory={category}
         activeStacks={stacks}
```

## components/FilterBar.tsx

Status: modified; +9; −10

```diff
@@ -1,5 +1,6 @@
 "use client";
 
+import type { RefObject } from "react";
 import { ChevronDown, RotateCcw, Search } from "lucide-react";
 import { CATEGORIES, STACKS, USE_CASES, type Category, type Stack, type UseCase } from "@/data/libraries";
 import { Button } from "@/components/ui/button";
@@ -11,7 +12,6 @@
   DropdownMenuTrigger,
 } from "@/components/ui/dropdown-menu";
 import { Input } from "@/components/ui/input";
-import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
 
 interface FilterDropdownProps {
   label: string;
@@ -48,6 +48,7 @@
   activeStacks: Stack[];
   activeUseCases: UseCase[];
   query: string;
+  searchRef: RefObject<HTMLInputElement | null>;
   onQueryChange: (query: string) => void;
   onCategoryChange: (category: Category | null) => void;
   onStackChange: (stack: Stack | null) => void;
@@ -55,7 +56,7 @@
   onClearAll: () => void;
 }
 
-export function FilterBar({ activeCategory, activeStacks, activeUseCases, query, onQueryChange, onCategoryChange, onStackChange, onUseCaseChange, onClearAll }: FilterBarProps) {
+export function FilterBar({ activeCategory, activeStacks, activeUseCases, query, searchRef, onQueryChange, onCategoryChange, onStackChange, onUseCaseChange, onClearAll }: FilterBarProps) {
   const hasFilters = query.trim() !== "" || activeCategory !== null || activeStacks.length > 0 || activeUseCases.length > 0;
 
   return (
@@ -63,8 +64,8 @@
       <div className="grid gap-2 lg:grid-cols-[minmax(320px,1fr)_180px_210px_auto]">
         <label className="relative block">
           <Search className="theme-muted pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2" aria-hidden />
+          <Input ref={searchRef} value={query} onChange={(event) => onQueryChange(event.target.value)} placeholder="Search libraries, tags, or keywords..." aria-label="Search libraries" className="coss-input h-10 w-full pr-16 pl-10" />
+          <kbd className="theme-muted theme-border pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 rounded border px-2 py-1 text-[10px]">Ctrl/⌘ K</kbd>
-          <Input value={query} onChange={(event) => onQueryChange(event.target.value)} placeholder="Search libraries, tags, or keywords..." aria-label="Search libraries" className="coss-input h-10 w-full pr-16 pl-10" />
-          <kbd className="theme-muted theme-border pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 rounded border px-2 py-1 text-[10px]">Ctrl K</kbd>
         </label>
 
         <FilterDropdown
@@ -83,12 +84,10 @@
         {hasFilters && <Button type="button" variant="outline" onClick={onClearAll} className="coss-trigger"><RotateCcw aria-hidden /> Clear</Button>}
       </div>
 
+      <div role="group" aria-label="Category" className="flex flex-wrap justify-start gap-1">
+        <button type="button" aria-pressed={activeCategory === null} onClick={() => onCategoryChange(null)} className="filter-tab flex-none rounded-md border border-transparent px-3 py-1.5">All</button>
+        {CATEGORIES.map((category) => <button type="button" key={category} aria-pressed={activeCategory === category} onClick={() => onCategoryChange(category)} className="filter-tab flex-none rounded-md border border-transparent px-3 py-1.5">{category}</button>)}
+      </div>
-      <Tabs value={activeCategory ?? "all"} onValueChange={(value) => onCategoryChange(value === "all" ? null : value as Category)}>
-        <TabsList className="h-auto! w-full flex-wrap justify-start gap-1 rounded-none bg-transparent p-0">
-          <TabsTrigger value="all" className="filter-tab flex-none rounded-md px-3 py-1.5">All</TabsTrigger>
-          {CATEGORIES.map((category) => <TabsTrigger key={category} value={category} className="filter-tab flex-none rounded-md px-3 py-1.5">{category}</TabsTrigger>)}
-        </TabsList>
-      </Tabs>
     </div>
   );
 }
```

## components/GlareHover.tsx

Status: deleted; +0; −108

```diff
@@ -1,108 +1,0 @@
-import React, { useRef } from 'react';
-
-interface GlareHoverProps {
-  width?: string;
-  height?: string;
-  background?: string;
-  borderRadius?: string;
-  borderColor?: string;
-  children?: React.ReactNode;
-  glareColor?: string;
-  glareOpacity?: number;
-  glareAngle?: number;
-  glareSize?: number;
-  transitionDuration?: number;
-  playOnce?: boolean;
-  className?: string;
-  style?: React.CSSProperties;
-}
-
-const GlareHover: React.FC<GlareHoverProps> = ({
-  width = '500px',
-  height = '500px',
-  background = '#000',
-  borderRadius = '10px',
-  borderColor = '#333',
-  children,
-  glareColor = '#ffffff',
-  glareOpacity = 0.5,
-  glareAngle = -45,
-  glareSize = 250,
-  transitionDuration = 650,
-  playOnce = false,
-  className = '',
-  style = {}
-}) => {
-  const hex = glareColor.replace('#', '');
-  let rgba = glareColor;
-  if (/^[\dA-Fa-f]{6}$/.test(hex)) {
-    const r = parseInt(hex.slice(0, 2), 16);
-    const g = parseInt(hex.slice(2, 4), 16);
-    const b = parseInt(hex.slice(4, 6), 16);
-    rgba = `rgba(${r}, ${g}, ${b}, ${glareOpacity})`;
-  } else if (/^[\dA-Fa-f]{3}$/.test(hex)) {
-    const r = parseInt(hex[0] + hex[0], 16);
-    const g = parseInt(hex[1] + hex[1], 16);
-    const b = parseInt(hex[2] + hex[2], 16);
-    rgba = `rgba(${r}, ${g}, ${b}, ${glareOpacity})`;
-  }
-
-  const overlayRef = useRef<HTMLDivElement | null>(null);
-
-  const animateIn = () => {
-    const el = overlayRef.current;
-    if (!el) return;
-
-    el.style.transition = 'none';
-    el.style.backgroundPosition = '-100% -100%, 0 0';
-    el.style.transition = `${transitionDuration}ms ease`;
-    el.style.backgroundPosition = '100% 100%, 0 0';
-  };
-
-  const animateOut = () => {
-    const el = overlayRef.current;
-    if (!el) return;
-
-    if (playOnce) {
-      el.style.transition = 'none';
-      el.style.backgroundPosition = '-100% -100%, 0 0';
-    } else {
-      el.style.transition = `${transitionDuration}ms ease`;
-      el.style.backgroundPosition = '-100% -100%, 0 0';
-    }
-  };
-
-  const overlayStyle: React.CSSProperties = {
-    position: 'absolute',
-    inset: 0,
-    background: `linear-gradient(${glareAngle}deg,
-        hsla(0,0%,0%,0) 60%,
-        ${rgba} 70%,
-        hsla(0,0%,0%,0) 100%)`,
-    backgroundSize: `${glareSize}% ${glareSize}%, 100% 100%`,
-    backgroundRepeat: 'no-repeat',
-    backgroundPosition: '-100% -100%, 0 0',
-    pointerEvents: 'none'
-  };
-
-  return (
-    <div
-      className={`relative grid place-items-center overflow-hidden border cursor-pointer ${className}`}
-      style={{
-        width,
-        height,
-        background,
-        borderRadius,
-        borderColor,
-        ...style
-      }}
-      onMouseEnter={animateIn}
-      onMouseLeave={animateOut}
-    >
-      <div ref={overlayRef} style={overlayStyle} />
-      {children}
-    </div>
-  );
-};
-
-export default GlareHover;
```

## components/Header.tsx

Status: modified; +6; −3

```diff
@@ -115,7 +115,7 @@
           >
             <span ref={highlightRef} className="site-header-link-highlight" aria-hidden="true" />
             {links.map(([label, href]) => {
-              const active = pathname === href || pathname.startsWith(`${href}/`);
+              const active = !href.includes("#") && (pathname === href || pathname.startsWith(`${href}/`));
               return (
                 <a
                   key={href}
@@ -165,8 +165,11 @@
 
         {menuOpen && (
           <nav id="site-header-mobile-menu" aria-label="Mobile primary" className="site-header-mobile-menu">
+            {links.map(([label, href]) => <a key={href} href={href} onClick={() => setMenuOpen(false)} aria-current={!href.includes("#") && (pathname === href || pathname.startsWith(`${href}/`)) ? "page" : undefined}>{label}</a>)}
+            <a href="/#hero-search" onClick={() => {
+              setMenuOpen(false);
-            {links.map(([label, href]) => <a key={href} href={href} aria-current={pathname === href || pathname.startsWith(`${href}/`) ? "page" : undefined}>{label}</a>)}
-            <a href="/#hero-search">Search libraries</a>
+              if (pathname === "/") document.querySelector<HTMLInputElement>("#hero-search input")?.focus();
+            }}>Search libraries</a>
           </nav>
         )}
       </div>
```

## components/HeroLogoTrail.tsx

Status: deleted; +0; −99

```diff
@@ -1,99 +1,0 @@
-"use client";
-
-import { useEffect, useRef } from "react";
-
-const DOMAINS = [
-  "ui.shadcn.com",
-  "reactbits.dev",
-  "21st.dev",
-  "magicui.design",
-  "ui.aceternity.com",
-  "motion.dev",
-  "www.radix-ui.com",
-  "mui.com",
-  "mantine.dev",
-  "chakra-ui.com",
-  "lucide.dev",
-  "gsap.com",
-];
-
-const favicon = (domain: string) =>
-  `https://www.google.com/s2/favicons?domain=${domain}&sz=128`;
-
-export function HeroLogoTrail() {
-  const trailRef = useRef<HTMLDivElement>(null);
-
-  useEffect(() => {
-    const trail = trailRef.current;
-    const hero = trail?.parentElement;
-    if (
-      !trail ||
-      !hero ||
-      !matchMedia(
-        "(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)",
-      ).matches
-    )
-      return;
-
-    const logos = [...trail.querySelectorAll<HTMLImageElement>("img")];
-    const safeZones = [
-      ...hero.querySelectorAll<HTMLElement>("[data-trail-safe]"),
-    ];
-    let index = 0;
-    let previousX = -100;
-    let previousY = -100;
-
-    const showLogo = (event: PointerEvent) => {
-      if (Math.hypot(event.clientX - previousX, event.clientY - previousY) < 72)
-        return;
-
-      const overContent = safeZones.some((element) => {
-        const rect = element.getBoundingClientRect();
-        return (
-          event.clientX >= rect.left - 32 &&
-          event.clientX <= rect.right + 32 &&
-          event.clientY >= rect.top - 32 &&
-          event.clientY <= rect.bottom + 32
-        );
-      });
-      if (overContent) return;
-
-      const bounds = hero.getBoundingClientRect();
-      const logo = logos[index++ % logos.length];
-      previousX = event.clientX;
-      previousY = event.clientY;
-      logo.style.left = `${event.clientX - bounds.left}px`;
-      logo.style.top = `${event.clientY - bounds.top}px`;
-      logo.animate(
-        [
-          { opacity: 0, transform: "translate(-50%, -50%) scale(.45) rotate(-8deg)" },
-          { opacity: 0.95, offset: 0.12, transform: "translate(-50%, -50%) scale(1) rotate(0deg)" },
-          { opacity: 0.85, offset: 0.68, transform: "translate(-50%, calc(-50% - 14px)) scale(.96) rotate(3deg)" },
-          { opacity: 0, transform: "translate(-50%, calc(-50% - 38px)) scale(.82) rotate(8deg)" },
-        ],
-        { duration: 1800, easing: "cubic-bezier(.16, 1, .3, 1)" },
-      );
-    };
-
-    hero.addEventListener("pointermove", showLogo);
-    return () => hero.removeEventListener("pointermove", showLogo);
-  }, []);
-
-  return (
-    <div
-      ref={trailRef}
-      aria-hidden
-      className="hero-logo-trail pointer-events-none absolute inset-0 z-[1] overflow-hidden"
-    >
-      {DOMAINS.map((domain) => (
-        // eslint-disable-next-line @next/next/no-img-element
-        <img
-          key={domain}
-          src={favicon(domain)}
-          alt=""
-          className="absolute size-12 object-contain opacity-0 drop-shadow-[0_12px_18px_rgba(0,0,0,0.65)]"
-        />
-      ))}
-    </div>
-  );
-}
```

## components/LibraryCard.tsx

Status: modified; +2; −2

```diff
@@ -48,8 +48,8 @@
 
       <CardFooter className="library-subtle theme-border flex justify-between border-t px-5 pt-3 text-[10px]">
         <div className="flex items-center gap-4">
-          <span className="flex items-center gap-1.5"><Layers3 className="size-3" aria-hidden />{library.stacks.length} stacks</span>
-          <span className="flex items-center gap-1.5"><Target className="size-3" aria-hidden />{library.useCases.length} uses</span>
+          <span className="flex items-center gap-1.5"><Layers3 className="size-3" aria-hidden />{library.stacks.length} {library.stacks.length === 1 ? "stack" : "stacks"}</span>
+          <span className="flex items-center gap-1.5"><Target className="size-3" aria-hidden />{library.useCases.length} {library.useCases.length === 1 ? "use" : "uses"}</span>
         </div>
         <Button asChild size="icon-xs" variant="ghost" className="library-subtle rounded-md hover:opacity-80">
           <a href={library.url} target="_blank" rel="noopener noreferrer" aria-label={`Open ${library.name}`}><ArrowUpRight aria-hidden /></a>
```

## components/LibraryExplorer.tsx

Status: modified; +3; −4

```diff
@@ -2,7 +2,7 @@
 
 import { useEffect, useState } from "react";
 import Image from "next/image";
-import { BookOpen, GitFork, Layers3, LayoutGrid } from "lucide-react";
+import { BookOpen, Layers3, LayoutGrid } from "lucide-react";
 import { CATEGORIES, STACKS, libraries } from "@/data/libraries";
 import { SearchBar } from "./SearchBar";
 import { Button } from "@/components/ui/button";
@@ -88,7 +88,6 @@
     { value: `${libraries.length}`, label: "Curated libraries", Icon: BookOpen },
     { value: `${CATEGORIES.length}`, label: "Categories", Icon: LayoutGrid },
     { value: `${STACKS.length}`, label: "Tech stacks", Icon: Layers3 },
-    { value: "100%", label: "Open source", Icon: GitFork },
   ];
 
   return (
@@ -113,7 +112,7 @@
             the libraries that developers love, <span className="hero-accent-text">all in one place</span>
           </h1>
           <p className="hero-description mt-5 max-w-[560px] text-base leading-[1.55] sm:text-[17px]">
+            Discover UI libraries, components, and tools by stack and use case.
-            Discover, explore, and use the best open source libraries, components, and tools in one place. Curated by developers for developers.
           </p>
           <div className="animate-fade-up delay-1 mt-8 flex w-full justify-start">
             <SearchBar
@@ -140,7 +139,7 @@
         </div>
       </div>
 
+      <div data-trail-safe className="hero-stats absolute bottom-10 left-1/2 z-10 hidden w-[min(1408px,calc(100%-3rem))] -translate-x-1/2 grid-cols-2 md:grid md:grid-cols-3">
-      <div data-trail-safe className="hero-stats absolute bottom-10 left-1/2 z-10 hidden w-[min(1408px,calc(100%-3rem))] -translate-x-1/2 grid-cols-2 md:grid md:grid-cols-4">
         {stats.map(({ value, label, Icon }, index) => (
           <div key={label} className={`hero-stat flex items-center gap-4 px-5 py-2 ${index > 0 ? "border-l" : ""}`}>
             <span className="hero-stat-icon grid size-12 shrink-0 place-items-center rounded-xl border">
```

## components/Marquee.tsx

Status: deleted; +0; −22

```diff
@@ -1,22 +1,0 @@
-import { libraries } from "@/data/libraries";
-
-export function Marquee() {
-  const names = libraries.map((l) => l.name);
-  // Rendered twice side-by-side so translateX(-50%) loops seamlessly.
-  const row = [...names, ...names];
-
-  return (
-    <div className="marquee-mask overflow-hidden border-y border-white/[0.06] py-5">
-      <div className="animate-marquee flex w-max items-center">
-        {row.map((name, i) => (
-          <span key={i} className="flex items-center">
-            <span className="px-6 font-mono text-xs tracking-[0.25em] whitespace-nowrap text-zinc-600 uppercase">
-              {name}
-            </span>
-            <span aria-hidden className="size-1 rounded-full bg-zinc-800" />
-          </span>
-        ))}
-      </div>
-    </div>
-  );
-}
```

## components/RevealText.tsx

Status: deleted; +0; −53

```diff
@@ -1,53 +1,0 @@
-"use client";
-
-import { useRef } from "react";
-import gsap from "gsap";
-import { ScrollTrigger } from "gsap/ScrollTrigger";
-import { useGSAP } from "@gsap/react";
-
-gsap.registerPlugin(ScrollTrigger);
-
-interface RevealTextProps {
-  text: string;
-  className?: string;
-}
-
-/** Words scrub from dim to fully lit as the user scrolls through them. */
-export function RevealText({ text, className = "" }: RevealTextProps) {
-  const ref = useRef<HTMLParagraphElement>(null);
-
-  useGSAP(
-    () => {
-      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
-      const words = ref.current?.querySelectorAll("[data-word]");
-      if (!words?.length) return;
-      gsap.fromTo(
-        words,
-        { opacity: 0.12 },
-        {
-          opacity: 1,
-          stagger: 0.06,
-          ease: "none",
-          scrollTrigger: {
-            trigger: ref.current,
-            start: "top 82%",
-            end: "bottom 45%",
-            scrub: 0.6,
-          },
-        },
-      );
-    },
-    { scope: ref },
-  );
-
-  return (
-    <p ref={ref} className={className}>
-      {text.split(" ").map((word, i) => (
-        <span key={i} data-word className="inline-block">
-          {word}
-          {i < text.split(" ").length - 1 ? "\u00A0" : ""}
-        </span>
-      ))}
-    </p>
-  );
-}
```

[Next page](https://git.cafe/screen/col/pull/8/diffs?after=ImNvbXBvbmVudHMvUmV2ZWFsVGV4dC50c3gi&expectedVersion=1&format=markdown)
