From f36269d9f4de936c875001c8a300137bd898b569 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 15:19:29 +0000 Subject: [PATCH 01/12] add atlas-cropper log entry draft Draft of log 13 covering the atlas-cropper tool: what it does, its four-tab workflow, capabilities, and keyboard shortcuts. Includes a placeholder section for the Joyco use case. https://claude.ai/code/session_018ausLaQNYXDo46Tb2mzC5Q --- content/logs/13-atlas-cropper.mdx | 110 ++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 content/logs/13-atlas-cropper.mdx diff --git a/content/logs/13-atlas-cropper.mdx b/content/logs/13-atlas-cropper.mdx new file mode 100644 index 0000000..036c4bc --- /dev/null +++ b/content/logs/13-atlas-cropper.mdx @@ -0,0 +1,110 @@ +--- +title: 13 - Atlas Cropper +description: A visual tool for defining, arranging, and extracting crop regions from atlas images — built for texture workflows where precision matters. +author: 'matiasperz' +--- + +## The problem + +You have a single atlas image — a spritesheet, a texture map, a design comp — and you need to extract specific rectangular regions from it. Maybe you need those crops mapped onto other images. Maybe you need their coordinates exported as JSON for a renderer. Maybe both. + +The usual workflow is painful: open Photoshop, eyeball coordinates, manually note pixel values, re-export every time something shifts. For game dev and WebGL work, where atlases are the standard unit of delivery, this cycle repeats constantly. + +Atlas Cropper is a browser-based tool we built to replace that entire loop. Define crops visually, arrange them in a layout, export the config, and batch-extract UV crops — all from a single interface. + +## How it works + +The tool is organized into four sequential tabs, each handling one stage of the workflow: + +### 1. Crop Definition + +Upload an atlas image (drag-and-drop or file picker) and draw rectangular regions directly on it. Each crop gets a unique name and color for identification. + +The editor behaves like you'd expect from a design tool: + +- **Click and drag** to create or move crops +- **Grab handles** to resize (8-point: corners + edges) +- **Shift + drag** on a corner to lock aspect ratio +- **Alt + drag** on an existing crop to duplicate it +- **Arrow keys** to nudge (1px, or 10px with Shift) +- **Magnetic snapping** aligns crops to edges and centers of neighboring regions — toggle with `S`, bypass with `Alt` + +The canvas is infinite. Middle-click drag to pan, `Ctrl+scroll` to zoom (cursor-centered). `Cmd+0` fits everything to view. + +### 2. Layout Arrangement + +Position your defined crops in 2D space. Each crop has `x`, `y`, and `z-index` coordinates. This is where you define how the crops relate to each other spatially — useful when your atlas regions need to be reassembled or composited in a specific arrangement. + +Features carry over from the crop editor (snapping, pan/zoom, keyboard shortcuts), plus: + +- **Origin controls** — define a reference point in absolute pixels or percentages +- **Constrain mode** — clamp crops within image bounds (`C` to toggle) +- **Background overlay** — 30% opacity atlas image for reference (`B` to toggle) +- **Sidebar reordering** — drag crop items to reorder draw priority + +### 3. Output + +Generates a JSON configuration for the defined crops: + +```json +{ + "atlasSize": { "width": 1024, "height": 1024 }, + "crops": [ + { + "crop": { "x": 10, "y": 10, "width": 256, "height": 256 }, + "position": [0, 0, 0] + } + ] +} +``` + +One-click copy to clipboard. This is the bridge between the visual tool and whatever consumes the crop data — a game engine, a WebGL shader, a build pipeline. + +### 4. UV Crop + +Upload one or more base images and associate each with a defined crop. The tool scales the crop coordinates from the atlas dimensions to each target image's dimensions, then extracts the corresponding region. + +- **Individual download** as PNG +- **Batch download** as a ZIP archive +- **Drag-and-drop** upload for base images + +This is where atlas definitions become actual image files you can drop into a project. + +## Quality-of-life details + +A few things that make repeated use less painful: + +| Feature | Shortcut | +|---------|----------| +| Add crop | `A` | +| Delete selected | `Delete` / `Backspace` | +| Deselect | `Escape` | +| Undo / Redo | `Cmd+Z` / `Cmd+Shift+Z` | +| Switch tabs | `1` `2` `3` `4` | +| Toggle snap | `S` | +| Toggle background | `B` | +| Toggle constrain | `C` | +| Fit to view | `Cmd+0` | +| Zoom in/out | `Cmd+` / `Cmd-` | + +Full undo/redo history (up to 50 snapshots). Save and load crop configurations as JSON files so you can pick up where you left off. + +## Why not just use Photoshop / Figma? + +Two reasons: + +1. **Structured output.** Design tools give you pixels. Atlas Cropper gives you a JSON config with exact coordinates and spatial relationships. No manual transcription of values into code. + +2. **UV mapping.** The crop-to-image pipeline lets you define regions once on an atlas and then apply those regions to any number of target images. Resizing is handled automatically. This isn't something general-purpose design tools are built for. + +The tool is purpose-built for the specific loop of: define regions on an atlas, export coordinates, extract those regions from other images. It does that loop well and nothing else. + +{/* ## Our use case + +TODO: Add the specific Joyco use case here — what project drove the need, what the atlas workflow looks like in practice, before/after. + +*/} + +## Links + +- [GitHub Repository](https://github.com/joyco-studio/atlas-cropper) From 17ea16604250f1654dbfaa6770543f002f1ecab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20R=2E=20Montes?= Date: Wed, 8 Apr 2026 14:57:52 -0300 Subject: [PATCH 02/12] fix: wrong dynamic rending (#114) --- app/(registry)/layout.tsx | 27 ++++++++++++--------------- app/api/screenshot/route.ts | 4 ++-- components/layout/mobile-nav.tsx | 11 ++++++++++- components/layout/sidebar/index.tsx | 11 ++++++++++- hooks/use-team-cookie.ts | 18 ++++++++++++++++++ 5 files changed, 52 insertions(+), 19 deletions(-) create mode 100644 hooks/use-team-cookie.ts diff --git a/app/(registry)/layout.tsx b/app/(registry)/layout.tsx index cf05a54..bf6bd2a 100644 --- a/app/(registry)/layout.tsx +++ b/app/(registry)/layout.tsx @@ -1,5 +1,4 @@ import type { CSSProperties } from 'react' -import { cookies } from 'next/headers' import { source, getGameSlugs, @@ -16,21 +15,19 @@ import { MobileNav } from '@/components/layout/mobile-nav' import { getExperiments } from '@/lib/lab' import { CommandPalette } from '@/components/layout/command-palette' -export default async function Layout({ children }: LayoutProps<'/'>) { - const cookieStore = await cookies() - const isTeam = cookieStore.has('joyco-team') - - const itemMeta: Record< - string, - { - badge?: 'new' | 'updated' | 'internal' - dot?: 'red' | 'blue' | 'green' | 'yellow' - hidden?: boolean - } - > = { - '/toolbox/skills': { badge: 'new' }, - '/toolbox/ui': isTeam ? { badge: 'internal' } : { hidden: true }, +const itemMeta: Record< + string, + { + badge?: 'new' | 'updated' | 'internal' + dot?: 'red' | 'blue' | 'green' | 'yellow' + hidden?: boolean } +> = { + '/toolbox/skills': { badge: 'new' }, + '/toolbox/ui': { hidden: true }, +} + +export default async function Layout({ children }: LayoutProps<'/'>) { const gameSlugs = getGameSlugs() const effectSlugs = getEffectSlugs() const canvasSlugs = getCanvasSlugs() diff --git a/app/api/screenshot/route.ts b/app/api/screenshot/route.ts index 1ff8f3f..fc16a75 100644 --- a/app/api/screenshot/route.ts +++ b/app/api/screenshot/route.ts @@ -108,7 +108,7 @@ const getCachedExperimentScreenshot = unstable_cache( ) }, ['experiment-screenshot'], - { revalidate: 86400 } + { revalidate: 604800 } ) export async function GET(request: NextRequest) { @@ -139,7 +139,7 @@ export async function GET(request: NextRequest) { return new NextResponse(imageBuffer, { headers: { 'Content-Type': 'image/png', - 'Cache-Control': 'public, s-maxage=31536000', + 'Cache-Control': 'public, max-age=604800, s-maxage=604800, stale-while-revalidate=86400', }, }) } catch (error) { diff --git a/components/layout/mobile-nav.tsx b/components/layout/mobile-nav.tsx index 396fb97..4482c85 100644 --- a/components/layout/mobile-nav.tsx +++ b/components/layout/mobile-nav.tsx @@ -16,6 +16,7 @@ import FileIcon from '@/components/icons/file' import FlaskIcon from '@/components/icons/flask' import type { SidebarItemMeta } from './sidebar/section' import type { Experiment } from '@/lib/lab' +import { useIsTeam } from '@/hooks/use-team-cookie' import { MetaBadge } from '@/components/layout/meta-badge' import { SearchResults } from './sidebar/search-results' import { NoResults } from './sidebar/no-results' @@ -52,9 +53,17 @@ const sectionIcons: Record< export function MobileNav({ tree, - itemMeta = {}, + itemMeta: itemMetaProp = {}, experiments = [], }: MobileNavProps) { + const isTeam = useIsTeam() + const itemMeta = React.useMemo( + () => + isTeam + ? { ...itemMetaProp, '/toolbox/ui': { badge: 'internal' as const } } + : itemMetaProp, + [isTeam, itemMetaProp] + ) const pathname = usePathname() const router = useRouter() const inputRef = React.useRef(null) diff --git a/components/layout/sidebar/index.tsx b/components/layout/sidebar/index.tsx index cddf5a1..d70661e 100644 --- a/components/layout/sidebar/index.tsx +++ b/components/layout/sidebar/index.tsx @@ -13,6 +13,7 @@ import { LabSidebarSection } from './lab-section' import { SocialLinks } from './social-links' import { NavAside } from '../nav-aside' import { useSearch } from '@/hooks/use-search' +import { useIsTeam } from '@/hooks/use-team-cookie' import type { Experiment } from '@/lib/lab' export type { SidebarItemMeta } @@ -34,6 +35,14 @@ export function RegistrySidebar({ canvasSlugs = [], experiments = [], }: RegistrySidebarProps) { + const isTeam = useIsTeam() + const resolvedMeta = React.useMemo( + () => + isTeam + ? { ...itemMeta, '/toolbox/ui': { badge: 'internal' as const } } + : itemMeta, + [isTeam, itemMeta] + ) const pathname = usePathname() const router = useRouter() const { @@ -106,7 +115,7 @@ export function RegistrySidebar({ void) { + const interval = setInterval(callback, 2000) + return () => clearInterval(interval) +} + +export function useIsTeam() { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +} From e1f94dfaf6a1f9522402838885ca21f6d69d02cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20R=2E=20Montes?= Date: Wed, 8 Apr 2026 15:09:17 -0300 Subject: [PATCH 03/12] fix: team cookie hook review fixes (#115) * fix: add 'use client' directive and remove unnecessary polling in team cookie hook Co-Authored-By: Claude Opus 4.6 (1M context) * fix: redirect unauthorized access to toolbox UI based on team cookie presence * fix order --------- Co-authored-by: Claude Opus 4.6 (1M context) --- hooks/use-team-cookie.ts | 7 ++++--- proxy.ts | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/hooks/use-team-cookie.ts b/hooks/use-team-cookie.ts index 8d4c6f0..6621f0e 100644 --- a/hooks/use-team-cookie.ts +++ b/hooks/use-team-cookie.ts @@ -1,3 +1,5 @@ +'use client' + import { useSyncExternalStore } from 'react' function getSnapshot() { @@ -8,9 +10,8 @@ function getServerSnapshot() { return false } -function subscribe(callback: () => void) { - const interval = setInterval(callback, 2000) - return () => clearInterval(interval) +function subscribe() { + return () => {} } export function useIsTeam() { diff --git a/proxy.ts b/proxy.ts index bc4d126..6975d8b 100644 --- a/proxy.ts +++ b/proxy.ts @@ -19,6 +19,10 @@ export async function proxy(request: NextRequest) { return response } + if (pathname === '/toolbox/ui' && !request.cookies.has('joyco-team')) { + return NextResponse.redirect(new URL('/', request.url)) + } + return NextResponse.next() } From 789829d8c3ebe462fcb0d7d8dd40ae844e99f562 Mon Sep 17 00:00:00 2001 From: Matias Perez Date: Sun, 12 Apr 2026 02:39:41 -0300 Subject: [PATCH 04/12] fix: adjust scrollbar positioning in ScrollArea component - Updated the positioning of scrollbar elements in the ScrollAreaViewport to ensure they align correctly with the edges of the viewport. - Simplified padding classes in ScrollAreaContent for better readability and maintainability. --- public/r/scroll-area.json | 2 +- registry/components/scroll-area.tsx | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/public/r/scroll-area.json b/public/r/scroll-area.json index 93f869a..57037b1 100644 --- a/public/r/scroll-area.json +++ b/public/r/scroll-area.json @@ -10,7 +10,7 @@ "files": [ { "path": "registry/components/scroll-area.tsx", - "content": "'use client'\n\nimport * as React from 'react'\nimport { Presence } from '@radix-ui/react-presence'\nimport { cn } from '@/lib/utils'\n\ninterface ScrollAreaContextValue {\n scrollRef: React.RefObject\n hasScroll: boolean\n orientation: 'vertical' | 'horizontal'\n}\n\nconst ScrollAreaContext = React.createContext(\n null\n)\n\nfunction useScrollAreaContext() {\n const ctx = React.useContext(ScrollAreaContext)\n if (!ctx) {\n throw new Error('ScrollArea components must be used within ScrollArea Root')\n }\n return ctx\n}\n\ninterface ScrollAreaViewportProps extends React.ComponentProps<'div'> {\n orientation?: 'vertical' | 'horizontal'\n topShadowGradient?: string\n bottomShadowGradient?: string\n leftShadowGradient?: string\n rightShadowGradient?: string\n}\n\nexport function ScrollAreaViewport({\n className,\n children,\n orientation = 'vertical',\n topShadowGradient,\n bottomShadowGradient,\n leftShadowGradient,\n rightShadowGradient,\n ref,\n ...props\n}: ScrollAreaViewportProps) {\n const scrollRef = React.useRef(null)\n const [hasScrollTop, setHasScrollTop] = React.useState(false)\n const [hasScrollBottom, setHasScrollBottom] = React.useState(false)\n const [hasScrollLeft, setHasScrollLeft] = React.useState(false)\n const [hasScrollRight, setHasScrollRight] = React.useState(false)\n\n const update = React.useCallback(() => {\n const el = scrollRef.current\n if (!el) return\n\n if (orientation === 'vertical') {\n // Vertical scroll detection\n const scrollY = Math.ceil(el.scrollTop)\n const scrollHeight = el.scrollHeight\n const clientHeight = el.clientHeight\n\n const newHasScrollTop = scrollY > 0\n const newHasScrollBottom = scrollY + clientHeight < scrollHeight\n\n setHasScrollTop(newHasScrollTop)\n setHasScrollBottom(newHasScrollBottom)\n } else {\n const scrollX = Math.ceil(el.scrollLeft)\n const scrollWidth = el.scrollWidth\n const clientWidth = el.clientWidth\n\n const newHasScrollLeft = scrollX > 0\n const newHasScrollRight = scrollX + clientWidth < scrollWidth\n\n setHasScrollLeft(newHasScrollLeft)\n setHasScrollRight(newHasScrollRight)\n }\n }, [orientation])\n\n React.useEffect(() => {\n const el = scrollRef.current\n if (!el) return\n\n const handleScroll = () => requestAnimationFrame(update)\n el.addEventListener('scroll', handleScroll, { passive: true })\n\n const ro = new ResizeObserver(update)\n ro.observe(el)\n\n const mo = new MutationObserver(update)\n mo.observe(el, { childList: true })\n\n update()\n\n return () => {\n el.removeEventListener('scroll', handleScroll)\n ro.disconnect()\n mo.disconnect()\n }\n }, [update])\n\n const hasScroll =\n hasScrollTop || hasScrollBottom || hasScrollLeft || hasScrollRight\n\n const ctxValue = React.useMemo(\n () => ({\n scrollRef,\n hasScroll,\n orientation,\n }),\n [hasScroll, orientation]\n )\n\n const shadowBaseClasses =\n 'pointer-events-none absolute z-20 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-300 ease-out'\n\n const shadows =\n orientation === 'vertical'\n ? [\n {\n present: hasScrollTop,\n position: 'left-0 top-0 right-2 h-8',\n slide:\n 'data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2',\n gradient:\n topShadowGradient ||\n 'bg-linear-to-b from-background to-transparent',\n },\n {\n present: hasScrollBottom,\n position: 'left-0 bottom-0 right-2 h-8',\n slide:\n 'data-[state=closed]:slide-out-to-bottom-2 data-[state=open]:slide-in-from-bottom-2',\n gradient:\n bottomShadowGradient ||\n 'bg-linear-to-t from-background to-transparent',\n },\n ]\n : [\n {\n present: hasScrollLeft,\n position: 'inset-y-0 left-0 bottom-2 w-8',\n slide:\n 'data-[state=closed]:slide-out-to-left-2 data-[state=open]:slide-in-from-left-2',\n gradient:\n leftShadowGradient ||\n 'bg-linear-to-r from-background to-transparent',\n },\n {\n present: hasScrollRight,\n position: 'inset-y-0 right-0 bottom-2 w-8',\n slide:\n 'data-[state=closed]:slide-out-to-right-2 data-[state=open]:slide-in-from-right-2',\n gradient:\n rightShadowGradient ||\n 'bg-linear-to-l from-background to-transparent',\n },\n ]\n\n const dataAttributes =\n orientation === 'vertical'\n ? {\n 'data-scroll-top': hasScrollTop,\n 'data-scroll-bottom': hasScrollBottom,\n }\n : {\n 'data-scroll-left': hasScrollLeft,\n 'data-scroll-right': hasScrollRight,\n }\n\n return (\n \n \n {children}\n\n {shadows.map((shadow, index) => (\n \n \n \n ))}\n \n \n )\n}\n\ntype ScrollAreaContentProps = React.ComponentProps<'div'>\n\nexport function ScrollAreaContent({\n className,\n children,\n ref,\n ...props\n}: ScrollAreaContentProps) {\n const { scrollRef, orientation } = useScrollAreaContext()\n\n const combinedRef = React.useCallback(\n (node: HTMLDivElement | null) => {\n scrollRef.current = node\n if (typeof ref === 'function') ref(node)\n else if (ref)\n (ref as React.RefObject).current = node\n },\n [ref, scrollRef]\n )\n\n const overflowClasses =\n orientation === 'horizontal'\n ? 'overflow-x-auto overflow-y-hidden'\n : 'overflow-y-auto h-full'\n\n const paddingClasses =\n orientation === 'horizontal'\n ? 'pb-2' // Padding bottom para empujar el scrollbar horizontal hacia abajo\n : 'pr-2' // Padding right para empujar el scrollbar vertical hacia afuera\n\n return (\n \n {children}\n \n )\n}\n", + "content": "'use client'\n\nimport * as React from 'react'\nimport { Presence } from '@radix-ui/react-presence'\nimport { cn } from '@/lib/utils'\n\ninterface ScrollAreaContextValue {\n scrollRef: React.RefObject\n hasScroll: boolean\n orientation: 'vertical' | 'horizontal'\n}\n\nconst ScrollAreaContext = React.createContext(\n null\n)\n\nfunction useScrollAreaContext() {\n const ctx = React.useContext(ScrollAreaContext)\n if (!ctx) {\n throw new Error('ScrollArea components must be used within ScrollArea Root')\n }\n return ctx\n}\n\ninterface ScrollAreaViewportProps extends React.ComponentProps<'div'> {\n orientation?: 'vertical' | 'horizontal'\n topShadowGradient?: string\n bottomShadowGradient?: string\n leftShadowGradient?: string\n rightShadowGradient?: string\n}\n\nexport function ScrollAreaViewport({\n className,\n children,\n orientation = 'vertical',\n topShadowGradient,\n bottomShadowGradient,\n leftShadowGradient,\n rightShadowGradient,\n ref,\n ...props\n}: ScrollAreaViewportProps) {\n const scrollRef = React.useRef(null)\n const [hasScrollTop, setHasScrollTop] = React.useState(false)\n const [hasScrollBottom, setHasScrollBottom] = React.useState(false)\n const [hasScrollLeft, setHasScrollLeft] = React.useState(false)\n const [hasScrollRight, setHasScrollRight] = React.useState(false)\n\n const update = React.useCallback(() => {\n const el = scrollRef.current\n if (!el) return\n\n if (orientation === 'vertical') {\n // Vertical scroll detection\n const scrollY = Math.ceil(el.scrollTop)\n const scrollHeight = el.scrollHeight\n const clientHeight = el.clientHeight\n\n const newHasScrollTop = scrollY > 0\n const newHasScrollBottom = scrollY + clientHeight < scrollHeight\n\n setHasScrollTop(newHasScrollTop)\n setHasScrollBottom(newHasScrollBottom)\n } else {\n const scrollX = Math.ceil(el.scrollLeft)\n const scrollWidth = el.scrollWidth\n const clientWidth = el.clientWidth\n\n const newHasScrollLeft = scrollX > 0\n const newHasScrollRight = scrollX + clientWidth < scrollWidth\n\n setHasScrollLeft(newHasScrollLeft)\n setHasScrollRight(newHasScrollRight)\n }\n }, [orientation])\n\n React.useEffect(() => {\n const el = scrollRef.current\n if (!el) return\n\n const handleScroll = () => requestAnimationFrame(update)\n el.addEventListener('scroll', handleScroll, { passive: true })\n\n const ro = new ResizeObserver(update)\n ro.observe(el)\n\n const mo = new MutationObserver(update)\n mo.observe(el, { childList: true })\n\n update()\n\n return () => {\n el.removeEventListener('scroll', handleScroll)\n ro.disconnect()\n mo.disconnect()\n }\n }, [update])\n\n const hasScroll =\n hasScrollTop || hasScrollBottom || hasScrollLeft || hasScrollRight\n\n const ctxValue = React.useMemo(\n () => ({\n scrollRef,\n hasScroll,\n orientation,\n }),\n [hasScroll, orientation]\n )\n\n const shadowBaseClasses =\n 'pointer-events-none absolute z-20 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-300 ease-out'\n\n const shadows =\n orientation === 'vertical'\n ? [\n {\n present: hasScrollTop,\n position: 'left-0 top-0 right-0 h-8',\n slide:\n 'data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2',\n gradient:\n topShadowGradient ||\n 'bg-linear-to-b from-background to-transparent',\n },\n {\n present: hasScrollBottom,\n position: 'left-0 bottom-0 right-0 h-8',\n slide:\n 'data-[state=closed]:slide-out-to-bottom-2 data-[state=open]:slide-in-from-bottom-2',\n gradient:\n bottomShadowGradient ||\n 'bg-linear-to-t from-background to-transparent',\n },\n ]\n : [\n {\n present: hasScrollLeft,\n position: 'inset-y-0 left-0 bottom-0 w-8',\n slide:\n 'data-[state=closed]:slide-out-to-left-2 data-[state=open]:slide-in-from-left-2',\n gradient:\n leftShadowGradient ||\n 'bg-linear-to-r from-background to-transparent',\n },\n {\n present: hasScrollRight,\n position: 'inset-y-0 right-0 bottom-0 w-8',\n slide:\n 'data-[state=closed]:slide-out-to-right-2 data-[state=open]:slide-in-from-right-2',\n gradient:\n rightShadowGradient ||\n 'bg-linear-to-l from-background to-transparent',\n },\n ]\n\n const dataAttributes =\n orientation === 'vertical'\n ? {\n 'data-scroll-top': hasScrollTop,\n 'data-scroll-bottom': hasScrollBottom,\n }\n : {\n 'data-scroll-left': hasScrollLeft,\n 'data-scroll-right': hasScrollRight,\n }\n\n return (\n \n \n {children}\n\n {shadows.map((shadow, index) => (\n \n \n \n ))}\n \n \n )\n}\n\ntype ScrollAreaContentProps = React.ComponentProps<'div'>\n\nexport function ScrollAreaContent({\n className,\n children,\n ref,\n ...props\n}: ScrollAreaContentProps) {\n const { scrollRef, orientation } = useScrollAreaContext()\n\n const combinedRef = React.useCallback(\n (node: HTMLDivElement | null) => {\n scrollRef.current = node\n if (typeof ref === 'function') ref(node)\n else if (ref)\n (ref as React.RefObject).current = node\n },\n [ref, scrollRef]\n )\n\n const overflowClasses =\n orientation === 'horizontal'\n ? 'overflow-x-auto overflow-y-hidden'\n : 'overflow-y-auto h-full'\n\n const paddingClasses = orientation === 'horizontal'\n\n return (\n \n {children}\n \n )\n}\n", "type": "registry:component" } ] diff --git a/registry/components/scroll-area.tsx b/registry/components/scroll-area.tsx index 5216315..5108b91 100644 --- a/registry/components/scroll-area.tsx +++ b/registry/components/scroll-area.tsx @@ -117,7 +117,7 @@ export function ScrollAreaViewport({ ? [ { present: hasScrollTop, - position: 'left-0 top-0 right-2 h-8', + position: 'left-0 top-0 right-0 h-8', slide: 'data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2', gradient: @@ -126,7 +126,7 @@ export function ScrollAreaViewport({ }, { present: hasScrollBottom, - position: 'left-0 bottom-0 right-2 h-8', + position: 'left-0 bottom-0 right-0 h-8', slide: 'data-[state=closed]:slide-out-to-bottom-2 data-[state=open]:slide-in-from-bottom-2', gradient: @@ -137,7 +137,7 @@ export function ScrollAreaViewport({ : [ { present: hasScrollLeft, - position: 'inset-y-0 left-0 bottom-2 w-8', + position: 'inset-y-0 left-0 bottom-0 w-8', slide: 'data-[state=closed]:slide-out-to-left-2 data-[state=open]:slide-in-from-left-2', gradient: @@ -146,7 +146,7 @@ export function ScrollAreaViewport({ }, { present: hasScrollRight, - position: 'inset-y-0 right-0 bottom-2 w-8', + position: 'inset-y-0 right-0 bottom-0 w-8', slide: 'data-[state=closed]:slide-out-to-right-2 data-[state=open]:slide-in-from-right-2', gradient: @@ -221,10 +221,7 @@ export function ScrollAreaContent({ ? 'overflow-x-auto overflow-y-hidden' : 'overflow-y-auto h-full' - const paddingClasses = - orientation === 'horizontal' - ? 'pb-2' // Padding bottom para empujar el scrollbar horizontal hacia abajo - : 'pr-2' // Padding right para empujar el scrollbar vertical hacia afuera + const paddingClasses = orientation === 'horizontal' return (
Date: Sun, 12 Apr 2026 02:59:51 -0300 Subject: [PATCH 05/12] feat: add containers documentation and update meta.json - Introduced a new documentation file for "Containers" detailing all container variants and their differences. - Updated meta.json to include the new "containers" page and reorganized the existing pages for better structure. --- content/toolbox/containers.mdx | 16 ++++++++++++++++ content/toolbox/meta.json | 17 +++++++++-------- 2 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 content/toolbox/containers.mdx diff --git a/content/toolbox/containers.mdx b/content/toolbox/containers.mdx new file mode 100644 index 0000000..9286211 --- /dev/null +++ b/content/toolbox/containers.mdx @@ -0,0 +1,16 @@ +--- +title: Containers +description: All possible container variants and their differences. +maintainers: ['matiasperz'] +docLinks: + [ + { + label: 'Live Demo', + href: 'https://containers.joyco.studio', + }, + ] +--- + +Interactive reference showcasing every container layout variant. Resize the browser, toggle between variants, and adjust the text base size and scale type to see how they behave across different types of sites. + +Visit [containers.joyco.studio](https://containers.joyco.studio) to explore. diff --git a/content/toolbox/meta.json b/content/toolbox/meta.json index 3fee540..f76a83b 100644 --- a/content/toolbox/meta.json +++ b/content/toolbox/meta.json @@ -1,15 +1,16 @@ { "pages": [ - "quick-links", - "settings", - "tailwind", - "scripts", - "pr-guidelines", + "containers", + "ui", "pw-manager-ignore", + "skills", + "cw", "aliases", "agents", - "cw", - "skills", - "ui" + "pr-guidelines", + "tailwind", + "settings", + "quick-links", + "scripts" ] } From a2380c8e5635ccaf7658bb6dab148784d8709ebb Mon Sep 17 00:00:00 2001 From: Matias Perez Date: Sun, 12 Apr 2026 03:10:13 -0300 Subject: [PATCH 06/12] pd --- components/category-index.tsx | 30 ++++++++++++++++++++++++++++++ content/toolbox/figma.mdx | 18 ------------------ 2 files changed, 30 insertions(+), 18 deletions(-) delete mode 100644 content/toolbox/figma.mdx diff --git a/components/category-index.tsx b/components/category-index.tsx index c38660f..d80df5c 100644 --- a/components/category-index.tsx +++ b/components/category-index.tsx @@ -10,6 +10,24 @@ import { Badge } from './ui/badge' import { EyeIcon } from 'lucide-react' import { Fragment } from 'react' +type PageTreeNode = { + type?: string + $id?: string + url?: string + children?: PageTreeNode[] +} + +function getPageTreeOrder(category: string): string[] { + const children = source.pageTree.children as unknown as PageTreeNode[] + const folder = children.find( + (child) => child.type === 'folder' && child.$id?.split(':')[1] === category + ) + if (!folder?.children) return [] + return folder.children + .filter((child) => child.type === 'page' && child.url) + .map((child) => child.url!) +} + export async function CategoryIndex({ category, }: { @@ -18,6 +36,18 @@ export async function CategoryIndex({ const pages = source .getPages() .filter((page) => page.slugs[0] === category && page.slugs.length > 1) + + // Sort pages based on meta.json order (reflected in the page tree) + const pageTreeOrder = getPageTreeOrder(category) + if (pageTreeOrder.length > 0) { + const orderMap = new Map(pageTreeOrder.map((url, i) => [url, i])) + pages.sort((a, b) => { + const aIndex = orderMap.get(a.url) ?? Infinity + const bIndex = orderMap.get(b.url) ?? Infinity + return aIndex - bIndex + }) + } + if (category === 'logs') pages.reverse() const typeMap: Record = { components: 'component', diff --git a/content/toolbox/figma.mdx b/content/toolbox/figma.mdx deleted file mode 100644 index 998c581..0000000 --- a/content/toolbox/figma.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Figma Plugin -description: A Figma plugin to bridge design and development. Work in progress. ---- - -
- - - - - Work in Progress -
- -JOYCO is building something that will close the gap between design and development. Coming soon (bother [@joyboy](https://x.com/__JOYBOY___) to speed it up 😉). - -## RFC Documents - -[Typography tokenization approach RFC](https://joycostudio.notion.site/Design-systems-RFC-Code-Figma-2b7ee5fdb1b580a484abe564755581bc) From 16e7d9e7927f26df6ed6d2932669aec5f84cb9b4 Mon Sep 17 00:00:00 2001 From: Matias Perez Date: Sun, 12 Apr 2026 15:36:19 -0300 Subject: [PATCH 07/12] upd --- content/toolbox/containers.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/content/toolbox/containers.mdx b/content/toolbox/containers.mdx index 9286211..6eb479e 100644 --- a/content/toolbox/containers.mdx +++ b/content/toolbox/containers.mdx @@ -13,4 +13,6 @@ docLinks: Interactive reference showcasing every container layout variant. Resize the browser, toggle between variants, and adjust the text base size and scale type to see how they behave across different types of sites. +
{!isTopCategoryPage && relatedItems.length > 0 && ( ) { const gameSlugs = getGameSlugs() const effectSlugs = getEffectSlugs() const canvasSlugs = getCanvasSlugs() + const librarySlugs = getLibrarySlugs() const { experiments } = await getExperiments() return ( @@ -55,6 +57,7 @@ export default async function Layout({ children }: LayoutProps<'/'>) { gameSlugs={gameSlugs} effectSlugs={effectSlugs} canvasSlugs={canvasSlugs} + librarySlugs={librarySlugs} experiments={experiments} /> {children} diff --git a/components/code-source.tsx b/components/code-source.tsx index 50dbfef..5a7d938 100644 --- a/components/code-source.tsx +++ b/components/code-source.tsx @@ -4,7 +4,7 @@ import { highlightCode, getLanguageFromExtension, stripFrontmatter, -} from '@/lib/shiki' +} from '@/lib/mdx' import { CodeBlock } from '@/components/code-block' export async function FileCodeblock({ diff --git a/components/content.tsx b/components/content.tsx new file mode 100644 index 0000000..dd2f3f2 --- /dev/null +++ b/components/content.tsx @@ -0,0 +1,15 @@ +import { Markdown } from 'fumadocs-core/content' +import { getMDXComponents } from '@/mdx-components' +import { rehypePlugins, remarkPlugins } from '@/lib/mdx' + +export function MDXContent({ content }: { content: string }) { + return ( + + {content} + + ) +} diff --git a/components/layout/sidebar/index.tsx b/components/layout/sidebar/index.tsx index d70661e..b418b41 100644 --- a/components/layout/sidebar/index.tsx +++ b/components/layout/sidebar/index.tsx @@ -24,6 +24,7 @@ type RegistrySidebarProps = { gameSlugs?: string[] effectSlugs?: string[] canvasSlugs?: string[] + librarySlugs?: string[] experiments?: Experiment[] } @@ -33,6 +34,7 @@ export function RegistrySidebar({ gameSlugs = [], effectSlugs = [], canvasSlugs = [], + librarySlugs = [], experiments = [], }: RegistrySidebarProps) { const isTeam = useIsTeam() @@ -119,6 +121,7 @@ export function RegistrySidebar({ gameSlugs={gameSlugs} effectSlugs={effectSlugs} canvasSlugs={canvasSlugs} + librarySlugs={librarySlugs} /> ) diff --git a/components/layout/sidebar/section.tsx b/components/layout/sidebar/section.tsx index 09b1e7d..7659bef 100644 --- a/components/layout/sidebar/section.tsx +++ b/components/layout/sidebar/section.tsx @@ -11,7 +11,7 @@ import FileIcon from '@/components/icons/file' import FlaskIcon from '@/components/icons/flask' import GamepadIcon from '@/components/icons/gamepad' import TextScanIcon from '@/components/icons/text-scan' -import { Frame, Minus, Plus } from 'lucide-react' +import { Frame, Library, Minus, Plus } from 'lucide-react' import { getLogNumber, stripLogPrefixFromTitle } from '@/lib/log-utils' import { MetaBadge } from '@/components/layout/meta-badge' @@ -214,6 +214,7 @@ type SidebarSectionProps = { gameSlugs?: string[] effectSlugs?: string[] canvasSlugs?: string[] + librarySlugs?: string[] } /** @@ -227,6 +228,7 @@ export function SidebarSection({ gameSlugs = [], effectSlugs = [], canvasSlugs = [], + librarySlugs = [], }: SidebarSectionProps) { const [isOpen, setIsOpen] = React.useState(defaultOpen) const pathname = usePathname() @@ -309,7 +311,44 @@ export function SidebarSection({ ) } - // For other sections (Toolbox, Logs), render with collapsible header + // For toolbox section, split into General and Libraries + if (sectionId === 'toolbox' && librarySlugs.length > 0) { + const isLibrary = (url: string) => { + const slug = url.split('/').pop() ?? '' + return librarySlugs.includes(slug) + } + + const pages = folder.children.filter( + (child): child is PageTree.Item => child.type === 'page' + ) + const generalPages = pages.filter((page) => !isLibrary(page.url)) + const libraryPages = pages.filter((page) => isLibrary(page.url)) + + return ( +
+ + {libraryPages.length > 0 && ( + + )} +
+ ) + } + + // For other sections (Logs), render with collapsible header return (