diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx index 72276ae..e4743e8 100644 --- a/apps/docs/app/layout.tsx +++ b/apps/docs/app/layout.tsx @@ -3,6 +3,7 @@ import { DocsRoot, isAssistantEnabled } from 'docora'; import docsConfig from '../docs.config'; import { rootMetadata, rootViewport } from '../lib/metadata'; import { flattenSections } from '../lib/navigation'; +import { isNewHref } from '../lib/new-items'; import { source } from '../lib/source'; import './globals.css'; @@ -14,7 +15,7 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac return ( {children} diff --git a/apps/docs/components/mdx/component-card.tsx b/apps/docs/components/mdx/component-card.tsx index 74b4f7b..e28b9b0 100644 --- a/apps/docs/components/mdx/component-card.tsx +++ b/apps/docs/components/mdx/component-card.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { prefersReducedMotion } from '@/lib/motion'; +import { isNewItem } from '@/lib/new-items'; import { hrefFor } from '@/lib/registry'; import { cn } from '@/lib/utils'; import type { RegistryEntry } from '@/registry/schema'; @@ -63,8 +64,8 @@ export function ComponentCard({ entry }: { entry: RegistryEntry }) { >

{entry.title}

- {entry.isNew && ( - + {isNewItem(entry.name) && ( + New )} diff --git a/apps/docs/components/mdx/component-gallery.tsx b/apps/docs/components/mdx/component-gallery.tsx index 29a2408..552b0e3 100644 --- a/apps/docs/components/mdx/component-gallery.tsx +++ b/apps/docs/components/mdx/component-gallery.tsx @@ -38,26 +38,15 @@ const GRID = 'grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3'; const byTitle = (a: RegistryEntry, b: RegistryEntry) => a.title.localeCompare(b.title); /** - * Gallery page. Renders the registry as cards, with a "New" section first - * (like the reference layout), then all components, then all blocks. + * Gallery page. Renders the registry as cards: all components, then all blocks. + * The newest of each wear a "New" badge on their card and in the sidebar. */ export function ComponentGallery() { const components = registry.filter((e) => e.type === 'component').sort(byTitle); const blocks = registry.filter((e) => e.type === 'block').sort(byTitle); - const newest = components.filter((e) => e.isNew); return (
- {newest.length > 0 && ( - -
- {newest.map((entry) => ( - - ))} -
-
- )} -
{components.map((entry) => ( diff --git a/apps/docs/components/nav/sidebar-nav.tsx b/apps/docs/components/nav/sidebar-nav.tsx index dbf564a..595f616 100644 --- a/apps/docs/components/nav/sidebar-nav.tsx +++ b/apps/docs/components/nav/sidebar-nav.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import type { NavItem } from 'docora'; import gsap from 'gsap'; +import type { TweenNavItem } from '@/lib/navigation'; import { cn } from '@/lib/utils'; import { AnimatedIcon } from './animated-icon'; @@ -24,7 +25,7 @@ function NavTree({ onNavigate, rows, }: Readonly<{ - items: NavItem[]; + items: TweenNavItem[]; level: number; onNavigate?: () => void; rows: RowState; @@ -72,6 +73,11 @@ function NavTree({ /> )} {item.label} + {item.isNew && ( + + New + + )} ) : ( diff --git a/apps/docs/lib/navigation.ts b/apps/docs/lib/navigation.ts index ceb18da..a885e65 100644 --- a/apps/docs/lib/navigation.ts +++ b/apps/docs/lib/navigation.ts @@ -1,5 +1,11 @@ import type { NavItem } from 'docora'; +/** A sidebar row, plus whether its page is one of the newest items. */ +export type TweenNavItem = Omit & { + isNew?: boolean; + children?: TweenNavItem[]; +}; + /** Collect every linkable leaf page beneath the given nav items, depth-first. */ function collectLeaves(items: NavItem[]): NavItem[] { return items.flatMap((item) => @@ -26,8 +32,14 @@ const isCatalog = (leaves: NavItem[]) => * * The on-disk folder structure (and therefore page URLs) is untouched — this * only reshapes what the sidebar renders. + * + * `isNew` picks the catalog pages that get a "New" badge; it is passed in so + * this stays a pure reshape of whatever tree it is given. */ -export function flattenSections(items: NavItem[]): NavItem[] { +export function flattenSections( + items: NavItem[], + isNew: (href: string) => boolean = () => false +): TweenNavItem[] { return items.map((item) => { // Only transform section groups: no link of their own, but with children. if (item.href !== undefined || !item.children || item.children.length === 0) { @@ -42,7 +54,9 @@ export function flattenSections(items: NavItem[]): NavItem[] { return { ...item, label: `${item.label} (${leaves.length})`, - children: leaves.sort((a, b) => a.label.localeCompare(b.label)), + children: leaves + .sort((a, b) => a.label.localeCompare(b.label)) + .map((leaf) => (leaf.href && isNew(leaf.href) ? { ...leaf, isNew: true } : leaf)), }; }); } diff --git a/apps/docs/lib/new-items.ts b/apps/docs/lib/new-items.ts new file mode 100644 index 0000000..a8e239d --- /dev/null +++ b/apps/docs/lib/new-items.ts @@ -0,0 +1,41 @@ +import { hrefFor, registry } from '@/registry/index'; +import type { RegistryEntry, RegistryType } from '@/registry/schema'; + +/** How many items of each type wear the "New" badge at once. */ +export const NEW_LIMIT = 4; + +/** + * The newest `limit` entries of one type, newest first. Ordered by `addedAt`; + * items added the same day fall back to registry order, later entries first, + * so the one appended last always counts as the newest. Adding a fifth item + * therefore pushes the oldest of the four out. + */ +export function newestOfType( + entries: readonly RegistryEntry[], + type: RegistryType, + limit = NEW_LIMIT +): RegistryEntry[] { + return entries + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => entry.type === type) + .sort((a, b) => b.entry.addedAt.localeCompare(a.entry.addedAt) || b.index - a.index) + .slice(0, limit) + .map(({ entry }) => entry); +} + +/** Names of the newest components and the newest blocks, `NEW_LIMIT` of each. */ +export function newestNames(entries: readonly RegistryEntry[], limit = NEW_LIMIT): Set { + return new Set( + (['component', 'block'] as const).flatMap((type) => + newestOfType(entries, type, limit).map((entry) => entry.name) + ) + ); +} + +const NEW_NAMES = newestNames(registry); +const NEW_HREFS = new Set(registry.filter((e) => NEW_NAMES.has(e.name)).map(hrefFor)); + +export const isNewItem = (name: string) => NEW_NAMES.has(name); + +/** Doc URL form, for the sidebar, which only knows each page by its href. */ +export const isNewHref = (href: string) => NEW_HREFS.has(href); diff --git a/apps/docs/registry/registry-blocks.ts b/apps/docs/registry/registry-blocks.ts index 97f1ec7..daf99f1 100644 --- a/apps/docs/registry/registry-blocks.ts +++ b/apps/docs/registry/registry-blocks.ts @@ -9,6 +9,7 @@ export const blocks: RegistryEntry[] = [ { name: 'pricing-plan-switch', type: 'block', + addedAt: '2026-09-09', title: 'Pricing Plan Switch', description: 'A pricing section where picking a plan morphs the name badge, crossfades the description and spins the price with Number Flow, while the checklist lights up its included rows. A monthly/yearly toggle re-spins the price.', @@ -41,6 +42,7 @@ export default function Example() { { name: 'process-card-reveal', type: 'block', + addedAt: '2026-09-09', title: 'Process Card Reveal', description: 'Numbered steps on a timeline whose connector fills before the next card slides and blurs into place — image one way, copy the other. Hover pauses the loop; clicking a step jumps there.', @@ -73,6 +75,7 @@ export default function Example() { { name: 'process-sticky-steps', type: 'block', + addedAt: '2026-09-09', title: 'Process Sticky Steps', description: 'A process block whose heading, divider and step numbers stay sticky inside the section while the cards scroll past them. No GSAP — just Intersection Observer.', @@ -105,6 +108,7 @@ export default function Example() { { name: 'strip-wipe', type: 'block', + addedAt: '2026-09-09', title: 'Strip Wipe', description: 'A split testimonial slider whose photo wipes in as tiled strips beside the quote, the index ticking over on Number Flow. Prev and next reverse the wipe direction.', @@ -137,6 +141,7 @@ export default function Example() { { name: 'rating-carousel', type: 'block', + addedAt: '2026-09-09', title: 'Rating Carousel', description: 'A Swiper testimonial row of rating cards: drag or swipe it, and off-window cards fade and blur as they leave. Autoplay pauses on hover, and dots jump to a card.', @@ -169,6 +174,7 @@ export default function Example() { { name: 'cta-image-fan', type: 'block', + addedAt: '2026-09-09', title: 'CTA Image Fan', description: 'A call-to-action with a fanned photo stack auto-advancing above the headline and a shiny button below. Built from Image Fan Slider and Shiny Button.', @@ -201,6 +207,7 @@ export default function Example() { { name: 'cta-photo-headline', type: 'block', + addedAt: '2026-09-09', title: 'CTA Photo Headline', description: 'A call-to-action where avatars pop in above a heading with two photos tucked into the copy — a pill that grows from the left, then a tilted square that drops into place.', @@ -233,6 +240,7 @@ export default function Example() { { name: 'blog-hover-expand', type: 'block', + addedAt: '2026-09-09', title: 'Blog Hover Expand', description: 'A three-post blog row where the hovered card grows wider, its photo scales and tilts, and the title draws an underline. Pure CSS.', @@ -265,6 +273,7 @@ export default function Example() { { name: 'integration-hub', type: 'block', + addedAt: '2026-09-09', title: 'Integration Hub', description: 'A hub-and-spoke integrations diagram: the center pops in, paths draw out to six logos, then a dot travels each line. Uses MotionPathPlugin.', @@ -297,6 +306,7 @@ export default function Example() { { name: 'infinite-rotating-cards', type: 'block', + addedAt: '2026-09-20', title: 'Infinite Rotating Cards', description: 'A hero wheel of photo cards set on a large circle so only the top arc shows, spinning slowly so the next idea always comes into view.', @@ -329,6 +339,7 @@ export default function Example() { { name: 'card-hover-expand', type: 'block', + addedAt: '2026-09-09', title: 'Card Hover Expand', description: 'A card row where the hovered card grows wider, its photo fades in and the title drops to the bottom over a gradient. Every card stays expanded on small screens. Pure CSS.', @@ -361,6 +372,7 @@ export default function Example() { { name: 'card-spotlight-grid', type: 'block', + addedAt: '2026-09-20', title: 'Card Spotlight Grid', description: 'A staggered project grid — tile heights cycle every six cards — where the hovered tile tilts toward the cursor in 3D while its siblings scale back and dim. Pointer-only.', @@ -393,6 +405,7 @@ export default function Example() { { name: 'column-drift', type: 'block', + addedAt: '2026-09-09', title: 'Column Drift', description: 'Three columns of review cards that swing in from the sides as the section scrolls, then drift past each other at different speeds. Cards lift on hover with an underline wipe. GSAP + ScrollTrigger.', @@ -425,6 +438,7 @@ export default function Example() { { name: 'team-scatter-focus', type: 'block', + addedAt: '2026-09-09', title: 'Team Scatter Focus', description: 'A scattered team collage whose cards each fly in from their own direction, then blur and shrink back from whichever one you hover. Falls back to a stacked grid below lg.', @@ -457,6 +471,7 @@ export default function Example() { { name: 'tab-wipe', type: 'block', + addedAt: '2026-09-20', title: 'Tab Wipe', description: 'A centered screenshot stage that wipes between slides on a clip-path edge while the incoming shot settles out of a slight scale and drift. The thumbnail strip is a real tablist, and the stage autoplays between clicks.', @@ -489,6 +504,7 @@ export default function Example() { { name: 'logo-wall-shuffle', type: 'block', + addedAt: '2026-09-09', title: 'Logo Wall Shuffle', description: 'An integrations wall of staggered logo tiles where one mark at a time lifts away and the next rises in behind it. Every tile is visited before any repeats, and the loop pauses off-screen and in background tabs.', @@ -527,6 +543,7 @@ export default function Example() { { name: 'testimonial-line-sweep', type: 'block', + addedAt: '2026-09-09', title: 'Testimonial Line Sweep', description: 'A paged testimonial pair whose text sweeps out line by line behind masks while the portrait irises shut, then the next quote arrives the same way. Pages with buttons or arrow keys. GSAP + SplitText.', @@ -559,6 +576,7 @@ export default function Example() { { name: 'cta-starfall', type: 'block', + addedAt: '2026-09-09', title: 'CTA Starfall', description: 'A dark call-to-action section under a slow field of drifting stars lit by a violet bloom. The field is full on the first frame rather than spawning over time, and pauses off-screen and in background tabs.', @@ -591,6 +609,7 @@ export default function Example() { { name: 'pricing-usage-slider', type: 'block', + addedAt: '2026-09-09', title: 'Pricing Usage Slider', description: 'A two-plan pricing section where the second card prices itself from a volume slider, the figure spinning with Number Flow as you drag while the track fills behind the handle.', @@ -623,6 +642,7 @@ export default function Example() { { name: 'grid-cascade', type: 'block', + addedAt: '2026-09-09', title: 'Grid Cascade', description: 'A three-up testimonial grid that pages a whole set at a time — the current cards drop away on a stagger, then the next set cascades in from above, so the two never overlap mid-flight.', @@ -655,10 +675,10 @@ export default function Example() { { name: 'cube-roll-item', type: 'block', + addedAt: '2026-09-22', title: 'Cube Roll Item', description: 'A numbered section index where each row is a 3D drum that rolls a quarter turn onto a filled face on hover. The roll follows the pointer — in from the top rolls down, out through the bottom keeps rolling down — while the rules draw in on load.', - isNew: true, media: {}, dependencies: ['gsap', '@gsap/react'], registryDependencies: [], @@ -688,10 +708,10 @@ export default function Example() { { name: 'shutter-slider', type: 'block', + addedAt: '2026-09-23', title: 'Shutter Slider', description: 'An autoplaying feature slider. Each image opens through vertical slats that alternate from the top and bottom, the headline letters roll in like drum faces, copy lines rise through masks and the caption pulls into focus — all mirrored when you travel back.', - isNew: true, media: {}, dependencies: ['gsap', '@gsap/react'], registryDependencies: [], @@ -721,10 +741,10 @@ export default function Example() { { name: 'testimonial-reel-focus', type: 'block', + addedAt: '2026-09-27', title: 'Testimonial Reel Focus', description: 'A reel of testimonial cards wider than the window, run sideways by vertical page scroll from inside a sticky stage. Each card rises as it crosses in, so scrolling sends a swell along the row; a tick strip reads the run back like a scrubber, and hovering a card pulls it into focus while the rest of the row dims and blurs behind it.', - isNew: true, media: {}, dependencies: ['gsap', '@gsap/react'], registryDependencies: [], @@ -755,10 +775,10 @@ export default function Example() { { name: 'kinetic-type-ring', type: 'block', + addedAt: '2026-10-01', title: 'Kinetic Type Ring', description: 'A phrase wrapped once around a ring in real 3D. A falling dot pulls the letters out of a blur, then drag or scroll spins the ring.', - isNew: true, media: {}, dependencies: ['gsap', '@gsap/react'], registryDependencies: [], @@ -789,10 +809,10 @@ export default function Example() { { name: 'footer-wordmark-rise', type: 'block', + addedAt: '2026-10-04', title: 'Footer Wordmark Rise', description: 'A dark site footer that rises into view over a glow swelling off the bottom edge, closed by a giant wordmark fitted to the column whose letters climb out of their masks one by one.', - isNew: true, media: {}, dependencies: ['gsap', '@gsap/react'], registryDependencies: [], @@ -823,10 +843,10 @@ export default function Example() { { name: 'loader-line-fold', type: 'block', + addedAt: '2026-10-07', title: 'Loader Line Fold', description: 'A page preloader: the wordmark rises letter by letter while a hairline fills with a counter riding its tip. Once the page is ready the line travels to the middle, folds into a single point, and the page opens out of that point to every edge.', - isNew: true, media: {}, dependencies: ['gsap', '@gsap/react'], registryDependencies: [], @@ -857,10 +877,10 @@ export default function Example() { { name: 'loader-iris-ring', type: 'block', + addedAt: '2026-10-07', title: 'Loader Iris Ring', description: 'A page preloader built around a ring gauge: the arc fills in uneven steps while images flash through its lens and four corner counters sync and go live. Then an iris opens from the lens and the ring flies into the logo in your nav.', - isNew: true, media: {}, dependencies: ['gsap', '@gsap/react', '@number-flow/react'], registryDependencies: [], @@ -891,10 +911,10 @@ export default function Example() { { name: 'testimonial-ring', type: 'block', + addedAt: '2026-10-07', title: 'Testimonial Ring', description: 'Testimonial cards stood on a 3D ring over a reflecting grid floor. Drag it and it spins with inertia before snapping to a card; scrolling turns it too. Whichever card comes to the front blurs its quote in and rolls its metric on Number Flow.', - isNew: true, media: {}, dependencies: ['gsap', '@gsap/react', '@number-flow/react'], registryDependencies: [], diff --git a/apps/docs/registry/registry-ui.ts b/apps/docs/registry/registry-ui.ts index 33ed621..a2a9f7a 100644 --- a/apps/docs/registry/registry-ui.ts +++ b/apps/docs/registry/registry-ui.ts @@ -8,6 +8,7 @@ export const ui: RegistryEntry[] = [ { name: 'icon-trail-button', type: 'component', + addedAt: '2026-09-09', title: 'Icon Trail Button', description: 'A pill button whose icon pill expands on hover or focus, playing out a staggered chevron trail before it parks. CSS transitions and a small state machine — no animation library.', @@ -46,6 +47,7 @@ export default function Example() { { name: 'sliding-tabs', type: 'component', + addedAt: '2026-09-09', title: 'Sliding Tabs', description: 'A pill nav whose lime indicator scales in under the first tab, then slides to follow the pointer or keyboard focus. Leaving the nav returns it to the active tab.', @@ -87,6 +89,7 @@ export default function Example() { { name: 'avatar-reveal', type: 'component', + addedAt: '2026-09-09', title: 'Avatar Reveal', description: 'Three stacked avatars that scale and unblur in from the left on an elastic ease, then a caption slides in from the right.', @@ -119,6 +122,7 @@ export default function Example() { { name: 'auth-modal', type: 'component', + addedAt: '2026-09-09', title: 'Auth Modal', description: 'A login modal: the backdrop fades, the panel scales up, then the close button, title, sign-in providers and email field stagger in. Closing plays the sequence in reverse.', @@ -161,6 +165,7 @@ export default function Example() { { name: 'number-counter', type: 'component', + addedAt: '2026-09-09', title: 'Number Counter', description: 'An animated stats counter whose digits spin up once when the stat scrolls into view. ScrollTrigger fires it, Number Flow handles the count.', @@ -202,6 +207,7 @@ export default function Example() { { name: 'voice-sample-player', type: 'component', + addedAt: '2026-09-09', title: 'Voice Sample Player', description: 'An audio player pill: press play and the name slides away, a lime waveform equalizer slides in and starts oscillating, and the icon morphs to pause. Pass a src to play real audio.', @@ -241,6 +247,7 @@ export default function Example() { { name: 'flip-card', type: 'component', + addedAt: '2026-09-09', title: 'Flip Card', description: 'A card that flips in 3D on hover or tap — the front rotates out while the back rotates in with its description, feature list and CTA staggering into place.', @@ -284,6 +291,7 @@ export default function Example() { { name: 'faq-accordion', type: 'component', + addedAt: '2026-09-09', title: 'FAQ Accordion', description: 'A single-open accordion that animates its height open while the answer reveals line by line through a SplitText mask and the icon morphs from plus to minus.', @@ -321,6 +329,7 @@ export default function Example() { { name: 'shiny-button', type: 'component', + addedAt: '2026-09-09', title: 'Shiny Button', description: 'A pill button with a diagonal light sweep that glides across on hover while the leading sparkle twinkles. Pure CSS — no GSAP dependency.', @@ -353,6 +362,7 @@ export default function Example() { { name: 'text-roll-button', type: 'component', + addedAt: '2026-09-09', title: 'Text Roll Button', description: 'A pill button whose label rolls up character by character on hover — the current text lifts out the top as a fresh copy rolls in from below, on a fine per-character stagger.', @@ -385,6 +395,7 @@ export default function Example() { { name: 'slide-arrow-button', type: 'component', + addedAt: '2026-09-09', title: 'Slide Arrow Button', description: 'A pill button whose label rolls up on hover while the chevron slides out of its lime badge and a fresh one slides in from the left. Pure CSS — no GSAP dependency.', @@ -417,6 +428,7 @@ export default function Example() { { name: 'glow-button', type: 'component', + addedAt: '2026-09-09', title: 'Glow Button', description: 'A pill button floating over a soft red, amber, violet and cyan glow that sharpens as the button lifts and a chevron slides through its slot. Pure CSS — no GSAP dependency.', @@ -449,10 +461,10 @@ export default function Example() { { name: 'magnetic-button', type: 'component', + addedAt: '2026-09-30', title: 'Magnetic Button', description: 'A pill button pulled toward the cursor once it comes near, its label drifting a little further for parallax depth, then easing back to rest when the cursor leaves the field.', - isNew: true, media: {}, dependencies: ['gsap', '@gsap/react'], registryDependencies: [], @@ -482,10 +494,10 @@ export default function Example() { { name: 'dot-bloom-button', type: 'component', + addedAt: '2026-10-04', title: 'Dot Bloom Button', description: 'A button whose leading dot blooms into a fill that floods the whole surface on hover, while the label rolls up to a fresh copy and flips to the contrasting color.', - isNew: true, media: {}, dependencies: [], registryDependencies: [], @@ -515,6 +527,7 @@ export default function Example() { { name: 'logo-orbit', type: 'component', + addedAt: '2026-09-09', title: 'Logo Orbit', description: 'Logos spaced evenly around a ring that rotates continuously, each mark counter-rotated so it always stays upright. Hover pauses the orbit.', @@ -553,6 +566,7 @@ export default function Example() { { name: 'logo-cycle', type: 'component', + addedAt: '2026-09-09', title: 'Logo Cycle', description: 'A logo row that holds a fixed count and cycles the extras: the current set staggers up and blurs out as the next slides in from below. Hover pauses the loop.', @@ -596,6 +610,7 @@ export default function Example() { { name: 'logo-wave', type: 'component', + addedAt: '2026-09-09', title: 'Logo Wave', description: 'An endless logo strip where the mark nearest the midpoint rises and scales up while its neighbors step down into a wave. Hover pauses the marquee.', @@ -637,6 +652,7 @@ export default function Example() { { name: 'image-fan-slider', type: 'component', + addedAt: '2026-09-09', title: 'Image Fan Slider', description: 'An image gallery slider: a fan of photos that auto-advances — the active one large and sharp, its neighbors rotated and blurred on each side. Hover pauses. Pure CSS transitions.', @@ -675,10 +691,10 @@ export default function Example() { { name: 'morphing-text', type: 'component', + addedAt: '2026-09-21', title: 'Morphing Text', description: 'A headline text animation whose last word cycles through a list with a gooey blur morph — an SVG alpha threshold fuses the outgoing and incoming words into one liquid shape while the slot eases between word widths.', - isNew: true, media: {}, dependencies: ['gsap', '@gsap/react'], registryDependencies: [], @@ -714,10 +730,10 @@ export default function Example() { { name: 'wordmark-reveal', type: 'component', + addedAt: '2026-09-27', title: 'Wordmark Reveal', description: 'A display wordmark text reveal that assembles itself on the first scroll — every letter turns in on its own vertical axis, then a script line leans in along the baseline. A cue holds the screen until you answer it, or it plays on mount with `instant`. ScrollTrigger and SplitText.', - isNew: true, media: {}, dependencies: ['gsap', '@gsap/react'], registryDependencies: [], diff --git a/apps/docs/registry/schema.ts b/apps/docs/registry/schema.ts index f0fcd2c..2cd6b03 100644 --- a/apps/docs/registry/schema.ts +++ b/apps/docs/registry/schema.ts @@ -43,8 +43,12 @@ export interface RegistryEntry { type: RegistryType; title: string; description: string; - /** Show a "New" badge and list under the New section on the home gallery. */ - isNew?: boolean; + /** + * The day the item joined the registry, as `YYYY-MM-DD`. The newest few of + * each type wear a "New" badge in the sidebar and on their gallery card + * (see `lib/new-items.ts`), so a fresh item pushes the oldest badge off. + */ + addedAt: string; /** Gallery card media slot (between title and description). */ media?: RegistryMedia; /** npm deps a consumer needs (e.g. "gsap"). Empty for CSS-only. */ diff --git a/apps/docs/tests/navigation.test.ts b/apps/docs/tests/navigation.test.ts index 67b8cf1..ce8ae33 100644 --- a/apps/docs/tests/navigation.test.ts +++ b/apps/docs/tests/navigation.test.ts @@ -37,4 +37,34 @@ describe('flattenSections', () => { 'Shiny Button', ]); }); + + it('marks only the catalog pages it is told are new', () => { + const [installation, components] = flattenSections( + [ + { label: 'Installation', children: [page('Setup Guide', '/installation/setup-guide')] }, + { + label: 'Components', + children: [ + page('Shiny Button', '/component/shiny-button'), + page('Flip Card', '/component/flip-card'), + ], + }, + ], + (href) => href === '/component/shiny-button' || href === '/installation/setup-guide' + ); + + expect(installation?.children?.[0]).not.toHaveProperty('isNew'); + expect(components?.children?.map((child) => [child.label, child.isNew ?? false])).toEqual([ + ['Flip Card', false], + ['Shiny Button', true], + ]); + }); + + it('marks nothing when no picker is given', () => { + const [components] = flattenSections([ + { label: 'Components', children: [page('Flip Card', '/component/flip-card')] }, + ]); + + expect(components?.children?.[0]?.isNew).toBeUndefined(); + }); }); diff --git a/apps/docs/tests/new-items.test.ts b/apps/docs/tests/new-items.test.ts new file mode 100644 index 0000000..debdf76 --- /dev/null +++ b/apps/docs/tests/new-items.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { isNewHref, isNewItem, NEW_LIMIT, newestNames, newestOfType } from '@/lib/new-items'; +import { registry } from '@/lib/registry'; +import type { RegistryEntry, RegistryType } from '@/registry/schema'; + +const item = (name: string, type: RegistryType, addedAt: string) => + ({ name, type, addedAt }) as RegistryEntry; + +const names = (entries: RegistryEntry[]) => entries.map((entry) => entry.name); + +describe('newestOfType', () => { + it('keeps the newest four, newest first', () => { + const entries = [ + item('a', 'component', '2026-01-01'), + item('b', 'component', '2026-01-05'), + item('c', 'component', '2026-01-03'), + item('d', 'component', '2026-01-04'), + item('e', 'component', '2026-01-02'), + ]; + + expect(names(newestOfType(entries, 'component'))).toEqual(['b', 'd', 'c', 'e']); + }); + + it('drops the oldest of the four when a fifth item is added', () => { + const four = [ + item('one', 'block', '2026-02-01'), + item('two', 'block', '2026-02-02'), + item('three', 'block', '2026-02-03'), + item('four', 'block', '2026-02-04'), + ]; + expect(names(newestOfType(four, 'block'))).toContain('one'); + + const five = [...four, item('five', 'block', '2026-02-05')]; + expect(names(newestOfType(five, 'block'))).toEqual(['five', 'four', 'three', 'two']); + }); + + it('breaks a same-day tie by registry order, the later entry being newer', () => { + const entries = [ + item('old', 'block', '2026-03-01'), + item('first-today', 'block', '2026-03-09'), + item('second-today', 'block', '2026-03-09'), + ]; + + expect(names(newestOfType(entries, 'block', 2))).toEqual(['second-today', 'first-today']); + }); + + it('counts components and blocks separately', () => { + const entries = [ + ...['a', 'b', 'c', 'd', 'e'].map((n, i) => item(`c-${n}`, 'component', `2026-04-0${i + 1}`)), + item('b-old', 'block', '2025-01-01'), + ]; + + const picked = newestNames(entries); + expect(picked.size).toBe(5); + expect(picked.has('b-old')).toBe(true); + expect(picked.has('c-a')).toBe(false); + }); +}); + +describe('the live registry', () => { + for (const type of ['component', 'block'] as const) { + it(`badges exactly ${NEW_LIMIT} ${type}s, the newest ones`, () => { + const ofType = registry.filter((entry) => entry.type === type); + const badged = ofType.filter((entry) => isNewItem(entry.name)); + expect(badged).toHaveLength(Math.min(NEW_LIMIT, ofType.length)); + + const newestBadged = Math.min(...badged.map((entry) => Date.parse(entry.addedAt))); + for (const entry of ofType.filter((e) => !isNewItem(e.name))) { + expect(Date.parse(entry.addedAt)).toBeLessThanOrEqual(newestBadged); + } + }); + } + + it('answers by doc URL as well as by name', () => { + const [entry] = newestOfType(registry, 'block', 1); + expect(entry).toBeDefined(); + expect(isNewHref(`/block/${entry!.name}`)).toBe(true); + expect(isNewHref(`/component/${entry!.name}`)).toBe(false); + }); +}); diff --git a/apps/docs/tests/registry.test.ts b/apps/docs/tests/registry.test.ts index 65bb21a..5bc707a 100644 --- a/apps/docs/tests/registry.test.ts +++ b/apps/docs/tests/registry.test.ts @@ -19,6 +19,13 @@ describe('registry integrity', () => { expect(entry.description).toBeTruthy(); }); + it('records the day it was added as a real YYYY-MM-DD date', () => { + expect(entry.addedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(new Date(`${entry.addedAt}T00:00:00Z`).toISOString().slice(0, 10)).toBe( + entry.addedAt + ); + }); + it('declares a cssVars object (may be empty when values are inline)', () => { expect(typeof entry.cssVars).toBe('object'); });