Leveling Up the Developer Experience: Portraits, Docs, and a Clear Path Forward
A deep dive into our latest development session, covering new persona portraits, significant enhancements to our documentation viewer, and a strategic look at our upcoming task list and persistent development challenges.
There's a unique satisfaction that comes from wrapping up a development session with a clear list of completed features and a comprehensive plan for what's next. This past session, I tackled a trio of improvements designed to enhance both user experience and developer productivity: bringing our AI personas to life with unique portraits, overhauling our in-app documentation viewer, and consolidating our project's open tasks into a single, prioritized list.
The best part? All three goals were achieved with a clean slate – type checks passed on the first attempt, and no major issues cropped up. It's those sessions that make the persistent challenges feel a little less daunting.
Let's dive into what we built.
Bringing Personas to Life with Visuals
Our platform uses AI personas, and while their capabilities are powerful, they can feel a bit abstract. Adding unique portrait images was a crucial step to make them more tangible and engaging. This involved work across the full stack.
The Foundation: Database & Assets
The journey began in a prior session by extending our Persona model in Prisma, adding a nullable imageUrl field. This simple addition paved the way for storing image paths. We then populated a PORTRAIT_IMAGES array in our constants.ts with 54 distinct image paths, mirroring the actual PNG files copied into public/images/personas/.
// prisma/schema.prisma
model Persona {
id String @id @default(cuid())
name String
// ... other persona fields
imageUrl String? // The new field for persona portraits
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Smart Image Selection with tRPC
To ensure a diverse and fresh experience, we couldn't just pick images randomly. Our src/server/trpc/routers/personas.ts was enhanced with a nextImage procedure. This clever bit of logic queries all currently used imageUrl values, filters our PORTRAIT_IMAGES array for any unused ones, and then returns a random pick from the remaining options. If, by some chance, all images are in use, it gracefully falls back to picking any random image, preventing UI breakage.
// src/server/trpc/routers/personas.ts
export const personasRouter = t.router({
// ... other procedures
nextImage: publicProcedure.query(async ({ ctx }) => {
// Logic to fetch all used image URLs from the database
const usedImageUrls = await ctx.db.persona.findMany({
select: { imageUrl: true },
where: { imageUrl: { not: null } },
}).then(personas => personas.map(p => p.imageUrl!).filter(Boolean));
// Filter PORTRAIT_IMAGES to find unused ones
const unusedImages = PORTRAIT_IMAGES.filter(
(url) => !usedImageUrls.includes(url)
);
// Pick a random unused image, or any image if all are used
const availableImages = unusedImages.length > 0 ? unusedImages : PORTRAIT_IMAGES;
const randomIndex = Math.floor(Math.random() * availableImages.length);
return availableImages[randomIndex];
}),
// ...
});
Frontend Integration: Display & Creation
On the client side, specifically in src/app/(dashboard)/dashboard/personas/page.tsx, each persona card now proudly displays an 80x80 rounded-full portrait avatar, conditioned on the imageUrl field existing.
For creating new personas (src/app/(dashboard)/dashboard/personas/new/page.tsx), the experience is even more interactive. Upon loading, we fetch a nextImage preview. Users can see the generated portrait and, thanks to a "New portrait" button that calls refetchNextImage(), they can shuffle through options until they find one they like before finalizing the persona creation.
// src/app/(dashboard)/dashboard/personas/new/page.tsx
import Image from 'next/image';
import { RefreshCw } from 'lucide-react'; // For the refresh icon
// ... inside the component
const { data: nextImageUrl, refetch: refetchNextImage } = trpc.personas.nextImage.useQuery();
// ... in the customize phase
{nextImageUrl && (
<div className="flex items-center gap-4">
<Image
src={nextImageUrl}
alt="Persona Portrait Preview"
width={120}
height={120}
className="rounded-full object-cover"
/>
<button
onClick={() => refetchNextImage()}
className="btn btn-ghost flex items-center gap-2"
>
<RefreshCw size={18} />
New portrait
</button>
</div>
)}
Elevating the Documentation Experience
Good documentation is crucial, but accessible and navigable documentation is gold. Our DocsTab component within src/app/(dashboard)/dashboard/projects/[id]/page.tsx received a significant overhaul to make it more powerful and user-friendly.
Search, Structure, and Sticky Headers
- Integrated Search: A new search input, complete with a Lucide
Searchicon, now allows users to quickly filter documentation files by name or path. No more endless scrolling to find that one specific guide! - Section Badges: We introduced an
extractSectionNumber()helper function. This utility parses filename prefixes (e.g.,01-executive-summary.mdbecomes§01), and these§{num}badges are now prominently displayed on each document card in the list view and within the sticky header. This provides immediate context and helps users quickly grasp the document's place within a larger structure. - Sticky Header in Doc View: When viewing a specific document, a
sticky top-0 z-10 bg-nyx-bgheader now ensures key navigation elements are always visible. This includes a "back-to-list" button, the document's title, its section badge, and a convenient Summary/Full tab switcher (which replaced a simpler booleanfullViewstate with a more robustdocView: "summary" | "full"enum). This significantly improves navigation and context retention as users scroll through lengthy documents.
// src/app/(dashboard)/dashboard/projects/[id]/page.tsx
import { Search, ChevronLeft } from 'lucide-react'; // Example icons
// Helper to extract section numbers
const extractSectionNumber = (filename: string): string | null => {
const match = filename.match(/^(\d+)-/);
return match ? `§${match[1]}` : null;
};
// ... inside the DocsTab component
// Search input
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-nyx-text-light" size={20} />
<input
type="text"
placeholder="Search docs..."
className="w-full pl-10 pr-4 py-2 rounded-md bg-nyx-dark-1 text-nyx-text focus:outline-none focus:ring-2 focus:ring-nyx-accent"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
// Sticky header when a doc is selected
{selectedDoc && (
<