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

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

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

Visibility: public

[Refresh library discovery, navigation, and shader rendering](https://git.cafe/screen/col/pull/14.md)

Version: 1

Head commit: 30cc5956cab64886a00b92d7df8c1b202e1d2c1a

Base commit: 34fe961379acf920c254daa19f9660725b227852

Comparison base: 9eb826d3ee52535c62f9bff6a241186bc73d6a5a

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

## \.agents/skills/motion-catalogue/SKILL.md

Status: added; +88; −0

```diff
@@ -1,0 +1,88 @@
+---
+name: motion-catalogue
+description: |
+  Domain navigator for motion, animation, 3D, and generative media skills.
+  Routes to the right skill for GSAP, Framer Motion, Three.js, Lottie,
+  shaders, generative art, video, and AI media generation.
+triggers:
+  - "motion skills"
+  - "animation skill"
+  - "GSAP skill"
+  - "Three.js skill"
+  - "Framer Motion skill"
+  - "Lottie skill"
+  - "shader skill"
+  - "generative art skill"
+  - "WebGL skill"
+  - "video generation skill"
+  - "creative coding skill"
+---
+
+# motion-catalogue
+
+Domain navigator for motion design, animation, 3D, shaders, and generative media.
+Activate when the user asks for a skill in one of these areas.
+
+→ For the full catalogue across all domains: use `design-catalogue`.
+
+Source: https://github.com/podo/design-agent-skills/tree/main/skills/motion-catalogue
+
+## When to use
+
+Activate when the user:
+- Asks about animation libraries (GSAP, Framer Motion, Lottie, CSS animations)
+- Wants 3D (Three.js, WebGPU, shaders, WebGL, R3F)
+- Needs generative art (p5.js, algorithmic art, creative coding)
+- Wants video/media generation (Remotion, fal.ai, generative media)
+
+## Motion & Animation
+
+| Skill | Best for |
+|-------|----------|
+| `gsap-skills` | Official GSAP: 8 skills — including core, timeline, ScrollTrigger, Flip, Draggable, SplitText |
+| `framer-motion-skills` | 6 Framer Motion skills: core, Next.js, variants, scroll, gestures, layout |
+| `motion-design-skill` | Official LottieFiles: timing, easing, choreography, Disney 12 principles |
+| `animate-skill` | Emil-inspired Next.js/React patterns: hover, toast, text reveal, modals |
+| `animate-css-skill` | Animate.css v4 + scroll triggers + RTL + prefers-reduced-motion |
+| `css-animation-skill` | Scrapes live app design language → standalone animation file |
+| `wiggle-claude-skill` | Animates SVG logos → Lottie JSON → GIF + MP4 |
+| `emilkowalski-skill` | Animation decision framework, easing curves, interaction principles |
+| `claudedesignskills` | 22-skill bundle: Three.js, GSAP, R3F, Framer, Babylon, A-Frame, PixiJS, Rive, Lottie — https://github.com/freshtechbro/claudedesignskills |
+| `hyperframes` | "Write HTML → render video": GSAP/CSS/Lottie/Three.js + TTS (HeyGen) |
+
+## Generative Art & Creative Coding
+
+| Skill | Best for |
+|-------|----------|
+| `algorithmic-art` | Generative art with p5.js, flow fields, particle systems |
+| `p5js-hermes` | p5.js creative coding: noise, particles, GLSL, audio-reactive |
+| `remotion` | Programmatic video with React |
+
+## 3D, Shaders & Media
+
+| Skill | Best for |
+|-------|----------|
+| `shader-dev` | GLSL shaders: ray marching, fluid simulation, WebGL effects |
+| `cloudai-threejs` | Three.js 3D scenes, animations, WebGL |
+| `webgpu-claude-skill` | Three.js TSL, node materials, GPU compute, WGSL — r183+ |
+| `threejs-ecs-ts` | Three.js + Entity Component Systems + TypeScript |
+| `threejs-claude-skill-package` | 24 skills: WebGL, WebGPU, R3F, physics, IFC/BIM architecture |
+| `fal-ai-skills` | fal.ai image/video and 3D model generation |
+| `generative-media-skills` | 41 workflows: Midjourney v7, Flux Kontext, Kling 3.0, Veo3, Suno audio |
+
+## Workload → Library quick routing
+
+Use this when selecting the best UI/animation/3D library for the task:
+
+- Simple UI micro-interactions, hover, toast, reveal, modals → Framer Motion (`motion` / `framer-motion`) or CSS / Animate.css
+- Scroll-driven storytelling, pinning, scrubbing, SplitText → GSAP + ScrollTrigger
+- Lottie / SVG logo animation → Lottie / wiggle workflow
+- Interactive 3D in React, product configurator → React Three Fiber + drei
+- Vanilla / max-control WebGL, non-React → Three.js vanilla
+- Games / heavy 3D physics → Babylon.js
+- Lightweight background 3D / Vanta-style effects → lightweight-3d-effects / Vanta
+- 2D canvas particles, charts → PixiJS / p5.js
+- Custom shaders, fluid, ray-marching → GLSL ShaderMaterial / shader-dev
+- Quick designer prototype, no code → Spline
+
+Full upstream catalogue: https://github.com/podo/design-agent-skills
```

## app/Alexandria/page.tsx

Status: added; +175; −0

```diff
@@ -1,0 +1,175 @@
+import type { Metadata } from "next";
+import Image from "next/image";
+import { Header } from "@/components/Header";
+import { LibraryLogo } from "@/components/LibraryLogo";
+import { libraries } from "@/data/libraries";
+import { BookOpen, ArrowUpRight } from "lucide-react";
+
+export const metadata: Metadata = {
+  title: "Alexandria — A Col Concept",
+  description:
+    "Alexandria held every scroll. Col holds every UI library. A minimal hero concept preserving Col brand colors.",
+};
+
+const SHELF = [
+  { slug: "radix-ui", name: "Radix UI" },
+  { slug: "gsap", name: "GSAP" },
+  { slug: "mantine", name: "Mantine" },
+  { slug: "base-ui", name: "Base UI" },
+  { slug: "flowbite", name: "Flowbite" },
+  { slug: "motion", name: "Motion" },
+  { slug: "lucide", name: "Lucide" },
+  { slug: "magic-ui", name: "Magic UI" },
+] as const;
+
+const GLOW_BACKDROP = [
+  { src: "/hero-logos/21st-dev-glow.png", name: "21st.dev" },
+  { src: "/hero-logos/aceternity-glow.png", name: "Aceternity UI" },
+  { src: "/hero-logos/mobbin-glow.png", name: "Mobbin" },
+  { src: "/hero-logos/shadcn-glow.png", name: "shadcn/ui" },
+  { src: "/hero-logos/react-bits-glow.png", name: "React Bits" },
+] as const;
+
+const FEATURED_SLUGS = ["shadcn-ui", "magic-ui", "gsap"] as const;
+
+function hostname(url: string): string {
+  try {
+    return new URL(url).hostname.replace(/^www\./, "");
+  } catch {
+    return url;
+  }
+}
+
+export default function AlexandriaPage() {
+  const featured = FEATURED_SLUGS.map((slug) =>
+    libraries.find((library) => library.slug === slug),
+  ).filter((library) => library !== undefined);
+
+  return (
+    <>
+      <Header />
+      <main className="w-full max-w-full overflow-x-hidden">
+        <section className="hero-wash relative flex min-h-svh flex-col items-center justify-center overflow-hidden px-6 pt-28 pb-20 text-center sm:px-8">
+          <div className="hero-registers pointer-events-none" aria-hidden />
+          <div
+            aria-hidden
+            className="pointer-events-none absolute inset-x-0 top-20 z-0 hidden justify-center gap-10 opacity-25 blur-[1px] sm:flex"
+          >
+            {GLOW_BACKDROP.map(({ src, name }) => (
+              <span key={name} className="relative block size-20 shrink-0">
+                <Image src={src} alt="" fill sizes="80px" className="object-contain" />
+              </span>
+            ))}
+          </div>
+
+          <div className="relative z-10 mx-auto w-full max-w-3xl">
+            <p className="animate-fade-up inline-flex items-center gap-2 rounded-full border theme-border theme-panel px-4 py-1.5 font-mono text-[11px] tracking-[0.18em] uppercase theme-muted">
+              <BookOpen className="size-3.5" aria-hidden />
+              Alexandria — a Col concept
+            </p>
+
+            <h1 className="hero-headline animate-fade-up delay-1 mx-auto mt-6 max-w-2xl text-[clamp(2.75rem,6vw,4.5rem)] leading-[1.02] font-bold tracking-[-0.03em]">
+              The ancient library,
+              <span className="mt-1 block font-pixel-square text-[clamp(1.5rem,3.4vw,2.5rem)] leading-[1.15] font-bold tracking-normal hero-pixel-line">
+                rebuilt for interfaces
+              </span>
+            </h1>
+
+            <p className="hero-description animate-fade-up delay-2 mx-auto mt-5 max-w-xl text-base leading-[1.6] font-medium sm:text-[17px]">
+              Alexandria held every scroll. Col holds every UI library —
+              minimal, curated, and open.
+            </p>
+
+            <div className="animate-fade-up delay-2 mt-8 flex flex-wrap items-center justify-center gap-3">
+              <a href="/libraries" className="hero-cta hero-cta-primary min-h-[52px] px-8">
+                Browse the collection
+                <ArrowUpRight className="size-4" aria-hidden />
+              </a>
+              <a href="/docs" className="hero-cta hero-cta-secondary h-[52px] px-7">
+                How Col curates
+              </a>
+            </div>
+
+            <div className="animate-fade-up delay-3 mt-12 flex flex-col items-center gap-4">
+              <p className="hero-popular font-pixel text-[11px] tracking-[0.18em] uppercase">
+                Shelved in this hall
+              </p>
+              <ul
+                aria-label="Sample of libraries shelved in Col"
+                className="flex max-w-full flex-wrap items-center justify-center gap-2.5"
+              >
+                {SHELF.map(({ slug, name }) => (
+                  <li
+                    key={slug}
+                    title={name}
+                    className="theme-panel theme-border flex items-center gap-2.5 rounded-full border py-1.5 pr-4 pl-1.5"
+                  >
+                    <span className="relative block size-7 shrink-0 overflow-hidden rounded-full">
+                      <Image
+                        src={`/hero-logos/background/${slug}.png`}
+                        alt={`${name} logo`}
+                        fill
+                        sizes="28px"
+                        className="object-contain"
+                      />
+                    </span>
+                    <span className="theme-muted text-xs font-medium whitespace-nowrap">
+                      {name}
+                    </span>
+                  </li>
+                ))}
+              </ul>
+            </div>
+          </div>
+
+          <div className="relative z-10 mx-auto mt-12 w-full max-w-4xl">
+            <ul
+              aria-label="Featured libraries, styled like the Col directory"
+              className="grid grid-cols-1 gap-3 text-left sm:grid-cols-3"
+            >
+              {featured.map((library) => (
+                <li
+                  key={library.slug}
+                  className="library-card flex flex-col gap-3 rounded-xl border p-5"
+                >
+                  <div className="flex items-center gap-3">
+                    <span className="theme-border grid size-9 shrink-0 place-items-center rounded-lg border bg-white/[0.035]">
+                      <LibraryLogo url={library.url} name={library.name} size={24} />
+                    </span>
+                    <span className="min-w-0">
+                      <span className="block truncate text-[15px] font-semibold tracking-[-0.01em]">
+                        {library.name}
+                      </span>
+                      <span className="library-subtle block truncate text-[11px]">
+                        {hostname(library.url)}
+                      </span>
+                    </span>
+                  </div>
+                  <p className="library-muted line-clamp-2 text-[13px] leading-5">
+                    {library.description}
+                  </p>
+                  <div className="flex flex-wrap gap-1.5">
+                    <span className="library-chip rounded-md border px-2 py-0.5 text-[10px]">
+                      {library.category}
+                    </span>
+                    {library.stacks.slice(0, 2).map((stack) => (
+                      <span
+                        key={stack}
+                        className="library-chip rounded-md border px-2 py-0.5 text-[10px]"
+                      >
+                        {stack}
+                      </span>
+                    ))}
+                  </div>
+                </li>
+              ))}
+            </ul>
+            <p className="theme-muted mt-4 text-center text-xs">
+              Styled like the directory cards — same logos, chips, and brand base.
+            </p>
+          </div>
+        </section>
+      </main>
+    </>
+  );
+}
```

## app/contributors/page.tsx

Status: modified; +2; −2

```diff
@@ -46,7 +46,7 @@
   return (
     <>
       <Header />
-      <main className="w-full max-w-full overflow-x-hidden">
+      <main className="contributors-page w-full max-w-full overflow-x-hidden">
         <div className="mx-auto min-h-screen max-w-7xl px-5 pt-36 pb-28 sm:px-8 sm:pt-44">
         <div className="flex flex-col gap-8 sm:flex-row sm:items-end sm:justify-between">
           <div>
@@ -57,7 +57,7 @@
         </div>
 
         {contributors.length ? (
-          <ul className="theme-border mt-20 grid grid-cols-2 border-t sm:grid-cols-3 lg:grid-cols-5">
+          <ul className={`theme-border mt-20 grid grid-cols-2 border-t ${contributors.length > 2 ? "sm:grid-cols-3 lg:grid-cols-4" : "sm:grid-cols-2"}`}>
             {contributors.map((contributor) => (
               <li key={contributor.id} className="theme-border border-b p-4 sm:p-6 lg:border-r">
                 <a href={contributor.html_url} target="_blank" rel="noopener noreferrer" className="group block" aria-label={`View ${contributor.login} on GitHub`}>
```

## app/docs/layout.tsx

Status: modified; +2; −0

```diff
@@ -1,5 +1,6 @@
 import { DocsSidebar } from "@/components/DocsSidebar";
 import { Header } from "@/components/Header";
+import { SiteFooter } from "@/components/SiteFooter";
 import { ArrowUpRight } from "lucide-react";
 import { CATEGORIES, libraries } from "@/data/libraries";
 
@@ -32,6 +33,7 @@
           </div>
         </aside>
       </main>
+      <SiteFooter />
     </>
   );
 }
```

## app/globals.css

Status: modified; +31; −0

```diff
@@ -412,6 +412,37 @@
   box-shadow: none;
 }
 
+#library-search { scroll-margin-top: 80px; }
+.filter-mobile-toggle, .docs-mobile-toggle { display: none; }
+.library-save[aria-pressed="true"] { color: #8be5ef; }
+html.light .library-save[aria-pressed="true"] { color: #0800ff; }
+.contributors-page { background: #10181a; }
+html.light .contributors-page { background: #f7f7f5; }
+.footer-link { min-height: 44px; align-items: center; }
+.site-header-menu, .site-header-search, .site-header-github { min-height: 44px; }
+.coss-trigger { min-height: 44px; }
+.docs-sidebar-search, .docs-sidebar-link { min-height: 44px; }
+
+@media (max-width: 1023px) {
+  .docs-mobile-toggle { display: flex; }
+  .docs-sidebar-content { display: none; }
+  .docs-sidebar-content-open { display: block; }
+  .filter-mobile-toggle { display: flex; }
+  .filter-advanced { display: none; }
+  .filter-advanced-open { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; }
+  .filter-advanced-open > :last-child { grid-column: 1 / -1; }
+  .filter-categories { display: none; }
+  .filter-categories-open { display: block; }
+}
+@media (max-width: 760px) {
+  .search-key-hint, .site-header-search kbd { display: none; }
+  .site-header-menu { min-width: 44px; }
+  .site-header-mobile-menu a { min-height: 44px; display: flex; align-items: center; }
+  .directory-section { padding-top: 100px; }
+  .filter-advanced-open { grid-template-columns: 1fr; }
+  .filter-advanced-open > :last-child { grid-column: auto; }
+}
+
 .site-header--docs .site-header-link-highlight { border-color: rgba(136,213,223,.14); background: #141c1f; box-shadow: none; }
 
 .site-header-left, .site-header-actions, .site-header-brand, .site-header-links, .site-header-link, .site-header-search, .site-header-github, .site-header-menu {
```

## app/libraries/page.tsx

Status: modified; +2; −0

```diff
@@ -1,5 +1,6 @@
 import { DirectoryExplorer } from "@/components/DirectoryExplorer";
 import { Header } from "@/components/Header";
+import { SiteFooter } from "@/components/SiteFooter";
 
 export default async function LibrariesPage({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
   const { q = "" } = await searchParams;
@@ -10,6 +11,7 @@
       <main className="w-full max-w-full overflow-x-hidden">
         <DirectoryExplorer initialQuery={q} />
       </main>
+      <SiteFooter />
     </>
   );
 }
```

## app/not-found.tsx

Status: added; +18; −0

```diff
@@ -1,0 +1,18 @@
+import Link from "next/link";
+import { Header } from "@/components/Header";
+import { SiteFooter } from "@/components/SiteFooter";
+
+export default function NotFound() {
+  return (
+    <>
+      <Header />
+      <main className="docs-page-shell flex min-h-screen flex-col items-center justify-center px-5 text-center">
+        <p className="docs-accent text-sm font-semibold">404</p>
+        <h1 className="theme-text mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">Page not found</h1>
+        <p className="theme-muted mt-4">The page you requested does not exist.</p>
+        <Link href="/" className="hero-cta hero-cta-primary mt-8 min-h-11">Back to home</Link>
+      </main>
+      <SiteFooter />
+    </>
+  );
+}
```

## components/DirectoryExplorer.tsx

Status: modified; +4; −4

```diff
@@ -90,7 +90,7 @@
           <p className="theme-muted mt-2 text-sm">Search and filter the complete Col library directory.</p>
         </div>
         <div className="flex flex-wrap items-center gap-3">
-          <span className="theme-muted whitespace-nowrap text-sm tabular-nums">{visibleResults.length} libraries</span>
+          <span className="theme-muted whitespace-nowrap text-sm tabular-nums">{visibleResults.length} {visibleResults.length === 1 ? "library" : "libraries"}</span>
           <FilterDropdown
             label={sort === "curated" ? "Curated order" : "Name A–Z"}
             value={sort}
@@ -98,7 +98,7 @@
             onValueChange={(value) => setSort(value as "curated" | "name")}
             className="w-40"
           />
-          <Button type="button" variant="outline" onClick={() => setShowSaved((current) => !current)} aria-pressed={showSaved} className="theme-control hover:opacity-80">
+          <Button type="button" variant="outline" onClick={() => setShowSaved((current) => !current)} aria-pressed={showSaved} className="theme-control min-h-11 hover:opacity-80">
             <Heart fill={showSaved ? "currentColor" : "none"} aria-hidden /> Saved {saved.size}
           </Button>
         </div>
@@ -130,8 +130,8 @@
       ) : (
         <div className="theme-border mt-6 flex flex-col items-center border border-dashed py-24 text-center">
           {showSaved ? <Heart className="theme-muted size-7" aria-hidden /> : <SearchX className="theme-muted size-7" aria-hidden />}
-          <p className="theme-text mt-5 font-medium">{showSaved ? "No saved libraries yet" : "Nothing matches that search"}</p>
-          <p className="theme-muted mt-1.5 text-sm">Try another keyword or clear the filters.</p>
+          <p className="theme-text mt-5 font-medium">{showSaved ? (saved.size === 0 ? "No saved libraries yet" : "No saved libraries match") : "Nothing matches that search"}</p>
+          <p className="theme-muted mt-1.5 text-sm">{showSaved ? (saved.size === 0 ? "Leave Saved to browse the directory, then tap the heart on any library to save it here." : "Clear the filters to see your saved libraries.") : "Try another keyword or clear the filters."}</p>
         </div>
       )}
     </section>
```

## components/DocsSidebar.tsx

Status: modified; +7; −2

```diff
@@ -1,9 +1,9 @@
 "use client";
 
-import { useCallback, useEffect, useRef } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
 import { usePathname } from "next/navigation";
 import Link from "next/link";
+import { ChevronDown, Search } from "lucide-react";
-import { Search } from "lucide-react";
 
 const getStarted = [
   ["/docs", "Overview"],
@@ -18,6 +18,7 @@
 
 export function DocsSidebar() {
   const pathname = usePathname();
+  const [open, setOpen] = useState(false);
   const linksRef = useRef<HTMLDivElement>(null);
   const highlightRef = useRef<HTMLSpanElement>(null);
   const pendingLinkRef = useRef<HTMLAnchorElement>(null);
@@ -64,12 +65,15 @@
 
   return (
     <nav aria-label="Documentation pages" className="docs-sidebar rounded-xl border p-4">
+      <button type="button" className="docs-mobile-toggle theme-text min-h-11 w-full items-center justify-between text-sm font-medium" aria-expanded={open} onClick={() => setOpen((current) => !current)}>Documentation menu <ChevronDown className={`size-4 ${open ? "rotate-180" : ""}`} aria-hidden /></button>
+      <div className={`docs-sidebar-content ${open ? "docs-sidebar-content-open" : ""}`}>
       <a href="/libraries#library-search" className="docs-sidebar-search mb-6 flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
         <Search className="size-4" aria-hidden /> Search libraries
       </a>
       <div
         ref={linksRef}
         className="relative grid gap-6 sm:grid-cols-2 lg:grid-cols-1"
+        onClick={(event) => { if ((event.target as HTMLElement).closest("a")) setOpen(false); }}
         onMouseLeave={restoreActiveHighlight}
         onBlur={(event) => {
           if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) restoreActiveHighlight();
@@ -89,6 +93,7 @@
           </ul>
         </div>
       </div>
+      </div>
     </nav>
   );
 }
```

## components/FilterBar.tsx

Status: modified; +14; −11

```diff
@@ -1,5 +1,6 @@
 "use client";
 
+import { useState } 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";
@@ -56,18 +57,19 @@
 }
 
 export function FilterBar({ activeCategory, activeStacks, activeUseCases, query, onQueryChange, onCategoryChange, onStackChange, onUseCaseChange, onClearAll }: FilterBarProps) {
+  const [filtersOpen, setFiltersOpen] = useState(false);
   const hasFilters = query.trim() !== "" || activeCategory !== null || activeStacks.length > 0 || activeUseCases.length > 0;
 
   return (
     <div className="filter-shell space-y-3 rounded-xl p-3">
+      <div className="grid gap-2 lg:grid-cols-[minmax(320px,1fr)_180px_210px_90px]">
+        <label className="relative block min-w-0">
-      <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 id="library-search" value={query} onChange={(event) => onQueryChange(event.target.value)} placeholder="Search libraries..." aria-label="Search libraries" className="coss-input h-11 w-full pr-12 pl-10" />
-          <Input id="library-search" 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-20 pl-10" />
+          <kbd className="search-key-hint 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]">/</kbd>
-          <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
+        <div className={`filter-advanced ${filtersOpen ? "filter-advanced-open" : ""} contents`}><FilterDropdown
           label={activeStacks[0] ?? "All stacks"}
           value={activeStacks[0] ?? "all"}
           items={[{ label: "All stacks", value: "all" }, ...STACKS.map((stack) => ({ label: stack, value: stack }))]}
@@ -80,15 +82,16 @@
           onValueChange={(value) => onUseCaseChange(value === "all" ? null : value as UseCase)}
         />
 
+        <Button type="button" variant="outline" onClick={onClearAll} disabled={!hasFilters} className="coss-trigger"><RotateCcw aria-hidden /> Clear</Button></div>
-        {hasFilters && <Button type="button" variant="outline" onClick={onClearAll} className="coss-trigger"><RotateCcw aria-hidden /> Clear</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>
+      <button type="button" className="filter-mobile-toggle min-h-11 w-full items-center justify-between text-sm font-medium" aria-expanded={filtersOpen} onClick={() => setFiltersOpen((open) => !open)}>Filters <ChevronDown className={`size-4 ${filtersOpen ? "rotate-180" : ""}`} aria-hidden /></button>
-          {CATEGORIES.map((category) => <TabsTrigger key={category} value={category} className="filter-tab flex-none rounded-md px-3 py-1.5">{category}</TabsTrigger>)}
+      <div className={`filter-categories ${filtersOpen ? "filter-categories-open" : ""}`}><Tabs value={activeCategory ?? "all"} onValueChange={(value) => onCategoryChange(value === "all" ? null : value as Category)}>
+        <TabsList className="filter-category-list h-auto! w-full flex-nowrap justify-start gap-1 overflow-x-auto rounded-none bg-transparent p-0">
+          <TabsTrigger value="all" className="filter-tab min-h-11 flex-none rounded-md px-3">All</TabsTrigger>
+          {CATEGORIES.map((category) => <TabsTrigger key={category} value={category} className="filter-tab min-h-11 flex-none rounded-md px-3">{category}</TabsTrigger>)}
         </TabsList>
+      </Tabs></div>
-      </Tabs>
     </div>
   );
 }
```

## components/Header.tsx

Status: modified; +5; −6

```diff
@@ -12,6 +12,7 @@
 const links = [
   ["Libraries", "/libraries"],
   ["Docs", "/docs"],
+  ["Contributors", "/contributors"],
   ["Sponsors", "/#sponsors"],
 ] as const;
 
@@ -38,14 +39,12 @@
   useEffect(() => {
     const handleShortcut = (event: KeyboardEvent) => {
       const target = event.target;
-      if (target instanceof HTMLElement && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))) return;
-
       const slashShortcut = event.key === "/" && !event.altKey && !event.ctrlKey && !event.metaKey;
       const commandShortcut = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k";
       if (!slashShortcut && !commandShortcut) return;
+      if (slashShortcut && target instanceof HTMLElement && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))) return;
 
       if (pathname === "/libraries") {
-        if (!slashShortcut) return;
         event.preventDefault();
         document.querySelector<HTMLInputElement>("#library-search")?.focus();
         return;
@@ -138,7 +137,7 @@
             }}
           >
             <span ref={highlightRef} className="site-header-link-highlight" aria-hidden="true" />
+            {links.filter(([, href]) => !landing || href !== "/contributors").map(([label, href]) => {
-            {links.map(([label, href]) => {
               const active = !href.includes("#") && (pathname === href || pathname.startsWith(`${href}/`));
               return (
                 <Link
@@ -175,7 +174,7 @@
           </a>
           <RainbowButton
             asChild
+            className="min-h-11 rounded-[10px] px-[19px] text-xs text-white! dark:text-black! max-[760px]:px-3"
-            className="h-9 rounded-[10px] px-[19px] text-xs text-white! dark:text-black! max-[760px]:h-[34px] max-[760px]:rounded-[9px] max-[760px]:px-3"
           >
             <a href="https://github.com/screen-gd/Col/issues/new" target="_blank" rel="noopener noreferrer">Submit</a>
           </RainbowButton>
@@ -193,7 +192,7 @@
 
         {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>)}
+            {links.filter(([, href]) => !landing || href !== "/contributors").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="/libraries#library-search" onClick={() => setMenuOpen(false)}>Search libraries</a>
           </nav>
         )}
```

## components/LibraryCard.tsx

Status: modified; +4; −3

```diff
@@ -33,7 +33,7 @@
         </CardTitle>
         <p className="library-subtle ml-12 truncate text-[11px]">{hostname(library.url)}</p>
         <CardAction className="relative z-10">
-          <Button type="button" size="icon-sm" variant="ghost" onClick={onToggleSaved} aria-label={saved ? `Remove ${library.name} from saved` : `Save ${library.name}`} aria-pressed={saved} className="library-subtle rounded-lg hover:opacity-80">
+          <Button type="button" size="icon-sm" variant="ghost" onClick={onToggleSaved} aria-label={saved ? `Remove ${library.name} from saved` : `Save ${library.name}`} aria-pressed={saved} className="library-save min-h-11 min-w-11 rounded-lg hover:opacity-80">
             <Heart fill={saved ? "currentColor" : "none"} aria-hidden />
           </Button>
         </CardAction>
@@ -41,11 +41,12 @@
 
       <CardContent className="flex-1 px-5">
         <p className="library-muted line-clamp-3 text-[13px] leading-5">{library.description}</p>
-        <div className="mt-4 flex flex-wrap gap-1.5">
+        {library.stacks.length > 0 && <div className="mt-4 flex flex-wrap gap-1.5">
           {library.stacks.slice(0, 3).map((stack) => (
             <Badge key={stack} variant="outline" className="library-chip px-2 py-0.5 text-[10px]">{stack}</Badge>
           ))}
+          {library.stacks.length > 3 && <Badge variant="outline" className="library-chip px-2 py-0.5 text-[10px]">+{library.stacks.length - 3}</Badge>}
+        </div>}
-        </div>
       </CardContent>
 
       <CardFooter className="library-subtle theme-border flex justify-between border-t px-5 pt-3 text-[10px]">
```

## components/LibraryExplorer.tsx

Status: modified; +2; −2

```diff
@@ -161,12 +161,12 @@
       </div>
 
       <a
-        href={`https://openshaders.com/@${webgpuUnavailable ? "screen" : "screendev"}`}
+        href="https://openshaders.com/@screendev"
         target="_blank"
         rel="noopener noreferrer"
         className="hero-shader-credit absolute bottom-4 right-5 z-10 text-[10px] tracking-[0.02em] sm:right-8"
       >
-        Background shader by @{webgpuUnavailable ? "screen" : "screendev"} on OpenShaders
+        Background shader by @screendev on OpenShaders
       </a>
     </section>
   );
```

## components/ScreenShader.tsx

Status: modified; +146; −45

```diff
@@ -1,4 +1,4 @@
-/* Background shader by @screen on OpenShaders: https://openshaders.com/@screen */
+/* Background shader by @screendev on OpenShaders: https://openshaders.com/@screendev */
 "use client";
 
 import { useEffect, useRef } from "react";
@@ -21,40 +21,40 @@
 uniform vec3 uLightBackground;
 out vec4 fragColor;
 
-const float HUE = 0.0247616936;
-const float HUE_SPREAD = 0.00896027964;
-const float HUE_TRAVEL = 1.61191618;
-const float CHROMA = 0.0828986764;
-const float LIGHTNESS = 0.566419363;
-const float COLOUR_CYCLE = 0.210922211;
-const float THETA = 2.11860061;
-const float SHEAR = 0.955188811;
-const float SHRINK = 0.958665013;
-const float LAYERS = 72.0;
-const float WARP_FREQ_X = 0.595242977;
-const float WARP_FREQ_Y = 2.40765452;
-const float WARP_AMP_X = 0.104781911;
-const float WARP_AMP_Y = 0.0273131337;
-const float ASPECT_X = 2.19655395;
-const float ASPECT_Y = 0.187779561;
-const float OFFSET_X = 0.391835809;
-const float OFFSET_Y = -0.0259866789;
-const float TILT = 1.37964761;
-const float ZOOM = 1.16922402;
-const float CENTRE_X = -0.606007397;
-const float CENTRE_Y = -0.400004715;
-const float GLOW_SIZE = 0.00274323416;
-const float FALLOFF = 0.303328395;
-const float VIGNETTE = 0.0613339283;
-const float FLOW_SPEED = 0.518149257;
-const float FLOW_DIRECTION = -1.0;
-const float BREATH_RATE = 0.471057415;
-const float BREATH_AMOUNT = 0.0587214343;
+const float HUE = 0.592417479;
-const float PHASE = 78.3789444;
+const float HUE_SPREAD = 0.00895982143;
+const float HUE_TRAVEL = 1.81699848;
+const float CHROMA = 0.105831832;
+const float LIGHTNESS = 0.561614811;
+const float COLOUR_CYCLE = 0.157766372;
+const float THETA = 2.11315823;
+const float SHEAR = 0.959939361;
+const float SHRINK = 0.9488644;
+const float LAYERS = 73.0;
+const float WARP_FREQ_X = 0.463788897;
+const float WARP_FREQ_Y = 2.47012258;
+const float WARP_AMP_X = 0.101558559;
+const float WARP_AMP_Y = 0.0322007388;
+const float ASPECT_X = 2.52213597;
+const float ASPECT_Y = 0.203344896;
+const float OFFSET_X = 0.407597601;
+const float OFFSET_Y = -0.0278026797;
+const float TILT = -1.14852977;
+const float ZOOM = 1.0978626;
+const float CENTRE_X = 0.22665225;
+const float CENTRE_Y = 0.488793045;
+const float GLOW_SIZE = 0.00219408958;
+const float FALLOFF = 0.316368669;
+const float VIGNETTE = 0.00159253448;
+const float FLOW_SPEED = 0.433230639;
+const float FLOW_DIRECTION = 1.0;
+const float BREATH_RATE = 0.447767794;
+const float BREATH_AMOUNT = 0.0520217642;
+const float PHASE = 23.3064098;
 const float ECHO = 0.0;
-const float ECHO_SHIFT = -0.127812564;
-const float SOFTNESS = 0.00230249879;
+const float ECHO_SHIFT = 0.184553832;
-const float LIGHT_SWING = 0.2629686;
+const float SOFTNESS = 0.00184572604;
+const float LIGHT_SWING = 0.283909291;
 
 const float TAU = 6.28318530718;
 
@@ -129,6 +129,44 @@
 }
 `;
 
+const POST_SHADER = `#version 300 es
+precision highp float;
+
+uniform sampler2D tScene;
+uniform vec2 iResolution;
+uniform float uLightMode;
+uniform float uPixelRatio;
+uniform vec3 uDarkBackground;
+uniform vec3 uLightBackground;
+out vec4 fragColor;
+
+const float SCALE = 0.804260671;
+
+vec3 toInk(vec3 color) {
+  return mix(color - uDarkBackground, uLightBackground - color, uLightMode);
+}
+
+vec3 fromInk(vec3 ink) {
+  return mix(uDarkBackground + ink, uLightBackground - ink, uLightMode);
+}
+
+vec3 sceneInk(vec2 uv) {
+  return toInk(texture(tScene, clamp(uv, 0.0, 1.0)).rgb);
+}
+
+void main() {
+  float cell = max(3.0, floor(SCALE * 6.0 * uPixelRatio + 0.5));
+  vec2 grid = floor(gl_FragCoord.xy / cell);
+  vec2 centre = (grid + 0.5) * cell;
+  vec3 ink = vec3(0.0);
+  ink += sceneInk((centre + cell * vec2(-0.25, -0.25)) / iResolution);
+  ink += sceneInk((centre + cell * vec2(0.25, -0.25)) / iResolution);
+  ink += sceneInk((centre + cell * vec2(-0.25, 0.25)) / iResolution);
+  ink += sceneInk((centre + cell * vec2(0.25, 0.25)) / iResolution);
+  fragColor = vec4(clamp(fromInk(clamp(ink * 0.25, 0.0, 1.0)), 0.0, 1.0), 1.0);
+}
+`;
+
 export function ScreenShader({ theme = "dark", background, time, onError, className, style }: ShaderProps) {
   const canvas = useRef<HTMLCanvasElement>(null);
   const shader = useRef<ShaderHandle | null>(null);
@@ -189,20 +227,30 @@
 function attach(gl: WebGL2RenderingContext, program: WebGLProgram, type: number, source: string) {
   const shader = gl.createShader(type);
   if (!shader) throw new Error("WebGL could not create a shader object.");
-  gl.shaderSource(shader, source);
-  gl.compileShader(shader);
-  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(`Shader failed to compile: ${gl.getShaderInfoLog(shader)}`);
+  try {
-  gl.attachShader(program, shader);
+    gl.shaderSource(shader, source);
+    gl.compileShader(shader);
+    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(`Shader failed to compile: ${gl.getShaderInfoLog(shader)}`);
+    gl.attachShader(program, shader);
+  } catch (error) {
+    gl.deleteShader(shader);
+    throw error;
+  }
   gl.deleteShader(shader);
 }
 
 function compile(gl: WebGL2RenderingContext, fragmentSource: string) {
   const program = gl.createProgram();
   if (!program) throw new Error("WebGL could not create a shader program.");
-  attach(gl, program, gl.VERTEX_SHADER, VERTEX_SHADER);
-  attach(gl, program, gl.FRAGMENT_SHADER, fragmentSource);
-  gl.linkProgram(program);
-  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(`Shader failed to link: ${gl.getProgramInfoLog(program)}`);
+  try {
+    attach(gl, program, gl.VERTEX_SHADER, VERTEX_SHADER);
+    attach(gl, program, gl.FRAGMENT_SHADER, fragmentSource);
+    gl.linkProgram(program);
+    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(`Shader failed to link: ${gl.getProgramInfoLog(program)}`);
+  } catch (error) {
+    gl.deleteProgram(program);
+    throw error;
+  }
   return program;
 }
 
@@ -211,14 +259,50 @@
 }
 
 function createShader(canvas: HTMLCanvasElement, options: ShaderOptions = {}): ShaderHandle {
-  const gl = canvas.getContext("webgl2", { alpha: false, antialias: false, depth: false, stencil: false });
+  const context = canvas.getContext("webgl2", { alpha: false, antialias: false, depth: false, stencil: false });
-  if (!gl) throw new Error("WebGL2 is not available in this browser.");
+  if (!context) throw new Error("WebGL2 is not available in this browser.");
+  const gl = context;
   const dark = parseHex(options.background?.dark ?? "#090909");
   const light = parseHex(options.background?.light ?? "#ffffff");
 
   const field = compile(gl, FIELD_SHADER);
   const fieldUniforms = uniforms(gl, field, ["iResolution", "iTime", "uLightMode", "uDarkBackground", "uLightBackground"]);
+  let post: WebGLProgram;
+  try {
+    post = compile(gl, POST_SHADER);
+  } catch (error) {
+    gl.deleteProgram(field);
+    throw error;
+  }
+  const postUniforms = uniforms(gl, post, ["tScene", "iResolution", "uLightMode", "uPixelRatio", "uDarkBackground", "uLightBackground"]);
+  const scene = gl.createTexture();
+  const framebuffer = gl.createFramebuffer();
+  if (!scene || !framebuffer) {
+    if (scene) gl.deleteTexture(scene);
+    if (framebuffer) gl.deleteFramebuffer(framebuffer);
+    gl.deleteProgram(post);
+    gl.deleteProgram(field);
+    throw new Error("WebGL could not create the shader scene.");
+  }
+  gl.bindTexture(gl.TEXTURE_2D, scene);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+  let sceneWidth = 0;
+  let sceneHeight = 0;
 
+  function fitScene() {
+    if (sceneWidth === canvas.width && sceneHeight === canvas.height) return;
+    sceneWidth = canvas.width;
+    sceneHeight = canvas.height;
+    gl.bindTexture(gl.TEXTURE_2D, scene);
+    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, sceneWidth, sceneHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
+    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
+    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, scene, 0);
+    if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) throw new Error("WebGL shader scene is incomplete.");
+  }
+
   const setFrame = (locations: Record<string, WebGLUniformLocation | null>, time: number, theme: number) => {
     gl.uniform2f(locations.iResolution, canvas.width, canvas.height);
     gl.uniform1f(locations.iTime, time);
@@ -227,12 +311,29 @@
     gl.uniform3fv(locations.uLightBackground, light);
   };
 
+  return animate(options, (time, theme, pixelRatio) => {
-  return animate(options, (time, theme) => {
+    fitScene();
     gl.viewport(0, 0, canvas.width, canvas.height);
+    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
     gl.useProgram(field);
     setFrame(fieldUniforms, time, theme);
     gl.drawArrays(gl.TRIANGLES, 0, 3);
+
+    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+    gl.useProgram(post);
+    gl.activeTexture(gl.TEXTURE0);
+    gl.bindTexture(gl.TEXTURE_2D, scene);
+    gl.uniform1i(postUniforms.tScene, 0);
+    gl.uniform2f(postUniforms.iResolution, canvas.width, canvas.height);
+    gl.uniform1f(postUniforms.uLightMode, theme);
+    gl.uniform1f(postUniforms.uPixelRatio, pixelRatio);
+    gl.uniform3fv(postUniforms.uDarkBackground, dark);
+    gl.uniform3fv(postUniforms.uLightBackground, light);
+    gl.drawArrays(gl.TRIANGLES, 0, 3);
   }, canvas, () => {
     gl.deleteProgram(field);
+    gl.deleteProgram(post);
+    gl.deleteFramebuffer(framebuffer);
+    gl.deleteTexture(scene);
   }, Math.min(gl.getParameter(gl.MAX_TEXTURE_SIZE), gl.getParameter(gl.MAX_RENDERBUFFER_SIZE)));
 }
```

## components/ThemeToggle.tsx

Status: modified; +1; −1

```diff
@@ -80,7 +80,7 @@
       onClick={toggle}
       aria-label={`Use ${light ? "dark" : "light"} mode`}
       title={light ? "Dark mode" : "Light mode"}
-      className="theme-toggle grid size-9 place-items-center rounded-lg border"
+      className="theme-toggle grid size-11 place-items-center rounded-lg border"
     >
       {light ? <Moon className="size-4" aria-hidden /> : <Sun className="size-4" aria-hidden />}
     </button>
```

## components/shader-parity.test.mjs

Status: added; +15; −0

```diff
@@ -1,0 +1,15 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { test } from "node:test";
+
+const glsl = readFileSync(new URL("./ScreenShader.tsx", import.meta.url), "utf8");
+const wgsl = readFileSync(new URL("./ScreendevShader.tsx", import.meta.url), "utf8");
+
+function fieldConstants(source) {
+  const field = source.split("const FIELD_SHADER = `")[1].split("`;", 1)[0];
+  return Object.fromEntries([...field.matchAll(/const (?:float )?(\w+)(?:: f32)? = ([\d.-]+);/g)].map(([, name, value]) => [name, Number(value)]));
+}
+
+test("WebGL2 field constants match the WebGPU design", () => {
+  assert.deepEqual(fieldConstants(glsl), fieldConstants(wgsl));
+});
```
