Skip to main content

Notes

Notes from James

What I'm building, thinking about, and working on.

Check out in .

Building

The client dashboard fetches Prisma data before Stripe data because the second query needs the first result

The portal dashboard at app/portal/page.tsx runs two queries in sequence, not in parallel. First it calls getClientDashboard with the Clerk user ID, which returns the client record with active projects, milestones, the latest session, and the next upcoming booking — all from Prisma in one nested include. Then it passes client.stripeCustomerId to getClientInvoices, which calls the Stripe API to list PaymentIntents for that customer. The two queries cannot run in parallel because the Stripe query needs a customer ID that only exists after the Prisma query returns. The admin dashboard at portal/admin/page.tsx does not have this constraint — its six queries are all Prisma calls against different tables, so they run inside a single Promise.all. The client dashboard crosses two data sources where the second depends on the first. Once both queries resolve, the page derives four different views from the combined result: active projects from client.projects, the next booking from client.bookings at index zero, the latest session note via flatMap across all projects picking the first item, and the most recent three invoices sliced from the Stripe response. Four widgets, two data sources, one sequential waterfall that exists because Stripe needs a customer ID and only Prisma knows it.

software
Building

The Stripe invoice query maps payment intents to a portal type so no component ever imports from Stripe

The getClientInvoices function in lib/portal/stripe-queries.ts takes a stripeCustomerId string and returns an array of PortalInvoice objects — a seven-field interface defined in the same file. The function calls stripe.paymentIntents.list, filters to succeeded and requires_payment_method statuses, then maps each PaymentIntent into the portal shape. The description field falls through three options — metadata.productName first, then pi.description, then the literal string Payment. The date converts from a Unix timestamp by multiplying pi.created by one thousand and formatting with Intl.DateTimeFormat in en-GB locale. The status maps Stripe values to simpler labels: succeeded becomes succeeded, anything else becomes pending, both typed with as const so TypeScript narrows the union. The receiptUrl builds a Stripe dashboard link from the PaymentIntent ID rather than using Stripe's own receipt_url. If the Stripe API call fails, the function catches the error, logs it, and returns an empty array — the invoice section simply does not render rather than crashing the page. The InvoiceTable component that displays this data receives PortalInvoice and never imports anything from the Stripe SDK. The mapping function is the boundary. Every Stripe-specific field name, timestamp format, and status enum stays inside one forty-four-line file, and everything downstream works with a plain TypeScript interface that could be backed by any payment provider.

software
Building

The notification guard checks clerkId not email because a client record can exist before the person behind it

Every admin action in portal/admin/actions.ts that triggers a notification wraps the call in if project client clerkId. The client record always has an email — the createClient form requires it. But the notification only fires when clerkId is not null, meaning the client has actually signed up through Clerk. A client created in the admin panel with email sarah@example.com gets a database record immediately, but her clerkId stays null until she follows the portal link and creates her Clerk account. Sending a session notes notification to sarah@example.com before she has signed up would be confusing — she would receive an email with a portal link she cannot access yet because she has no Clerk credentials. The guard makes the notification system aware of the onboarding gap. I can create a client, add projects, set milestones, write session notes, post comments, add deliverables, and assign action items — all silently. The moment Sarah signs up through Clerk, the webhook at api/webhooks/clerk/route.ts matches her email to the existing record and sets her clerkId. From that point forward, every admin write that would interest a client triggers an email. Five notification functions in lib/portal/notifications.ts — notifySessionNotes, notifyMilestoneUpdate, notifyNewComment, notifyNewDeliverable, notifyNewActionItem — all fire through the same gate. The guard checks relationship status, not record existence, and the entire notification backlog is zero because there is no queue. Anything that happened before signup simply does not notify.

software
Building

The Clerk webhook links a new signup to a pre-created client record by matching email before creating anything

The Clerk webhook at api/webhooks/clerk/route.ts handles user.created with two paths, not one. When a new user signs up through Clerk, the webhook extracts their primary email and runs prisma.client.findUnique with where email equals that address. If a record exists and its clerkId is null, the webhook updates it — linking the existing client to the new Clerk account and keeping the name James already set in the admin panel. If no email match exists, it falls through to prisma.client.upsert on clerkId instead, creating a fresh record. The two-path logic solves a sequencing problem. James creates client records in the admin panel before the client has signed up. The admin sets their name, email, and company. Then the client receives a portal invite link and creates a Clerk account. At that moment the webhook fires. Without the email pre-check, the upsert on clerkId would create a second client record — one from the admin, one from Clerk — and the client would see an empty portal with no projects. The email match bridges the gap. The name preservation matters too. The update uses existing.name or name, so if James typed Sarah Chen in the admin panel and the Clerk signup came through as sarah chen, the admin version wins. The user.updated handler is simpler — a single prisma.client.update on clerkId with conditional spread for email and name. It silently catches errors because the client might not exist yet if they update their Clerk profile before the user.created webhook arrives. Two events, two strategies, one bridging query, and the client sees their projects the moment they first log in.

software
Building

The contact API formats unknown form fields into email text by converting snake case keys to title case

The formatServiceFields function in api/contact/route.ts does not know what fields each service sends. It receives the entire request body as a Record of string to unknown, creates a skip set containing name, email, message, service, and website, then iterates every remaining key-value pair. Each key gets two regex replacements — underscores become spaces, then the first letter of each word capitalises via a word-boundary match. The value prints as-is. The message field gets special treatment — it appends last with a blank line separator so the freeform text always appears at the bottom. This means the audio intake form can send track_count and genre, the software form can send budget_range and project_type, and the musician form can send instruments_needed and session_length, and all of them arrive in the email as Track Count, Budget Range, Instruments Needed — readable labels generated from the form field names themselves. Adding a new field to a service intake form in ServiceSidebar requires zero changes to the API. The FieldConfig array in ServiceSidebar defines the field name, label, and type. The ServiceIntakeForm component renders it. The form posts every field. The API formats whatever it receives. Four files in the chain and only one needs editing. The function accepts a service parameter that it never uses — the underscore prefix on _service marks it as intentionally ignored. The service type drives the email subject line via a separate SERVICE_LABELS lookup, but the field formatting is genuinely generic. One function, two regex calls, any number of fields, and the email reads like a human wrote it.

software
Building

The booking sync cron matches calendar attendees to clients by building an email lookup map

The syncBookingsForAllClients function in lib/portal/calendar-sync.ts starts by loading every non-archived client and building a Map from lowercased email to client ID. Then it pulls Google Calendar events from ninety days ago to thirty days ahead, paginating through up to 250 events per page. For each event, it lowercases every attendee email, maps each through the client email Map, and takes the first match. If no attendee matches a client, the event is skipped. If one does, the function upserts a booking record keyed on calendarEventId — updating the title, times, and Meet link if the record exists, creating it if it does not. The Map construction is the critical decision. A naive implementation would query the database for each event's attendees, making one round trip per calendar event. The Map makes it zero — one query loads all clients, one in-memory lookup per attendee resolves the match. The Meet link extraction walks two paths. First it checks conferenceData.entryPoints for a video type entry point. If that fails, it falls back to hangoutLink. The fallback covers older events created before Google migrated to the conferenceData structure. The upsert on calendarEventId means events booked through the site's BookCall component and events created manually in Google Calendar both end up in the same portal view. The cron route itself supports both POST and GET — the GET handler just calls POST, so a developer can trigger a sync by visiting the URL in a browser with the right Bearer token. The result object returns a synced count and an errors array. Individual event failures push to the array without breaking the loop, so one corrupt event does not prevent the other two hundred and forty-nine from syncing.

software
Building

The client toggle action proves ownership through the database relation chain, not a role check

The toggleActionItem server action in portal/actions.ts does not use a role-based guard. It uses data-scoped verification. The first query retrieves the client record from the Clerk user ID. The second query runs prisma.actionItem.findFirst with three simultaneous conditions — id matches the action item ID from the form, project.clientId matches the authenticated client, and assignedTo equals client. If any condition fails, the query returns null and the action returns an error. The client cannot toggle an item that belongs to another client's project because the clientId mismatch eliminates it. The client cannot toggle an item assigned to James because the assignedTo condition eliminates it. Two queries, three permission checks, zero role strings. The admin side works differently. The addAdminComment and createMilestone actions call requireAdmin at the top — one function, one role check, full access. The asymmetry is structural. The admin proves identity once and acts freely. The client proves ownership on every mutation through the shape of the database query itself. The Prisma relation chain does the authorization work. No manual join, no separate permission table, no middleware. The data model is the access model.

software
Building

New milestones land at the end because the sort order is a count, not a choice

The createMilestone admin action in portal/admin/actions.ts sets the sortOrder field by counting existing milestones. The line reads const count equals await prisma.milestone.count where projectId matches. A project with zero milestones gets its first at sort order zero. A project with three milestones gets its fourth at sort order three. The count query runs before the create call, so the value is always one behind the new total — which is exactly the zero-indexed position at the end of the list. No drag-and-drop API. No reorder endpoint. No manual position input in the admin form. The admin types a name, submits, and the milestone appears at the bottom. This only works because the admin interface does not support reordering or deleting milestones. They are append-only. If the interface ever added drag-to-reorder, the count trick would break because deleting or moving a milestone would leave gaps in the sequence. The constraint is a design decision — milestones represent a linear project timeline, not a shuffleable backlog. You plan them in order and you execute them in order. The count-as-position pattern mirrors that assumption in the database layer.

software
Building

The booking API accepts a skipEmail flag so post-checkout consultations do not send duplicate confirmations

The booking API at api/book/route.ts accepts a skipEmail boolean in the request body. When skipEmail is true, the confirmation email does not send, even though the booking is still created in Google Calendar and the response still includes the Meet link and times. The flag exists because of the checkout flow. When a client buys a fixed-price package, the Stripe webhook fires and sends a purchase confirmation email with the order details. The checkout confirmation page then renders a ConsultationPicker component where the client can book a follow-up consultation. That booking request passes skipEmail set to true. Without the flag, the client would receive two emails in quick succession — one for the purchase, one for the consultation — and the second would feel redundant because the calendar invite already arrives from Google. The sidebar BookCall component never sets skipEmail, so 15-minute calls from the service pages always send a confirmation. The API does not know about Stripe. It does not check whether a payment preceded the booking. It accepts a boolean and the caller decides. The checkout confirmation page knows it already sent an email. The service page sidebar knows it did not. One parameter, two calling contexts, zero coupling between the booking system and the payment system.

software
Building

The accordion panel is a button when collapsed and a container when expanded

The FormAccordion renders up to three panels — packages, book a call, and send a brief. Each panel is an AccordionPanel component that receives an active boolean. When active is false, the panel gets role set to button, tabIndex set to 0, and an onKeyDown handler that listens for Enter and Space. When active is true, all three attributes disappear. The panel becomes a plain div with no interactive semantics. A screen reader user tabbing through the sidebar hears Book a call, button for a collapsed panel. When they activate it, the panel expands and the button role vanishes — there is nothing to activate because it is already open. The expanded panel's heading keeps its text, but the element is no longer announced as interactive. This matters because a button that does nothing when pressed is worse than no button at all. The cursor property follows the same logic. Collapsed panels set cursor to pointer through the cursor-pointer class on the outer div. The expanded panel has no cursor override so it falls back to the default arrow. The click handler itself also short-circuits on active panels — the onClick fires only when the negation of active evaluates to true. The border transitions from one pixel at the glass border colour to two pixels at the service accent. The background shifts from the glass token to a five percent color-mix of the accent, using the CSS color-mix function in srgb space. The heading text changes from white to the accent colour. Three visual cues — border width, background tint, heading colour — all driven by one boolean. The BlurReveal wrapper on each panel staggers their appearance by 100 milliseconds so the accordion builds itself visually from top to bottom. One component, one boolean, three conditional ARIA attributes, and the sidebar is fully keyboard-accessible without a single aria-expanded in sight.

softwareaccessibility
Building

The booking confirmation replaces the entire wizard because a completed flow needs no back button

The BookCall component runs four steps — date, time, details, confirmed. The first three render inside the same return block with conditional sections that show and hide based on the step state. The fourth step does not. When step equals confirmed, the component hits an early return before any of the form JSX. The calendar, the time list, the name and email inputs, the honeypot, the error message, the submit button — none of it renders. The DOM clears and a single div takes its place. The confirmation card uses a rounded-xl container with a border at emerald-500 and thirty percent opacity, a background at emerald-500 and ten percent opacity, and role set to status so a screen reader announces the update as a live region without interrupting focus. The first line reads Call booked in text-lg font-semibold at emerald-400. The second line formats the booking start time using toLocaleDateString with weekday long, day numeric, and month long, followed by at and the time from toLocaleTimeString in 24-hour format, followed by 15 minutes in parentheses. Both formatters use the en-GB locale. A third line says Check your email for the calendar invite. If the Google Calendar API returned a Meet link via the conferenceData entry points array, a Join Google Meet button renders below the text as an anchor element with target blank and rel noopener noreferrer. The button uses the service-btn class so it inherits the accent colour of whichever service page the BookCall sits on. No back button. No restart button. The wizard is done and the UI reflects that. The confirmed step stores the Meet link and the booking start time in separate state variables so the card can render without re-reading the API response. Four steps, one early return, one role attribute, and the form disappears the moment it has done its job.

softwareaccessibility
Building

The donation page tracks which tier is processing, not whether the page is busy

The DonationTiers component renders five preset amounts and one custom field. The loading state is not a boolean. It is a number or null. When a visitor clicks the twenty-five pound tier, loading becomes 3 — the index of that button in the tiers array. When they type a custom amount and click Donate, loading becomes negative one. When nothing is processing, loading is null. Every tier button checks disabled against loading not equal to null. If any donation is in progress, all six buttons disable. But only the active button changes its label. The tier at index 3 shows three dots where its price would be. The other four still show their prices — they are greyed out, not rewritten. The custom Donate button follows the same pattern. If loading equals negative one, it shows three dots. Otherwise it shows Donate. This matters because replacing every label with a spinner would lose the prices, and a user who second-guesses their choice mid-click would no longer see the alternatives. The custom amount field validates twice — once for minimum and once for courtesy. Below one pound, the error reads Minimum donation is one pound. Above ten thousand, the error reads That is very generous, please contact me directly for large donations. The validation runs client-side with parseFloat before the fetch fires. The amount converts from pounds to pence with Math.round to avoid floating-point drift — 25.50 becomes 2550, not 2549.9999. The donate API receives amountPence as an integer and passes it straight to Stripe price_data as unit_amount. No product records exist in Stripe. Every donation creates a one-off Checkout Session with an inline product name built from the amount — Donation followed by a pound sign and the amount formatted to two decimal places. One component, one number-or-null state, six buttons, and the loading indicator lives where the information was rather than on top of it.

software
Building

The photo gallery gives the first image two columns because one landscape sets the scene

The PhotoGallery component is fifty lines. It takes a photos array, an accentColor string, and an optional baseDelay for staggered entrance animations. The grid is two columns. The first image spans both using a single index check — const isWide = i === 0. That image renders at 800 by 450 pixels. Every subsequent image renders at 400 by 400. The wide image is the establishing shot. The squares are the details. No prop controls this. The array order controls it. Put the strongest image first and the layout does the rest. Every image has a gradient overlay built from the service accent colour. The background value is a template literal — linear-gradient to top, accentColor plus 15 at zero percent, transparent at fifty percent. The 15 is a hex opacity suffix. It means roughly eight percent opacity at the bottom edge, fading to nothing halfway up. The overlay tints the lower portion of each image with the service accent — fuchsia on the audio page, emerald on the production page, purple on software — without obscuring the photograph. The effect is subtle enough that most visitors will not consciously notice it, but it ties the images into the page colour scheme. On hover, the image scales to 105 percent over 500 milliseconds using group-hover and transition-transform. The overflow is hidden on the container so the zoom crops rather than overflows. The object-position is set to center 30 percent so faces and focal points sit above centre rather than dead centre, which is where most subject matter sits in performance and studio photography. Each image wraps in a BlurReveal with a delay calculated from the baseDelay plus the index times 100 milliseconds. The first image appears, then each square follows in sequence. One component, one index check, one gradient formula, and every service page gets a photo section that matches its colour without the gallery knowing which service it belongs to.

software
Building

The checkout progress shows four circles that replace numbers with checkmarks as you advance

The CheckoutSteps component renders four steps — Package, Details, Pay, Done — as a horizontal sequence of numbered circles connected by lines. The steps are defined in a const array with as const so the StepKey type is a union of the four literal strings, not just string. The component receives the current step key and finds its index with findIndex. Every other step is classified from that index — isActive when the indices match, isCompleted when the step index is lower, isFuture when it is higher. The active circle gets a solid service accent background with black text. The completed circle gets the same accent at twenty percent opacity with accent-coloured text. A future circle gets the glass background with muted text. The transition is visual, not structural — the same span element renders in all three states, only the class string changes. When a step is completed, the number disappears and an SVG checkmark takes its place. The checkmark is a hand-coded path element — M5 13l4 4L19 7 — with a stroke width of three and round line caps. No icon library, no font dependency, one path string. The connecting lines between circles use the same colour logic. A completed connection fills with the service accent. An incomplete connection stays at the glass border colour. The combination means the accent colour flows from left to right as the checkout progresses, pooling at the current step and fading beyond it. The step labels hide on mobile using hidden sm:inline so narrow screens see only the numbered circles and checkmarks. The nav element carries aria-label set to Checkout progress. The active step carries aria-current set to step. A screen reader announces exactly where the client is in the flow without reading all four labels. Four steps, three visual states, one SVG path, zero dependencies.

softwareaccessibility
Building

The action item checklist splits tasks into yours and mine so neither side loses track

The ActionItemChecklist component receives an array of action items, a toggleAction server action, and a canToggle boolean. The first thing it does is filter the array into two groups using the assignedTo field — items where assignedTo is client go into myItems, items where assignedTo is james go into jamesItems. The component renders two labelled sections. The client section heading reads Your action items. The James section heading reads James's action items in muted text. The visual hierarchy tells the client which tasks are theirs before they read a single title. Each action item renders as its own form. A hidden input carries the actionItemId. The checkbox is a button styled as a square with a two-pixel border. When the item is completed, the border turns emerald and the background fills emerald with a white checkmark SVG inside. When it is incomplete, the border matches the glass border colour and the interior is empty. The button has an aria-label built from the completion state and the item title — Mark or Unmark followed by the title in quotes followed by as done. The submit is handled by useActionState so the form posts without a page navigation and the pending state disables the button during the round trip. The canToggle prop controls who can check what. Client items pass canToggle from the parent. James items always pass false. The client can tick off their own tasks. They can see James's tasks but they cannot toggle them. The cursor changes from pointer to default when toggling is disabled. The line-through class and fifty percent opacity apply to completed items regardless of who completed them. Both sides see the same state. One component, two filter calls, two sections, two permission levels, and the project checklist becomes a shared workspace where both parties track their responsibilities without stepping on each other.

softwareaccessibility
Building

The comment thread uses one form with two hidden inputs to serve three entity types

The portal has inline comment threads on sessions, milestones, and goals. All three use the same CommentThread component. It takes four props — a comments array, an addCommentAction server action, an entityType string that is one of session, milestone, or goal, and an entityId string. The component does not branch on entity type. It does not know whether it sits below a mixing session or an accessibility audit milestone. Two hidden inputs inside the form carry the routing information. One input holds the entityType. The other holds the entityId. When the form submits through useActionState, the server action reads both values from the FormData and uses conditional spread operators to set exactly one foreign key on the comment record — sessionId, milestoneId, or goalId. The component passes strings through. The server decides where they go. The avatar next to each comment is five chained string methods inline in the JSX — split the authorName on spaces, map each word to its first character, join into a string, slice to two characters, uppercase. Sarah Chen becomes SC. James becomes JA. The circle is 32 pixels wide with a border that changes colour based on a single boolean. When isJames is true, the border and the name text use the portal accent colour. When isJames is false, both use the muted glass colour. Two visual signals from one database field. The timestamp uses Intl.DateTimeFormat with en-GB locale, day numeric, month short, hour and minute in 24-hour format. The opacity is set to fifty percent so the time recedes behind the comment body. The time element has a machine-readable ISO datetime attribute for crawlers and assistive technology. The form input is a single text field with a thousand-character maxLength, a 44-pixel minimum height for WCAG touch compliance, and a placeholder that reads Add a comment. The label is visually hidden with sr-only but present in the accessibility tree. The submit button disables during pending state and shows three dots instead of Send. On the project detail page, a single scroll can pass through five or six independent CommentThread instances — one per milestone, one per session, one per goal — each with its own form, its own hidden inputs, its own useActionState hook. They do not share state. They do not interfere. One component, two hidden inputs, three entity types, zero conditional rendering.

softwareaccessibility
Building

The intake form validates on blur so the error appears before the submit button

Every service page has a contact form in the sidebar. All five — software, audio, production, accessibility, musician — render through the same ServiceIntakeForm component. The form validates each field individually when the client tabs or taps away from it, not when they hit submit. The handleBlur function receives the field config and the current value, runs validateField, and stores the result in an errors state object keyed by field name. A separate touched state object tracks which fields the client has interacted with. A required field that has never been focused does not show an error even though it is empty. The error only appears after the first blur. The client sees what is wrong as they fill in the form, field by field, not in a batch after they think they are done. When an error is present, aria-invalid is set to true on the input and aria-describedby points to the error paragraph's ID. A screen reader announces the problem immediately when focus leaves the field. The error ID is constructed from the field name plus -error so every instance is unique even when multiple fields fail simultaneously. The error paragraph has role alert so assistive technology treats it as a live region and reads it without the client needing to navigate to it. The honeypot check runs before any validation on submit. If the invisible website field has a value, the handler returns silently. No error message, no state change, no fetch call. The bot thinks it submitted. It did not. The submission guard is a useRef, not a useState. A ref read is synchronous — two rapid clicks both see the current value before either can change it. A state read is batched — two clicks could both read false before either update lands. The ref flips to true before the fetch call and back to false in the finally block. The entire form is driven by a FieldConfig array passed as a prop. Each config has a name, label, type, required flag, placeholder, and optional options array for selects. The component maps over the array and renders the right HTML element — input, select, or textarea — based on the type string. Five different service pages pass five different field arrays to the same component. The audio form asks for genre and track count. The software form asks for project type and budget range. Adding a new field to any service means adding one object to the array on that service page. No component change, no API update, no deployment beyond the page file.

softwareaccessibility
Building

The project showcase structures every case study as a four-part narrative

The data/projects.ts file defines every portfolio project with eight fields and a nested detail object containing four prose sections — challenge, approach, outcome, and techDeepDive. The eight top-level fields cover scanning. An id, a title, a subtitle, a description, a techStack array, an optional liveUrl, a metrics array, and a category string. The four nested fields cover reading. A visitor scanning the project cards sees the title, the category label, the metrics badges, and the tech stack pills. A visitor who clicks through sees the full story in four structured paragraphs. The separation from the software page's inline caseStudies array is deliberate. That array stores six-field marketing summaries designed for a three-second scan — title, category, optional URL, description, metrics, and techStack. The projects.ts file stores the deep breakdowns with narrative paragraphs that explain why, not just what. Different files for different audiences at different levels of commitment. Each project tells a story. The challenge section explains the problem — music teachers needed a unified platform, no existing solution combined all these needs. The approach section describes the architectural decisions — multi-tenant Next.js, role-based access, Prisma with PostgreSQL, NextAuth, Stripe Connect for multi-mentor payouts. The outcome section quantifies the result — shipped in eleven days, five milestones, twenty-eight phases, one hundred and thirteen individual plans executed. The techDeepDive section names every integration and every tool choice so a technical reader can assess the stack without asking. The metrics arrays carry the numbers that do not need paragraphs. Idea to launch in eleven days. Sub-second latency. Real-time AI responses. These sit as accent-coloured badges on the project cards so the achievements are visible before anyone reads a word. The techStack arrays list specific libraries — Next.js 14, TypeScript, Prisma, PostgreSQL, Stripe, Resend, Socket.io, Claude API, TikTok Live API — because vague labels like modern frontend framework tell a hiring manager nothing. The category labels position each project — SaaS Platform, Real-Time Streaming Platform, AI Product — so the reader knows the scale before they know the detail. Three projects, four narrative sections each, twelve paragraphs of context. One data file and every portfolio entry reads like a case study because the data structure forces it to be one.

software
Building

Every service page suggests a second service because the work overlaps

At the bottom of each service page, just before the testimonials, sits a glass card with a question and a link. The software page asks whether you need accessible software and links to the accessibility page. The accessibility page asks whether you need software built with accessibility baked in and links back to the software page. The cross-link is always a single sentence, a short explanation, and a coloured anchor tag using the target service's accent colour — fuchsia for accessibility when viewed from the software page, purple for software when viewed from the accessibility page. The colour is hardcoded in each page file because the cross-link is hand-picked, not generated. It would be possible to build a config object mapping every service to its complement and render the cross-link automatically. I chose not to because the phrasing matters. Need accessible, inclusive software reads differently from Need software built with accessibility baked in, and both of those are better than a generic You might also like followed by a service name. The cross-link sits after all the content sections — What I Build, Case Studies, Why Work With Me, Tech Stack — because by that point the visitor has absorbed the full picture of the current service. If their project sits at the intersection of two disciplines, the link catches them at the moment they are most likely to think about it. One glass card, one sentence, one accent-coloured link, and the site quietly says you do not have to choose between two things that work better together.

software
Building

The software page defines case studies as typed inline data with two layers of badges

The software service page has a Case Studies section rendering three projects — Blind Drummer, the Live Interactive Music Platform, and the TikTok AI Live Assistant. The data for these sits in a const array called caseStudies at the top of the page file, not in the central data/projects.ts showcase file. Each entry has six fields — title, category, an optional url, description, a metrics array, and a techStack array. The separation is deliberate. The projects.ts file stores detailed structured breakdowns with challenge, approach, outcome, and techDeepDive sections for the portfolio showcase. The inline caseStudies array stores lightweight marketing summaries designed for scanning, not reading. Different audiences, different levels of detail, different files. Each case study renders inside a glass card. The category label — Product Build, Real-Time Product, AI Product — sits at the top in uppercase tracking-wider text using the service accent colour. Below it the title in bold white. If the project has a live URL, a Visit arrow link appears in the top-right corner with target blank and noopener noreferrer. The description follows in muted text. Then two rows of pills. The first row renders metrics — Idea to launch in 11 days, Sub-second latency, Real-time AI responses — as accent-coloured badges with a ten-percent opacity background. The second row renders the tech stack — Next.js, Prisma, Stripe, WebSockets, Claude API — as muted pills with a secondary background colour. Two layers of badges on every card. The accent layer tells you what the project achieved. The muted layer tells you what it was built with. A visitor scanning the page sees the outcomes first and the technology second, which is the right priority for someone deciding whether to hire an engineer.

software
Building

The Testimonials component uses figure elements and disappears when there is nothing to show

Every service page ends with a What clients say section rendered by the Testimonials component. The component receives a filtered array from getTestimonialsByService and the first thing it does is check the length. If the array is empty, the component returns null. Not an empty div. Not a Coming soon placeholder. Null. React renders nothing and the page layout closes cleanly without a gap. No conditional wrapper in the parent, no CSS display none, no empty state component. The data simply determines whether the section exists. When testimonials do exist, each one renders inside a figure element. The quote sits in a blockquote. The attribution sits in a figcaption with the name in medium-weight text and the role and optional company in muted text separated by an em dash. This is not arbitrary HTML. A figure with a blockquote and a figcaption is the semantic structure that HTML defines for a quoted passage with attribution. Screen readers announce it correctly. Search engines parse it correctly. A div with a paragraph and a span would look the same visually but would carry zero structural meaning. The heading uses aria-labelledby pointing to its own ID so the section is discoverable via the accessibility tree. The accent colour for the double-slash prefix comes from the service accent CSS custom property, so the heading matches the rest of the page without the component knowing which service it belongs to. The grid shows one column on mobile, two on desktop. The glass card styling — rounded border, translucent background, backdrop blur — matches every other card on the site. One component, one null check, one semantic structure, and every service page either shows real client feedback or shows nothing at all.

softwareaccessibility
Building

The attachment list plays audio inline but downloads everything else

The portal shows files attached to sessions and deliverables through an AttachmentList component. Every file gets a download button. Audio files get a second button — Play. The distinction comes from two functions. The first, isAudio, checks whether the MIME type starts with audio/ or matches one of three specific types — audio/mpeg, audio/mp4, audio/m4a. That function controls whether the file row shows the Audio label next to the file size. The second function, isStreamableAudio, is stricter. It checks against a whitelist of six MIME types that browsers can decode natively — mpeg, mp3, mp4, m4a, aac, ogg. A WAV file is audio but not streamable in this context because browser support is inconsistent and the files are typically hundreds of megabytes. A streamable file gets the Play button. A non-streamable audio file gets Download only. When the client taps Play, the component fetches a signed R2 download URL from the file endpoint and sets it as the source of an HTML audio element that appears below the file list. The audio element uses autoPlay so playback begins immediately, controls so the client can scrub and adjust volume, and an onEnded callback that clears the URL and resets the playing state. Tapping Play on a file that is already playing stops it — the same button toggles between Play and Stop based on whether the playingId matches the file ID. Only one file plays at a time because there is one audio element and one URL in state. Starting a new file replaces the previous one. The signed URL expires after twenty-four hours but the audio element only needs it for the initial fetch — once the browser starts streaming, the connection stays open until playback completes or the user navigates away. One component, two MIME checks, one audio element, and every mastered track, rough mix, and session recording is playable without leaving the portal.

software
Building

The invoice page reads Stripe directly so the portal never stores payment data

The portal invoices page does not query the database for payment records. It queries Stripe. The page is a server component that authenticates with Clerk, looks up the client record, reads the stripeCustomerId field, and passes it to getClientInvoices. That function hits the Stripe API directly — listing PaymentIntents filtered to the customer ID, pulling succeeded and pending statuses. The response maps into a flat array of invoice objects with six fields — id, description from the metadata, formatted date, amount in the smallest currency unit, the currency code, status, and an optional receipt URL. The InvoiceTable component formats the amount using Intl.NumberFormat with the style set to currency and the currency code passed from the PaymentIntent itself. A GBP payment renders as £250.00. A USD payment would render as $250.00. A EUR payment would render as €250.00. The formatter divides by one hundred because Stripe stores amounts in the smallest unit — pence, cents, centimes. The table uses semantic HTML — thead with scope attributes on every th, tbody with consistent row structure, and an aria-label on the table element. The status column renders a pill badge — emerald for paid, orange for pending, red for failed — using a config record that maps status strings to Tailwind classes. The receipt column conditionally renders a link to Stripe's hosted receipt page. If Stripe has no receipt URL for that payment, the cell is empty. The link opens in a new tab with noopener noreferrer and uses an aria-label that includes the description so screen readers can distinguish between multiple receipt links. On mobile, the date column hides completely using a responsive class — hidden on base, table-cell on sm — because the description and amount are more important on a narrow screen. No payment data lives in the database. No PCI compliance burden. No sync job keeping records in step. The portal reads Stripe in real time and formats the response. If a refund happens in the Stripe dashboard, the next page load reflects it immediately.

software
Building

The quote page says email your dream because quote products need a different energy

When a product has a fixed price, the checkout page loads a payment wizard with Stripe Elements. When a product is quote-based, the same checkout route loads a completely different component — QuoteRequest. The component shows the package name, description, price label — which reads something like From £5,000 or Price on enquiry — the delivery estimate, and a feature list with accent-coloured checkmarks. Below the features sit two buttons side by side. The first is solid with the service accent background and black text — Call me about this project. The second is outlined with a glass border and white text — E-mail your dream. Both are anchor links, not buttons. The first points to the hash #book-call. The second points to #send-brief. Those hashes trigger the sidebar accordion on the same page. The FormAccordion component watches for hash changes and opens the matching panel — the booking calendar or the email intake form. One click on either button scrolls the sidebar into view and opens the right panel. No page navigation, no modal, no new route. The client stays on the product page with the package details visible while they fill in the form or pick a time slot. The language is deliberate. Call me about this project is direct and warm — it says I am available and I want to hear about your idea. E-mail your dream is aspirational — it says this is not a support ticket, it is the start of something creative. The solid button draws the eye first because it has visual weight. The outlined button is there for clients who prefer asynchronous communication. Two paths to the same outcome — a conversation about a project — with vocabulary that matches the tone of a creative services business, not a SaaS checkout funnel.

software
Building

The booking email says call or consultation depending on how long the session is

The booking API at /api/book sends a confirmation email after every successful calendar event creation. The language in that email adapts based on the duration parameter. A fifteen-minute booking is a call. Anything longer is a consultation. The ternary sits in the email body — duration greater than fifteen renders Your 45-minute consultation with James has been confirmed, while a default-length booking renders Your 15-minute call with James has been confirmed. The same distinction carries into the Google Calendar event title. The insert call builds the summary as a template literal — 15min Call colon name dash service label, or 45min Consultation colon name dash service label. One word changes in two places and the entire interaction feels proportional to the commitment. A fifteen-minute intro call is casual. A ninety-minute consultation is substantial. The vocabulary should match. The email subject is the same for both — Call confirmed followed by the formatted date and time. Dates use en-GB with weekday, day, month, and year. Times use twenty-four-hour format with no seconds. The email body uses an array of strings joined by newline characters, with a filter-Boolean pass to strip empty lines. The Google Meet link line is an empty string when no meet link exists, and the filter removes it so there is no blank line in the middle of the email. The skipEmail flag adds another layer. When a booking originates from the post-payment checkout flow, the caller passes skipEmail as true. The Stripe webhook has already sent a purchase confirmation, so sending a second booking email would be redundant. The calendar event still creates, the Google Calendar invite still sends, but the Resend email stays silent. One flag, one conditional, and the client gets exactly one notification per action regardless of which flow created the booking.

software
Building

The attachment record stores an R2 key, not a URL, so the download link is always fresh

The Attachment model has a column called url, but it does not hold a URL. It holds a key — a string like uploads/1714000000-mastered-track.wav. The key is generated at upload time by the upload route at /api/portal/upload. It prepends Date.now to the original filename after sanitising it with a regex that replaces every character outside the set of alphanumerics, dots, dashes, and underscores with an underscore. The timestamp prevents collisions — two files with the same name uploaded a second apart get different keys. The regex prevents path traversal, encoding issues, and characters that would break Content-Disposition headers on download. Spaces become underscores. Forward slashes become underscores. Unicode characters become underscores. The resulting key is safe for R2, for Postgres, and for every HTTP header it will ever appear in. The upload itself is a three-step flow. Step one — the admin requests a presigned upload URL from the server. The server generates the key, calls getUploadUrl with a one-hour expiry, and returns both the signed URL and the key. Step two — the browser PUTs the file directly to R2 using the signed URL. The file never passes through the Next.js server. Step three — the browser calls /api/portal/upload/complete with the key, filename, size, MIME type, project ID, and optional session ID. The server creates the Attachment record in Prisma with the key in the url column and uploadedBy set to james. If step two fails — network error, timeout, the file exceeds the five-hundred-megabyte limit — step three never fires and the database stays clean. No orphaned records. No references to files that do not exist. When a client clicks download, the file download route reads the key from the Attachment record and generates a fresh signed URL with a twenty-four-hour expiry. The URL expires but the key never does. The same key survives R2 endpoint changes, bucket renames, and region migrations. Only the URL generation function needs to know the current R2 configuration. One key, three steps, zero permanent URLs.

software
Building

The contact form auto-formats unknown fields into the notification email

Every service on the site has its own intake form with different fields — audio asks for genre and track count, software asks for budget range and project type, accessibility asks for a compliance target. All five forms submit to the same /api/contact route. The route does not have a mapping of field names to display labels. It has a function called formatServiceFields that iterates over every key in the request body, skips the five standard keys — name, email, message, service, and website — and formats whatever remains. The formatting is two operations. First, underscores become spaces. Second, each word gets its first letter capitalised. A field called track_count becomes Track Count. A field called budget_range becomes Budget Range. A field called compliance_target becomes Compliance Target. The function does not know these fields exist. It processes them generically. If I add a new field called preferred_format to the audio service config, the next enquiry email will include Preferred Format followed by whatever the client selected. No API change, no email template update, no deployment beyond the config edit. The message field gets special treatment — it appears last, prefixed with a blank line and the label Message with a colon, separated from the structured fields above it. The rest of the fields appear in object iteration order, which in practice matches the order they appear in the form because JavaScript objects preserve insertion order for string keys. The subject line branches on whether the service is set. A service-specific enquiry gets a bracketed label — [Software Engineering] New enquiry from Sarah. A generic contact form submission without a service gets Contact from Sarah. The confirmation email to the enquirer also branches — Thanks for getting in touch about Software Engineering versus just Thanks for getting in touch. Two emails, one route, five services, and every new form field formats itself.

software
Building

The file download endpoint loads two relationship chains in one query to prove ownership

The portal stores attachments on projects and on sessions. A session belongs to a project and a project belongs to a client, so a file attached to a session is two joins away from the client who owns it. The file download route at /api/portal/files/[id] needs to verify that the person requesting the file actually owns it. It does this with a single Prisma query that loads both relationship chains at once. The findUnique call includes project with a select on clientId, and session with a nested select that goes through project to clientId. One database round trip returns both paths. The authorization check reads the result with a single OR expression — attachment.project?.clientId || attachment.session?.project?.clientId. If the attachment sits directly on a project, the first path resolves. If it sits on a session, the second path resolves through the intermediate join. Either way, the result is a clientId that gets compared against the logged-in user's client record. Admin users bypass the check entirely — the route reads publicMetadata.role from the Clerk user object and skips the ownership verification if the role is admin. The whole file is forty-nine lines. It authenticates with Clerk, loads the attachment with both relationship paths, checks admin or ownership, generates a signed R2 download URL, and returns it with the filename. No file bytes pass through the server. The route returns a URL and the browser fetches the file directly from Cloudflare R2. Two relationship chains, one query, one authorization decision, and the same endpoint serves audio stems, accessibility reports, software deliverables, and session recordings without knowing or caring what type of file it is.

software
Building

The Clerk webhook verifies with Svix before it trusts a single field

Clerk does not sign its own webhook payloads. It delegates signature verification to Svix, an open-source webhook delivery platform. Every webhook request arrives with three headers — svix-id, svix-timestamp, and svix-signature. The handler reads the raw request body as text, not JSON, because the signature is computed over the exact byte sequence. Parsing to JSON and re-stringifying would change whitespace or key ordering and invalidate the signature. The Svix Webhook constructor takes the shared secret from the CLERK_WEBHOOK_SECRET environment variable. The verify method receives the raw body and the three headers. If the signature does not match — tampered payload, wrong secret, expired timestamp, replay attack — verify throws and the handler returns a 400 before any database logic runs. After verification, the handler branches on event type. user.created has three paths. First it checks whether a client record already exists with that email but without a clerkId — that means I created the record through the admin panel before the client signed up. If so, it links the existing record by writing the Clerk ID onto it and preserving the name I set. Second, if no pre-created record exists, it upserts by clerkId — creating a fresh client or updating an existing one. Third, if a record already has a clerkId and the same email, the upsert updates the name and email in case they changed during signup. The user.updated handler is simpler — it updates the client's email and name from the latest Clerk data. The update query uses spread operators to only write fields that have values, so a missing first name does not overwrite an existing one with an empty string. The catch block on user.updated swallows errors silently because events can arrive out of order — a user.updated might fire before user.created finishes processing, and the client record would not exist yet. One webhook route, two event types, three Svix headers, three signup paths, and every field is trusted only after the cryptographic signature passes.

software
Building

The admin comment action walks three entity paths to find the client who needs the email

When I post a comment in the admin panel, the server action receives three values — a body, an entityType, and an entityId. The entityType is one of session, milestone, or goal. The comment needs to be created on the right entity and the right client needs to be notified by email. The create call uses spread operators to set exactly one foreign key — if entityType is session, the data includes sessionId set to the entityId. If milestone, milestoneId. If goal, goalId. The other two foreign keys are omitted entirely because spread on an empty object adds nothing. Three conditional spreads, one create call, and the comment attaches to the correct entity without any conditional branching around the Prisma query itself. The notification lookup is the interesting part. Comments do not have a direct relationship to clients. A session comment reaches the client through session, then project, then client. A milestone comment goes through milestone, then project, then client. A goal comment goes through goal, then project, then client. The action runs three if-blocks — one per entity type — each executing a Prisma findUnique with a nested include chain that traverses from the entity to its project to the project's client. All three chains select the same three fields — email, name, and clerkId. All three chains assign the result to the same two variables — client and projectName. After the branching, a single notification call fires if the client has a clerkId, meaning they have actually signed up and can receive emails. The notification itself is fire-and-forget — the catch callback is an empty function. If Resend is down, the comment still exists in the database. The client will see it when they next visit the portal. The authorName is hardcoded to James and isJames is hardcoded to true because only I can post comments through the admin panel. The client-side addComment action in the portal sets isJames to false and reads the client's name from the database. Two comment paths, one visual distinction, three notification chains, and the email always finds its way to the right inbox.

software
Building

The settings page strips a third-party widget so it looks native

The portal settings page renders Clerk's UserProfile component — a pre-built account management widget with password changes, email updates, and session controls. Out of the box it ships with its own card styling, shadows, borders, and padding. That visual identity clashes with the glass aesthetic the rest of the portal uses. The fix is four CSS overrides in an appearance prop. rootBox gets w-full so the widget spans the container instead of centering at a fixed width. card gets bg-transparent shadow-none border-0 p-0 — four resets that strip the card down to raw content. The result is that Clerk's inputs, buttons, and section headings float inside a GlassCard wrapper as though they were built for this portal. No custom auth management UI, no reimplemented password flow, no hand-rolled session list. Above the Clerk embed, the page renders the client profile from the database in a definition list — dl, dt, dd — with three rows: name, email, and company. The dt elements are fixed at 80 pixels wide using w-20 so the labels align in a column. The email field reads from three sources in priority order — client.email from the Prisma record, then user.emailAddresses[0].emailAddress from the Clerk session, then a dash character as the terminal fallback. Two data sources, one page, and a third-party widget that looks like it was built in-house because four CSS strings say so.

software
Tip

The donate redirect checks three hosting providers before it picks a URL

The donation API creates a Stripe Checkout Session with a success_url that points to the thanks page. That URL needs to be absolute — Stripe redirects the browser after payment, so a relative path would break. The getBaseUrl function resolves the correct domain by checking three environment variables in order. First it reads NEXT_PUBLIC_BASE_URL — the explicit override that any deployment can set. If that is missing it reads VERCEL_URL, which Vercel injects automatically on every deployment but without the https prefix, so the function prepends it. If that is also missing it reads RAILWAY_PUBLIC_DOMAIN, which Railway injects the same way — a bare hostname that needs the protocol added. If all three are absent the function returns the production domain hardcoded as a final fallback. Four lines, three providers, one URL. The same donate route works on localhost with NEXT_PUBLIC_BASE_URL set to http://localhost:3000, on a Vercel preview branch with VERCEL_URL set automatically to something like jamesmusic-uk-git-feature-jamesmusicltd.vercel.app, on Railway staging, and on the live production site. No conditional logic in the route handler. No environment detection. The getBaseUrl function picks the right domain and the route builds the redirect from there. The cancel_url uses the same function so both the success and cancellation redirects resolve consistently. One four-line utility and every deployment environment gets correct Stripe redirects without any configuration beyond what the hosting provider already supplies.

software
Building

The checkout API truncates the message and catches idempotency as a typed error

The checkout route creates a Stripe PaymentIntent with five metadata fields — productId, productName, customerName, customerEmail, and customerMessage. Those metadata values travel with the PaymentIntent through the entire Stripe lifecycle. The webhook reads them later to build the confirmation email without touching the database. The customer message is free-form text from a textarea with a 500-character maxLength on the frontend, but the API enforces the same limit server-side with String(message).slice(0, 500). Stripe metadata values have a 500-character ceiling. If a request somehow bypasses the frontend validation — a direct API call, a modified DOM — the slice catches it before Stripe rejects the payload. The idempotency key comes from the CheckoutWizard, which generates a crypto.randomUUID on mount and only regenerates it when the customer changes their name, email, or message. Same details, same key, same PaymentIntent — Stripe deduplicates the request. Different details, new key, new PaymentIntent. The catch block handles the case where two requests arrive with different details but the same key. Instead of a generic 500 error, the route checks whether err.type equals StripeIdempotencyError and returns a 409 with the message Payment already processing. The CheckoutWizard does not show a retry button for a 409 — it tells the user to wait. A specific error type, a specific status code, a specific user message. The route validates the product too. It finds the product by ID in the catalog and calls isPurchasable to confirm it has a fixed price. A quote-only product like a web application build cannot reach the PaymentIntent creation because the guard returns a 400 before the Stripe call happens.

software
Building

The product card switches its call to action with one boolean

The ProductCard component renders every package across all five services. It takes one prop — a product object from the catalog. The interesting part is the call to action at the bottom. The component calls isPurchasable once and stores the result in a const called canBuy. If canBuy is true, the link says Buy now with a solid accent-coloured background and black text. If canBuy is false, the link says Get a quote with a transparent background, a glass border, and white text. Two completely different visual treatments from one boolean. The isPurchasable function checks two things — the product type must be fixed and the pricePence must be a positive number. A mixing session at two hundred and fifty pounds passes both checks and gets the solid Buy now button. A web application with quote-based pricing fails the type check and gets the outline Get a quote button. The link itself always points to the same route — /checkout/ followed by the product ID. The checkout page handles the divergence from there. A purchasable product loads the CheckoutWizard with Stripe Elements. A quote product loads the QuoteRequest component with the package details and two hash-link buttons that open the sidebar accordion. Same card, same link pattern, same route. The boolean only changes the button's visual weight. A filled button says you can act right now. An outlined button says this needs a conversation first. The card also uses flex-1 on the features list so the call to action always sits at the bottom of the card regardless of how many features the product has. Cards with three features and cards with six features align their buttons on the same baseline when rendered side by side in the grid.

software
Building

The booking form recovers from a slot conflict by refreshing the calendar

The BookCall component has a four-step flow — date, time, details, confirmed. Steps one through three collect information. Step four shows the confirmation. The interesting behaviour is what happens between step three and step four when something goes wrong. When the user submits their details, the handleBook function sends a POST to the booking API with the selected start time. If another person booked the same slot between the page load and the submit click, the API returns a 409 status code. The component checks for that specific status. When it sees a 409, it does three things in sequence. First it clears the selected slot from state. Second it sends the user back to the time picker step. Third it immediately fires a fresh availability request for the same date to get the updated slot list. The user sees the time grid refresh with the taken slot removed. They pick another time and try again. The flow does not restart from the date step because the date is still valid — only the specific time slot was taken. The re-fetch runs inside the same error handler as the status check, chained after the step and slot state updates. The submittingRef flag — a useRef, not a useState — prevents double submission during the brief window between the button click and the API response. A ref is synchronous. A state update is batched. If the guard used state, two rapid clicks could both read false before either update lands. The ref reads true immediately after the first click sets it. The finally block resets both the booking loading state and the ref so the form is ready for the next attempt.

software
Building

The homepage passes filter state through the URL so the notes page opens where you left off

The homepage shows a notes section at the bottom with the same sentence-style filter as the full notes page. Check out everything in all topics. The two inline select elements let visitors filter by type and tag before they leave the homepage. The HomeNotesFilter component limits the visible results to three cards. If the filtered set contains more than three notes, a link appears below — View all 47 notes with an arrow. That link is not a static path to /notes. It carries the active filter state as query parameters. If the visitor selected tips in accessibility, the link points to /notes?type=tip&tag=accessibility. If they selected updates in all topics, the link points to /notes?type=update with no tag parameter. If both filters are set to their defaults, the link points to /notes with no query string at all. The URL construction handles the parameter joining inline — a question mark before the first parameter, an ampersand between two parameters, and nothing when a parameter is at its default value. The result is that clicking through to the full notes page preserves the visitor's intent. They do not land on an unfiltered list and have to re-select the same filters. The homepage filter acts as a preview and the URL acts as a handoff. The same HomeNotesFilter component also handles the empty state — if no notes match the filter combination, a centred message replaces the grid. The component never makes a network request because the entire notes array is a static import. Filtering is a synchronous array operation that completes before the next render.

software
Building

The theme hook watches a DOM attribute instead of wrapping the app in a provider

The site has a dark mode and a light mode. Every component that changes colour between themes needs to know which theme is active. In React, the standard approach is a Context provider — wrap the tree, consume the value, re-render on change. The useTheme hook takes a different path. It reads the data-theme attribute from the document element and subscribes to changes through a MutationObserver. The observer watches one attribute on one element. When the ThemeToggle component sets data-theme to light or dark, the observer callback fires, reads the new value, and updates the hook's state. Every component that calls useTheme gets its own independent subscription to the DOM. No provider wrapping the app. No context value threading through the component tree. No cascading re-renders from a context change at the root. The hook is twenty-seven lines. It initialises the state to dark, reads the current attribute on mount, creates a MutationObserver filtered to the data-theme attribute, and disconnects the observer on cleanup. The attributeFilter option means the observer only fires for the one attribute it cares about — not every class change, not every style update, just theme switches. The ThemeToggle itself is equally simple. It reads the initial theme from localStorage, toggles between dark and light on click, sets the attribute on the document element, and persists the choice. The toggle and the hook never import each other. They communicate through the DOM — the toggle writes the attribute, the hook reads it. This pattern scales without modification. Adding a new themed component means calling useTheme and reading the return value. No provider to update, no context shape to extend, no barrel export to maintain. The DOM is the state bus.

software
Building

The Stripe payment iframe matches the page because it reads the same CSS variable

Every service page on the site sets a CSS custom property called --service-accent on its outermost container. Software pages set it to purple. Audio pages set it to cyan. Production pages set it to emerald. Accessibility pages set it to fuchsia. Musician pages set it to orange. Every component inside the page — the sidebar, the form, the navigation, the product cards — reads that variable for accent-coloured text, borders, and backgrounds. The Stripe payment iframe needs to match. The CheckoutWizard passes an appearance object to the Elements provider with five tokens. The colorPrimary is set to var(--service-accent, #60a5fa) — the CSS variable with a blue fallback in case the variable is missing. The colorBackground is the same near-black used across the dark theme. The colorText matches the site's text colour. The colorDanger is red-400. The borderRadius is eight pixels. The fontFamily is set to inherit. Six lines configure the entire payment form's visual identity. When a client checks out on the audio page, the payment form highlights in cyan. On the software page, it highlights in purple. The Stripe iframe reads the CSS variable at render time and applies it to the input focus states, the submit button, and the card brand icons. No conditional logic in the component. No service-to-colour mapping. The same appearance object works for all five services because the CSS variable is already set by the page above it. The theme property is set to night — Stripe's built-in dark mode — so the iframe background, text, and input styling align with the site's dark-first design. Light mode overrides in globals.css swap the accent colours but the Stripe Elements config does not need to change because the CSS variable resolves to the light variant automatically.

software
Building

The booking list queries future and past in parallel with one time boundary

The portal booking page shows two sections. Upcoming bookings at the top sorted by start time ascending — the next meeting first. Past bookings below sorted by start time descending — the most recent first. The query function creates a single Date object and uses it as the boundary for both queries. Upcoming bookings have a startTime greater than or equal to now. Past bookings have a startTime less than now. Both queries run inside a Promise.all call so they execute in parallel against the database. The past query is capped at ten results because scrolling through months of old meetings has no value — the client only needs to reference recent ones. The upcoming query has no limit because every future booking matters. Both queries are scoped to the client's ID. The function first resolves the client record from their Clerk authentication ID, then uses that internal ID to filter both booking queries. If the client record does not exist, the function returns two empty arrays immediately without touching the database. The same pattern appears in other portal queries. Projects, sessions, goals, and invoices all resolve the client by clerkId first, then scope every subsequent query to that client's ID. No query in the portal can return data belonging to another client because every query chain starts with the same authentication lookup. The booking split is a small detail but it reflects a broader principle in the portal — do two fast queries in parallel rather than one flexible query that the frontend has to sort and split. The database handles the filtering. The component receives exactly the shape it needs.

software
Building

A new domain goes from zero to sending email in fifteen minutes

Standing up transactional email on a fresh domain takes three DNS records and one environment variable. Resend handles the infrastructure. You create an account, add the domain, and Resend returns three DNS records to configure — two TXT records for DKIM authentication and one MX record for bounce and complaint handling. The TXT records prove to receiving mail servers that the domain authorises Resend to send on its behalf. The MX record routes bounced messages back to Resend so delivery failures update the sender reputation automatically. You paste the records into the domain's DNS provider and wait. Propagation usually completes within five minutes. Resend's dashboard shows a green verification badge when all three records resolve correctly. After verification, the setup moves to code. The API key goes into the deployment environment variables. The Resend client initialises with one constructor call. Sending an email is a single function call with six fields — from, to, subject, replyTo, and the body as plain text. No HTML template engine, no MJML compiler, no inline CSS processor. Plain text for transactional messages because plain text renders identically in every email client on every device, never triggers promotional tab filtering, and weighs a fraction of a kilobyte. The from address uses noreply at the domain because the email is automated. The replyTo carries the real email address where human replies should land. The noreply convention tells the recipient not to reply to the automated address while the replyTo header gives them a path to a real person. The contact form API route validates the incoming fields, constructs the email payload, sends it through Resend, and returns a success response. If Resend is unreachable — network failure, rate limit, service outage — the catch block returns an error message without crashing the application. The form shows the error to the user so they can try again or use an alternative contact method. From creating the Resend account to receiving the first test email on the live domain takes under fifteen minutes. Most of that time is waiting for DNS.

software
Building

The project type dropdown uses three phrases, not three categories

The contact form has a project type field with three options. A new website. An accessibility audit or fixes. Not sure yet, let us talk. The options are not database categories or internal labels. They are phrases a person would say in conversation. Each one starts with a lowercase article or pronoun and reads as a sentence fragment that completes the question what are you looking for. The first option covers defined build projects — a client who knows they need a website and wants to discuss scope. The second covers audit and remediation work — a client who has an existing site and needs accessibility compliance. The third covers everything else — a client who has a problem but does not know what the solution looks like yet. Three options span the full range of inbound enquiry intent without forcing the person to classify their own request. The third option matters most. A contact form without a catch-all option loses the visitors who do not see themselves in the predefined categories. They leave the page. The not sure yet option gives them permission to reach out without knowing exactly what they want. The phrase let us talk sets the expectation that the first step is a conversation, not a purchase. The project type appears in the notification email body as a labelled line so you can see what the person is looking for before you read their message. It does not appear in the subject line because the subject already carries the name and optional business name, and adding a third piece of information would push it past the point where most email clients truncate. Three options, three sentence fragments, one dropdown. The field does the intake triage before any human reads the message.

software
Tip

The email pipeline gets tested on production because DNS only exists there

Transactional email cannot be fully tested in staging. Resend validates the sending domain through DKIM records configured on the production DNS. A staging environment on a different hostname would need its own domain, its own DNS records, its own Resend verification — at which point it is no longer testing the production pipeline. So the tests run on production. Each test submission carries a message that describes what it is verifying. The first confirms the form submits and the notification arrives in the inbox. The second confirms the subject line formats correctly with a business name in parentheses. The third tests without a business name to verify the conditional formatting. The fourth adds the auto-responder and checks that the enquirer receives a confirmation. The fifth and sixth iterate on the auto-responder wording and verify the final version delivers correctly. Each test message reads like a code comment — it states the expected outcome so the inbox becomes a verification log. If an email is missing, the message on the previous test tells you what should have arrived. If everything is present, the inbox contains a chronological record of passing checks. The whole process takes under twenty minutes. No test framework, no mock SMTP server, no assertion library. Real form submissions on the real domain with real Resend delivery to a real inbox. The alternative — mocking Resend in a test suite — would verify that the code calls the SDK correctly but would not verify that the DNS records work, the domain is verified, the API key has the right permissions, or the emails actually arrive. Unit tests verify code paths. Production tests verify delivery. Both matter. The unit tests run in CI with mocked transports. The production tests run once after deployment, by hand, with messages that explain themselves.

software
Building

The mobile menu renders six service colours without a context provider

The homepage has a floating hamburger button fixed to the top-left corner on mobile. Tap it and a dropdown appears with six links — Software, Accessibility, Audio, Production, Musician, Notes. Each link has an icon coloured to match the service accent. Purple for software. Fuchsia for accessibility. Cyan for audio. Emerald for production. Orange for musician. Pink for notes. The MobileNav component stores six NavItem objects in a static array. Each item carries two hex values — a dark variant and a light variant. The dark values are the same -400 shades used across the rest of the site. The light values are the -600 shades that pass WCAG contrast on white backgrounds. When the component renders each link, it reads the current theme from the useTheme hook and picks the matching hex value inline — theme equals light means item.light, otherwise item.dark. That decision happens per icon, per render, with no context provider wrapping the tree. The useTheme hook watches the data-theme attribute on the document element through a MutationObserver. When the ThemeToggle flips the attribute, the observer fires, the hook re-renders the MobileNav, and every icon colour updates simultaneously. The menu itself is two layers. A fixed backdrop with fifty percent black opacity covers the viewport and closes the menu on tap. The dropdown sits absolute below the button with a glass background at ninety-five percent opacity, a backdrop blur, and a rounded border. The button is forty-four pixels square — above the WCAG minimum touch target. The aria-expanded attribute flips with the open state. The aria-label switches between Open menu and Close menu. The icon swaps from a hamburger to an X mark. The whole component is seventy-four lines including the nav items array. Six services, two colour modes, one hook call, zero context providers.

softwareaccessibility
Building

The footer links every package for search engines but hides them from phones

The Footer component renders two elements. The first is a nav that only appears on desktop — hidden on base, block on the lg breakpoint. Inside is a five-column grid where each column represents a service. The column header is a link to the service page. Below it is an unordered list of every product in that service, each linking to its checkout page. Sound Engineering has four links. Music Production has five. Software Engineering has four. Accessibility has four. Musician has four. Twenty-one internal links in total, all generated dynamically from the same products array that powers the service pages and checkout flows. If I add a new package to the products array, the footer picks it up on the next build with zero additional configuration. The grid is hidden on mobile because twenty-one links stacked vertically would push the actual footer content off-screen. On desktop, the five-column layout fits cleanly above the copyright section. The purpose is SEO internal linking. Every checkout page is set to noindex nofollow — search engines cannot find them through crawling. But the footer links exist on every public page of the site, giving search engines a path from the indexed service pages to the individual product pages. The links also tell search engines about the site structure — five services, four to five packages each, a clear hierarchy. The second element is the visible footer with the company logos, copyright line, registered office address, company number, legal links, and a WCAG 2.1 AA compliance badge. The legal links have a minimum height of forty-four pixels using an inline-flex alignment wrapper. The logos have hover opacity transitions from seventy percent to full. The entire Footer is one hundred and four lines. One component, two sections, twenty-one dynamic links, and the product catalog drives both the service pages and the sitemap alternative in the footer.

software
Building

The milestone bar fills with a gradient and glows on the active dot

Every project card in the portal shows a MilestoneTimeline. The component takes a milestones array and renders two things — a progress bar and a dot list. The progress bar is a one-pixel-tall rounded div with a five percent white background. Inside it, a child div fills from left to right with a gradient running from cyan-400 to emerald-400. The width is a percentage calculated from the number of completed milestones divided by the total. Three out of five milestones completed means the bar fills to sixty percent. The bar has role progressbar with aria-valuenow set to the computed percentage, aria-valuemin at zero, aria-valuemax at one hundred, and aria-label reading Project progress followed by the number. Screen readers announce the project's completion state without needing to count dots. Below the bar, each milestone renders as a flex item with a two-pixel dot and a text label. The dot colour comes from a config object keyed by milestone status. Done milestones get emerald-400 — a solid green that matches the right end of the gradient. Active milestones get cyan-400 with a box shadow — zero horizontal, zero vertical, six pixels blur, and the shadow colour at forty percent opacity. That creates the glow. The active dot appears to emit light while the others are flat. Todo milestones get ten percent white — nearly invisible against the dark background, signalling work that has not started. The whole component is forty-nine lines. It takes one prop, computes one percentage, and maps one array. The gradient fill gives a sense of overall momentum. The glowing dot tells you exactly where the work is right now. The dim dots show what is ahead. Three visual signals — fill, glow, dim — from one status field on each milestone record.

softwareaccessibility
Building

The sidebar card stops being a link when you are already looking at it

Every service page has a product sidebar listing every package for that service. The ProductSidebar component renders each package as a SidebarCard. The interesting part is the wrapper. When a card is not the active product, it wraps in a Next.js Link pointing to that product's checkout page. When a card is the active product — the one the client is currently viewing — it wraps in a plain div. No link, no pointer cursor, no navigation. You cannot click the thing you are already looking at. The implementation avoids duplicating the card markup by building the inner JSX first as a variable called inner, then conditionally wrapping it. If isActive is true, return a div containing inner. If false, return a Link containing inner. The Link uses scroll set to false so clicking a different package in the sidebar swaps the content without jumping the page back to the top. The scroll position stays put. The client can browse all four software packages without the viewport lurching with every click. The active card gets a visual treatment through inline styles. The border colour switches to the service accent CSS custom property. The background uses color-mix in sRGB to blend ten percent of the accent into transparent, creating a subtle tinted glass effect that signals which package is selected. The border width increases from one pixel to two. Inactive cards use the default glass border and glass background tokens so they recede visually. The hover state on inactive cards uses Tailwind's arbitrary variant syntax — hover colon ampersand greater-than div colon border-accent at fifty percent opacity. That nested selector targets the inner div from the outer Link's hover state, so the border tint appears on mouseover without any JavaScript event handlers or state updates. The whole sidebar is sixty-six lines. One component renders every product for a service, adapts its wrapper element based on selection state, and staggers its entrance with BlurReveal delays of one hundred milliseconds between cards.

software
Building

Light mode rewrites twenty-four Tailwind colours in CSS so no component needs to change

The site was designed dark-first. Every component uses Tailwind's -400 shade classes — text-cyan-400 for audio accents, text-emerald-400 for production, text-fuchsia-400 for accessibility. Those shades look great on a near-black background. On a white background, they fail WCAG AA contrast. Cyan-400 on white is about 2.5 to 1. The minimum is 4.5 to 1. The fix is not in the components. It is in globals.css. Twenty-four CSS overrides sit under the data-theme light selector. Thirteen text colour rules swap every -400 and -300 shade to its -600 or -700 equivalent. text-cyan-400 becomes 0891b2 — cyan-600 at roughly 4.5 to 1 contrast. text-emerald-400 becomes 059669 — emerald-600 at 4.5 to 1. text-orange-400 becomes ea580c — orange-600 at 4.6 to 1. Every override has a comment with the target shade name and its exact contrast ratio so future changes can verify they still pass. Five border overrides match the text swaps — border-cyan-400, border-emerald-400, border-orange-400, border-fuchsia-400, and border-pink-400 all shift to the same darker values. Four background overrides handle badges and pills where the accent is a filled surface rather than text. Then there is the SVG rule. Every SVG logo on the site is white for dark mode. The light mode override applies filter invert one and hue-rotate 180 degrees to any image source ending in dot svg. The invert flips white to black. The hue-rotate corrects the colour shift that invert introduces. The logos appear in the correct brand colours on both backgrounds without maintaining two image files. The total cost is twenty-four CSS rules with the important flag. No component was edited. No conditional className was added. No ternary operator decides between text-cyan-400 and text-cyan-700 at render time. The components write dark-mode classes and the stylesheet rewrites them for light mode. One file, zero component changes, full WCAG AA compliance across both themes.

softwareaccessibility
Building

Font Awesome disables its own CSS injection so icons never flash full-size on first load

The root layout has two lines that solve a problem most Next.js sites with Font Awesome icons never fix. The first line is config.autoAddCss equals false. The second is an import of fontawesome-svg-core slash styles.css. Without those two lines, Font Awesome injects a style tag into the document at runtime. On the server, the icon renders as an inline SVG with no CSS applied. The SVG has an intrinsic size based on the viewBox — often several hundred pixels wide. When the browser receives the server-rendered HTML, it paints those oversized SVGs immediately. A fraction of a second later, the Font Awesome runtime JavaScript executes, injects the style tag, and the icons snap down to their intended size. The visual result is a flash — every icon on the page appears enormous for one frame, then shrinks. It looks broken. The fix is to load the CSS at build time instead of runtime. The manual import of styles.css tells Next.js to include Font Awesome's sizing and positioning rules in the page's CSS bundle. When the browser receives the HTML, the stylesheet is already parsed and the icons render at their correct size from the first paint. Setting autoAddCss to false tells the Font Awesome runtime not to inject its own style tag since the CSS is already present. Two lines of configuration. One import, one boolean. The result is that the service card icons on the homepage, the navigation icons in the mobile menu, the checkmark in the checkout progress bar, and every other Font Awesome icon across the site renders at the right size on the first frame. No flash, no layout shift, no runtime style injection. The same two lines appear in the Font Awesome documentation for server-side rendering but they are easy to miss if you are following a quick-start guide that assumes client-side rendering only.

software
Building

The calendar only shows weekdays because weekends do not exist in this booking system

The booking calendar on every service page renders a five-column grid. Not seven. Five. Monday through Friday. The component loops through every day of the month, checks the day of the week with getDay, and skips any day that returns zero or six. Saturday and Sunday never make it into the cells array. They are not greyed out. They are not disabled. They do not exist in the DOM. A client looking at the calendar sees a compact weekday-only layout that communicates availability at a glance without wasting space on days I do not work. The grid is not static. Every future date starts in a blurred state — literally. The CSS class blur-[3px] with sixty percent opacity creates a frosted effect on dates that have not been checked yet. The component fires availability requests in batches of four using Promise.all inside a sequential loop. As each batch resolves, the dates in that batch transition from blurred to sharp over five hundred milliseconds. The effect is progressive revelation — the calendar sharpens from top to bottom as the API responds, giving the client a visual sense of the system working. A date with zero available slots gets a strikethrough and forty percent opacity. A date with slots becomes selectable — the blur lifts, the border appears, the hover scale activates. The whole thing looks like a grid of frosted glass tiles that clear one by one to reveal which days are open. The cancellation pattern is a boolean flag in a closure. When the month changes, the useEffect cleanup sets cancelled to true. Any in-flight fetch that returns after the month switch checks the flag and silently discards its result instead of updating stale state. Four parallel requests, sequential batches, progressive blur reveal, and zero wasted renders for months the client has already left behind.

software
Building

The checkout progress bar uses aria-current step so screen readers know where you are

The checkout wizard has four steps — Package, Details, Pay, Done. The progress indicator at the top is a nav element with an ordered list inside. Each step is a list item. The active step carries aria-current set to step. That single attribute tells a screen reader exactly where the user is in the flow without them needing to parse visual cues like filled circles or accent colours. The visual layer is built on three states per step — completed, active, and future. A completed step shows a checkmark SVG inside a translucent accent circle. The accent colour comes from the service CSS custom property so the progress bar matches the page theme automatically — purple for software, cyan for audio, emerald for production. The active step fills its circle solid with the accent and renders the step number in black for maximum contrast. Future steps use a glass background with muted text. The connector lines between steps fill with the accent colour as you progress, creating a visual trail of completion. On mobile, the step labels disappear entirely. Only the numbered circles remain in a tight horizontal row. The labels are hidden with a responsive class — hidden on base, inline on sm breakpoint. The circles are twenty-eight pixels square, well above the minimum touch target for a non-interactive indicator. The whole nav is sixty-seven lines of code including the type definition. It takes one prop — currentStep — and derives everything else from the step index. No animation library, no transition group, no spring physics. Just conditional classes based on array position.

softwareaccessibility
Building

The notes page filters with a sentence, not a toolbar

The notes page does not have a filter bar with dropdowns and labels and a reset button. It has a sentence. Check out everything in all topics. The words everything and all topics are select elements styled to look like inline text. They use the same font size as the surrounding prose, a bottom border in the accent colour, a transparent background, and bold weight. When you tap one, a native dropdown opens with the options — thoughts, updates, building notes, tips for the type filter, and software, accessibility, audio for the topic filter. The sentence reads differently depending on your selection. Check out tips in accessibility. Check out updates in all topics. The language adapts naturally because the option labels are written as words that fit the grammar — everything, thoughts, updates, building notes, tips. Not Everything, Not All Types. Lowercase plural nouns that slot into a sentence. The select styling removes the default browser appearance with a negative webkit-appearance and a custom appearance-none class. The options themselves are styled with the site dark background and white text so the dropdown matches the page instead of rendering as a jarring system-native white box. The result count at the bottom updates live — 12 of 117 notes — giving immediate feedback on how many entries match. If nothing matches, a centred message replaces the list. The component does not debounce. It does not animate. It filters the array on every state change and React renders the new list. With a hundred-plus notes the filter is instantaneous because there is no network request — the entire notes array lives in memory as a static import. Two selects, one sentence, zero toolbars.

softwareaccessibility
Building

The background gradient is fixed to the viewport so the page scrolls over it

The site does not have a flat background colour. The body element has a CSS gradient at a hundred and sixty-five degrees with four colour stops — near-black at the top, deep purple at forty percent, a cooler dark at seventy percent, and back to near-black at the bottom. The key property is background-attachment fixed. That pins the gradient to the viewport, not the document. When you scroll, the content moves but the background stays still. The effect is subtle — you probably do not notice it consciously, but the page feels like it has depth. Content floats over a static atmosphere instead of sliding on a flat surface. The same gradient drives every page. Service pages, checkout pages, the notes section, the portal — they all inherit the body gradient because it is set once in globals.css. No per-page background configuration. No component-level overrides. Light mode swaps all four stops to lavender tones at the same angle and the same positions. The gradient structure is identical — only the palette changes. The colour-scheme property on the html element switches between dark and light based on the data-theme attribute, so native browser controls like scrollbars and select dropdowns match the theme automatically. One CSS rule on the body creates the entire visual foundation. No images, no JavaScript, no performance cost. The four-stop gradient is roughly sixty bytes of CSS. The fixed attachment means the browser composites the background as a single layer and never repaints it on scroll. It is the cheapest visual effect on the entire site and the one that touches every pixel.

software
Building

Every notification email opens with a first name because automated updates should not feel automated

The portal sends five types of notification email — session notes added, milestone status changed, new comment posted, new deliverable uploaded, and new action item assigned. Every one of them opens with Hi followed by the client's first name. Not their full name, not Dear Client, not a generic greeting. The code calls clientName.split and takes the first element. Sarah Thompson becomes Hi Sarah. That is a deliberate decision about tone. These are not marketing emails. They are not transactional receipts. They are project updates between me and a client I am working with. The language should match the relationship. The subject lines follow the same principle. New session notes uses the project name as context. Milestone completed names the specific milestone. Action needed tells the client something is waiting for them. Every subject line describes what happened without padding it with branding or exclamation marks. The body text is two to four lines of plain text. No HTML template, no header image, no footer links, no unsubscribe mechanism. Just a sentence explaining what changed, a link to the portal, and James at the bottom. The reply-to on every email is my actual email address so the client can respond directly to the notification and it lands in my inbox. The getResend factory function returns null if the RESEND_API_KEY is missing. Every notification function checks for null before attempting to send. If the key is not configured — during local development, during testing, during a CI run — the notification silently skips instead of throwing an error. Email is useful but it is not a dependency. The project update still happens in the database whether or not the email goes out. Five notification types, one tone, zero HTML templates, and every greeting is personal.

software
Building

The checkout page hides from search engines because a product URL is a funnel, not a destination

Every checkout page on the site sets robots to noindex nofollow. The route at checkout slash productId generates metadata dynamically — the title says Checkout colon product name for purchasable products and Get a Quote colon product name for quote-based products. Both tell search engines to stay away. The rationale is simple. A checkout page is the middle of a funnel. Nobody searches for checkout remote per track or get a quote web app. They search for sound engineering services or software development. That is what the service pages are for. The five service pages are fully indexed with OpenGraph tags, Twitter cards, and structured descriptions. They rank in search. The checkout pages convert. The separation is deliberate. The same page also adapts its sidebar behaviour based on whether the product has a price. When you are buying a fixed-price package, the sidebar defaults to the packages section — you can see the other options while you check out. When you are requesting a quote for a product without a fixed price, the sidebar defaults to the email section — the contact form is immediately visible because that is what you need. Both flows share the same layout, the same ServiceNav breadcrumb, the same sticky sidebar, the same accent colour inherited from the service. The accent colour itself is set as a CSS custom property on the outermost div of the page, read from a five-entry config map keyed by service name. Every child component — the wizard, the sidebar, the form accordion, the product cards — reads the accent through the CSS variable without receiving it as a prop. One route handles twenty-two products across five services. It adapts its metadata, its content, its sidebar state, and its visual theme based on a single product lookup. The only thing all twenty-two pages have in common is that none of them appear in search results.

software
Building

The theme switches before React hydrates so nobody sees a flash

The site has a dark mode and a light mode. The toggle is a button fixed in the top-right corner of every page. When you click it, it does three things — sets a data-theme attribute on the document element, saves the value to localStorage, and updates local state. That last one re-renders the button so the icon swaps from a sun to a moon. The interesting part is what happens before any of that. The root layout has a six-line inline script in the head element, rendered with dangerouslySetInnerHTML. It reads localStorage synchronously and sets the data-theme attribute on the html element before the browser paints a single pixel. The script runs during HTML parsing, before React hydrates, before any component mounts. If you chose light mode yesterday, the page opens in light mode today with zero flash. No white-to-dark flicker, no layout shift, no FOUC. The script is one hundred and three characters. It costs nothing. The reactivity system is equally minimal. Every component that needs to know the current theme calls the useTheme hook. The hook does not use a context provider. It does not use an event listener. It sets up a MutationObserver that watches the data-theme attribute on the document element. When ThemeToggle flips the attribute, the observer fires, the hook updates its state, and the component re-renders with the new theme. No prop drilling, no context wrapper, no event bus. One DOM attribute is the single source of truth. The MutationObserver disconnects on unmount. The CSS layer handles the rest — the root selector defines dark mode custom properties, the data-theme light selector overrides them. Every Tailwind class, every accent colour, every glass surface reads from those properties. One attribute drives the entire visual system. The ThemeToggle button itself is forty-four pixels square with a glass background and the accent border on hover. It meets the WCAG minimum touch target. The aria-label updates dynamically — Switch to light mode when you are in dark, Switch to dark mode when you are in light. One button, one script, one observer, zero context providers.

softwareaccessibility
Building

The checkout wizard reads the URL so 3D Secure returns land on the right step

When a client pays with a card that requires 3D Secure verification, Stripe redirects the browser to the bank's authentication page. After the client confirms, the bank redirects back to the return URL. The problem is that the checkout wizard is a client component with local state. A full page navigation wipes that state. The wizard solves this with four lines in the initial state function. When the component mounts, it reads the URL search parameters. If step equals confirmation and redirect_status equals succeeded, it sets the initial state to confirmation and immediately calls window.history.replaceState to strip the query parameters from the URL. The browser address bar shows the clean product URL. The wizard opens on the confirmation screen. The client sees the payment confirmed message, the consultation booking calendar, and the what happens next steps — exactly as if the payment had succeeded without a redirect. If the query parameters are missing or the status is not succeeded, the wizard starts on the package step as normal. The state initialiser is a lazy function passed to useState so it only runs once on mount, not on every render. The replaceState call uses an empty object for state and an empty string for title because those parameters are ignored by modern browsers — only the URL matters. The confirmation step itself does not know or care how the client got there. It renders the same full-screen overlay with backdrop blur, the same checkmark in a tinted circle, the same consultation picker with real Google Calendar availability. The name and email fields are empty after a redirect because local state was lost, but the greeting falls back to there — Thanks, there — and the Stripe webhook has already sent the confirmation email with the full details. The booking request still works because the form fields are not required for the consultation. One URL check, one state initialiser, one replaceState call, and the redirect flow is invisible to the client.

software
Building

Every focus ring uses the accent colour and every animation respects the user

The site has one global focus style. The focus-visible pseudo-class sets a two-pixel solid outline using the accent colour custom property with a two-pixel offset. Every interactive element on every page — buttons, links, inputs, the theme toggle, the skip-to-content link, the service card navigation, the checkout wizard — gets the same visible focus ring when reached by keyboard. No element has outline none without a replacement. No element uses a custom focus style that differs from the global one. The accent colour is purple by default and shifts to the service accent on service pages, so the focus ring adapts to context automatically. The skip-to-content link works through CSS positioning alone. By default, it sits at position absolute with left set to negative nine thousand nine hundred and ninety-nine pixels. It is off-screen but still in the accessibility tree. When a keyboard user tabs into the page, the link receives focus and a single CSS rule fires — left zero. The link slides into view at the top-left corner with a z-index of nine hundred and ninety-nine so it always appears above other content. The background uses the accent colour. The text uses the background colour. The padding is 0.75rem by 1.5rem, giving it a touch target well above forty-four pixels. The border-radius is zero on three corners and 0.5rem on the bottom-right, so it tucks into the top-left corner of the viewport. When focus moves away, the link slides back off-screen. Zero JavaScript. The reduced motion preference is handled at the root. When prefers-reduced-motion is set to reduce, the scroll-behavior on the html element switches from smooth to auto. Every scroll-into-view call, every anchor link jump, every hash-based sidebar switch snaps instantly instead of animating. The color-scheme property switches between dark and light based on the data-theme attribute so the browser's native form controls — scrollbars, select dropdowns, date pickers — match the theme without any additional styling. Three CSS rules handle three accessibility concerns — focus visibility, motion sensitivity, and native control theming.

softwareaccessibility
Building

The database connects when you query, not when you import

The Prisma client is a Proxy that pretends to be a PrismaClient but does not create one until the first property access. The module exports a const called prisma. That const is not an instance of PrismaClient. It is a Proxy wrapping an empty object. The get trap checks whether a real PrismaClient has been stashed on globalThis. If it has not, the trap calls createPrismaClient which reads DATABASE_URL, creates a PrismaPg adapter with the connection string, passes the adapter to the PrismaClient constructor, and stores the result on globalThis so every subsequent access reuses the same instance. If it has, the trap calls Reflect.get on the cached instance and returns whatever property was requested. The entire module is twenty-six lines. The reason this matters is Next.js imports every server module at build time to analyse the dependency graph. If the Prisma client was created at import time, the build would crash because DATABASE_URL is not set in the CI environment. The CI pipeline runs four checks — lint, type check, test, build — and none of them have any environment variables configured. No STRIPE_SECRET_KEY, no RESEND_API_KEY, no DATABASE_URL. The lazy Proxy solves the build problem without conditional imports, without dynamic requires, without splitting the module into a factory and a consumer. Any file can import prisma at the top level and use it in a server action or API route. The Proxy intercepts the first real call — prisma.client.findUnique, prisma.project.findMany, whatever — creates the connection at that moment, and every call after that hits the cached instance. One Proxy, one globalThis slot, zero build-time connections.

software
Building

The sidebar reads the URL hash and opens to your intent

Every service page has a sidebar with up to three accordion panels — packages, book a call, send a brief. The FormAccordion component decides which panel opens based on the URL hash at load time. If the hash is #send-brief the email panel opens. If the hash is #book-call the call panel opens. If the hash is #packages-sidebar the packages panel opens. If there is no hash, the default is packages when packages exist or call when they do not. The component also listens for hashchange events after mount so clicking a link anywhere on the page can switch the active panel without a full navigation. The ServiceNav at the top of every service page has two call-to-action buttons — Book a Call and Send a Brief. Each one is an anchor link to the corresponding hash. Clicking either scrolls the sidebar into view and opens the right panel in one motion. The AccordionPanel component handles its own accessibility. When a panel is collapsed it gets role button and tabIndex zero so keyboard users can reach it and activate it with Enter or Space. When a panel is active those attributes are removed because the panel is now a container not a control. The Space key handler calls preventDefault to stop the browser from scrolling the page. Each panel has scroll-mt-24 so it clears the sticky header when scrolled into view. The border colour switches from the glass border token to the service accent colour when active. The background shifts from the glass token to a five percent tint of the accent using color-mix in sRGB. The border width goes from one pixel to two. Three panels, one state variable, one hashchange listener. The URL drives the UI and the UI stays in sync with the URL.

softwareaccessibility
Building

The Google OAuth routes tell you to delete them after you use them

The Google Calendar integration needs a refresh token. Getting a refresh token from Google requires a one-time OAuth consent flow. The site has two API routes dedicated to this — /api/auth/google and /api/auth/google/callback. The first route creates an OAuth2 client with the Google client ID and secret, generates an authorization URL with offline access and the consent prompt forced, and redirects the browser to Google. The scope is calendar only. After the user consents, Google redirects to the callback route with an authorization code in the query string. The callback route exchanges the code for tokens using the same OAuth2 client. Then it renders a full HTML page — not a JSON response, not a redirect, a styled HTML page with a monospace font on a dark background that looks like the rest of the site. The page shows the refresh token in a pre block with user-select all so you can click and copy. Below the token it tells you exactly what to do — add GOOGLE_REFRESH_TOKEN to your env file, restart the dev server, then delete the entire src/app/api/auth/google directory. The routes are designed to be used once and thrown away. They exist in the codebase as a setup tool, not as a production feature. The comment at the top of each file says the same thing — delete these routes after you have your refresh token. The callback page says it again in the rendered HTML. Three reminders across two files. Once the refresh token is in the environment, the Google Calendar client in google-calendar.ts uses it to generate access tokens on every request. The OAuth routes never run again. They are scaffolding that documents its own removal.

software
Building

Forty-one tests guard the checkout before any human reviews the code

The site has forty-one unit tests across three files — products, checkout, and contact. Every test runs through Vitest with a Node environment and path aliases matching the tsconfig so imports like @/lib/products resolve the same way they do in production. The product tests validate the entire catalog at compile time. Every product must have a truthy id, name, description, and priceLabel. Every product must have at least one feature. Every id must be unique across the full array. Fixed-price products must have a positive pricePence. Contact products must have null pricePence. The isPurchasable function gets six tests — true for fixed with a positive price, false for contact, false for fixed with null, false for fixed with zero, true for every real fixed product in the catalog, false for every real contact product. The getProductsByService function gets a generated test per service — a loop over all five service keys asserting that every returned product matches the key and that the sum across all services equals the total product count. The checkout tests mock Stripe at the module level using vi.mock before the route is imported so the real Stripe SDK never loads. Eleven tests cover missing fields, invalid emails, honeypot submissions that return success without calling Stripe, non-existent product IDs, contact-only products that cannot be purchased, valid purchases that verify the PaymentIntent amount and metadata, idempotency keys passed through to the Stripe options object, message truncation to five hundred characters, whitespace trimming on names, and both idempotency and network errors. The contact tests mock Resend the same way and cover thirteen cases — missing name, missing email, invalid email, honeypot, invalid service type, valid contact without a service, valid contact with a service, all five valid service types in a loop, Resend errors, and missing API keys. Every test creates a real Request object with the right method, headers, and JSON body. No test touches a real third-party API. The coverage config targets src/lib and src/app/api so library code and API routes are measured but components and pages are not. Forty-one tests, three files, zero network calls.

software
Building

The CI pipeline runs four checks and none of them need secrets

Every push to main and every pull request triggers a GitHub Actions workflow called CI. It runs on ubuntu-latest with Node 20 and npm caching enabled. Four steps run in sequence — lint, type check, test, build. The lint step runs the Next.js ESLint config. The type check runs tsc with noEmit so it validates every TypeScript file without producing output. The test step runs Vitest which executes all forty-one unit tests. The build step runs next build to confirm the production bundle compiles clean. None of these steps require environment variables or API keys. The Stripe SDK is mocked in tests. The Resend SDK is mocked in tests. The Prisma client generates from the schema without a database connection. The Google Calendar client is never imported in test files. The build succeeds because every external dependency is either mocked, tree-shaken, or loaded conditionally at runtime. That means the CI pipeline has zero secrets configured in GitHub. No STRIPE_SECRET_KEY, no RESEND_API_KEY, no DATABASE_URL, no CLERK_SECRET_KEY. The workflow file is thirty-three lines. One job, four steps, four quality gates. If any step fails, the pipeline fails and the merge is blocked. Railway watches the main branch and auto-deploys on every merge, so the deploy pipeline is one line of config on their side — connect the repo, pick the branch, done. CI catches the code problems. Railway handles the infrastructure. The two never overlap.

software
Building

The sitemap knows ten URLs and the portal is not one of them

The sitemap is a TypeScript function that returns an array of ten URL objects. The homepage at priority one with weekly change frequency. The five service pages — software, accessibility, audio, production, musician — at priority 0.9 with monthly frequency. The notes page at 0.7 with weekly frequency because new entries appear regularly. The donate page at 0.4 with yearly frequency. The terms and privacy pages at 0.3 with yearly frequency. Every entry has a lastModified set to new Date at build time so crawlers know the content is current. The portal is not in the sitemap because it is authenticated content. The robots file is a separate TypeScript function that returns a single rule — allow everything for all user agents — and points to the sitemap URL. The portal pages handle their own exclusion through metadata. The portal layout sets robots to noindex nofollow on every child route. The donate thanks page does the same because a thank-you page has no search value. The note detail pages go in the opposite direction — each one generates its own metadata through generateMetadata with the note title, a 160-character excerpt as the description, and full OpenGraph and Twitter card tags for social sharing. The root layout sets the global metadata — title, description, OpenGraph with en_GB locale, Twitter card — and the metadataBase to the production URL so all relative paths resolve correctly. Ten public URLs in the sitemap. Fifteen portal routes excluded. Every page has metadata. Every exclusion is deliberate.

software
Building

One middleware rule protects the portal and ignores everything else

The entire authentication boundary for the site is nine lines of middleware. Clerk's createRouteMatcher takes one pattern — /portal(.*) — and returns a function that checks whether the current request URL matches. If it matches, auth.protect fires and redirects unauthenticated users to the sign-in page. If it does not match, the middleware does nothing and the request passes through untouched. Every service page, every checkout flow, every API route outside the portal, the homepage, the notes, the donate page — none of them hit the auth check. The matcher config at the bottom excludes static files and Next.js internals so the middleware only runs on actual page and API requests. Two regex patterns handle that — one for pages that are not static assets, one for API and tRPC routes. The whole file imports two functions from Clerk and exports one middleware function and one config object. No role checks at this layer. No redirect logic. No session inspection. The middleware answers one question — is this a portal route and is the user signed in. Role-based access happens deeper in the stack. The admin layout checks publicMetadata.role. The server actions check requireAdmin or auth individually. The middleware is the outer wall. Everything inside handles its own permissions. One pattern, one check, one redirect. The marketing site stays completely open. The portal stays completely locked.

software
Building

Ten models, three enums, and every delete cascades

The database schema is one Prisma file with ten models and four enums. Client is the root — every other model connects back to it directly or through Project. A Client has an optional clerkId that starts null until the Clerk webhook links it, a unique email, an optional stripeCustomerId for invoice lookups, and an archivedAt timestamp for soft deletes. Project belongs to Client and carries a ServiceType enum — SOFTWARE, ACCESSIBILITY, AUDIO, PRODUCTION, MUSICIAN — and a ProjectStatus enum — ACTIVE, IN_REVIEW, COMPLETED, ON_HOLD. Six models hang off Project with cascading deletes — Milestone, Session, Goal, Deliverable, ActionItem, and Attachment. Delete a project and everything underneath disappears in one operation. Milestone has a sortOrder integer so the timeline renders in the right sequence and a MilestoneStatus enum — TODO, ACTIVE, DONE. Session stores a durationMin integer, optional notes as a Text column for long content, and an optional calendarEventId that links it to Google Calendar. Comment is the most connected model — it has optional foreign keys to Session, Milestone, and Goal so the same comment model serves inline threads on three different entity types. Each comment carries an isJames boolean and an authorName string for the avatar system, plus editedAt and deletedAt timestamps for audit trails. Attachment stores the R2 key as url, the file size, the MIME type, and an uploadedBy string defaulting to james. Deliverable has its own four-stage enum — PENDING, IN_PROGRESS, DELIVERED, APPROVED — and an optional one-to-one link to an Attachment. Booking stands apart from the project tree — it connects directly to Client with a unique calendarEventId for upsert-based sync and indexed startTime for chronological queries. Every foreign key that connects to Project or Session uses onDelete Cascade. Every model that needs frequent lookups has an index on its parent key. Ten models, fourteen indexes, four enums, and the whole schema fits in two hundred lines.

software
Building

Nine accent colours, two shades each, and one function picks the right one

The colour system lives in one file called theme-colors. It exports an accentColors object with nine named pairs — purple, violet, fuchsia, cyan, emerald, orange, blue, amber, and pink. Each pair has a dark value and a light value. The dark values are Tailwind's -400 shades — vibrant enough to read on dark backgrounds. The light values are -600 and -700 shades that pass WCAG AA 4.5 to 1 contrast ratio on white. One object, eighteen hex values, and every accent on the site pulls from it. The noteTypeColorPairs record maps each note type to its colour pair — thought gets violet, update gets blue, building gets emerald, tip gets amber. The tagColorPairs record maps each tag to its service accent — software gets purple, ai gets purple, accessibility gets fuchsia, audio gets cyan, production gets emerald. Both records reference the same accentColors entries so there is no duplication. The pickColor function takes a colour pair and a theme string — dark or light — and returns the right hex value. That is three lines of code. Every component that needs a theme-aware accent calls pickColor with the pair and the current theme from the useTheme hook. The MobileNav calls it for icon colours. The NoteCard calls it for badge and tag colours. The HomeNotesFilter calls it for dropdown accents. The service pages do not use this system because they set their accent as a CSS custom property at the page level and everything underneath inherits it. The theme-colors file handles the components that live outside a service context — notes, navigation, the homepage. Nine colours, two modes, one function. No conditional classNames, no ternary chains, no theme context provider. Just a lookup and a return.

softwareaccessibility
Building

Twenty packages, two pricing modes, one catalog file

Every service I offer — sound engineering, music production, session musician work, software engineering, accessibility consulting — is defined in a single TypeScript array called products. Twenty entries. Each one carries an id, a name, a description, a price in pence or null, a human-readable price label, a list of features, a service key, a delivery estimate, and a consultation duration in minutes. The key field is type — either fixed or contact. Fixed means the price is set and you can buy it right now. Contact means you need a quote. The isPurchasable function checks for fixed type and a non-null pricePence greater than zero. That one function decides everything downstream. If isPurchasable returns true, the service page renders a checkout wizard with Stripe Elements embedded in the page. If it returns false, the page shows the package details with an intake form that sends an enquiry email. Two completely different user journeys, one boolean. Sound engineering has four packages — single track mix and master at two hundred and fifty pounds, EP mix and master priced per track, album mix and master as a quote, and podcast or voiceover edit at a hundred pounds. Music production has five — composition at five hundred, full track at a thousand, TV and film as a quote, advertising as a quote, custom sample packs as a quote. Musician has four — remote track at a hundred, half day at four hundred, full day at eight hundred, multi-day as a quote. Software has four — landing page at fifteen hundred, brochure site at thirty-five hundred, web app as a quote, MVP from five thousand as a quote. Accessibility has four — quick audit at five hundred, full site audit at two thousand, remediation as a quote, training from a thousand as a quote. The getProductsByService function filters the array by service key. Each service page calls it once and maps the result into product cards. Adding a new package means adding one object to the array. No database migration, no dashboard update, no deploy config. Push to main and it is live.

software
Building

The availability window is thirty days and every slot is checked twice

The availability API accepts a date and a duration. The date must be in YYYY-MM-DD format. The duration must be 15, 30, 45, 60, 90, or 120 minutes — anything else is rejected. Bookings more than thirty days out are blocked before the Google Calendar query even runs. The API fetches real calendar events from Google, finds gaps in the schedule, and returns an array of available slots. Each slot has a start and end time. The ConsultationPicker component on the front end renders these as a grid of buttons — one per slot, grouped by day. When a client picks a slot and hits confirm, the booking API receives the start time and duration. Before creating the calendar event, it calls getAvailableSlots again with the same date and duration. It checks whether the requested start time still exists in the returned array. If someone else booked that slot between the page load and the confirm click, the slot is gone. The API returns a 409 conflict with a message telling the client to choose another time. The booking only proceeds if the slot survives both checks — the initial load and the confirmation request. The actual calendar event is created via createBooking, which returns the start time, end time, and a Google Meet link if conference data exists. The booking confirmation email includes the date formatted as a full weekday and month, the time in 24-hour UK format, the service label, and the Meet link. If the booking came from the checkout confirmation page — where the client just paid for a package — the skipEmail flag is set to true and no email fires because the Stripe webhook already handled that. Two checks, one calendar, zero double bookings.

software
Building

Five services, five colours, one layout

Every service page on the site follows the same structure. A ServiceNav header with the service name and call-to-action buttons. A two-column layout — content on the left, a sticky ServiceSidebar on the right with an intake form or package selector. Cross-links to sibling services at the bottom. The layout is identical across all five pages. What changes is the accent colour. Sound engineering is cyan — hex 22d3ee. Music production is emerald — hex 34d399. Professional musician is orange — hex fb923c. Software engineering is purple — hex c084fc. Accessibility consulting is fuchsia — hex e879f9. The colour is set as a CSS custom property at the page level. Every interactive element downstream reads from that variable — buttons, borders, hover states, focus rings, the ServiceNav highlight, the sidebar accent, the product card call-to-action, the checkout wizard progress indicator, the confirmation page checkmark. One variable propagates through the entire component tree. The ServiceIntakeForm renders different fields per service. The audio form asks for genre and track count. The software form asks for project type and timeline. The accessibility form asks about the platform and compliance target. But the form component itself is one file — ServiceIntakeForm.tsx. It accepts a service key and a fields array. The fields array is a configuration object defined on each service page. Each field has a name, label, type, required flag, placeholder, and optional select options. The component maps over the array and renders inputs, selects, or textareas based on the type. Five services, five configurations, one form component. The same pattern runs through the product cards, the sidebar, the checkout flow. One set of components, five colour themes, five field configurations. The service pages are not five separate implementations. They are five datasets rendered through one shared system.

softwareaccessibility
Building

Five tiers, one custom field, zero product records

The donate page does not use Stripe Products or Prices. Every donation is a one-off Checkout Session created from scratch with a price_data object containing the amount in pence, the currency set to GBP, and a product_data name that reads Donation — £5.00. No pre-created products in the Stripe dashboard. No price IDs stored in environment variables. The DonationTiers component renders five preset buttons — five pounds for a coffee, ten for phone credit, fifteen for infrastructure, twenty-five for pizza and a late night coding session, fifty for new strings or drumsticks. Each button calls the donate API with the amount in pence. Below the presets is a custom amount field — a number input with a minimum of one pound and a maximum of ten thousand. The validation is client-side first and server-side second. The API rejects anything below 100 pence or above 1,000,000 pence before Stripe ever sees it. The success URL points to a dedicated thanks page at /donate/thanks. The page is excluded from search indexing with robots noindex nofollow because there is no reason for a search engine to find a thank-you page. The thanks page has one emoji, one heading, one sentence, and a button back to the homepage. The entire donation flow is one client component, one API route, one Stripe Checkout redirect, and one static thank-you page. No subscription model, no recurring billing, no donor database. Someone wants to buy me a coffee, they pick an amount, pay through Stripe, and land on a thank-you page. That is it.

software
Building

The newsletter catches bots before they subscribe

The newsletter signup sits at the bottom of the homepage in a glass card. One email input, one subscribe button, one honeypot. The honeypot is an invisible text field named website with aria-hidden true and tabIndex negative one. A real user never sees it. A bot filling out every field on the page fills it in. When the API receives a request where the website field has a value, it returns success true without doing anything. No error message, no feedback, nothing for the bot to learn from. It thinks it subscribed. It did not. For real subscribers, the API adds the email to a Resend Audience using the contacts.create endpoint. If the contact already exists — the API returns an error message containing already exists — the handler catches it and returns alreadySubscribed true instead of treating it as a failure. The component reads that flag and shows You're already subscribed instead of the generic success message. For new subscribers, the API sends a welcome email immediately after the contact is created. The email is plain text — a greeting, a one-sentence description of what to expect, and a sign-off. No HTML template, no images, no tracking pixels. The component handles four states — idle, loading, success, and error. The loading state disables the button and shows three dots. The success state replaces the entire form with the confirmation message so there is no way to accidentally double-subscribe. One form, one honeypot, one Audience, one welcome email, four states.

softwareaccessibility
Building

Every enquiry sends two emails and the client gets a reply-to

The contact form on every service page sends two emails through a single API call. The first goes to me — subject line tagged with the service name in square brackets, body containing every field the client filled in, reply-to set to the client's email so I can respond directly from my inbox. The second goes to the client — a plain text confirmation that their enquiry was received, a one-to-two working day response estimate, and a reply-to pointing back to my personal email. Two emails, two directions, one API route. The service-specific formatting is handled by a formatServiceFields function that iterates over every key in the request body, skips the standard fields — name, email, message, service, website — and formats the rest as labelled lines. The key is converted from snake_case to Title Case with a regex replacement. If the client filled in genre, track_count, and project_type on the audio intake form, the email reads Genre, Track Count, and Project Type followed by their values. The message field gets its own section at the bottom with a blank line above it. The subject line adapts to the service — [Sound Engineering] New enquiry from Sarah or [Accessibility Consulting] New enquiry from David. If no service is specified, the subject falls back to Contact from followed by the name. The honeypot check runs before any validation. Same pattern as the newsletter — if the invisible website field has a value, the API returns success without sending anything. Five services, five different intake form configurations, one API route, two emails per enquiry.

software
Building

The purchase webhook writes to Stripe before it writes to any inbox

When a client buys a package — a mixing session, a software build, an accessibility audit — Stripe fires a payment_intent.succeeded webhook. The handler does four things in order. First it verifies the Stripe signature using the raw request body so tampered payloads are rejected before any logic runs. Second it reads the PaymentIntent metadata for a field called emailSent. If the value is true, the handler returns immediately. This is the idempotency check. Stripe retries webhooks. Serverless functions cold-start. Without this check, a client could receive two identical confirmation emails. Third — and this is the key — it writes emailSent true back to the PaymentIntent metadata before sending a single email. If two webhook deliveries hit simultaneously, only the one that wins the metadata update race proceeds. The loser reads emailSent as true on its next check and exits. Fourth it sends two emails through Resend. The customer gets an order summary — package name, price, delivery estimate, and a three-step guide: book your consultation, discuss the project, I deliver. If they left a message during checkout, it is quoted in the email. The reply-to is my personal email so they can respond directly. I get a separate notification with the customer name, email, product, their message, and a direct link to the Stripe dashboard payment page. Both emails are plain text. Both are wrapped in independent try-catch blocks. If the customer email fails, I still get notified. If my email fails, the customer still gets confirmation. Two emails, two catch blocks, one metadata field, zero duplicates.

software
Building

The confirmation page turns a payment into a meeting

After a client completes checkout, the wizard does not show a thank-you message with a link to go somewhere else. It replaces the entire viewport with a full-screen confirmation splash — a fixed-position overlay with backdrop blur that fills the screen edge to edge. At the centre is a checkmark in a tinted circle, the client's name, and the package they just bought. Below that is a consultation booking calendar. The client just paid, and the very next thing they see is a grid of available time slots for a free 45-minute call. The ConsultationPicker component fetches real availability from Google Calendar through the availability API. Slots that are already taken do not appear. When the client picks a slot, the booking request includes the product name in the notes field — Purchased: Landing Page, Purchased: Quick Audit — so I know what the meeting is about before I join the call. The booking fires with skipEmail set to true because the Stripe webhook already sent a confirmation. No duplicate emails. If the booking request fails — network error, API timeout, calendar conflict — the payment is still confirmed. The client sees the consultation section but can book later through the confirmation email or by replying directly. The what happens next section adapts to whether they booked. If they did, step two reads Join your consultation at the scheduled time. If they did not, it reads Book your consultation call above. A Back to services link at the bottom takes them to the service page they came from, not the homepage, because the URL includes the product's service type. One confirmation page, two states, and the distance between paying and talking to me is one click.

software
Building

Three files keep the portal from ever showing a blank screen

The portal has three fallback components and each one catches a different failure mode. The loading skeleton renders during server-side rendering while the page data is being fetched. It is a server component — no JavaScript, no state, just HTML with a pulse animation. The skeleton shapes match the real dashboard layout: a heading bar at 48 pixels wide, a subheading bar at 72 pixels, three summary cards in a responsive grid, and a large content area below. Every shape uses the same glass border and glass background as the real components so the skeleton feels like part of the portal, not a placeholder from a different design system. The error boundary is a client component because React error boundaries need component state to capture the error and the reset callback. If any server component or client component inside the portal throws, the boundary catches it and renders the error message with a Try again button. The button calls React's reset function which re-renders the errored component tree from scratch. The not-found page handles two cases with one sentence — This portal page does not exist or you do not have access. To a client, the distinction does not matter. Whether they typed a wrong URL or tried to access another client's project, the response is the same: a message and a link back to the dashboard. All three fallbacks share the same visual language. Centred text, accent-coloured interactive elements, 44-pixel minimum touch targets on every button and link. The error button uses the accent border. The not-found link uses the accent text colour. The loading skeleton uses the glass surface colours. Three files, fifteen lines each on average, and the portal never crashes to a raw error trace or a white screen.

softwareaccessibility
Building

Client records exist before the client signs up

When I take on a new client, the first thing I do is create their record in the admin panel. Name, email, optional company, optional Stripe customer ID. That is four fields and a submit button. No invitation email fires. No magic link gets sent. The client record sits in the database with a null clerkId, which means the notification guard — the check that prevents emails from going out — stays closed. No session notes emails, no milestone updates, no deliverable alerts. The record is just a placeholder that says this person exists and this is their email. When the client eventually signs up through the Clerk-powered sign-in page, a webhook fires. The Clerk user.created event hits the webhook endpoint. The handler checks whether a client with that email already exists in the database. If it does and the clerkId field is null, it links the two together — one update query that writes the Clerk ID onto the existing record. From that moment forward, the client can log in and see their projects, and the notification guard opens because clerkId is no longer null. If the client signs up before I create their record — maybe they find the sign-in page on their own — the webhook creates a fresh client record with their Clerk ID already attached. Either order works. Admin first, client first, it does not matter. The upsert handles both paths. The same webhook also handles user.updated events. If a client changes their email or name in Clerk, the database stays in sync automatically. One webhook route, two event types, zero manual data entry after the initial setup.

software
Building

Two-letter avatars cost zero storage and zero API calls

Every comment in the portal shows an avatar next to the author name. The avatar is not an image. It is two letters — the initials of the person who wrote the comment — rendered in a 32-pixel circle with a border. The initials are generated at render time from the authorName field. Split on spaces, map to the first character of each word, join into a string, slice to two characters, uppercase. A client named Sarah Chen gets SC. I get JM. A single-name client gets the first two letters of their name. The generation is five chained string methods inline in the JSX. No utility function, no database field, no pre-computed value. The circle itself has two visual modes controlled by the isJames boolean on the comment record. When isJames is true, the border colour is the portal accent — the service-specific colour that runs through every accent element. When isJames is false, the border uses the muted glass colour that every non-interactive border shares. That single boolean — stored in the database at comment creation time — tells the client at a glance which comments are from me and which are from them. The author name below the avatar uses the same conditional. My name renders in the accent colour. Their name renders in the default text colour. Two visual signals — border and name colour — both driven by one boolean. No profile picture upload feature, no Gravatar lookup, no external avatar service. The comment model stores a plain string for authorName and a boolean for isJames. The component turns those two fields into a complete visual identity.

softwareaccessibility
Building

The booking sync matches clients to calendar events by attendee email

The portal shows every client their upcoming and past meetings with me. Those bookings do not come from a form or a manual entry. They come from my Google Calendar via a cron job that runs daily. The sync function starts by loading every active client from the database and building a Map — email address lowercase as the key, client ID as the value. Then it authenticates with Google using an OAuth2 client configured with a refresh token and fetches calendar events from ninety days ago to thirty days ahead. For each event, it reads the attendee list and checks every attendee email against the client Map. If an attendee matches a client, that event becomes a booking. The function extracts four things from the calendar event — the title, the start time, the end time, and the Google Meet link. The Meet link has a fallback chain: first it checks conferenceData.entryPoints for a video type, then it falls back to the hangoutLink property. If neither exists, the booking renders without a Join button. Each booking is upserted by calendarEventId so the same event never creates duplicate records. If I reschedule a meeting in Google Calendar, the next sync picks up the new times and updates the existing record. If I cancel it, it stays in the database as a past booking — the portal does not delete records that once existed. The cron endpoint authenticates with a CRON_SECRET bearer token so it cannot be triggered by anyone who does not have the secret. It also exposes a GET handler so I can trigger a manual sync from a browser during development. One cron job, one Map lookup, one upsert per event, and every client sees their meetings without me touching the portal.

software
Building

One guard function protects every admin write operation

Every server action in the admin panel starts with the same four lines. Call requireAdmin. Get the Clerk session. Fetch the user. Check publicMetadata.role. If the role is not admin, throw before any database write runs. That is the entire authorisation model for the admin side of the portal. Seven server actions — createClient, createProject, addSessionNotes, updateMilestone, createMilestone, createDeliverable, createActionItem, createGoal, addAdminComment — all call the same function on line one. No decorator pattern, no middleware chain, no role-checking wrapper component. Just a plain async function that throws if you are not me. The client-side server actions use a different pattern. They call auth from Clerk, look up the client by clerkId, and verify ownership of the specific resource before writing. The toggleActionItem action goes further — it checks that the action item is assigned to client before allowing the toggle. A client cannot complete my tasks. I cannot accidentally run a client action because the client actions check clerkId, not role. Two guard patterns, ten server actions, zero unprotected writes. The admin guard is four lines. The client guard is three queries. Both throw before touching the database if anything is wrong. Security is not a feature you bolt on at the end. It is the first line of every function.

software
Building

Notifications fire and forget because email is not a dependency

The portal sends five types of email notification — session notes added, milestone status changed, comment posted, deliverable uploaded, action item assigned. Every one is a plain text email sent through Resend with a single API call. No HTML template, no inline CSS, no image hosting. Just lines of text joined with newline characters. Every notification follows the same shape. A greeting using the client's first name extracted with split and index zero. A one-sentence update explaining what changed. A link back to the portal. A sign-off. The subject line includes the project name so the client can see which project the update relates to without opening the email. Every notification is called with a catch that swallows errors — fire and forget. If Resend is down, the notification silently fails. The admin action still succeeds. The database still updates. The portal still reflects the change. The email is a courtesy, not a dependency. The getResend function checks for the API key at call time, not import time. If the key is missing — in CI, in local development, in a test environment — the function returns null and every notification function returns early. No conditional imports, no environment checks scattered through the codebase. One null check in one factory function. The notification guard also checks whether the client has a clerkId before sending. If I create a client record through the admin panel before they sign up, they do not get bombarded with emails for every update. Notifications only fire for clients who have actually joined the portal.

software
Building

The GlassCard is one component with two visual modes

Every card in the portal — dashboard summaries, project cards, booking entries, action items, session notes, settings panels — uses the same GlassCard component. It is nineteen lines of code. A div with rounded corners, a border, and backdrop blur. The component takes one boolean prop called accent. When accent is false, the card uses the default glass border and glass background — a subtle translucent container that blends with the dark portal background. When accent is true, the border shifts to twenty-five percent of the service accent colour blended into transparent using color-mix, and the background shifts to four percent of the accent. That single boolean creates two distinct visual weights. Default cards recede. Accent cards draw attention. The admin dashboard uses accent cards for upcoming bookings so they stand out from the project list. The client dashboard uses them for the next meeting. The bookings page uses them for upcoming sessions and default cards for past ones. The same component handles both without a className override. The colour values come from CSS custom properties — --color-glass-border and --color-glass for default, --color-accent for the tinted variant. Those properties are set once on the portal layout and every GlassCard underneath inherits them. No prop drilling, no theme context, no conditional class logic inside the cards. One component, one prop, two modes, and every surface in the portal has a consistent look because they all render through the same nineteen lines.

softwareaccessibility
Building

Server actions replaced every API route in the portal

The client portal and admin panel do not use a single REST endpoint. Every form submission — adding session notes, creating milestones, toggling action items, posting comments, creating deliverables — goes through React server actions. The admin side has seven server actions in one file. Each one starts with a requireAdmin call that checks the Clerk session, fetches the user, and reads publicMetadata.role. If the role is not admin, the function throws before any database write happens. The client side has three server actions — addComment, toggleGoal, and toggleActionItem. Each one authenticates with auth from Clerk, looks up the client by clerkId, and verifies ownership before touching the database. The toggleActionItem action goes one step further — it checks that the action item is assigned to client before allowing the toggle. A client cannot mark my tasks as done. Every server action follows the same shape. It receives the previous state and a FormData object. It validates, writes, and returns either a success flag or an error string. The component calls useActionState with the action and renders the result. No fetch call, no JSON.parse, no try-catch around a network request. The form posts directly to the server function and the page revalidates automatically. Seven admin actions and three client actions handle every write operation across fifteen portal routes. The entire portal has zero custom API endpoints.

software
Building

Every portal query is scoped to one client and nothing leaks

The portal query layer is a single TypeScript file with seven exported functions. Every function takes a clerkId as its first argument. Every function starts by looking up the client record with that clerkId. If no client exists, it returns null or an empty array. No data is ever returned without first proving that the requesting user owns it. The getClientDashboard function uses a single Prisma query with nested includes — projects filtered to active and in-review statuses, milestones ordered by sort position, the latest session with its comments, and the next upcoming booking. One query, one round trip, one render. The getProjectDetail function takes a project ID and a clerkId. It finds the client first, then queries the project with a where clause that checks both the project ID and the client ID. If someone tries to access a project that belongs to a different client, the query returns null and the page renders a 404. The getClientBookings function splits results into upcoming and past using a Promise.all with two parallel Prisma queries — one for bookings after now ordered ascending, one for bookings before now ordered descending and limited to ten. The Stripe invoice query works differently because invoices live in Stripe, not the database. The getClientInvoices function takes a stripeCustomerId, hits the Stripe API directly, filters to succeeded and pending intents, and formats the response with Intl.NumberFormat using the actual currency code from each payment. Seven functions serve the entire portal. The data access pattern is always the same — authenticate, look up the client, scope the query, return the result. Adding a new portal page means calling an existing query function or adding one to the same file.

software
Building

The admin project editor runs seven forms on one page

When I open a project in the admin panel, I land on a page with five tabs — Session Notes, Milestones, Deliverables, Action Items, and Comments. Each tab has its own form. A sixth form for adding goals sits permanently at the bottom. The page initialises seven useActionState hooks simultaneously — one for each server action. Each hook returns a state object, an action function, and a pending boolean. The forms are independent. I can add a milestone, switch to the session tab, write notes, switch to deliverables, add a file, and come back to milestones — each form maintains its own state and its own success or error message. The session notes tab has a duration field defaulting to forty-five minutes, a textarea for the notes, and a FileDropZone component for attaching recordings or documents. The milestones tab has a quick-add form and a status update form where I paste a milestone ID and select a new status from a dropdown. The deliverables tab collects a title, optional description, and optional due date. The action items tab has an assignment selector — James or Client — that controls who sees the task and who gets the notification email. The comments tab lets me post as James on any session, milestone, or goal by pasting its entity ID. Every form has a forty-eight pixel minimum height on inputs and buttons. Every form disables its submit button while pending and shows a text indicator — Saving, three dots, or the action name. Every form clears its feedback message when you switch tabs because the tab switch re-renders only the active panel. One client component, seven hooks, five tabs, zero page navigations between admin operations.

software
Building

One project page shows everything without a single tab switch

When a client taps into a project from the portal dashboard, they land on a single page that shows every dimension of the work. The project detail page is an async server component that calls getProjectDetail with the project ID and the user's Clerk ID. If the project does not belong to that user, notFound fires and the page never renders. If it does, everything loads in one query — milestones with their comments, sessions with their attachments and comment threads, deliverables with status tracking, action items with assignment labels, and goals with discussion threads. The page lays it all out vertically. Milestones come first with a MilestoneTimeline gradient bar at the top and each milestone listed underneath with a coloured dot — emerald for done, cyan with a glow for active, dim white for upcoming. Every milestone has an inline CommentThread so the client can ask a question about a specific phase without navigating away. Sessions come next, each in its own GlassCard with the date, duration, notes, and any attached files rendered through AttachmentList. Audio attachments get a Play button. Everything else gets Download. Each session has its own CommentThread too. Deliverables follow with a vertical connector line tracking the four-stage pipeline — pending, in progress, delivered, approved. Then action items with the shared checklist. Then goals with their completion dots and discussion threads. One page, one query, one scroll. A client working on a mixing project and a software build sees the same layout for both because the components do not know or care what service type the project belongs to. The service type is a database field. The UI is universal.

software
Building

The admin dashboard loads six queries at once and nothing waits

The admin dashboard is the command centre for every service I deliver. It needs to show active projects, open action items, pending deliverables, upcoming bookings, recent sessions, and the full client list — all on one page, all from the database, all before a single pixel renders. The page is a server component that opens with a Promise.all containing six Prisma queries running in parallel. Clients with a count of their projects and bookings. Active and in-review projects with their milestones, session counts, deliverable counts, action item counts, and the client name. The ten most recent sessions with their project and client names. Upcoming bookings ordered by start time with client names. All incomplete action items with their parent project. All pending or in-progress deliverables with their parent project. Six queries, one round trip, one render. The summary cards at the top show four numbers — active projects, open actions, pending deliverables, and upcoming bookings. Below that, every active project is a card with a MilestoneTimeline, a status badge, and counts for sessions, deliverables, and actions. Each card links to the admin project editor. The upcoming bookings section shows the next ten meetings with client names, formatted dates, and a Join button for each one that has a Google Meet link. The open action items section shows every incomplete task with a colour-coded badge — cyan for mine, orange for the client's — and a link to the project it belongs to. The pending deliverables section does the same with due dates. One page gives me the full picture across every client and every service type. No switching between tabs, no filtering by service, no separate tools for audio and software projects. Six parallel queries and one vertical scroll.

software
Building

The portal layout wraps every page in the same shell

Every portal page — dashboard, projects, sessions, invoices, bookings, goals, settings — renders inside the same layout component. The PortalLayout is an async server component that checks authentication first. If currentUser returns nothing, the redirect fires before any child page even attempts to render. If the user is authenticated, the layout renders three structural layers around the page content. The first layer is a sticky top navigation bar that mirrors the ServiceNav breadcrumb pattern from the public site. The James Music logo on the left links home. A slash separator and the word Portal in the accent colour link to the dashboard. On the right, a line of text shows who is signed in — first name if available, email address if not, with a fallback to Client. The second layer is a horizontal tab bar with six links — Dashboard, Projects, Sessions, Invoices, Bookings, Goals. The tabs sit in a scrollable container with hidden scrollbars so on mobile you can swipe through them without a visible scroll track. Every tab link has a 44-pixel minimum height and rounded hover state. The third layer is the main content area, constrained to 900 pixels wide with horizontal padding. Below the content sits a footer with the logo, Privacy, and Terms links. The metadata sets robots to noindex nofollow because the portal is authenticated content that should never appear in search results. The layout does not know which page it is wrapping. It provides the shell — authentication, navigation, structure, footer — and the page fills in the content. One layout, fifteen portal routes, zero duplicated navigation code.

softwareaccessibility
Building

The dashboard is a personalised briefing, not a list of links

When a client logs into the portal, they do not land on a generic welcome page. They land on a dashboard that knows who they are and shows them exactly where things stand. The PortalDashboard is a server component that calls currentUser from Clerk, fetches the client record with all their projects, bookings, and sessions, pulls their invoices from Stripe, and renders the whole picture in one page load. No client-side fetching, no loading spinners, no skeleton screens. The page renders with real data from the first frame. The layout is three summary cards at the top — active project count, outstanding balance, and total sessions — followed by the next upcoming booking with a Join Meet button, then active projects with milestone progress bars, then the latest session notes, then recent invoices. A client with three active projects sees all three with their milestone timelines. A client with no projects yet sees a single message explaining that James will set things up shortly. The summary cards use a GlassCard component with backdrop blur and a subtle border. The outstanding balance shows emerald green with a checkmark when everything is paid. The projects section links to the full projects list. The invoices section links to the full invoice history. Every link has a 44-pixel minimum touch target. The whole dashboard is one async function, one database query with nested includes, one Stripe API call. No useEffect, no useState, no client-side state management. Server components mean the data is already there when the HTML arrives.

software
Building

Goals track the big picture when milestones track the detail

Milestones are granular. Ship the homepage. Integrate Stripe. Pass the accessibility audit. Goals are broader. Launch the platform. Reach WCAG AA compliance across all pages. Get the first paying client through the portal. The goals page in the portal shows every goal across every project a client has with me. Each goal is a GlassCard with a circular status indicator — an emerald filled dot when complete, a hollow bordered circle when still in progress. Completed goals get a strikethrough and reduced opacity so the eye goes straight to what is still outstanding. Every goal links back to the project it belongs to so you can drill into the detail without switching context. The page header shows the completion ratio — 3/7 completed — giving the client a single number that answers the question how far along are we. The data comes from a server query that filters goals by the logged-in client's Clerk ID, includes the parent project name, and orders by completion status so active goals appear first. The whole page is a server component with no client-side JavaScript. It renders with the data already present. No loading state, no optimistic updates, no WebSocket subscriptions. One query, one render, one answer to one question — what are we working toward and how close are we.

software
Building

Every session has a paper trail the client can read

When I finish a mixing session, a software sprint call, or an accessibility audit walkthrough, I write session notes in the admin panel. Those notes appear in the client's portal immediately. The SessionCard component renders each session with four pieces of context — the date formatted in UK English using Intl.DateTimeFormat, the duration in minutes, the comment count, and a link to the parent project. The notes themselves are line-clamped to three lines on the session list so every card has a consistent height, but the full text is available when you tap through to the project. The date uses a semantic time element with an ISO datetime attribute so machines can parse it and humans can read it. The comment count appears with proper pluralisation — 1 comment or 3 comments — using a single ternary. If there are no comments, that metadata disappears entirely rather than showing zero comments. The project name links back to the full project view using the accent colour so it visually connects to the navigation. The card itself is a glass container with the same border and blur treatment as every other portal component. One component handles every session type. A ninety-minute mixing session and a thirty-minute software standup use the same card, the same date format, the same comment indicator. The client has a complete record of every interaction without me sending a single follow-up email.

software
Building

Audio files play inside the portal without a download

When I deliver a mix or a mastered track through the portal, the client does not have to download it to hear it. The AttachmentList component checks every file's MIME type and renders a Play button next to any streamable audio format — MP3, MP4, AAC, OGG. Tap Play and the component fetches a signed URL from the API, sets it as the source of a native HTML audio element, and starts playback immediately. The audio element renders inline with full browser controls — play, pause, scrub, volume. When the track ends, the state resets and the button goes back to Play. Tap it again while it is playing and it stops. One toggle, one audio element, one signed URL per play. The same component handles every file type in the portal. Audio files get a Play button and a Download button. PDFs, images, and code archives get Download only. The distinction comes from two utility functions — isAudio checks whether the MIME type starts with audio or matches a known audio format, and isStreamableAudio narrows that to formats the browser can actually play. WAV files are audio but not streamable in every browser, so they get Download only. File sizes display in human-readable format — bytes, kilobytes, or megabytes — using a three-step formatter. The signed URL expires after twenty-four hours, but that does not matter because a fresh one is generated every time you press Play or Download. The client can listen to their mix, leave a comment in the thread underneath, and come back tomorrow for another listen with a new URL. One component handles stems, masters, accessibility reports, and software deliverables. The audio preview is just a feature of the same list.

softwareaudio
Building

Every booking has a one-tap Join Meet button

The portal bookings page shows every meeting the client has with me — upcoming and past. Each one is a BookingCard with the title, the date formatted in UK English, the time range, and a Join Meet button that opens Google Meet in a new tab. The button only appears on upcoming bookings. Past bookings render the same card but dimmed to sixty percent opacity with no button — you can see that the meeting happened, but there is nothing to tap. The visual distinction between upcoming and past is a single boolean prop called isPast. When isPast is true, the card uses the default glass border. When false, it uses an accent-tinted border generated with color-mix — twenty-five percent of the portal accent blended into transparent. That subtle border shift is enough to tell you at a glance which meetings are still ahead. The date uses Intl.DateTimeFormat with en-GB locale so it reads like Mon 5 May, not 5/5/2026. The time range uses the same formatter for both start and end with an en dash between them. Every timestamp is wrapped in a time element with an ISO datetime attribute for machine readability. The Join Meet button is forty-four pixels tall, uses the accent colour as a solid background, and labels its destination with a trailing arrow. One card component for every booking across all five service types. A mixing session review, a software sprint call, an accessibility audit walkthrough — they all use the same card, the same button, the same date format.

software
Building

Not every package has a price tag and that is deliberate

Some services cannot be priced upfront. A full web application, a multi-day musician booking, an enterprise accessibility remediation — these need a conversation before a number. The product catalog handles this with a single check. Every product has a pricePence field. Fixed-price packages store a real number. Quote-based packages store null and a priceLabel like Get a quote. The isPurchasable function checks whether pricePence is a valid positive number. If it is, the checkout page renders the full four-step Stripe payment wizard. If it is not, the page renders a QuoteRequest component instead. The QuoteRequest shows the package name, description, features with accent-coloured checkmarks, the price label, and two calls to action — Call me about this project and E-mail your dream. Both are anchor links that jump to sections in the sidebar accordion on the same page. The call button uses a solid accent background. The email button uses a bordered outline. Both have a two-percent scale on hover. No dead ends. A client who lands on a quote-based package is guided straight into a conversation instead of bouncing off a missing Buy Now button. The ProductCard component on the service page adapts too. Purchasable products show a Buy now button with the accent background. Quote products show a Get a quote button with a bordered outline. Same card, same layout, one conditional style. The entire branching logic between paid checkout and quote request is one boolean check on one field. No separate routes, no feature flags, no conditional page components. One product array, one checkout page, one boolean.

software
Building

The homepage service cards are a gateway, not a grid

The homepage does not list my services in a vertical stack or a flat grid. It arranges them in two rows — software and accessibility on the first row, audio, production, and musician on the second. The ServiceCards component maps over a typed array of five service definitions, each one storing a title, a description, an href, a Font Awesome icon, and two colour values — dark and light. The useTheme hook picks the right shade at render time. Each card is a Next.js Link wrapping an icon, a heading, a description, and a Learn more arrow. On mobile, the cards stack vertically with a compact inline layout — icon and title side by side with the description underneath. On desktop, they fan out horizontally with large centered icons. The hover effect is a three percent scale transform, pure CSS. The accent colour for each card is set as a CSS custom property called --card-color on the link element so the border tint, the icon colour, the heading colour, and the arrow colour all read from one variable. No conditional classNames per service. One card component, one colour variable, five services. The layout intentionally puts the two consultancy services — software and accessibility — on the top row because those are the services most visitors are looking for. The creative services sit underneath. The visual hierarchy guides attention before anyone reads a word.

software
Building

Uploaded files never touch the Next.js server

The portal has a FileDropZone component that handles file uploads for session recordings, stems, accessibility reports, and software deliverables. The upload flow is three steps and the file never passes through the Next.js server. Step one — the client-side code posts the filename, content type, and size to an API route that generates a presigned upload URL from Cloudflare R2. Step two — the browser uploads the file directly to R2 using that presigned URL with a PUT request. The file travels from the client's device to object storage without touching the application server, which means a 500MB WAV file does not consume server memory or block the event loop. Step three — the client calls a completion endpoint that creates an Attachment record in the database linking the R2 key to the project and optionally to a session. The drop zone itself is a div with role button, tabIndex zero, and keyboard handlers for Enter and Space so it works without a mouse. Drag a file over it and the border highlights. Tap it on mobile and the native file picker opens with an accept list filtered to audio, PDF, and image types. Multiple files upload in sequence with a progress label for each one. Completed files appear below the drop zone with an emerald checkmark, the filename, and a formatted file size. The same component works in the admin panel for uploading deliverables and in the session view for attaching recordings. One drop zone, one three-step flow, zero server-side file handling.

software
Building

Action items are a shared checklist between me and the client

Every project in the portal has an action item checklist. The ActionItemChecklist component splits items into two groups — the client's action items and my action items. The client can tick off their own items. They cannot tick off mine. That single boolean — canToggle — controls the entire permission model for the checklist. Each item is a form with a hidden input carrying the action item ID and a submit button styled as a checkbox. The button uses useActionState with a server action so toggling an item posts directly to the server without a fetch call or JSON serialisation. The aria-label on each checkbox adapts to state — it says Mark followed by the item title as done when unchecked, and Unmark followed by the title when checked. A screen reader user knows exactly what each toggle will do before they activate it. Completed items get a strikethrough and reduced opacity so the visual hierarchy shifts toward what still needs doing. The emerald fill on a checked box matches the same emerald used for completed milestones and approved deliverables — the portal's universal colour for done. My items render with muted heading text so the client's eye goes to their own responsibilities first. The whole component is one array, one filter, one server action. No WebSocket, no optimistic update, no local state management. The form posts, the server toggles, the page reflects the change. One checklist for every project type — audio, software, accessibility, production, musician.

softwareaccessibility
Building

The product grid scrolls one card at a time

The audio and production service pages display their packages in a horizontal carousel instead of a vertical list. The ProductGrid component renders a scrollable track with CSS snap points — snap-x and snap-mandatory on the container, snap-start on each card. Swipe on mobile and the next card snaps cleanly to the left edge. On desktop, arrow buttons appear at the edges. The arrows are not always visible. A scroll event listener checks the container's scrollLeft against scrollWidth minus clientWidth and shows or hides each arrow based on whether there is more content in that direction. Scroll all the way left and the left arrow disappears. Scroll all the way right and the right arrow disappears. The scroll distance is not a magic number. When you click an arrow, the component queries the first card element with data-product-card, reads its offsetWidth, adds the 16-pixel gap, and scrolls by exactly that distance. One card per click, every time, regardless of card width or screen size. The native scrollbar is hidden with three CSS rules — WebKit's pseudo-element, Firefox's scrollbar-width none, and IE's overflow style. The arrows are 44 by 44 pixels with aria-labels so keyboard and screen reader users get the same navigation. The component takes one prop — the service name. It calls getProductsByService internally, maps over the results, and renders a ProductCard for each one. Two service pages use the same component with different data. Zero duplicated scroll logic.

softwareaccessibility
Building

Invoices come straight from Stripe, not a database

The portal does not store invoice records in its own database. When a client opens the invoices page, the server hits the Stripe API with their stripeCustomerId and pulls back every payment charge. The InvoiceTable component receives that array and renders it as an accessible HTML table with proper scope attributes on every column header and an aria-label on the table itself. Amounts arrive in pence from Stripe. The formatCurrency function uses Intl.NumberFormat with the invoice's actual currency code so a GBP payment shows a pound sign and a USD payment shows a dollar sign. No hardcoded currency symbols, no manual conversion. The status column uses the same colour language as the rest of the portal — emerald for paid, orange for pending, red for failed. Each status maps to a badge class and a display label through two Record objects. Adding a new status from Stripe means adding one entry to each record. The receipt column links directly to Stripe's hosted receipt page. Each link has an aria-label that includes the invoice description so a screen reader user knows which receipt they are opening. On mobile screens below 640 pixels, the date column hides entirely using sm:table-cell. The table fits on a phone without horizontal scrolling because the least critical column disappears first. One component, one Stripe API call, one Intl formatter, and every client sees their full payment history without me manually entering a single invoice.

software
Building

Every note card adapts to the theme without a single conditional className

The NoteCard component renders each blog entry on the notes page and the homepage feed. It reads the current theme through the useTheme hook — the same mutation observer that watches the html element's data-theme attribute — and passes it to a single function called pickColor. That function takes a colour pair object with a dark value and a light value and returns the right one based on the theme. The type badge at the top of each card — thought, update, building, or tip — gets its text colour from noteTypeColorPairs. The tag pills at the bottom get theirs from tagColorPairs. Both are plain objects mapping a key to a dark and light hex value. Purple for software. Fuchsia for accessibility. Cyan for audio. Emerald for production. The badge background is the text colour with 20 in hex appended for twelve percent opacity. The tag pill background uses 15 in hex for eight percent opacity. That subtle difference gives badges more visual weight than tags without maintaining two separate colour systems. The card itself is a Next.js Link wrapped in a glass container with a blurred border. On hover, the border tints toward the service accent using a fifty percent opacity modifier, and the title text shifts from white to the accent colour. The body text is line-clamped to three lines so every card in the grid has a consistent height regardless of how long the note is. One hook, one function, two colour maps, and every accent on every card adapts to both themes automatically.

softwareaccessibility
Building

The breadcrumb dropdown knows every service and every colour

Every service page has a navigation bar at the top. It looks like a breadcrumb — my logo on the left, a slash, then the current service name. The service name is a button. Tap it and a dropdown appears listing all six sections — Software, Accessibility, Audio, Production, Musician, and Notes. Each item in the dropdown has its own icon rendered in that service's accent colour. The active service gets a tinted background using color-mix to blend twelve percent of the accent into transparent. The component is called ServiceNav. It takes the current service name as a prop, finds the matching entry in a navLinks array, and extracts the accent colour based on the current theme. Dark mode gets the -400 shade. Light mode gets the -600 or -700 shade. The dropdown button has aria-expanded and aria-haspopup attributes so assistive technology announces whether the menu is open or closed. A full-viewport invisible overlay behind the dropdown closes the menu when you tap outside it. The overlay is aria-hidden because it is decorative — screen readers close the menu through the button. On desktop, two action buttons appear to the right — Call me about a project and E-mail your dream. Both link to anchor sections in the sidebar accordion using hash fragments. Both buttons are styled with the current service accent — border, text, and a ten-percent tinted background for the call button, solid fill for the email button. Both hit the 44-pixel minimum touch target. The ServiceNav does not know which service it is rendering. It reads a prop and picks a colour. One component, six services, six colour pairs, two action buttons, and a dropdown that closes on outside click and keyboard escape.

softwareaccessibility
Building

The footer is a sitemap you can click

On desktop screens, the footer has two layers. The first is a five-column navigation grid that lists every product across every service. Software Engineering on the left, then Accessibility, Sound Engineering, Music Production, and Musician. Under each heading, every package is a link straight to its checkout page. A client scanning the footer can jump from the homepage to a specific package without navigating through the service page first. Search engines can too. Every checkout URL is discoverable from the footer of every page on the site. The grid is hidden on mobile because twenty-two product links stacked vertically would push the actual footer off the screen. On small screens, you get the second layer only — company information, legal links, and a WCAG 2.1 AA compliance badge. The legal links to Terms and Privacy have 44-pixel minimum touch targets through inline-flex with min-height. The company details include the registered office, company number, and a copyright range that auto-updates using new Date().getFullYear() so it never goes stale. Three brand logos sit in a row — Blind Drummer, the initials mark, and the full logo — each with hover opacity transitions. On desktop, logos sit to the right and info sits to the left. On mobile, the order reverses using Tailwind order utilities so logos appear first. The entire footer is a nav landmark for the package grid and a footer landmark for the company info. Two semantic regions, one component, twenty-two deep links to checkout pages.

software
Building

Every note gets its own page and its own metadata

Each note in this section has its own URL. Tap the card and you land on a standalone page at /notes/the-note-id. The page is statically generated at build time using generateStaticParams — Next.js reads every entry in the notes array and pre-renders a page for each one. No server-side rendering on request, no loading spinner, no API call. The HTML exists before anyone visits. Each page also gets its own metadata through generateMetadata. The title becomes the note title plus a suffix. The description is the first 160 characters of the body text. OpenGraph and Twitter cards are generated automatically so sharing a note on social media shows a proper preview with the correct title and excerpt. The note page itself is a narrow single-column article with a back link to the full notes list, a type badge showing whether it is a thought, update, building, or tip entry, a timestamp formatted for the UK, tag pills colour-coded by service area, and the full body text at a comfortable reading width. The service accent for the notes section is pink — set as a CSS custom property on the root div so the ServiceNav breadcrumb, the back link, and any accent elements all inherit the same colour without hardcoding it. At the bottom of every note page there is a placeholder for comments — sign in with TikTok to leave a comment, coming soon. The comment system is not built yet but the space is already reserved in the layout. When it ships, it will slot in without touching any of the surrounding markup. One TypeScript object per note, one static page per note, one metadata function per note, zero runtime database queries.

software
Building

Dark mode without a flash of the wrong theme

The site supports dark and light themes. The toggle is a 44px button fixed to the top right of every page. Click it and the entire site flips — backgrounds, text, accents, glass borders, everything. The trick is making it instant. The ThemeToggle component sets a data-theme attribute on the html element and saves the choice to localStorage. Every colour on the site is a CSS custom property that changes based on that attribute. But if React handled the initial theme, you would see a flash of the default dark theme before the saved preference loaded. So there is a six-line inline script in the document head that reads localStorage and sets data-theme before a single pixel renders. No flash. No hydration mismatch. The theme is correct from the first frame. The other half of the system is the colour pairs. Every accent colour on the site has a dark variant and a light variant. Dark values are the -400 Tailwind shades — vibrant on dark backgrounds. Light values are -600 and -700 shades that pass WCAG AA 4.5:1 contrast on white. A single pickColor function takes a colour pair and the current theme and returns the right shade. The MobileNav uses it for icon colours. The NoteTypeBadge uses it for category labels. The NoteTagPills use it for tag backgrounds. One function, one theme state, and every accent on the site adapts. The useTheme hook watches for changes using a MutationObserver on the html element's attributes. When the ThemeToggle flips the data-theme attribute, the observer fires, and every component that reads the theme re-renders with the correct colours. No context provider, no prop drilling. Just a DOM attribute and a mutation observer.

softwareaccessibility
Building

The photo gallery reveals images one at a time

Every service page has a photo gallery at the bottom. The first image spans the full width of the grid. The rest sit two per row underneath. Each image is wrapped in a BlurReveal component with a staggered delay — the base delay plus the image index times one hundred milliseconds. The first image appears immediately. The second image fades in a tenth of a second later. The third a tenth after that. The whole gallery cascades from left to right, top to bottom, like cards being dealt. The gallery component takes three props — a photos array, an accent colour, and an optional base delay. It does not know which service page it is rendering for. The accent colour comes from the page above and controls a gradient overlay at the bottom of each image — a subtle tint that ties the photos to the service's colour identity. Cyan for audio. Emerald for production. Purple for software. The overlay is a pointer-events-none div with a linear gradient that goes from the accent colour at fifteen percent opacity to transparent. It sits on top of the image but lets clicks through. On hover, the image scales up five percent over half a second. The zoom is CSS-only — a transition on transform with a group-hover trigger. No JavaScript animation, no intersection observer, no scroll handler. Next.js Image handles the optimisation. The first photo renders at 800 pixels wide. The rest render at 400. AVIF and WebP conversion happens at build time. The entire gallery is a responsive CSS grid, a staggered reveal, a gradient overlay, and a hover zoom. About fifty lines of code for a component that appears on every service page.

software
Building

The mobile menu knows which colour belongs to which service

On screens below 640 pixels, a hamburger button appears in the top left corner. Tap it and a navigation panel drops down with links to all six sections — Software, Accessibility, Audio, Production, Musician, and Notes. Each link has an icon next to it. Each icon is rendered in that service's accent colour. Purple for software. Fuchsia for accessibility. Cyan for audio. Emerald for production. Orange for musician. Pink for notes. The colours are not hardcoded once — each nav item stores a dark variant and a light variant. The MobileNav component reads the current theme using the useTheme hook and picks the right shade. Switch to light mode and the icons shift from -400 to -600 and -700 shades so they still pass contrast requirements on the lighter background. The menu button is a fixed-position element with a z-index high enough to sit above everything on the page. It is exactly 44 by 44 pixels — the WCAG minimum touch target. The aria-label switches between Open menu and Close menu depending on state. The aria-expanded attribute tracks whether the panel is visible. When the menu opens, a semi-transparent backdrop covers the full viewport. Tapping the backdrop closes the menu. The backdrop is aria-hidden because it is purely decorative — screen readers interact with the menu through the button, not the overlay. Every link in the panel has a 44-pixel minimum hit area through vertical padding. Tapping any link closes the menu before navigation. The whole component is seventy-five lines of code, handles two themes, six colour pairs, and full keyboard and screen reader accessibility.

softwareaccessibility
Building

The sidebar opens to the section you asked for

Every service page has a sidebar with three sections — packages, book a call, and send a brief. The sidebar is a FormAccordion component that reads the URL hash on mount. If you land on /software#book-call, the booking section is already open. If you land on /accessibility#send-brief, the contact form is already open. No scroll-to animation, no flash of the wrong section. It just opens to where you wanted to go. The accordion listens for hashchange events too, so in-page links work the same way. The homepage service cards link to /audio#packages-sidebar. The call-to-action buttons link to /production#book-call. Every link can target a specific accordion section on any service page. Under the hood, the FormAccordion initialises its active state from window.location.hash. Three string comparisons — one for each section ID. A useEffect registers a hashchange listener so browser back-and-forward navigation updates the accordion without a page reload. Each section is wrapped in a BlurReveal with staggered delays so the panels animate in sequence when the page first loads. The collapsed panels are still interactive — they respond to click, Enter, and Space — because they set role button and tabIndex 0 when inactive. One accordion component handles deep-linking, keyboard access, and progressive disclosure for all five service pages.

softwareaccessibility
Building

Every section on the page reveals itself in order

The service pages do not load all at once. Each section fades in with a staggered delay — hero first, then content blocks, then the sidebar panels, then the photo gallery. The entire effect is a single component called BlurReveal. It takes a delay prop in milliseconds and wraps its children in a div that starts blurred and translucent. After the delay, a state flip triggers a CSS transition that removes the blur and brings opacity to full. The transition takes 500 milliseconds. The component is eleven lines of code. No intersection observer, no scroll tracking, no animation library. Just a setTimeout that flips a boolean. The FormAccordion uses it with delays of 0, 100, and 200 milliseconds so the three sidebar panels cascade downward. The PhotoGallery uses it with an incrementing delay per image — baseDelay plus index times 100 — so photos reveal left to right, top to bottom. The hero image on each service page gets a delay of 0 so it appears instantly. The testimonials section gets a longer delay so it arrives last. The whole reveal sequence is controlled by passing different delay numbers to the same component. No choreography config, no timeline object. Just a number on each section.

software
Tip

The intake forms validate when you leave, not while you type

Every service has an intake form — audio asks for genre and track count, software asks for budget and project type, accessibility asks for compliance target. All five forms share one component called ServiceIntakeForm. It takes a FieldConfig array and renders the right inputs for each service. Validation runs on blur, not on change. You type your email, tab to the next field, and if the address is invalid the error appears under the field you just left. No red borders while you are still typing. No error messages that flash and disappear as you correct a character. The error uses role alert so screen readers announce it immediately. Each field tracks whether it has been touched. Errors only display when both conditions are true — the field has been touched and the field has an error. When you hit submit, every field is marked as touched at once so any remaining errors surface together. A useRef flag prevents double submission the same way the booking form does — synchronous check before any async work starts. A honeypot field sits off-screen with aria-hidden and tabIndex -1 so bots fill it out but real users and assistive technology skip it entirely. If the honeypot has a value, the form silently returns without sending. No error, no feedback, nothing for the bot to learn from. Five services, five different field configurations, one form component, one validation pattern.

softwareaccessibility
Building

The filter is a sentence, not a form

The notes section on the homepage does not have a filter bar. It has a sentence. Check out everything in all topics. The two underlined words are native select elements styled to look like inline text. Pick thoughts from the first dropdown and the sentence reads Check out thoughts in all topics. Pick software from the second and it reads Check out thoughts in software. The selects use appearance-none to strip the browser chrome, a bottom border to hint that they are interactive, and bold pink text to stand out from the surrounding paragraph. The options render with a dark background so they are readable in the dropdown. Because they are real select elements, they work with keyboards, screen readers, and every browser without a single line of JavaScript for accessibility. The filter state is two useState hooks — activeType and activeTag. The filtered array is one chained conditional. The whole thing is seven lines of logic. No filter chips, no toggle buttons, no multi-select popovers. Just a sentence that you edit by choosing words from a dropdown. When more notes match than the homepage can show, a link at the bottom says View all 47 notes and carries the active filters into the URL query string so the notes page opens pre-filtered. One sentence, two selects, zero form labels.

softwareaccessibility
Building

The booking flow recovers when a slot gets taken

The book-a-call component is a four-step state machine — date, time, details, confirmed. Pick a day from the calendar, pick a time from the available slots, enter your name and email, confirm. If everything works, you land on a confirmation screen with a Google Meet link. But the interesting part is what happens when it does not work. Between selecting a slot and hitting confirm, someone else might book the same time. When the API returns a 409 conflict, the component does not show a generic error and give up. It sends you back to the time picker, re-fetches the availability for that date, and renders the updated slot list with the taken slot removed. You pick another time and try again. The whole recovery happens without leaving the component or losing your name and email. Underneath the conflict handling, a useRef flag prevents double submissions. The ref check runs synchronously before any async work starts, so two rapid clicks cannot both enter the fetch call. The state-based booking flag handles the visual feedback — disabling the button and showing Booking... — but the ref handles the actual guard. The finally block resets both the ref and the state so the form is always recoverable. One component, four steps, one ref, one conflict recovery path.

software
Tip

The checkout progress bar talks to screen readers

The checkout wizard has a four-step progress indicator at the top — Package, Details, Pay, Done. Sighted users see numbered circles connected by lines. The active step is filled with the service accent colour. Completed steps show a checkmark. Future steps are dimmed. Screen reader users get something different but equally clear. The component is a nav landmark with an aria-label of Checkout progress. Inside is an ordered list. Each step is a list item. The active step has aria-current set to step, which tells assistive technology exactly where you are in the sequence. A screen reader announces something like step 2 of 4, Details, current step. The connector lines between steps are marked aria-hidden because they are purely decorative. The checkmark SVGs on completed steps are inline and inherit the accent colour. The entire visual language — filled circles, dimmed circles, checkmarks, coloured lines — maps to a single CSS variable. A client checking out for a mixing package sees cyan progress. A client buying a software landing page sees purple. The component does not know which service it is rendering. It reads the accent from the page and adapts. Four steps, one ordered list, one aria-current attribute, one CSS variable. That is the entire accessible progress indicator.

softwareaccessibility
Building

The calendar checks availability before you click

The consultation picker does not wait for you to pick a date before checking whether I am free. As soon as the calendar renders, it starts hitting the availability API for every future weekday in the visible month — four dates at a time, in parallel batches. Dates blur until the response comes back. If a day has zero slots, it gets a strike-through and becomes unselectable. If a day has slots, the blur lifts and the button activates. By the time you have scanned the calendar, most dates are already resolved. The API behind it queries Google Calendar's freebusy endpoint for the requested day, generates every possible 45-minute slot in 15-minute increments between 9am and 5pm UK time, then filters out anything that overlaps an existing event. The overlap check is two comparisons — slotStart < busy.end && slotEnd > busy.start. That covers every case: partial overlaps, events that start mid-slot, events that contain the entire slot. Weekends are excluded at the component level — the grid is five columns, Monday to Friday. Past dates are excluded at the API level — the effective start time is either the beginning of business hours or right now, whichever is later. The whole thing uses a cancelled flag in the useEffect cleanup so switching months does not cause stale state updates. One calendar component, one API route, one freebusy query per date, and the client never has to pick a day only to find out I am booked solid.

software
Building

The checkout survives 3D Secure redirects

When a client pays with a card that requires 3D Secure, Stripe redirects them to their bank's authentication page. That means the checkout wizard unmounts. The client leaves the site entirely, verifies with their bank, and comes back to a fresh page load. The wizard handles this by reading URL parameters on mount. If step=confirmation and redirect_status=succeeded appear in the query string, the wizard initialises directly into the confirmation step. No intermediate loading screen, no flash of the package selection, no re-render of the payment form. It clears the URL with history.replaceState so the params do not linger, then renders the full-screen confirmation overlay with the consultation booking picker ready to go. The same confirmation flow fires whether the payment succeeded inline or after a 3D Secure redirect. The idempotency key system sits underneath all of this. Every PaymentIntent is created with a UUID. If the client goes back and changes their name or email, the key regenerates so a new intent is created. If the details have not changed, the existing intent and client secret are reused. That means hitting the back button and then forward again does not create a duplicate charge. One wizard component handles the entire four-step flow — package review, detail collection, Stripe Elements payment, and post-payment confirmation with calendar booking — and it survives a full browser redirect without losing state.

software
Building

Project progress is a gradient bar and a row of dots

The client portal shows project progress with two visual elements — a gradient progress bar and a row of coloured dots. The MilestoneTimeline component counts how many milestones are marked DONE, divides by the total, and renders a percentage-width bar with a cyan-to-emerald gradient. Below the bar, each milestone is a dot and a label. Emerald for done. Cyan with a glow shadow for the active milestone. Dim white for milestones that have not started. The same pattern carries through to deliverables. The DeliverableTimeline uses a vertical connector line with four status colours — dim white for pending, glowing cyan for in progress, orange for delivered, emerald for approved. Each deliverable shows its title, description, due date, and whether a file is attached. The ProjectCard on the dashboard wraps the MilestoneTimeline inside a link, so tapping a project shows at-a-glance progress before you even open it. The status badge in the corner uses the same colour language — cyan for active, orange for in review, emerald for completed, dim for on hold. Every status has a Record mapping it to a dot class, a badge class, and a label. Adding a new status means adding one entry to the record. The entire visual system for tracking project progress across all five service types is built from two timeline components, a few coloured dots, and one gradient bar.

software
Building

Five intake forms, one configuration object

Every service on the site has a different intake form. Audio asks for genre, track count, and project type — recording, mixing, mastering, full production, or podcast. Software asks for budget range, company name, and whether you want an MVP, a web app, AI integration, or fractional CTO. Accessibility asks for a compliance target — WCAG 2.1 AA, WCAG 2.2 AAA, ADA, European Accessibility Act, or not sure. Musician asks which instrument you need and whether it is a session, a live gig, a recording, or a dep. Production asks for genre and creative direction. Five completely different sets of questions. One component renders all of them. The ServiceSidebar reads a serviceConfigs object that maps each service to its own typed field definitions, headings, descriptions, and success messages. The FormAccordion underneath does not know which service it is rendering. It just receives props and draws the accordion with three sections — packages, book a call, send a brief. Each field is a FieldConfig object with a name, label, type, placeholder, and optional list of select options. Adding a new field to any service means adding one object to an array. Adding a sixth service means adding one key to the config map. The sidebar, the form, and the validation all adapt because they read the config, not the service name.

software
Building

Every comment lives on the thing it is about

The portal has one comment component that works on three different things — sessions, milestones, and goals. The CommentThread takes an entityType and an entityId. It does not care whether it is rendering under a mixing session or a software milestone or an accessibility audit goal. It renders the same thread, the same form, the same submit button. Comments from me show with the accent colour. Comments from the client show in the default text colour. Each comment has an avatar with the author's initials, a timestamp formatted for the UK, and the body text. The form uses React's useActionState hook with a server action — no fetch call, no JSON serialisation, no loading spinner beyond a three-dot indicator on the submit button. The form posts directly to the server. The input and the button both hit the 44px minimum touch target. The hidden fields carry the entity type and entity ID so the server knows where to attach the comment without any client-side state management. One component, three entity types, two visual styles, zero duplicated code. The same thread that tracks feedback on a mastered track also tracks discussion on a software sprint milestone.

softwareaccessibility
Tip

Ten lines of middleware protect the entire portal

The authentication layer for the client portal is ten lines of code. One import for Clerk's middleware, one import for the route matcher. One line creates a matcher for everything under /portal. One function checks whether the current request matches that pattern and, if it does, calls auth.protect() to redirect unauthenticated users to sign-in. That is it. Every public page on the site — the homepage, the five service pages, the notes section, the checkout flow, the donation page — loads without any auth check. Every portal page — dashboard, projects, sessions, bookings, invoices, goals, settings, admin — requires a logged-in user. The line between public and protected is a single regex pattern. No per-route wrappers. No higher-order components. No auth checks scattered through page files. The middleware runs before any page renders, so an unauthenticated request never even reaches the React tree. When Clerk redirects you to sign-in and you complete the flow, the webhook has already synced your profile to the database. By the time the portal loads, your data is ready. Ten lines of middleware and a webhook. That is the entire auth story.

software
Building

Five colours, one CSS variable

Every service page on the site has its own accent colour. Audio is cyan. Production is emerald. Musician is orange. Software is purple. Accessibility is pink. The entire colour system runs on a single CSS custom property — --service-accent — set as an inline style on the root div of each page. Every component underneath reads that variable. Headings, buttons, active states, card borders, progress indicators, form highlights — all of them pull from --service-accent. The checkout page reads the product's service type and sets the accent automatically, so a client buying a mixing package sees cyan through the whole flow and a client buying a landing page sees purple. No theme prop drilling, no context providers, no conditional className logic. One CSS variable at the top, and every child component adapts. The sidebar highlights the active product with an accent-coloured border using color-mix to generate a subtle tinted background. The wizard steps use the accent for progress. The form accordion uses it for the active section indicator. Adding a sixth service colour means changing one hex value in one config object. The components do not know or care which service they are rendering for. They just read the variable.

softwareaccessibility
Building

Signed URLs expire so files do not

The portal stores files in Cloudflare R2, but the database never holds a full URL. It holds a key — a path like uploads/1714000000-mastered-track.wav. When a client clicks download, the API generates a signed URL that expires in twenty-four hours. When an admin uploads a file, the API generates a signed upload URL that expires in one hour. The file lives in R2 indefinitely. The access to it is always temporary. This means there are no permanent public links to client files floating around in email threads or browser history. Every download is authenticated — the API checks whether the logged-in user is an admin or whether their client record matches the project the file belongs to. If neither, the request is rejected before a URL is ever generated. The upload flow is two steps. First, the admin requests a presigned upload URL. The client-side code uploads directly to R2 using that URL — the file never passes through the Next.js server. Second, the admin calls a completion endpoint that creates an Attachment record in the database linking the R2 key to the project. One file, one key, one record. The same pipeline handles audio stems, accessibility reports, software source code, and production session recordings.

software
Tip

Twenty-two products in one TypeScript file

Every service package on the site lives in a single TypeScript file. Twenty-two products across five services. Each one is a typed object with an id, a name, a description, a price in pence, a feature list, a delivery estimate, and a service category. Fixed-price packages store a real pricePence value. Quote-based packages store null and a label like Get a quote instead. A helper function called isPurchasable checks whether a product has a valid price — if it does, the checkout page renders a full Stripe payment flow. If it does not, the page shows a contact form instead. That one boolean check is the entire branching logic between a purchasable product and a quote request. Adding a new package to any service means adding one object to the array. The sidebar picks it up. The checkout page picks it up. The service page picks it up. No migration, no API call, no admin panel update. The TypeScript compiler checks every field at build time so a misspelled property name or a missing feature list fails the build before it ever deploys. Twenty-two products, five services, one file, zero runtime errors.

software
Building

Ten models, four enums, zero spreadsheets

The entire service delivery platform runs on a single Prisma schema. Ten models — Client, Project, Milestone, Session, Goal, Comment, Booking, Attachment, Deliverable, ActionItem. Four enums — ServiceType, ProjectStatus, MilestoneStatus, DeliverableStatus. That is everything. A mixing project and a software build both use the same Project model. The only difference is the ServiceType field — AUDIO, PRODUCTION, MUSICIAN, SOFTWARE, or ACCESSIBILITY. Every project has milestones with status tracking, sessions with notes and attachments, deliverables with approval workflows, action items with assignments, and comments threaded on milestones, sessions, and goals. The schema is the service contract. It defines what a project looks like, what can be tracked, what gets notifications, and what the client sees in the portal. Adding a new service type means one line in an enum. The relationships, the queries, the admin panel, the client view — they all work automatically because they query by project, not by service type. The commit that introduced the schema was the same commit that shipped the portal. One migration, one deploy, one data model for the entire operation.

software
Building

Deliverables track themselves from upload to approval

Before the portal, delivering finished work meant emailing a WeTransfer link and hoping the client downloaded it before it expired. Now deliverables are database records with a four-stage status — PENDING, IN_PROGRESS, DELIVERED, APPROVED. I upload a mastered track or an audit report through the admin panel. The file goes to Cloudflare R2. A deliverable record is created in the database, linked to the project and the attachment. The client gets an email notification. They log into the portal, see the deliverable with its description and status, and download it. When they are happy, the status moves to APPROVED. If revisions are needed, the status stays at DELIVERED and we discuss it in the comment thread. No shared drives. No expired links. No more checking spam for a file I sent last Tuesday. Every deliverable has a paper trail — when it was created, when it was uploaded, when it was delivered, when it was approved. The same workflow handles audio stems, software source code, accessibility reports, and production session recordings. One pipeline for every service type.

softwareaudio
Tip

The deployment pipeline is the publish button

There is no publish button for these notes. No save-draft state, no preview mode, no scheduling. I write a TypeScript object, add it to an array, commit, and push. GitHub Actions runs lint, typecheck, and 41 tests. If everything passes, the site deploys. The note is live. That is the entire publishing workflow. It works because the content and the code are the same thing. The notes array is type-checked — if I misspell a field name or forget a required property, the build fails before anything deploys. If a note has a malformed date string, TypeScript catches it. The CI pipeline that validates the checkout flow also validates the blog. Every note in this section went through the same pipeline as every feature commit. Same branch, same tests, same deploy. The friction is so low that writing a note takes less time than opening a CMS. That is not an accident. The whole system was designed so that documenting the work is part of the work, not something separate that happens after.

software
Building

One admin panel runs five different services

The admin dashboard manages audio mixing projects, music production sessions, session musician bookings, software builds, and accessibility audits from a single interface. Every project has the same shape — milestones, sessions, deliverables, comments, action items — regardless of whether the deliverable is a mastered track or a WCAG remediation report. The service type is a field on the project, not a separate system. That means the same admin workflow applies everywhere. I open the dashboard, see every active project across all five services, check which deliverables are pending, review which action items are overdue, and update session notes. One screen. One mental model. The alternative was five different tools — a DAW project tracker for audio, a Trello board for software, a spreadsheet for accessibility audits, a calendar app for musician bookings, a folder of production stems somewhere on a hard drive. Now it is all in the portal. The commit history shows each service type being added as a single enum value, not a new feature branch. That is the whole point of the architecture. A new service type is a database migration, not a rewrite.

software
Thought

Every automation replaces a conversation

Before the webhook chain existed, every service interaction required at least three emails. Client pays, I send a confirmation. Client wants to book a call, I check my calendar and suggest times. Client needs a progress update, I write one from memory. Now the confirmation sends itself. The booking checks my calendar and offers real slots. The progress lives in the portal where the client can see it any time. Each of these automations replaced a conversation I used to have manually. Not because the conversation was bad — because it was the same conversation every time. The webhook fires, the email sends, the calendar updates, the portal reflects the change. The client gets a faster response than I could ever type. I get time back to do the actual work — mixing a track, writing code, running an audit. The git log from the past week shows these automations landing one at a time. Stripe webhook first, then Clerk sync, then calendar cron, then portal notifications. Each one removed one more manual step from the service delivery pipeline. The system now handles the administrative side of running five services so I can focus on the craft side.

software
Update

Thirty notes in thirty days

This notes section started as a way to document the build. One note per feature, one paragraph per decision. Thirty days later there are over thirty entries covering everything from Stripe checkout flows to screen reader testing to the cron job that syncs Google Calendar. Each note is a TypeScript object in an array. No CMS, no database, no draft state. Write it, commit it, deploy it. The notes have become the changelog for the entire platform. When I look back at the sequence — first the landing page, then service pages, then checkout, then the portal, then admin, then webhooks, then notifications — it reads like a product being built in public. Every architectural decision is documented in the week it was made, not reconstructed months later for a blog post. The format works because it is low friction. No title image, no SEO keyword research, no publish button. Just a paragraph and a commit message. That is enough to show the work.

software
Building

Webhooks do the work so I do not have to

When a client buys a mixing package, I do not manually send a confirmation email. I do not open Google Calendar and create an event. I do not update a spreadsheet. A Stripe webhook fires on payment_intent.succeeded, verifies the signature, checks idempotency metadata so duplicate deliveries cannot happen, sends the client a confirmation email through Resend, sends me a notification with a link to the Stripe dashboard, and the confirmation page offers instant calendar booking. When someone signs up for the portal, a Clerk webhook fires, verifies via Svix, and upserts their profile into the database before the redirect even finishes. If a client record already exists from an admin-created project, the webhook links the Clerk account to the existing record instead of creating a duplicate. Two webhook endpoints. Two external services doing the triggering. Zero manual steps between a client action and the system responding. The git log shows these webhooks going in early because they had to. Every feature that came after — notifications, portal personalisation, booking sync — depends on the data these webhooks put in place.

software
Building

Five notification types, one pattern

The portal sends five different kinds of email notification. Session notes added. Milestone status changed. New comment posted. Deliverable uploaded. Action item assigned. Each one is a single function that takes the client email, the project name, and the relevant detail, then fires a Resend API call. No queue, no background worker, no retry logic. Fire and forget from the route handler. If Resend is down the notification silently fails and the portal still works — the email is a courtesy, not a dependency. Every notification links back to the portal so the client can see the full context. Every one uses the same from address, the same reply-to, the same tone. The pattern is so consistent that adding a sixth notification type would take ten minutes. That is what happens when you extract the pattern after building five real examples instead of designing an abstraction before building any.

software
Tip

A cron job keeps ninety days of bookings in sync

There is a cron endpoint that pulls my Google Calendar, matches attendees to client emails in the database, and upserts booking records. Ninety days back, thirty days ahead. It runs on a schedule, authenticated with a bearer token so nobody else can trigger it. Every calendar event with a client attendee becomes a booking row with the title, start time, end time, and Google Meet link extracted automatically. If the event already exists it updates. If it is new it creates. The client sees their upcoming and past bookings in the portal without me ever manually entering one. The sync also supports GET for health checks so I can hit it in a browser to verify it is working. One endpoint, one function, one source of truth. Google Calendar is where I manage my schedule. The portal is where clients see theirs. The cron job is the bridge.

software
Building

Authentication that stays out of the way

Clerk handles all the authentication for the client portal. Sign up, sign in, session management, protected routes — the whole thing. I did not build a custom auth system because building auth is how you introduce security bugs. The signup flow takes about thirty seconds. Once you are in, you see your projects, your bookings, your invoices, your deliverables. The portal knows who you are and shows you only your stuff. No shared dashboards, no magic links that expire after an hour. Just a proper auth layer that remembers you and gets out of the way. The commit history shows the auth integration going in alongside the portal features — not as an afterthought, not bolted on after launch. Authentication was part of the first portal commit because it has to be. A client portal without auth is just a public page. Clerk also syncs to the database through webhooks, so when someone signs up their profile exists in Prisma before they even finish the redirect. That means the portal can show personalised content from the first page load.

software
Building

Email runs through everything

Resend handles every email the site sends. Payment confirmations after Stripe checkout. Welcome emails when someone joins the newsletter. Notification emails when I update a project, add a deliverable, post a comment, or assign an action item. Each one is a single API call from a Next.js route handler — no email service dashboard, no template builder, no drag-and-drop editor. The templates are React components. The same TypeScript that renders the site renders the emails. The git log shows email touchpoints scattered across a dozen commits because email is not a feature — it is infrastructure. Every service interaction eventually produces an email. The client buys a mixing package and gets a confirmation. I upload stems and they get a notification. The newsletter subscriber gets a welcome. One provider, one API, one pattern. Resend and a single import statement.

software
Tip

Every feature is a TypeScript object before it is a UI component

Before I build any UI, the data exists as a typed object in a TypeScript file. Products are objects in an array. Services are objects in an array. Testimonials are objects in an array. These notes are objects in an array. The shape is defined by an interface, the compiler checks every field, and the component just maps over the data. Adding a new product to the software page means adding twelve lines to a data file. No migration, no API call, no CMS publish step. The entire content layer deploys with the code because it is the code. This pattern shows up in every commit that adds content — three new notes means three new objects pushed to the front of an array. A new service package means one object added to the products file. The UI already knows how to render it. The checkout already knows how to charge for it. The architecture does the work so I can focus on writing the content, not wiring up the plumbing.

software
Update

One week of privacy-first analytics

Plausible has been running for a week now. No cookies, no tracking scripts, no consent banners — just a 1KB script that tells me what pages people visit and where they come from. After seven days of real traffic data I can see which service pages get the most attention, which notes people actually read, and how long they stay. The whole analytics dashboard fits on one screen. No funnel analysis, no cohort segmentation, no heatmaps. Just the numbers that matter: visitors, page views, referrers, devices. Half the traffic is mobile, which validates every hour I spent on the mobile-first rebuild. Screen size distribution confirms the touch target work was worth it. Bounce rate tells me whether the landing page is doing its job. This is what analytics should be — a simple answer to a simple question. Who visited, what did they look at, and did they stick around. Everything else is noise.

softwareaccessibility
Thought

The git log is the case study

People ask for case studies and I point them at the commit history. Every feature on this site started as a commit message. Stripe checkout, Google Calendar sync, client portal, admin dashboard, accessibility audit, newsletter, file uploads — each one has a SHA, a timestamp, and a description of exactly what changed. No after-the-fact narrative, no polished retelling. Just a sequence of decisions recorded in real time. The git log for jamesmusic.uk now has over thirty commits spanning three weeks. It reads like a product roadmap that someone actually executed. That is the case study. Not a PDF with before-and-after screenshots. A live repo you can clone, inspect, and verify. Every architectural decision, every bug fix, every integration — documented in the same place the code lives. When a potential client asks what my development process looks like, I do not open a slide deck. I open the changelog.

softwareai
Thought

A platform, not just a portfolio

This started as a portfolio site. Show some work, list some skills, add a contact form. Three weeks later it is a platform. Clients can browse five services, pick a package, pay through Stripe, book a consultation through Google Calendar, sign up for the portal, track their project, receive deliverables, and download files. All in one Next.js app. The portfolio is still here — you are reading it — but it is wrapped inside something that actually runs the business. Every service I offer has its own page, its own pricing, its own checkout flow. The musician page and the software page look different but share the same payment infrastructure. The sound engineering page and the accessibility page have different copy but the same booking system. That is the whole point. One platform for everything I do. Not five separate tools duct-taped together. One codebase, one deploy, one place to look when something needs fixing.

softwareaudioproduction
Building

One payment flow for five completely different services

A client booking a mixing session and a client buying a software landing page go through the exact same four-step checkout. Same Stripe Elements form, same confirmation screen, same automatic consultation booking via Google Calendar. The only difference is the data — the product name, the price, the description. Everything else is shared. That was the whole point of the component architecture. The checkout wizard, the sidebar accordion, the product cards, the booking flow — they are all service-agnostic. Adding a new package to any service means adding one object to a TypeScript array. No new components, no new API routes, no new payment logic. The git log shows dozens of commits building this system. What it does not show is how little effort it takes to extend it now that the foundation is in place.

software
Thought

This site is the accessibility audit

I offer accessibility consulting as a service. The site you are reading is the portfolio. Every 44px touch target, every ARIA role, every keyboard trap handled correctly, every screen reader announcement — these are the same things I flag when I audit a client's site. I built jamesmusic.uk the way I tell other people to build theirs. Native HTML semantics over div soup. Focus management on every modal and dropdown. Colour contrast that passes WCAG AA. Font sizes that never drop below 12px. Line lengths constrained so text does not run edge to edge on tablets. If you want to know what an accessible site looks like, you are already on one. That is the pitch. Not a slide deck, not a case study. A working product you can tab through right now.

accessibilitysoftware
Update

Three weeks of commits and the site keeps growing

Last week I wrote about two weeks of git log taking the site from a landing page to a full platform. One more week in and the changelog keeps filling up. Since then: documented the five-integration architecture powering Stripe, Google Calendar, Resend, Clerk, and Cloudflare R2. Added reliability engineering that nobody sees — idempotency keys, webhook deduplication, lazy database connections, notification guards. Wrote up the shared component system that lets five service pages run on zero duplicated code. Shipped keyboard navigation coverage across every interactive element. Moved the entire content layer to typed TypeScript arrays with version control instead of a CMS. Each of these was a commit, a build, a deploy. No sprint planning. No standups. Just ship, write about it, ship again. Claude Code and a clear picture of what the site needs to be.

softwareai
Tip

Keyboard navigation is not optional

Every interactive element on this site can be reached and activated with a keyboard. Tab moves forward. Shift-Tab moves back. Enter and Space activate. Escape closes. Arrow keys navigate within groups. This is not a nice-to-have bolted on at the end. It was built into every component from the first commit. Buttons use native button elements. Links use native anchor tags. Dropdowns use proper ARIA roles and manage focus. The hamburger menu traps focus until closed. When you use native HTML semantics and manage focus correctly, keyboard navigation mostly comes for free. The bugs happen when developers reach for divs with onClick handlers instead of real interactive elements.

accessibilitysoftware
Building

Five pages, one checkout, zero duplication

Each of the five service pages — audio, production, musician, software, accessibility — has its own URL, its own copy, and its own product listings. But the underlying components are shared. One service navigation bar handles switching between pages. One sidebar accordion handles packages, booking, and contact. One checkout wizard handles the four-step payment flow. One product card grid handles the pricing display. Adding a sixth service would mean creating one data file and one page component. Everything else is already built. This is what reusable components are supposed to do — not premature abstractions, but proven patterns extracted from five real pages that all needed the same thing.

software
Tip

Static content does not need a database

The notes on this site, all the product listings, and the testimonials are TypeScript arrays in the codebase. Not a CMS. Not database rows. Just typed data exported from .ts files. Every content change goes through version control, gets type-checked at build time, and deploys with the code. There is no admin panel for editing posts because I do not need one — I edit a file, commit, and push. The tradeoff is obvious: you lose a rich editor UI. What you gain is a content layer that is impossible to break at runtime, impossible to lose to a database outage, and trivial to roll back with git revert.

software
Building

Five integrations, one Next.js codebase, zero microservices

The site now talks to five external services — Stripe for payments, Google Calendar for booking, Resend for emails, Clerk for authentication, and Cloudflare R2 for file storage. All of it runs inside one Next.js app. No microservices, no message queues, no separate backend. Each integration gets its own API route that validates input, calls the external service, and returns a response. Webhooks handle the async glue: Stripe fires on payment, which triggers a confirmation email through Resend and creates a calendar event. Clerk fires on signup, which syncs the user to the database. The whole thing deploys as a single unit. One repo, one build, one deploy. When something breaks, there is exactly one place to look.

software
Thought

The reliability work nobody sees

Spent time this week on the kind of work that never gets a feature page. Idempotency keys on every Stripe PaymentIntent so a network retry cannot create a duplicate charge. Webhook deduplication using Stripe event metadata so a retried webhook does not send a second confirmation email. A lazy Prisma client that only connects to the database at runtime, not at build time, so CI can build the app without a live database connection. Notification guards that check whether a client has actually signed up for the portal before emailing them. Nobody opens the site and notices any of this. That is the point. Reliability is invisible when it works.

software
Tip

Two Stripe flows for two different jobs

The site uses two different Stripe integration patterns. Service checkout uses PaymentIntents with Stripe Elements — the payment form is embedded directly on the page, no redirect, and the entire four-step flow stays on-site. The donation page uses Checkout Sessions — click a tier, get redirected to Stripe's hosted payment page, come back when done. Same provider, different approaches for different jobs. PaymentIntents make sense when you need full control over the UX and want to chain post-payment actions like booking a consultation. Checkout Sessions make sense when the flow is simple and you want Stripe to handle the entire page. Knowing when to use which saves a lot of overengineering.

software
Thought

Two weeks of git log: landing page to full platform

Went back through the commit history and counted it up. In two weeks, the site went from a basic landing page to five dedicated service pages, a four-step checkout with Stripe, a client portal with auth and project tracking, an admin dashboard with file uploads and deliverable management, automated calendar sync, email notifications, a newsletter, a donation page, a CI pipeline with 41 tests, a full SEO audit, Core Web Vitals optimisation, and WCAG accessibility fixes across every page. 30-plus commits. All built with Claude Code. The git log reads like a product roadmap that someone actually shipped.

softwareai
Building

Every package now auto-books a consultation

When someone buys a package, the confirmation page offers instant calendar booking. It hits an availability API that checks my Google Calendar for free 45-minute slots over the next 30 days, then renders a date picker with only the times I am actually free. Pick a slot, confirm, and the booking endpoint creates a Google Calendar event with a Meet link and sends both of us an email. The whole thing chains together: Stripe payment completes, webhook fires, confirmation page loads, calendar slots appear, booking is made. No back-and-forth emails needed.

software
Building

Shipped a support page with tiered Stripe Checkout

Added a Buy Me a Coffee page to the site. Five preset tiers plus a custom amount field. Each one hits a single API route that creates a Stripe Checkout Session and redirects. No embedded payment form here — donations are simple enough that a redirect to Stripe hosted page makes more sense than embedding Elements. The tiers are just a data array, the component maps over them, and the API route validates the amount and creates the session. Custom amounts are capped because at some point you should probably just email me directly.

software
Building

Built a four-step checkout with Stripe

Checkout is live on the site. Pick a package, enter your details, pay with Stripe Elements, and land on a full-screen confirmation. Behind the scenes, a webhook fires a confirmation email through Resend and books a 45-minute consultation via Google Calendar automatically. Used PaymentIntents instead of Checkout Sessions so the whole experience stays on-site with no redirects to a third-party page. Handles 3D Secure cleanly — reads the URL params on return and jumps straight to confirmation. Idempotency keys on every intent so duplicate charges cannot happen.

software
Update

Every service now has its own page

Each of my five services — sound engineering, music production, session musician, software engineering, and accessibility consulting — now has a dedicated page with full descriptions, fixed-price packages, and integrated checkout or contact forms. A sticky sidebar lets visitors switch between packages, booking a call, or sending an email without scrolling back up. Built a shared service navigation component so people can jump between services without going back to the homepage. Five pages, one Next.js codebase, no CMS.

software
Update

Redesigned the landing page from scratch

Ripped out the old single-page layout and rebuilt the homepage as a gateway to everything. New hero section with my profile photo, compact service cards linking to dedicated pages, and a notes feed at the bottom showing what I have been building. Added newsletter signup and a donation link. The old layout crammed everything onto one page. Now each service has room to breathe and the homepage just points you in the right direction.

software
Update

Added social proof to every service page

Every service page now has a testimonials section. Built a reusable component with glass-card styling that pulls quotes filtered by service type. Each testimonial sits in a proper blockquote with attribution. It is the kind of thing that builds trust before someone even reaches the contact form. Next step is replacing the placeholder content with real client quotes.

software
Update

Fixed every touch target on the site

Went through every interactive element on the site and made sure they all hit the 44x44px WCAG minimum. Scroll buttons, month navigation, dropdown items, footer links, back buttons — all of them were undersized. Also added a proper mobile hamburger menu with backdrop overlay, bumped the tag label font from 10px to 12px because that was below readable size, and constrained line lengths on service pages so text doesn't run edge to edge on tablets. Six accessibility bugs squashed in one pass.

accessibilitysoftware
Update

Rebuilt the layout mobile-first

Reworked the entire site layout starting from mobile. Hero section stacks vertically on small screens with tighter spacing. Service cards show an inline icon and title on mobile instead of the full expanded card. Footer flipped to horizontal on desktop with info on the left and logos on the right. The about section got broader copy covering all five services. Half my traffic is mobile so this was overdue.

softwareaccessibility
Building

File uploads, deliverables, and a proper admin panel

Rebuilt the admin dashboard from the ground up. It now tracks every active project, open action item, pending deliverable, and upcoming booking in one place. Added Cloudflare R2 for file storage so I can upload session recordings, stems, and documents directly from the admin panel. Clients see their deliverables with status tracking and can download files through signed URLs. Drag-and-drop uploads on desktop, tap-to-upload on mobile. The whole admin experience is mobile-first now because half the time I'm managing projects from my phone.

softwareai
Update

Shaved 4.5MB off my page loads

Ran a Core Web Vitals audit on the site and the numbers were not great. Resized all hero images down from their original camera resolution to 1920px max width. Converted one 2.8MB PNG to JPEG at 375K. Enabled AVIF and WebP auto-conversion in Next.js. Added font display swap so text is visible immediately instead of waiting for the web font to load. Total savings: about 4.5MB stripped from page loads. Performance is a feature, especially for users on slower connections.

softwareaccessibility
Update

Privacy-friendly analytics and an SEO sweep

Switched to Plausible for analytics. No cookies, no tracking scripts phoning home, GDPR-compliant out of the box, and the whole script is about 1KB. Also did a full SEO metadata audit — added OpenGraph and Twitter cards to every service page, the notes section, and the donate page. Blocked the portal from search indexing since that is authenticated content. Updated the sitemap to include pages that were missing. Small things but they add up.

softwareaccessibility
Update

Newsletter signup is live

Added a newsletter signup to the homepage using Resend Audiences. It validates your email, checks a honeypot field to block bots, adds you to the audience, and sends a welcome email. If you are already subscribed it handles that gracefully instead of throwing an error. Simple, clean, no third-party newsletter platform needed. Just Resend and a single API route.

software
Building

Automated calendar sync and client notifications

Wired up Google Calendar sync so bookings are pulled automatically — 90 days back and 30 days ahead. It matches calendar attendees to client emails and upserts booking records. On top of that, clients now get email notifications when I add session notes, update a milestone, post a comment, add a deliverable, or assign them an action item. All fire-and-forget through Resend so nothing blocks the admin workflow. Notifications only go out to clients who have actually signed up for the portal.

softwareai
Building

Building a client portal from scratch

I just shipped a full client portal for jamesmusic.uk. Clients can log in, see their projects, track milestones, read session notes, download invoices, and leave comments. Built with Clerk for auth, Prisma and PostgreSQL for data, all inside the same Next.js app. The whole thing went from design doc to deployed code in a single session with Claude Code. Zero to full portal in hours, not months.

softwareai
Thought

Automated accessibility tests miss the hard stuff

I run accessibility audits for clients and the most common thing I hear is 'but it passed the automated scan.' Automated tools catch maybe 30% of real issues. They find missing alt text and low contrast. They miss broken keyboard navigation, confusing screen reader announcements, and focus traps. The stuff that actually makes or breaks the experience for disabled users. That is why I test with a real screen reader, real keyboard navigation, and real assistive technology. Every time.

accessibility
Update

Added tests and CI to the site

Finally set up a proper test suite. 41 unit tests covering the product catalog, checkout API (Stripe PaymentIntent creation), and contact form API (Resend email). GitHub Actions runs lint, typecheck, test, and build on every push. Caught 18 pre-existing lint errors on the first run. The safety net is real now.

software
Building

I built this entire site with AI

This whole website was built using Claude Code — an AI coding agent. I described what I wanted, refined it step by step, and shipped it. As a blind developer, AI tools like this are a game-changer. I can focus on what I want to build, not fight with visual interfaces.

softwareaiaccessibility
Building

Turning my audio services into packages you can just buy

I've been working on turning my mixing, mastering, and production services into fixed-price packages. No awkward back-and-forth about pricing. You pick a package, you book it, you get your music back. Stripe integration coming soon so you can checkout right from the site.

audioproduction
Thought

Why I mix with my ears, not my eyes

Most engineers watch waveforms and meters. I can't. Every decision I make is based on listening. Turns out, that's exactly how your audience experiences the music too. No visual shortcuts, no cheating with my eyes. Just sound.

audioproduction
Update

My TikTok AI assistant now talks back

I built an AI companion for my TikTok live streams. It reads the chat, understands context, generates responses with Claude, and speaks them aloud with ElevenLabs voice synthesis. It's like having a co-host that never gets tired. Currently working on giving it memory so it remembers regulars.

softwareai
Thought

Still getting refused entry with my assistance dog

It's 2026 and businesses still don't know the law. Assistance dogs have a legal right of access to all public spaces in the UK. I get turned away at least once a month. That's why I consult on this — because the law exists but awareness doesn't.

accessibility
Tip

Stop hiding things from screen readers

Quick tip: if you use display:none or visibility:hidden, screen readers can't see it either. If you want something visually hidden but still accessible, use the sr-only pattern. And please stop putting aria-hidden='true' on things that aren't decorative. My screen reader literally skips them.

accessibilitysoftware
Update

Blind Drummer went from idea to live SaaS in 11 days

I shipped a full music education platform — student portals, mentor management, booking, Stripe payments, safeguarding compliance — in 11 days. 113 plans executed. People ask how. The answer: I think in systems, not screens. Being blind means I architect everything in my head before I write a line of code.

software

168 of 168 notes