# Joseph O'Driscoll: full public record > Everything public on https://jodriscoll.com, in one plain-text file. Generated from the same records the pages render. ## Profile Name: Joseph O'Driscoll Title: Web Application Consultant Location: Florida, US · works remote GitHub: https://github.com/jodriscoll LinkedIn: https://www.linkedin.com/in/jtodriscoll Contact: https://jodriscoll.com/#contact (Contact form). No public email address. I read code. Not only the code I write, but the framework underneath it, the library a team pulled in years ago, and the output of a build step nobody has opened. Over the past decade the platform of the moment has been WordPress, then Drupal, then Angular, then React and Vue, and these days whichever framework the internet has crowned this week. What carried across every one of them was knowing what the code actually does once it runs, not what its abstraction promises. That's what makes me useful when something breaks. I troubleshoot at the level the problem lives: the request, the cache header, the file a function opens, the thread a phone is blocking. The cause is rarely in the code everyone is staring at, and I find it by measuring before I guess. I use AI every day, and I use it to push how much can ship in a single business day. What I don't do is let it write code I couldn't read without it. Everything it produces, I can explain, change and debug myself, down to the language and the platform underneath. If AI went away tomorrow, the work would slow down, not stop. I want the same on any team I join, and it's why I mentor: developers who understand the code stay the source of truth for what they ship. ## Services - Web maintenance: Keeping small sites and large ones running. - Front-end rebuilds: From a single section through a full redesign. - Migrations: Moving build pipelines and content platforms. - Front-end tooling: Modernizing how the front end is built and shipped. - Coding standards: Writing them, then implementing them. - Testing: Checking the work before it ships. ## Skills Angular, React, Vue, HTML5, SASS, Node.JS, ExpressJS, NestJS, End-to-End Testing (E2E), Bootstrap, Material UI, TailwindCSS, WordPress Development, Web Performance, Continuous Integration & Continuous Deployment (CI/CD), JavaScript (ES5+), TypeScript, Search Engine Optimization (SEO), Test Driven Development (TDD), ADA, 508, and WAI-ARIA Compliance, Management, Mentoring, Tutoring Platforms counted across public projects: - WordPress: 43 projects - Pantheon: 37 projects - Bootstrap: 26 projects - PHP: 20 projects - Lando: 12 projects - AWS: 11 projects - React: 11 projects - TypeScript: 9 projects - MySQL: 8 projects - GitLab CI: 7 projects - HTML and SCSS: 7 projects - Drupal 8: 5 projects ## Experience ### Owner · Nomadic Digital LLC · Jan 2016 – Present Florida · Remote - I own Nomadic Digital. It has run since January 2016: on the side while I worked full time at Jellyfish and Mindgrub, and full time since May 2023. - I use what I know of a client's current systems to improve them, and I build something custom when that is what they need. - I manage projects for manufacturing, technology, and B2B clients. The current client work is held inside this practice. #### Solutions Engineer · Taoti Creative · Jul 2023 – Present Consultant · Washington, DC · Remote - I consult for Taoti Creative as a solutions engineer on the client work that studio delivers. - The contract is remote from Washington, DC, and it has been open since July 2023. - It is held under Nomadic Digital, at the same time as the other clients of the practice. #### Solutions Architect · Sageworx · Aug 2023 – Present Consultant · New York, NY · Remote - I consult for Sageworx as a solutions architect, remote from New York, NY. - The engagement started in August 2023 and is still open. - It is a contract under Nomadic Digital, carried alongside the other current clients rather than as a separate career. #### Managed Services Consultant · Mindgrub Technologies · Jan 2022 – Jun 2022 Consultant · Remote - A short consulting contract through Nomadic Digital, separate from my earlier full-time role at Mindgrub. - I helped service and maintain Mindgrub's client web apps through its managed services team while I moved from Mindgrub to Rehab. - The contract ran from January to June 2022. #### Web Engineering Lead · millermedia7 · Jun 2023 – Present Contractor · Ithaca, NY · Remote - I am the web engineering lead for millermedia7, on contract, remote from Ithaca, NY. - The role started in June 2023 and is still open. - I hold that lead seat inside Nomadic Digital while the rest of the client work runs with it. #### Open Source Solutions Engineer · PDA.nyc · Aug 2024 – Present Contractor · Brooklyn, NY · Remote - I am the open source solutions engineer for PDA.nyc, on contract, remote from Brooklyn, NY, and the public site is on Webflow. - The engagement started in August 2024 and is still open. - It sits under Nomadic Digital, concurrent with SAUCE.nyc, which shares this start. #### Design and Implementation · ZUGU · Undated - I assisted ZUGU with the product release of incaseofdeath, on the design and the implementation. - That release led me into supporting the go-live and the product branding. - ZUGU was a client of PDA.nyc, not of Nomadic Digital; I did this work through the PDA.nyc engagement. #### Open Source Solutions Engineer · SAUCE.nyc · Aug 2024 – Present Contractor · Brooklyn, NY · Remote - I am the open source solutions engineer for SAUCE.nyc, on contract, remote from Brooklyn, NY, and the public site is on Webflow. - The engagement started in August 2024, the same month as PDA.nyc, and it is still open. - It is held under Nomadic Digital with that shared start, not a separate one. #### Ancient UI Architect Lead · Ancient LLC · Jul 2026 – Present Contractor · Brooklyn, NY · Remote - Through Ancient LLC I'm the UI architect lead on PepsiCo's Pepviz, the single client collaboration of the engagement. - I work with one international designer to build uniquely designed marketing landing pages for Pepviz's messaging and brand communications. - Each page is hand-authored semantic HTML, one SCSS file and a small vanilla JavaScript file, staged on CodePen so the client can paste it straight into their own CMS. ### Director of Web Operations · Rehab LLC · Oct 2022 – May 2023 Contractor · Remote - Took charge of optimizing and streamlining a dated Symphony application, successfully reducing its codebase by over 60% through thorough cleanup and optimization measures. - Effectively oversaw the implementation of GitHub Actions and CloudFlare Pages technologies, enhancing our development workflow and optimizing the deployment of our website. - Played a crucial role in reducing AWS hosting costs for the business, resulting in a significant decrease of over 50% in monthly expenses. - Successfully eliminated a single point of failure in security across multiple web applications under my management at Rehab.com, enhancing their resilience and safeguarding them against potential vulnerabilities. - Managed multiple Vue.js applications, where I diligently implemented security enhancements, optimized code, and improved performance to deliver exceptional end-user experiences. - Responsible for overseeing an internal administrative platform, leveraging Vue.js, AWS Amplify, Lambda, Cognito, and GraphQL technologies, ensuring efficient operation and smooth functionality. - Played a pivotal role in facilitating the sale of an LLC by assisting in the seamless transition of several technical applications, ensuring a smooth handover and continuity of operations for the new owners. - I used Python to aggregate the data for the sale of the company, the domain, and its assets, and I replaced a pre-existing dashboard with a more modern automation and management system. #### Senior Performance Engineer · Rehab.com · Jan 2022 – Oct 2023 Contractor · Remote - Collaborating closely with Core Web Vitals and performance auditing tools, I meticulously fine-tuned the performance of a Symphony web application, aiming to achieve a higher ranking for organic users by delivering an optimized and seamless user experience. - Led adoption and implementation of AGILE methodologies for a small business, driving efficient collaboration and adaptability in our processes. - Adeptly managed the day-to-day operations of multiple web applications, addressing a wide range of issues, including performance optimization, bug resolution, SEO enhancements, payment integration, content management, and many other challenges that arise, ensuring smooth and efficient functionality across all products. - Successfully developed and delivered a Vue.js application seamlessly integrated with Symphony, empowering patients to curate treatment providers tailored to their needs and preferences. #### MentalHealth.com · Feb 2023 – Oct 2023 - Part of the Rehab LLC work in this window. #### Cicada Capital · Mar 2023 – Oct 2023 - Part of the Rehab LLC work in this window. ### Senior Software Engineer · Mindgrub Technologies · Aug 2017 – Jan 2022 Full-Time · Hybrid - Responsible for building ground-up user interfaces within WordPress, Drupal, React, and Angular frameworks utilizing MySQL, LocalStorage, MongoDB, AWS, GitLab CI, and Composer configurations. - Leveraged RESTful API services leveraging JSON, AJAX, LocalStorage, and SessionStorage. - Utilized AGILE and Waterfall methodologies to accomplish a variety of project deliverables. - Conducted observational user testing, reviewed designs against usability heuristics, and performed performance audits on various web applications. - Collaborated on usability and accessibility best practices for WAI-ARIA, 508 compliance, and ADA/AODA standards. - Optimized web apps for maximum speed and scalability utilizing modern PaaS tools focused on end-user deliverability and search engine requirements (Core Web Vitals). - Oversaw the UI application shell and content management of multiple Progressive Web Applications (PWAs), employing Material Design principles, manifest.json configurations, and service workers for offline caching. - Delivered web applications utilizing coding best practices, code documentation, and effective source control management. - Supported the vision of projects and worked closely with team members to see it through to completion. - Participated in code reviews and implemented unit tests to reduce technical debt. ### Senior Front-End Engineer · Jellyfish · Jul 2015 – Aug 2017 Full-Time · On-site - Developed web apps using HTML5, CSS3 (SASS & LESS), JavaScript ES5, Angular 3, Bootstrap, jQuery, WordPress, Drupal, Docker, and several other Front-End technologies. - Agile (SCRUM) team member delivering regular updates and participating in daily scrums, retrospectives, and backlog pruning. - Built and managed DoubleClick interactive ads. - Worked closely with graphic designers, UX designers, and interactive design specialists to create rich user experiences. - Introduced the adoption of CSS Flexbox and ES5 for improved user experiences and consolidated JavaScript composition. - Utilized JSON as a communication protocol between the front-end and back-end, enabling seamless data exchange and integration between the two components of the application. - Implemented and tested data management using MySQL, Postman, and API development/testing using Swagger. - Utilized jQuery extensively for event manipulation and employed various plugins to enhance and extend interactive functionalities across multiple web applications. - Efficiently managed code reviews and releases by utilizing GitHub and GitLab, ensuring smooth collaboration, version control, and quality assurance throughout the development process. ### Lead Web Engineer · kor group · Aug 2014 – Jul 2015 Full-Time · On-site - Collaborated as the front-end developer with the design team to establish workflows, user personas, and website content structure, ensuring a cohesive and user-friendly experience. - Applied UI and UX design expertise to contribute to the design and development process, effectively collaborating and presenting ideas to stakeholders. - Utilized knowledge of browser compatibilities and responsive site design to create visually appealing and functional web and mobile interfaces. - Conducted user research and user testing to gather insights and feedback, iteratively improving the user experience and optimizing website performance. - Managed multiple projects and time-sensitive deadlines. - Collaborated with the creative, technical, and client teams daily. - Translated Photoshop/Illustrator mockups into hand-coded HTML, CSS, and JS, ensuring pixel-perfect accuracy and attention to detail. - Efficiently manage multiple projects simultaneously, prioritizing tasks and meeting time-sensitive deadlines. - Fostered effective collaboration with creative, technical, and client teams, actively participating in discussions and providing valuable input to achieve project goals and client satisfaction. ### Interactive Quantitative Producer · CogoLabs · Feb 2014 – Jul 2014 Contractor · On-site - Collaborated with a cross-functional team of engineers, designers, and product managers to ideate, build, and launch web-based startups from inception to deployment. - Demonstrated experience building responsive email templates that utilize A/B testing methodologies and have undergone rigorous HTML testing with Litmus. - Developed and maintain high-quality web applications using a variety of programming languages such as Python, Ruby, Ruby on Rails, PHP, and JavaScript. - Implemented engaging user interfaces and experiences through front-end development tasks, including responsive email development, HTML, CSS (SASS and Dart), and JavaScript. - Stayed current with industry trends, technologies, and best practices and leveraged this knowledge to propose innovative solutions and approaches. - Actively participated in agile development processes, including daily stand-ups, sprint planning, and retrospectives, to ensure efficient project execution and timely delivery. - I used Python to automate internal tools that called the Uber system and distributed email across target lists. ### Interactive Multimedia Producer · Shawmut Construction · Aug 2013 – Feb 2014 Contractor · On-site - Executed daily web and email production for the company, located and resolved issues, conducted quality assurance, and tested the effectiveness of campaigns through A/B testing. - Performed regular updates on web pages using content management systems and standard HTML/CSS. - Worked with external web developers and the internal IS&T department to facilitate fixes of website and email platform bugs, enhancements, and new development. - Built, tested, and distributed emails using email management platforms, HTML/CSS editors, email service providers, and standard web browsers. - Created mock-ups and designs for web pages, email templates, banners, etc., using current industry best practices. - Worked collaboratively with marketing/creative departments to propose and deploy new creative/interactive marketing strategies, production methods, and design changes. - Expressed a solid grasp of online and offline direct response advertising principles and CRM. ### Interactive Web Producer · Mass General Hospital · Dec 2010 – Aug 2013 Contractor · On-site - Focused on producing web-ready assets and managing content across various websites using respective content management systems. - Creating front-end XHTML/CSS templates for CMSs and web-based applications using design files (e.g., Photoshop, Fireworks) and adapting/repurposing existing HTML/CSS. - Converting externally produced content to the appropriate electronic format and generating packages using supplied editorial content and visual assets. - Supported the Assistant Director of Communications for Electronic Communications and worked on assigned projects, including limited video and multimedia production, front-end web development and design, art direction, image cropping, optimization, e-mail template development, and other tasks. - Collaborated closely with the director to implement AWS hosting solutions for various web applications, ensuring optimal performance, scalability, and cost efficiency. - Provided technical troubleshooting support for the Department's applications and supported the Department's e-philanthropy platform, Blackbaud NetCommunity. ### Creative Media Specialist · Aptima · Mar 2008 – Oct 2010 Full-Time · On-site - Served as a Creative Media Specialist, specializing in graphical design and multimedia training implementation. - Expertise in applications such as Adobe, Autodesk, Animation, and Broadcast Graphics allowed him to excel in various areas. - Successfully developed courseware for the Army and Air Force, including 40 hours of Interactive Multimedia Instruction (IMI) for the Combined Arms Support Command (CASCOM). - Played a vital role in converting existing CD-based training to a web-based format, contributing to the accessibility and scalability of the training materials. ### Junior Web Developer · HealthcareSource · Sep 2007 – Feb 2008 Internship · On-site - Developed and managed web applications using languages such as ColdFusion, JavaScript, CSS, HTML, PHP, XML, and Ajax. - Worked with high-fidelity graphics and created layout designs based on pre-existing web applications. - Used Visual Source Safe as a code management system. - Managed projects using the SalesForce.com CMS system. - Assisted in creating dynamic JavaScript PDF forms leveraged within proprietary web apps. ### Junior Media Programmer · Earthbound Media Group · Aug 2004 – Aug 2005 Full-Time · On-site - Contributed to the design of electronic media such as interactive CD-ROMs, Flash animations, website design and development, corporate branding, dynamic e-mail, and marketing campaigns. - Successfully managed multiple projects simultaneously in a fast-paced environment heavily involved in higher education. - Part of an award-winning design and interactive team playing the role of the Front-End Developer. - Introduced new technologies within the industry, such as CSS2 and JavaScript. ## Projects ### GameKal URL: https://jodriscoll.com/things-built/gamekal Live: https://www.gamekal.com/ Kind: Creator Role: Founder, Designer and Solutions Architect Delivered through: Nomadic Digital LLC Period: Sep 2026 – Present Stack: Expo, React Native, React, TypeScript, Supabase, Vercel, Swift, Expo SDK, react-native-web, Reanimated, Gesture Handler, react-native-svg, Custom Expo module, expo-notifications, expo-image-picker, Vercel Functions, Static catalog on the Vercel CDN, Vercel BotID and Firewall, Store scrapers, Wikipedia and history backfills, Built search and picks indexes, Storybook, VitePress, Mermaid, Three.js, CodeMirror 6 and TanStack Table, Vite A calendar for the games you mean to play: one Expo codebase serving web, iPhone, iPad and Android, fed by a release catalog drawn from nine storefronts. Facts: - Games in the catalog: About 147,000, scraped from nine storefronts - Home page load: 1.81 s, down from about 20 s - Warm title search: 1.1 ms median, down from 19.6 ms - First search on a cold instance: 183 ms, down from 572 ms - Search response: 1.8 KB, down from 42 KB - Icon font: 8 KB, down from 573 KB - Catalog function: 35 MB, down from 640 MB - Automated tests: 538 - Commits: 130 - Current release: 0.1.25, November 10, 2026 - First commit: September 24, 2026 - Platforms: Web, iOS, Android - Release line: 0.1.0 to 0.1.25 Notes from the build: - Fonts cut to what's drawn: Fonts are self-hosted woff2 subsets. The icon font ships 8 KB of the glyphs the app uses, down from 573 KB. - Lighthouse: The production site scores 100 for accessibility and SEO. Performance started at about 84 on a wide window and about 61 on a phone. - A 78 MB catalog, opened in pieces: The live release file is 78 MB, so requests are answered from small per-year files and built indexes, and tests record every file a lookup opens. - Public data as files, not a function: Every catalog read used to run a bot-checked serverless function marked private, no-store. Days, counts and games are now static files on Vercel's CDN, built at deploy under a content stamp so everything beneath it caches forever and only current.json is ever rechecked. Home went from about 20 s to 1.81 s. - 147,000 games in 8,192 buckets: A file per game was too many, so every game id hashes with FNV-1a into one of 8,192 bucket files of about 10 KB. The server and the browser share the same gameBucket function, and a test compares every game's file against the API's answer. - 59 bytes that staled every index: Two small data fixes grew the live file by 59 bytes. Every index is stamped from file sizes, so all of them read as stale and the API fell back to parsing all 78 MB per request, 5 to 8 s each, while picks returned 503. Deploys now refuse to ship a stale index, checked before and after the catalog syncs. - 640 MB down to a 35 MB function: Picks, Similar Games and title search depend on the person or the query, so they moved to a small compute function that carries only the three indexes it reads. Its answers are cached at the CDN for a day, and Similar Games came back in 50 ms. - Retries that buried a cold instance: The client resent any request after 8 s while the first kept running. With Fluid compute sharing one Node instance, each resend queued behind JSON parsing until the instance was buried for about 50 s. Requests now wait 45 s for the catalog and 30 s for search, share one request per URL, and honor Retry-After on a 429. - Profiled on the phone, not the desktop: A 41-second Hermes CPU profile found 8.3 of 26 busy seconds in wishlist matching, which built two URL objects per comparison, and on the phone URL is a slow JavaScript polyfill. A one-regex link reader, animations moved to the UI thread with Reanimated, and a removed swipe gesture took the post-load freeze from 5.8 s to about 0.9 s and tab switches to 36 to 75 ms. - One temp folder, two names: The suite passed 538 of 538 in Linux and 537 on a Mac. On macOS the temp folder lives under /var, a link to /private/var, so a test that records every file a lookup opens saw the right file under the wrong name. Resolving the real path fixed it, and four negative checks that could never fail on a Mac can fail again. Delivery (pipeline built in-house): The pipeline is mine end to end. Vercel's Git integration is disconnected on purpose, so a push never deploys: the website ships from a machine that holds the catalog, and the phone builds go through EAS. 1. Checks: node --test, tsc for the app, the CMS and the ecosystem map, and boundary checks that keep web-only code out of the phone apps 2. Release prep: /prepare-release bumps the version, updates the changelog and every doc that cites it, and verifies the search index, regions and native project sync 3. Web: expo export, a static 404 page and a static catalog build, published to Vercel 4. iOS and Android: EAS production builds, shipped to TestFlight and the Play Console ### PepsiCo Pepviz URL: https://jodriscoll.com/things-built/pepviz Kind: Ancient LLC Role: UI Architect Lead Delivered through: Ancient LLC Period: Jul 2026 – Present Stack: HTML, Sass, JavaScript, CodePen, CSS, Image maps, Splide, Figma Marketing landing pages for PepsiCo's Pepviz, built with one international designer. Each article ships as hand-authored HTML, one SCSS file and a few lines of vanilla JavaScript, staged on CodePen so the client can paste it into their own CMS. Facts: - Pages: Seven article landing pages, LP1 to LP7 - Hand-off: CodePen, with minified and readable CSS - Team: One international designer and me Notes from the build: - Built to be pasted: No framework, no build step, no npm. The client re-implements each page in its own CMS, so every page keeps the same markup patterns, naming and breakpoint from version to version. - Desktop and mobile art: Charts, tables and infographics ship as separate desktop and mobile SVGs, swapped at 767px, with footnotes linked from superscripts inside the charts. ### Fisher of Fire URL: https://jodriscoll.com/things-built/fisher-of-fire Kind: Creator Role: Reverse Engineering, Design and Engineering Delivered through: Nomadic Digital LLC Period: Nov 2026 – Present Stack: TypeScript, Phaser, Vite, Ghidra, Node.js, ffmpeg, Java, WebGL, Vercel, pspdecrypt, llvm-objdump, Gamepad API, Web Audio API, npm, Vercel CLI Since I was a kid, I've wanted to rebuild the fishing game from Breath of Fire III as a mobile app, and expand on an experience I was so attached to. The game came out on PlayStation in 1997. I decrypted the program on a disc I own, decompiled all of it, and rebuilt Fishing Mode in TypeScript and Phaser 4 rather than emulating it. The rebuild runs in a browser at the game's native 480×272, and once it matched the original I built on top of it: save files, four playable fishers, a shop, a jukebox and support for any screen and any controller. Facts: - Functions decompiled: 11,676 (48 MB of C) - Port source: About 6,900 lines of TypeScript and tools - TypeScript modules: 57 - Game-rule modules: 11, no rendering: state machine, fish AI, saves - Phaser scenes: 13, one per screen - Typed data tables: 9 (spots, fish, gear, music and more) - Runtime dependencies: 1 (Phaser) - Extractor dependencies: 0, plain Node.js - Fishing spots: 17 - Music player: 128 tracks - Native resolution: 480×272 - Empty folder to 0.1.0 Beta: About 14 hours, November 2–3, 2026 - Input methods: 4 (keyboard, mouse, controllers and touch) Notes from the build: - Rebuilt, not emulated: No emulator runs the original code. The port re-implements the fishing rules, casting, the lure rhythm and the fights in TypeScript, read from the decompiled program. - Measured against the original: Six screenshots from the real hardware set every layout, so sprite positions and window sizes were measured rather than guessed. - Past the original: Three save files, Young Ryu, Ryu, Young Nina and Nina as playable fishers, the Manillo shop, a gallery, a 128-track music player, cheats, a CRT TV mode and any screen at 1–4× or fill. - Any input: Keyboard, mouse, game controllers, touch and an on-screen pad, with a controls screen in PlayStation symbols. - Tooling for the next one: An animation viewer and sheet editor, URL flags for testing, and a porting playbook written up so the method carries to other games. Delivery (pipeline built in-house): From an encrypted disc image to a playable, extended browser port. The repository holds no game data: the extractors read it from the owner's own disc. 1. Decrypt: pspdecrypt turns the encrypted EBOOT.BIN into a relocatable MIPS (Allegrex) program. 2. Decompile: Ghidra 12.2 headless with ghidra-allegrex and a custom export script writes every function out as C. 3. Trace: The fish table, matched against a PS1 speedrun's star table, located the fishing logic. Fishing turned out to run on world-map areas. 4. Extract: Node tools decode tiled textures, per-strip palettes, terrain meshes, sound banks and music: about 830 files. 5. Rebuild: Phaser 4 and TypeScript, with a PSX-style affine triangle renderer for the terrain. Every layout measured against reference screenshots. 6. Extend: Three save files, four fishers, a shop, a gallery, a jukebox, cheats, a CRT mode, 1–4× scaling and any input. ### PawDings URL: https://jodriscoll.com/things-built/pawdings Live: https://pawdings.com/ Kind: Creator Role: Product, Design and Engineering Delivered through: Nomadic Digital LLC Stack: Expo, React Native, React, TypeScript, expo-location, expo-notifications, expo-task-manager, expo-image-picker, Node.js, PostgreSQL PawDings is a startup app for iPhone, Android and the web that holds dog owners to being more active with their dogs. It is location-aware, sends one thoughtful, weather-aware idea a day, and tracks how active you actually are through GPS, check-ins and fully customizable notifications and reminders. Facts: - Status: Working Expo MVP, in progress - Platforms: iOS, Android and web from one codebase - API: Versioned OpenAPI contract Notes from the build: - One nudge, not a feed: A single daily recommendation ranked by the pet's energy and size, the weather, distance and opening hours, with a few alternatives. - Activity you can see: Starting an idea arms an on-device arrival check, so a walk counts as a walk. Reminders, their days and their times are all the owner's to set. - Privacy by restraint: Location is asked for only when it is needed, stored coarse for learning, and never sold. Sponsored places are always labelled and never outrank safety or fit. - Built to swap the backend: The UI talks to a typed repository interface: sample data today, the HTTP API tomorrow, with no screen rewritten. ### Nioh 3 Tracker URL: https://jodriscoll.com/things-built/nioh-3-tracker Live: https://nioh3tracker.vercel.app/ Kind: Hobby project Role: Design and Engineering, Built with the Community Delivered through: Nomadic Digital LLC Stack: Vercel A tracker for Nioh 3, built alongside the game's community to fill a gap: the wikis fell short and strategy guides are no longer printed, so players had nowhere reliable to track their progress. Notes from the build: - Built with its users: Shaped alongside the players who use it, where the wikis failed and printed strategy guides stopped. ### ZEFR URL: https://jodriscoll.com/things-built/zefr Live: https://zefr.com/ Kind: MillerMedia7 Role: Web Engineering Lead, through millermedia7 Delivered through: millermedia7 Period: Mar 2026 – Jul 2026 Stack: Craft CMS, PHP 8.4, nginx, MySQL 8.0, Craft Cloud, Vite, SEOmatic, Neo, SAML SSO, DDEV, CKEditor, Navigation, Retour, SAML SP, Lottie, PHP, nginx-fpm, MySQL Zefr classifies video content for brands and advertisers. Its public site was rebuilt on Craft CMS 5, with a component library editors assemble in Neo, and moved onto Craft Cloud. Facts: - CMS: Craft CMS 5.8 - PHP: 8.4 - Commits on this machine: 77 - Window: March 24 to July 17, 2026 Notes from the build: - A 500 that was really a missing rewrite: On the preview host, /dist/* missed the CDN artifact bundle and returned 500s. One URLPattern rewrite per path fixed it, because a grouped wildcard did not match every asset. - A block library, not page templates: Callouts, related resources and about-page components were added as Neo blocks, then regrouped in April so module groups matched their types. - Craft Cloud project config: Early commits aligned Craft Cloud settings in project.yaml so local DDEV and Cloud environments built the same site. ### Cascade Space URL: https://jodriscoll.com/things-built/cascade-space Kind: Client Role: Design System and Front-End Engineering Delivered through: Nomadic Digital LLC Period: Nov 2025 – Dec 2025 Stack: Next.js, React, TypeScript, Tailwind CSS, Storybook, Vitest, Vercel, Framer Motion, MDX, JSON content files, Vimeo Player, ESLint and Prettier Cascade Space builds technology for beyond low Earth orbit. Its site is a Next.js static export with a block library: every block ships with a typed interface, a Storybook story and unit tests, and every word lives in content files, ready for a CMS. Facts: - Commits: 36 - Window: November 6 to December 15, 2025 - Output: Static export Notes from the build: - Every block, three ways: Each block carries a data-module attribute, a TypeScript props interface, a Storybook story and unit tests: heroes, info-bubble hotspots, a beamforming visual, a reservation calculator, timelines and careers. - Content outside the code: Navigation, pages, blog posts, resources and legal copy live in content files, so a CMS can take them over without touching a component. ### Echoes of History URL: https://jodriscoll.com/things-built/echoes-of-history Live: https://echoeshistory.org/ Kind: Tutoring Role: Tutoring and Technical Guidance Delivered through: Nomadic Digital LLC Period: Aug 2026 – Present Stack: TanStack Start, React, TypeScript, Tailwind CSS, Supabase, Vercel, Vite, Radix UI, Google and Microsoft sign-in, Vercel BotID, Resend An educational site for evidence-based historical reasoning. Ask about an analogy, a claim or a piece of propaganda, and a report shows where the comparison holds, where it breaks, and how historians disagree. Facts: - Commits: 16 - Window: August 31 to November 6, 2026 - Status: Live, MVP Notes from the build: - Answers stay on the server: Example cards and Literacy Lab challenges load from /api/examples and /api/challenges, and answer keys stay server-side until a submission is graded. - An honest MVP: What ships and what is parked is written down in MVP.md, and nothing parked appears in menus or copy. ### TruBridge URL: https://jodriscoll.com/things-built/trubridge Live: https://trubridge.com/ Kind: MillerMedia7 Role: Web Engineering Lead, through millermedia7 Delivered through: millermedia7 Stack: WordPress, WP Engine, PHP, Timber, Twig, ACF, Custom post types, JavaScript, Alpine.js, Swiper, Lottie, Plyr, Vimeo Player, Headroom.js, Quicklink, CSS, Google Tag Manager, Adobe Fonts, Ninja Forms, Editor clean-up TruBridge sells revenue-cycle and EHR services to community hospitals. Its site is a custom Timber theme on WordPress, hosted on WP Engine, with resources, events and products as their own content types. Facts: - Platform: WordPress on WP Engine - Theme: Custom, Timber and Twig - Content types: Events, products, resources Notes from the build: - An admin built for the editors: The theme removes what editors never use: dashboard widgets, extra meta boxes, view buttons on custom types, and Customizer style sections, and adds resource columns to the list screen. ### Election Center URL: https://jodriscoll.com/things-built/election-center Live: https://electioncenter.org/ Kind: Taoti Creative Role: Solutions Engineer, through Taoti Creative Delivered through: Taoti Creative Stack: WordPress, PHP, Timber, Twig, Composer, ACF Pro, Yoast SEO, FileBird, Duplicate Post, WP Rocket, Redis Object Cache, EWWW Image Optimizer, WP 2FA, Taoti Security Scanner, JavaScript, jQuery, Gulp, Sass, webpack, Babel, PostCSS, BackstopJS, Behat, Nightwatch, PHP_CodeSniffer, GitHub Actions, AWS, Lando, Apache, MariaDB, Node The Election Center trains and certifies election officials. Its site is Bedrock WordPress with a Timber theme, WP Rocket and Redis, and visual regression tests in its CI folder. Facts: - PHP: 8.3 - Server: Apache 2.4 - Database: MySQL 8.0 ### International Women's Forum URL: https://jodriscoll.com/things-built/iwf Live: https://iwforum.org/ Kind: Taoti Creative Role: WordPress Development Delivered through: Taoti Creative Stack: WordPress, PHP, Sass, Gulp, webpack, GitHub Actions, Pantheon, Advanced Custom Fields, Gravity Forms, Yoast SEO, Solid Security, Babel, PostCSS, Accessible Slick, AOS, PHP_CodeSniffer, Stylelint, Prettier, Lando The International Women's Forum runs its public site on WordPress: a custom Taoti theme, Advanced Custom Fields and Gravity Forms, built and deployed to Pantheon by GitHub Actions. Facts: - Theme: iwf-wp, on the Taoti WordPress base - Deploys: GitHub Actions to Pantheon, with multidev builds Notes from the build: - Built on deploy, not committed: The Pantheon workflow runs composer build-assets on every push to main: Composer installs, then npm ci and a Gulp build in the theme, then the result is pushed to Pantheon. ### AAMI URL: https://jodriscoll.com/things-built/aami Live: https://aami.org/ Kind: Taoti Creative Role: Solutions Engineer, through Taoti Creative Delivered through: Taoti Creative Stack: WordPress, Bedrock, PHP 8.3, Apache 2.4, MySQL 8.0, Redis, ACF Pro, BuddyBoss, WP Rocket, Yoast SEO, SAML SSO, Lando, BuddyBoss Theme, Timber, Twig, ACF Extended, ACF Table Field, Gravity Forms add-on for ACF, Permalink Manager, Redirection, Members, Redis Object Cache, EWWW Image Optimizer, miniOrange SAML SSO, WP 2FA, WP Activity Log, Solid Security, Taoti Security Scanner, PHP, Apache, MySQL, Node, Gulp, Webpack The Association for the Advancement of Medical Instrumentation runs its member community on WordPress and BuddyBoss. The homepage mixes curated stories with the community’s activity stream. Facts: - WordPress: 7.1 (Bedrock) - PHP: 8.3 - Server: Apache 2.4 - Cache: Redis - Theme: BuddyBoss child theme, Timber and Twig Notes from the build: - Curated first, then automatic: Admins drag curated items into order. The feed then fills from Events, Flagship Events, News, Certifications, Focus Areas and Standards, 15 items a page. - One post, two places: Each WordPress post is synced to a BuddyBoss activity, so it appears on the homepage and in the community stream. Curated posts from private groups show on the homepage and stay private in their group. - Cached, de-duplicated, invalidated: Feed data is cached, duplicates are detected across posts and activities, and the cache clears when the curated list changes. Admin tools backfill activities and clean up duplicates. Delivery (client's pipeline): GitHub Actions deploy every environment branch to AWS. A separate Security Update workflow is dispatched from QA Pulse: it detects the PHP version, runs Composer updates, bumps git-tracked core, themes and plugins, and makes sure the Taoti Security Scanner loader is committed. 1. Lint: PHP_CodeSniffer and Sass checks on push 2. Deploy: taoti/actions-deploy-aws: build WordPress, back up, deploy, update the database, clear WordPress and CDN caches 3. Promote: A confirmed promotion from one AWS environment to the next 4. Security update: Dispatched from QA Pulse with a correlation id, pushed to a security-update branch ### Combioh URL: https://jodriscoll.com/things-built/combioh Live: https://www.combioh.com/ Kind: Creator Role: Design and Engineering Delivered through: Nomadic Digital LLC Period: May 2026 – Oct 2026 Stack: Next.js, React, TypeScript, Tailwind CSS, Vitest, ESLint Combioh teaches Nioh 3 combos. Pick a style, weapons and a difficulty, and a custom controller tool shows each combo's inputs mapped to your own controller configuration, as button sequences timed to the move, in five languages. Facts: - Commits: 50 - First commit: May 11, 2026 - Data: One JSON file of authored combos - Languages: English, Spanish, Japanese, Chinese, Polish Notes from the build: - Your controller, your inputs: A custom controller UI maps every combo to the player's own button configuration, so the glyphs on screen match the pad in their hands. - Timed sequences: Button sequences play back on the combo's own timing, so players learn the rhythm, not just the order. - State in the URL: Class, weapons, difficulty and controller layout persist in the URL, so a combo can be shared as a link. - Flags are not languages: Chinese support arrived on May 24. An October 14 commit replaced flag icons with language names. ### APTA URL: https://jodriscoll.com/things-built/apta Live: https://www.apta.org/ Kind: Taoti Creative Role: Solutions Engineer, through Taoti Creative Delivered through: Taoti Creative Stack: Bedrock, ACF Pro, ACF Better Search, Media Library Assistant, Enable Media Replace, Yoast SEO, tawk.to, Redis Object Cache, EWWW Image Optimizer, Solid Security, Taoti Security Scanner, GDPR Cookie Compliance The American Physical Therapy Association’s site runs on Bedrock WordPress, with ACF Pro, Redis and Taoti’s security scanner. Facts: - PHP: 8.3 - Server: Apache 2.4 (local) - Database: MySQL 8.0 ### OCAD University URL: https://jodriscoll.com/things-built/ocad Live: https://www.ocadu.ca/ Kind: Taoti Creative Delivered through: Taoti Creative Stack: Drupal Work on the public Drupal site for OCAD University, Canada's university of art and design. ### Sequoia Capital URL: https://jodriscoll.com/things-built/sequoia-capital Live: https://www.sequoiacap.com Kind: Mindgrub Technologies Role: WordPress Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, AOS + CSS Animations, Bootstrap 5.x, PHP + MySQL, WordPress VIP, Custom block theme, Bootstrap, Animate on Scroll, Lottie, CSS animations, PHP, MySQL A WordPress theme for Sequoia Capital, built around Gutenberg blocks and hosted on WordPress VIP, with CSS animation, Animate on Scroll and Lottie. Facts: - Host: WordPress VIP - Editor: Gutenberg blocks ### SageLife URL: https://jodriscoll.com/things-built/sagelife Live: https://www.sagelife.com Kind: Mindgrub Technologies Role: WordPress Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress Multisite, HTML5 + SCSS + Webpack, AOS + CSS Animations, Bootstrap 4.x, PHP + MySQL, Pantheon + AWS, Multisite, Bootstrap, SCSS, Webpack, Animate on Scroll, PHP, MySQL A WordPress multisite on Pantheon for a senior-living operator, with each community on its own domain through AWS name servers. Facts: - Platform: WordPress multisite - Host: Pantheon - DNS: AWS ### TAK.gov URL: https://jodriscoll.com/things-built/tak-gov Live: https://tak.gov Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: Ruby, AWS, GitLab, Bootstrap 5.x, JavaScript ES6+, Express, Bootstrap, JavaScript, SCSS TAK.gov serves the Team Awareness Kit community. The front end was Bootstrap 5, ES6 and SCSS, templated in a small standalone Express app and then imported into the Ruby application. Facts: - Application: Ruby - Host: AWS - Source control: GitLab ### i6tix URL: https://jodriscoll.com/things-built/i6tix Live: https://d2ywhp6fltyv6h.cloudfront.net/storefront.html Kind: Mindgrub Technologies Role: React Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: React 16 + Redux, ESLint + Jest, AWS + CloudFront, LocalStorage, SCSS + JavaScript ES6+, React, Redux, Canvas, ESLint, Jest, SCSS, JavaScript A React and Redux app for making event posters in the browser: users upload artwork to a canvas editor, review, and pay. Facts: - App: React 16 and Redux - Host: AWS CloudFront ### Rehab.com URL: https://jodriscoll.com/things-built/rehab-com Live: https://www.rehab.com Kind: Rehab LLC Role: Director of Web Operations and Senior Performance Engineer, Rehab LLC Delivered through: Rehab LLC Stack: Symphony + MySQL, VueJS, HTML5 + SCSS, AWS, Python, GitHub Actions, Symfony, MySQL, HubSpot, Vue, Angular, SCSS, Cloudflare Pages A Symfony application with API and HubSpot integrations, Vue and Angular in different sections, and a custom Vue patient portal. Facts: - Application: Symfony and MySQL - Host: AWS - CI: GitHub Actions Notes from the build: - From the resume: The codebase was reduced by over 60%, monthly AWS costs by over 50%, and a single point of failure in security was removed across the web applications. - A sale, handed over: Python aggregated the data for the sale of the company, the domain and its assets, and the technical applications were transitioned to the new owners. Delivery (pipeline built in-house): GitHub Actions and Cloudflare Pages were introduced to replace the old workflow and deployment. 1. Build: GitHub Actions 2. Deploy: Cloudflare Pages ### Portfolio ver. 3 URL: https://jodriscoll.com/things-built/portfolio-v3 Live: https://jodriscoll.com/ Source: https://github.com/jodriscoll/astro_jodriscoll Kind: Portfolio Role: Designer and Engineer Delivered through: Nomadic Digital LLC Period: Sep 2026 – Present Stack: Astro 7, TypeScript, CSS, Cloudflare Workers, Resend, Node 24, Astro, Shiki, View Transitions, @astrojs/cloudflare, Wrangler, JSON-LD, llms.txt, JSON Resume, Content API, RSS and sitemap, Node The third version of jodriscoll.com: an Astro 7 site on Cloudflare Workers, written for two readers at once, the person deciding whether to hire me and the AI agent screening the application first. Facts: - Framework: Astro 7.4 - Host: Cloudflare Workers - Pages rendered at build: 94 - Project pages: 76 Notes from the build: - Written for the screener too: Applications are often read by an AI agent before a person. Every project, role and post is also published as JSON-LD, llms.txt, JSON Resume and a JSON content API, from the same content files the pages use. - One source of truth: Projects are one JSON spec each. The Work page, project pages, resume, feeds and API all read from them, so a fact changes in one place. - Static first: Every page is rendered at build time. The Worker only runs for the contact form and the API, so pages load as plain files from Cloudflare's network. - From Pages to Workers: Ver. 2 built on Cloudflare Pages from Git. Ver. 3 moved to Workers with static assets, which the Astro adapter now targets, and kept the domain, DNS and account where they were. - No client framework: Interactions (filters, the timeline, the contents bar, the contact sheet) are small inline scripts on top of HTML and CSS. Delivery (pipeline built in-house): The site builds and deploys from my machine with Wrangler. Pushing to GitHub doesn't deploy; a release is a build and one deploy command. 1. Build: astro build renders 94 routes to static HTML: every page, project, post, the feeds and the machine-readable files. 2. Assets: The rendered pages, styles, fonts and screenshots upload to Workers static assets. Unchanged files are skipped. 3. Worker: The /api routes (contact form, events and the content API) run on the Worker with nodejs_compat. 4. Secrets: The Resend keys are Worker secrets, set with wrangler secret bulk. They never enter the repository. 5. Domain: jodriscoll.com is a custom domain on the Worker. DNS stays on the Cloudflare zone it has used for years. ### Portfolio ver. 2 URL: https://jodriscoll.com/things-built/angular-portfolio Source: https://github.com/jodriscoll/astro_jodriscoll/tree/d385d99 Kind: Portfolio Role: Design and Engineering Delivered through: Nomadic Digital LLC Period: Jul 2023 – Nov 2026 Stack: Angular 13, TypeScript, SCSS, Bootstrap 4, ngx-translate, AOS + CSS Animations, Karma, Cloudflare Pages, Angular, RxJS, Angular service worker, Bootstrap, ng-bootstrap, Animate on Scroll, ngx-owl-carousel-o, Font Awesome, Protractor, TSLint, Google Analytics The second version of jodriscoll.com: an Angular 13 single-page portfolio on Cloudflare Pages, with every word of copy in ngx-translate files, an Angular service worker for offline loads, Bootstrap 4, SCSS, Animate on Scroll motion and Karma unit tests. Facts: - Framework: Angular 13.2 - Host: Cloudflare Pages - Deploys: On every push to master - Replaced by: Portfolio ver. 3, November 2026 Notes from the build: - Copy out of the templates: Every word lived in an ngx-translate en.json file, so text changes never touched a component. - Offline loads: The Angular service worker cached the app shell, so repeat visits opened without the network. - Preview per branch: Cloudflare Pages gave each branch its own URL, which is how motion experiments were reviewed before they reached master. Delivery (pipeline built in-house): Cloudflare Pages built the site straight from GitHub. A push to master shipped production; any other branch got its own preview URL. 1. Push: Commits to the GitHub repository started a Cloudflare Pages build. 2. Build: ng build --configuration production, run by Cloudflare Pages. 3. Preview: Feature branches deployed to their own pages.dev URLs, such as the locomotive-scroll experiments. 4. Production: master deployed to jodriscoll.com, a custom domain on the Pages project. ### Patient Portal URL: https://jodriscoll.com/things-built/patient-portal Live: https://www.rehab.com/login Source: https://github.com/jodriscoll/vue_patient_portal Kind: Rehab LLC Role: Senior Performance Engineer, Rehab.com Delivered through: Rehab LLC Stack: VueJS + Symphony, HTML5 + SCSS, MySQL, AWS, REST API, Vue, Symfony, SCSS A Vue app inside the Symfony site that lets patients curate treatment providers, exchanging data with MySQL over a REST API. Facts: - App: Vue - Host app: Symfony ### Energy Rate Calculator URL: https://jodriscoll.com/things-built/energy-rate-calculator Live: https://www.nbutexas.com/bill-calculator/ Source: https://github.com/jodriscoll/wp_nbu_rate_calculator Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: PHP + WordPress + ACF, JavaScript ES5+, SCSS, ACF, PHP, JavaScript A rate calculator built into a utility’s WordPress site: customers enter their home usage and get a rate estimate. Facts: - Platform: WordPress and ACF ### Trinity Cyber URL: https://jodriscoll.com/things-built/trinity-cyber Live: https://www.trinitycyber.com/ Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: React, Redux, Redux-Saga, D3, react-vis, styled-components, Express, Docker, GitLab CI, API Blueprint, Apiary Trinity Cyber is a cybersecurity startup. Mindgrub built its customer dashboard in React with Redux-Saga and D3 visualizations, shipped in containers, against an API documented as an API Blueprint and published to Apiary. Facts: - Kind: Startup customer dashboard + API - Note: One of my first React applications ### Royal Farms Rewards Portal URL: https://jodriscoll.com/things-built/rofo-rewards Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: React, Redux, Jest, Enzyme, webpack, Docker, nginx, Skygear, GitLab CI ROFO Rewards is the loyalty program for Royal Farms, a large Maryland convenience chain. The work covered the web services behind the rewards app (authentication, scheduled tasks, persistence, cloud functions, Dockerized environments) and a React and Redux admin portal with Jest and Enzyme tests. Facts: - Client: Royal Farms, Maryland - Note: One of my first React applications ### Black & Decker · Nostos URL: https://jodriscoll.com/things-built/black-and-decker-nostos Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: React, react-intl, Reactstrap, Sass Nostos was a React application for Black & Decker, internationalized with react-intl and built on Reactstrap. ### PTC Therapeutics URL: https://jodriscoll.com/things-built/ptc-bio Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, WP REST API, React, Lando PTC Therapeutics ran as headless WordPress: content and languages managed in WordPress, served over the REST API to a separate React front end. ### File & Serve Xpress URL: https://jodriscoll.com/things-built/file-and-serve-xpress Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: Angular, Nx, Angular Material, DevExtreme, RxJS, TypeScript, Jest, Storybook, Azure Pipelines File & Serve Xpress is a legal e-filing and service platform. Its UI is an Angular application in an Nx monorepo, built on Angular Material and DevExtreme, tested with Jest, documented in Storybook, and shipped through Azure Pipelines. ### Project Health Dashboard URL: https://jodriscoll.com/things-built/mindgrub-project-health Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: React, CoreUI, Reactstrap, Chart.js, webpack, Sass An internal tool at Mindgrub: a React dashboard on CoreUI and Reactstrap with Chart.js charts for project health. ### UMBC Home Visiting Training URL: https://jodriscoll.com/things-built/umbc-miechv Live: https://homevisitingtraining.umbc.edu/ Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: Drupal 8, Pantheon, Lando, GitLab CI, GraphQL, OpenAPI, Simple OAuth, Paragraphs, Search API, BackstopJS, Behat UMBC’s Maryland MIECHV home-visiting training site runs on Drupal 8 on Pantheon. It exposes content through GraphQL and OpenAPI with OAuth, builds pages from Paragraphs, and is covered by BackstopJS visual regression and Behat. ### UNISYS URL: https://jodriscoll.com/things-built/unisys Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: Drupal 8, Pantheon, Docksal, Migrate Plus, Migrate Source CSV, Paragraphs UNISYS moved onto Drupal 8 on Pantheon. Content came across through a scripted migration (Migrate Plus with CSV and directory sources), with Paragraphs for layout and Docksal for local environments. ### NCIT URL: https://jodriscoll.com/things-built/ncit Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: Drupal 8, Pantheon, Docksal, Feeds, Search API Pantheon, Bootstrap A Composer-managed Drupal 8 site on Pantheon, with Feeds and Feeds Tamper importing content and Pantheon’s Solr behind search. ### NAMW URL: https://jodriscoll.com/things-built/namw Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: Drupal 8, Pantheon, Docksal, Paragraphs, Config Split, Behat A Composer-managed Drupal 8 site on Pantheon: Paragraphs for layout, Config Split for environments, and Behat for tests. ### Relief Wizard URL: https://jodriscoll.com/things-built/relief-wizard Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: Drupal 8, Pantheon, Lando, Webform, Bootstrap A Drupal 8.8 site on Pantheon built around Webform. ### The Children’s Inn at NIH URL: https://jodriscoll.com/things-built/childrens-inn-nih Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Composer, GitLab CI, Docker, ACF Pro, FacetWP, Gravity Forms, WP All Import, Sentry, Behat, PHPUnit Composer-managed WordPress with FacetWP, Gravity Forms and Behat and PHPUnit tests, deployed through GitLab CI. ### ComEd · Powering Lives URL: https://jodriscoll.com/things-built/comed-powering-lives Live: https://poweringlives.comed.com/ Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, WordPress Multisite, Lando, GitLab CI, Azure DevOps A WordPress multisite for ComEd, deployed from GitLab CI to Azure DevOps. ### Baltimore Gas & Electric (BGE) URL: https://jodriscoll.com/things-built/bge Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Bootstrap 4, jQuery, Lando, Swift Baltimore Gas & Electric: a custom WordPress theme for the site, and part of the interface work on BGE's iOS app, written in Swift. ### AARP Pharmacy URL: https://jodriscoll.com/things-built/aarp-pharmacy Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Bootstrap 4, PHP, Lando A custom WordPress theme for AARP Pharmacy. ### Entergy · Shore Power URL: https://jodriscoll.com/things-built/entergy-shore-power Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Bootstrap 4, Lando, GitLab CI A lead-generation site for Entergy’s Shore Power service. ### Entergy · TLG URL: https://jodriscoll.com/things-built/entergy-tlg Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Bootstrap 4, Lando, Terminus, GitLab CI A custom WordPress theme for Entergy’s TLG, deployed by GitLab CI to Pantheon. ### Dragos URL: https://jodriscoll.com/things-built/dragos Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Bootstrap 4, Lando, GitLab CI A redesign for the industrial-cybersecurity company, deployed by GitLab CI to Pantheon. ### Volexity URL: https://jodriscoll.com/things-built/volexity Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Gulp, jQuery, GitLab CI A custom WordPress theme for the security firm, built with Gulp and deployed by GitLab CI. ### Cloudtamer.io URL: https://jodriscoll.com/things-built/cloudtamer Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, FacetWP, Bootstrap 4, jQuery The October 2019 MVP site for the cloud-governance company, with FacetWP search. Facts: - Year: 2019 ### Check To Protect URL: https://jodriscoll.com/things-built/check-to-protect Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Bootstrap 4, PHP A national campaign site urging drivers to check and fix open vehicle recalls. Facts: - Year: 2018 ### Communities In Schools URL: https://jodriscoll.com/things-built/communities-in-schools Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Bootstrap 4, PHP A custom 2018 WordPress theme for the national nonprofit. Facts: - Year: 2018 ### Brown Goldstein Levy URL: https://jodriscoll.com/things-built/brown-goldstein-levy Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Bootstrap, jQuery, Lando, Terminus, GitLab CI A custom WordPress theme for the law firm, deployed by GitLab CI to Pantheon. ### EQRx URL: https://jodriscoll.com/things-built/eqrx Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Bootstrap 4, WP Cron A custom WordPress theme for EQRx with IMRE, plus a plugin that syncs careers daily from a jobs API. ### Gilchrist Hospice · Infoweb URL: https://jodriscoll.com/things-built/gilchrist-infoweb Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Bootstrap 4 A custom WordPress intranet for Gilchrist Hospice. ### Archdiocese of Baltimore · #iamcatholic URL: https://jodriscoll.com/things-built/iamcatholic Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon A campaign microsite for the Archdiocese of Baltimore, with Sizeable. ### InSource Solutions URL: https://jodriscoll.com/things-built/insource-solutions Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Bootstrap 4 A custom WordPress theme for InSource Solutions. ### New Braunfels Utilities URL: https://jodriscoll.com/things-built/new-braunfels-utilities Live: https://www.nbutexas.com/ Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, ACF, Bootstrap 4 The utility’s custom WordPress site, including its rate calculator. ### NDPI URL: https://jodriscoll.com/things-built/ndpi Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Bootstrap 4 A custom WordPress theme for NDPI. ### ThinkBabies URL: https://jodriscoll.com/things-built/think-babies Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Bootstrap 4 A custom WordPress theme for the ThinkBabies campaign. ### Venture Trailers URL: https://jodriscoll.com/things-built/venture-trailers Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Bootstrap 4, PHP A custom 2019 WordPress theme for the trailer manufacturer. Facts: - Year: 2019 ### Vyleesi URL: https://jodriscoll.com/things-built/vyleesi Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Bootstrap 4 A custom WordPress theme for the pharmaceutical brand. ### YouCanWA.org URL: https://jodriscoll.com/things-built/you-can-washington Live: https://youcanwa.org/ Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, GSAP, ScrollMagic, Bootstrap A campaign site for Washington State, animated with GSAP and ScrollMagic. ### Focus On URL: https://jodriscoll.com/things-built/focus-on Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, WP Engine A custom WordPress theme on WP Engine. ### Alive & Thrive URL: https://jodriscoll.com/things-built/alive-and-thrive Live: https://www.aliveandthrive.org/ Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon The nutrition initiative’s WordPress site. The theme is GMMB’s; I maintained the site. ### Chemonics URL: https://jodriscoll.com/things-built/chemonics Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon The international-development firm’s WordPress site. The theme is WDG’s; I maintained the site. ### ASAP URL: https://jodriscoll.com/things-built/asap Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, WordPress Multisite A WordPress multisite for ASAP. The theme is Social Driver’s; I maintained the site. ### Viaplace URL: https://jodriscoll.com/things-built/viaplace Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon, Genesis A Genesis Framework WordPress site. The theme is Appfinite’s; I maintained the site. ### MindBuzz URL: https://jodriscoll.com/things-built/mindbuzz Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: WordPress, Pantheon A WordPress site on a Themify theme. The theme is Themify’s; I maintained the site. ### Under Armour URL: https://jodriscoll.com/things-built/under-armour Kind: Mindgrub Technologies Role: Senior Software Engineer, through Mindgrub Technologies Delivered through: Mindgrub Technologies Stack: Jenkins, Laravel, PHP, Gulp, Bower, PHPUnit, phpspec Under Armour was the most complex system I have worked in. The shopping experience sat inside a web of Jenkins build systems and CI automation from before CI was a common word, with layers of approvals and dynamic environments spun up to unit test new features against the latest builds. Alongside it I built the NEXT event application in Laravel for the USA and Canada. Facts: - Focus: Shopping experience - Pipeline: Jenkins builds, approvals, per-build test environments - Also: NEXT event app, USA and Canada ### CVS Health · SilverScript Call Center URL: https://jodriscoll.com/things-built/cvs-silverscript Kind: Jellyfish Role: Senior Front-End Engineer, through Jellyfish Delivered through: Jellyfish Stack: Laravel, Vue, Bootstrap, Laravel Mix, PHPUnit, AWS CodeDeploy The SilverScript Call Center Application is a Laravel and Vue app for CVS Health’s Medicare Part D plan, with CSV import and seeding, PHPUnit tests and AWS CodeDeploy. ### Samsung Display URL: https://jodriscoll.com/things-built/samsung-display-pid Live: https://www.samsungdisplay.com Kind: Jellyfish Role: Senior Front-End Engineer, through Jellyfish Delivered through: Jellyfish Stack: Drupal 7, PHP, AWS CodeDeploy, RPM Samsung Display's product-information site, formerly pid.samsungdisplay.com and now part of samsungdisplay.com, on Drupal 7, packaged as RPMs and deployed to AWS with CodeDeploy. ### Transport Research Laboratory URL: https://jodriscoll.com/things-built/trl Kind: Jellyfish Role: Senior Front-End Engineer, through Jellyfish Delivered through: Jellyfish Stack: Drupal 7, PHP, RPM TRL, the UK’s Transport Research Laboratory, on Drupal 7. ### Jellyfish UK URL: https://jodriscoll.com/things-built/jellyfish-uk Kind: Jellyfish Role: Senior Front-End Engineer, through Jellyfish Delivered through: Jellyfish Stack: Drupal 7, PHP, MySQL Jellyfish’s UK website on Drupal 7, including a patched reCAPTCHA module that every contact form depended on. ### Channel 5 URL: https://jodriscoll.com/things-built/channel-5 Live: https://www.channel5.com/ Kind: Jellyfish Role: Senior Front-End Engineer, through Jellyfish Delivered through: Jellyfish Stack: WordPress, AWS, PHP Channel 5’s WordPress site, maintained from Jellyfish’s repository on AWS. ### Kahootz URL: https://jodriscoll.com/things-built/kahootz Kind: Jellyfish Role: Senior Front-End Engineer, through Jellyfish Delivered through: Jellyfish Stack: WordPress, AWS, PHP Kahootz’s WordPress site, on AWS. ### Avention URL: https://jodriscoll.com/things-built/avention Kind: Jellyfish Role: Senior Front-End Engineer, through Jellyfish Delivered through: Jellyfish Stack: WordPress, AWS, PHP A Jellyfish-authored WordPress theme for Avention, on AWS. ### Casepoint URL: https://jodriscoll.com/things-built/casepoint Kind: Jellyfish Role: Senior Front-End Engineer, through Jellyfish Delivered through: Jellyfish Stack: WordPress, AWS, PHP Casepoint’s WordPress site, on AWS. ### HomeProtect.co.uk URL: https://jodriscoll.com/things-built/homeprotect Live: https://www.homeprotect.co.uk/ Kind: Jellyfish Role: Senior Front-End Engineer, through Jellyfish Delivered through: Jellyfish Stack: Joomla, YOOtheme ZOO, Gulp, Sass, PHP HomeProtect.co.uk’s insurance site on Joomla 3.7 with YOOtheme’s ZOO product system, themed with Gulp and Sass. ### PAUL UK URL: https://jodriscoll.com/things-built/paul-uk Kind: Jellyfish Role: Senior Front-End Engineer, through Jellyfish Delivered through: Jellyfish Stack: Magento, PHP, MySQL PAUL UK’s Magento storefront. ### Drive Empower URL: https://jodriscoll.com/things-built/drive-empower Kind: Client Role: Consultant, through Nomadic Digital Delivered through: Nomadic Digital LLC Stack: WordPress, Gutenberg, Bootstrap, Pantheon A custom WordPress 5.x theme for Drive Empower, built for the block editor, written and authored by me under Nomadic Digital. ### JasonMPerry.com URL: https://jodriscoll.com/things-built/jason-perry Live: https://jasonmperry.com/ Kind: Client Role: Consultant, through Nomadic Digital Delivered through: Nomadic Digital LLC Stack: WordPress, Pantheon, Block Editor, @wordpress/scripts, webpack, Lando JasonMPerry.com was the 1.0 site Jason Perry used to get his business off the ground. I built it on WordPress as a contractor under Nomadic Digital. ### LightingAndSupplies.com URL: https://jodriscoll.com/things-built/lighting-and-supplies Live: https://lightingandsupplies.com/ Kind: Client Role: Consultant, through Nomadic Digital Delivered through: Nomadic Digital LLC Period: Jan 2016 – Present Stack: WordPress LightingAndSupplies.com is a US wholesale and government supplier of commercial and residential lighting and electrical supplies. One of Nomadic Digital's first clients: I troubleshoot the functionality and performance of its online catalog and the WordPress applications around it. Facts: - Client since: 2016 Notes from the build: - Since 2016: Taken on as a side client while I worked full time at Jellyfish, and still in my care. ## Weaknesses URL: https://jodriscoll.com/weaknesses Everything else on this site is the work. This page is the fine print: the patterns in how I build a career and work with people that cost something. Better you read them here, in full sentences, than discover them in week three. ### The stack never stops moving For two decades the ground under the work has kept shifting: WordPress, then Drupal, then a JavaScript framework, then the next one, then whatever the client already had running in production. Keeping up has been harder than any single launch, and it is not going away. A stack I once knew well enough to lead becomes one I relearn at the edges, and the relearning is slower than the industry's appetite for the next thing. The page became components, components became a design system, and the same job picked up a new name. I still take the layer in front of me. I just feel the lag while I do. ### I don't let go of the keyboard When a client needs their system improved, or something built because the current one cannot do the job, I take the layer that is actually there, including the ones I am still learning. The work gets finished. It also means I stay in the implementation longer than someone whose only job is to direct it. At the hour a director would have handed it off, I am still in the theme, the migration, the performance budget or the failing test. That can look like a ceiling. It is also how I trust what ships: I would rather stay with a change until I can account for it than speak over a layer I have not touched. ### Slow to be dazzled by AI Right now the industry mostly wants to talk about AI. I use the tools every day and I am glad to have the conversation. But I want specialists to stay the source of truth for what their users receive, and to be able to explain why it shipped. A draft from a model is a draft. The review, and the call about what a person actually gets, stay with a human. That can read as caution or as being hard to impress, and honestly, it is caution. Holding that line makes some rooms impatient. I have made peace with the impatience more easily than I could with handing over the accountability. ### Mentoring loses the hours Mentoring is the part I care about most. I want developers, early on or well along, to see where they fit when the field will not hold still. But these consulting years are one practice serving several clients at once, and that spread is the job. It leaves less time for mentoring than I want, and I notice the gap in a busy month long before the resume does. Developers learn the shape of this work by sitting near someone who will explain why a thing shipped. When the week is split across studios, that nearness wears thin. Wanting to fix it has not fixed it. ### Wide, and thin in places JavaScript and WordPress are where the years went. Python and Ruby are real, but small beside them. A practice spread across many clients can start a great many things and still run thin where a long stay matters: the apprenticeship inside one team, the hour that should have gone to teaching, the platform I am still catching up on. So here is the shape, said plainly. The resume is the record of the work. This page is what it cost to do it this way. If any of these sound like a dealbreaker, better to know now. If they sound familiar, we will probably work well together. ## Writing ### Two names for one temp folder URL: https://jodriscoll.com/blog/two-names-for-one-temp-folder Published: 2026-11-12 Tags: GameKal, Testing, macOS 537 passes on a Mac, 538 in Linux. One temp folder with two names, and a one-line fix. Run GameKal's test suite on a Mac this evening and you got 537 passes and 1 failure. Run the same suite on the same code in the Linux sandbox my coding agent works in, and all 538 passed. The difference was one folder with two names, and the fix is one line in a test. The fix is in `server/catalogStore.disk.test.mjs`, and that file's 9 tests now pass on the Mac. The server code didn't change. This post covers how the failure surfaced, why it only happened on macOS, and the one line that fixed it. It also covers a quieter problem it exposed: four checks in the same file that couldn't fail on a Mac at all. ## A test that watches what the server opens `server/catalogStore.cjs` answers catalog requests from JSON files under `src/data`. The live file, `releases2026.json`, is 78 MB, so a lot of work has gone into making sure a request opens only the small file it needs ([Locked out by my own fortress](/blog/locked-out-by-our-own-fortress)). `catalogStore.disk.test.mjs` checks that on real files. It builds a tiny catalog in a temp folder, moves into that folder with `process.chdir`, and wraps `fs.readFileSync` so every read is recorded. Each recorded path is made relative to the test's own `data` folder, so a test can say "this lookup opened `years/2025.json`, not `releases2026.json`." The store finds its files through `findData`, which starts from `process.cwd()`. Keep that in mind, because it's the whole story. ## November 10: the evening the agent couldn't run tests All day, the tests ran inside the Linux VM the Claude desktop app runs for its coding agent. All 538 passed, run after run. That evening, one run came back with 28 failures. After that, every command the agent sent failed before it started: ```text listen ENOSPC: no space left on device ``` The Mac itself had 558 GB free, so the full disk was the VM's, not mine. Restarting the desktop app didn't clear it. - **Measured:** the error, the 28 failures in the run before it, and the Mac's free space. - **Inferred:** the 28 failures came from the VM's disk filling up. All 538 had passed in that same VM all day. - **Not known:** what filled the disk. So I ran the suite in my own terminal on the Mac. 537 passed and 1 failed: ```text ✖ an archive id opens its year file, not the live file assert.ok(files.includes('years/2025.json')) ``` Run on its own, the file failed the same way every time: 8 of 9. ## One folder, two names On macOS, `os.tmpdir()` returns a path under `/var/folders/`. But `/var` is a symbolic link to `/private/var`, so both names reach the same folder. The test made its temp folder with the short name and built `data` from it. Then it called `process.chdir` into the folder. When the store asked `process.cwd()` where it was, macOS answered with the real path, under `/private/var/folders/`. Every read the wrapper recorded used that real path, and `path.relative` from a `/var` folder turned `years/2025.json` into a long `../../private/var/…` path. The server opened the right file. The test just didn't recognise it. In the sandbox, the temp folder isn't behind a link, so both names were the same and the test passed. ```steps { "title": "Where years/2025.json was recorded", "subtitle": "The same test, in the Linux sandbox and on the Mac. Steps in order, not to scale.", "rows": [ { "label": "Linux", "sublabel": "sandbox", "steps": [ { "title": "Temp folder", "detail": "one name, no link" }, { "title": "process.cwd()", "detail": "the same name" }, { "title": "Recorded read", "detail": "years/2025.json" } ], "caption": "The recorded name matches, and the test passes." }, { "label": "macOS", "sublabel": "before the fix", "steps": [ { "title": "Temp folder", "detail": "/var/folders/…" }, { "title": "process.cwd()", "detail": "/private/var/folders/…" }, { "title": "Recorded read", "detail": "../../private/var/…/years/2025.json" } ], "caption": "The server reads the right file, but under a name the test doesn't expect." }, { "label": "macOS", "sublabel": "after the fix", "group": { "title": "fs.realpathSync", "detail": "the test starts from the real path" }, "steps": [ { "title": "Temp folder", "detail": "/private/var/folders/…" }, { "title": "Recorded read", "detail": "years/2025.json" } ] } ], "note": "From server/catalogStore.disk.test.mjs and findData in server/catalogStore.cjs, November 10, 2026." } ``` Why did only one check fail? Because it was the only one asking for a file to be there. ## Four checks that couldn't fail The file makes five claims about what a lookup opened. Four of them are negative: `releases2026.json` wasn't opened (twice), no year file was opened, and a stale month file wasn't read. On a Mac, the recorded list never held a short name like `releases2026.json`, so those four would have passed whatever the server did. That's the more useful finding. A check that something is missing from a list proves nothing if nothing in the list can ever match. The one positive check is what caught it. ## The change The test now resolves the temp folder before using it: ```js const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gamekal-store-'))); ``` On the Mac, `data`, the recorded reads, and `process.cwd()` now all use the `/private/var` name. Nothing changes on Linux. `findData` was right all along, so the server is untouched. ## Results - **Measured:** on the Mac, `server/catalogStore.disk.test.mjs` went from 8 of 9 to 9 of 9. - **Inferred:** the evening's full run on the Mac failed only this test, so the suite should now pass 538 of 538 there. I haven't rerun the full suite on the Mac since the fix. - **Restored:** the four negative checks compare like with like again, so they can fail when they should. ## What I learned Looking back, three lessons stand out. **Run the suite on more than one machine.** It had passed all day in one place. A different machine found the bug within one run. **A negative check needs a positive partner.** "Not opened" only means something next to a check that the list holds what it should. **Resolve paths before comparing them.** Temp folders can have two names, and `fs.realpathSync` costs one line. ## What's next? Rerun the full suite on the Mac to confirm 538 of 538, and look through the other tests that build paths under `os.tmpdir()` for the same pattern. The server opened the right file the whole time. It took a Mac to show that the test was looking for it under the wrong name. ### The AI that ate my file handles URL: https://jodriscoll.com/blog/the-ai-that-ate-my-file-handles Published: 2026-11-12 Tags: GameKal, Debugging, macOS A Mac said it was out of open files. The cause was the coding agent, and three commands proved it. Run one command on a Mac and get told the whole machine is out of open files. That's what happened the morning I tried to put GameKal's new static catalog on an iPhone. The cause wasn't Node, OpenSSL, or the laptop. It was the coding agent I'd been working with all morning, and it took three commands to prove it. This post covers the error, the three commands that found the cause, what I know and what I'm only inferring, and what I watch now. ## The morning it happened It started as a good morning. GameKal's website had just gone from a 20-second Home page to 1.8 seconds by moving the release catalog onto Vercel's CDN ([Locked out by my own fortress](/blog/locked-out-by-our-own-fortress)). The next step was to make the iPhone app read the same files, so I plugged in a phone and ran the usual device build, `npm run ios -- --device`. It failed before it did anything: ```text node: OpenSSL configuration error: 80A1D1ED01000000:error:80000017:system library:BIO_new_file:Too many open files in system:crypto/bio/bss_file.c:67:calling fopen(/opt/homebrew/etc/openssl@3/openssl.cnf, rb) ``` Node couldn't open a 10 KB config file. Nothing was wrong with that file. The whole Mac had run out of room to open any file at all. The timing made it worse. Minutes earlier, the local iOS project had turned out to be a week behind `package.json`, and `pod install` had refused to run. I regenerated the project, and the very next command hit this wall. Two unrelated failures back to back is how you lose an hour, and it's why this one deserved a real diagnosis instead of another guess. ## "In system" is the important part macOS has two different "too many open files" errors, and they mean very different things: | Error | Meaning | Usual fix | | --- | --- | --- | | `EMFILE`, "Too many open files" | **This process** hit its own limit (`ulimit -n`) | Raise the limit for that shell, or fix a leak in that program | | `ENFILE`, "Too many open files **in system**" | **The whole machine** hit its limit (`kern.maxfiles`) | Find whoever is holding everything | Mine said *in system*. Raising `ulimit` would have done nothing. Something on the Mac was holding a huge share of every file handle the kernel allows, and no amount of RAM or CPU raises that ceiling. ## Three commands to find it ```steps { "title": "Three questions, three commands", "subtitle": "Steps in order. Each answer pointed to the next question.", "rows": [ { "label": "Diagnosis", "sublabel": "read-only", "steps": [ { "title": "How full is it?", "detail": "sysctl: 273,531 of 491,520" }, { "title": "Who holds them?", "detail": "lsof: two pids, 261,118" }, { "title": "What are they?", "detail": "ps: two VMs" } ] } ] } ``` **How full is the table?** ```bash sysctl kern.num_files kern.maxfiles ``` ```text kern.num_files: 273531 kern.maxfiles: 491520 ``` ```bars { "title": "The Mac's file table", "subtitle": "Files open across the whole system, minutes after the failure.", "max": 500000, "ticks": [ 0, 100000, 200000, 300000, 400000, 500000 ], "tickFormat": "k", "groups": [ { "label": "Open", "sublabel": "kern.num_files", "bars": [ { "value": 273531, "tone": "accent" } ] }, { "label": "Ceiling", "sublabel": "kern.maxfiles", "bars": [ { "value": 491520, "tone": "muted" } ] } ], "note": "Read with sysctl on November 10, 2026, after some handles had already been released." } ``` That's 273,531 files open out of a 491,520 ceiling, measured a few minutes after the failure, once some handles had already been released. More than half the system's table was still in use. **Who is holding them?** ```bash lsof -n 2>/dev/null | awk '{print $1, $2}' | sort | uniq -c | sort -rn | head -8 ``` ```text 191008 com.apple 9482 70110 com.apple 21217 2094 phpstorm 55059 836 Google 586 711 corespotl 714 555 LogiPlugi 73940 363 Cursor 1799 333 Messages 89938 ``` ```bars { "title": "Who held the open files", "subtitle": "Open files per process, from lsof.", "max": 200000, "ticks": [ 0, 50000, 100000, 150000, 200000 ], "tickFormat": "k", "groups": [ { "label": "Virtual machine", "sublabel": "pid 9482", "bars": [ { "value": 191008, "tone": "accent" } ] }, { "label": "Virtual machine", "sublabel": "pid 21217", "bars": [ { "value": 70110, "tone": "accent" } ] }, { "label": "PhpStorm", "bars": [ { "value": 2094 } ] }, { "label": "Chrome", "bars": [ { "value": 836 } ] }, { "label": "Spotlight", "bars": [ { "value": 711 } ] }, { "label": "Logi plugin", "bars": [ { "value": 555 } ] }, { "label": "Cursor", "bars": [ { "value": 363 } ] }, { "label": "Messages", "bars": [ { "value": 333 } ] } ], "note": "lsof -n, grouped by process, November 10, 2026. The two virtual machines held 261,118 between them." } ``` Everything you'd expect (PhpStorm, Chrome, Spotlight, Messages) held a few hundred to a couple of thousand. Two processes held 261,118 between them, 95% of everything open. `lsof` cuts command names to nine characters, so all I knew so far was "something from Apple." **What are they?** ```bash ps -o pid,comm -p 9482,21217 ``` ```text PID COMM 9482 /System/Library/Frameworks/Virtualization.framework/.../com.apple.Virtualization.VirtualMachine 21217 /System/Library/Frameworks/Virtualization.framework/.../com.apple.Virtualization.VirtualMachine ``` Two virtual machines. ## The culprit was the assistant These weren't Docker or a forgotten emulator. They were the Linux VMs the Claude desktop app runs on the Mac so a coding agent can work in a shell against a connected folder. The agent I'd been working with that morning was running inside one of them, with the GameKal repository mounted. Here's what it had been doing: - **`npm test`**, more than 500 tests across the repo, after nearly every change. - **`tsc --noEmit`** over the whole project, which reads every type file in `node_modules`. - **`grep`, `find`, and `ls`** across `src/`, `docs/`, and `src/data/`, where the catalog alone is hundreds of megabytes spread over hundreds of files. - **Generating about 19,000 static catalog files** to check the new CDN build. Every one of those reads goes through the VM's shared-folder bridge to the Mac's disk. From what I measured, that bridge kept a handle open on the Mac side for files the guest had touched, and held them well after the command finished. Two VMs suggests an earlier agent session was still alive next to the current one, holding its own pile. It's worth being precise about what I know and what I don't: - **Measured:** two `com.apple.Virtualization.VirtualMachine` processes held 191,008 and 70,110 open files, with the system at 273,531 of 491,520 minutes after the failure. Restarting the Claude desktop app released them. - **Inferred:** the handles came from the agent's own repo-wide reads through the shared folder. I didn't instrument the VM itself. The timing and the agent's command history make it by far the likeliest cause. - **Not known:** whether the bridge would have released them on its own, or how long that would take. ## The fix Quit and reopen the Claude desktop app. That ends its VMs, and the kernel releases everything they held. The next `npm run ios -- --device` built, installed, and launched on the phone, and the Metro log showed the app booting and reading the new static catalog files from the Mac: ```text iOS Bundled 793ms App.tsx (1662 modules) LOG boot enter +1243ms LOG catalog ok 246ms http://192.168.50.188:8787/data/34407b86f368/day/2026-11-10.json ``` From the first error to the right answer took three commands, because I asked the machine for measurements before guessing at a cause. ## Why this is easy to miss Nothing about it looked like an AI problem. **The error names Node and OpenSSL.** Neither was involved. **Activity Monitor shows CPU and memory, not file handles.** The VMs looked idle. **The failure lands on whatever runs next.** Here it was a build, but it could as easily have been Xcode, a browser tab, or a backup. The program that breaks is rarely the program that caused it. **Agents are thorough on purpose.** Running the full test suite after every change is good practice for correctness. It's also the most file-hungry thing you can do in a big repo. ## What I watch now **A one-line health check.** Run it after a long agent session, or whenever a tool fails with a strange file error: ```bash sysctl -n kern.num_files kern.maxfiles | paste -sd' ' - | awk '{printf "open files: %d of %d (%.0f%%)\n", $1, $2, 100*$1/$2}' ``` Under 20% is normal for a busy developer Mac. Over 50% means it's time to find the holder before it finds you. **The top holders, with their full names**, instead of `lsof`'s nine-character cut: ```bash lsof -n 2>/dev/null | awk 'NR>1 {print $2}' | sort | uniq -c | sort -rn | head -5 \ | while read count pid; do printf "%7d %s\n" "$count" "$(ps -o comm= -p "$pid" | xargs basename)"; done ``` **Changes on the agent side:** - Full test and type-check runs happen once per finished change, not after every edit. While working on one file, its own tests are enough. - Finished agent sessions get closed, and the desktop app gets restarted after a long day of agent work. - Repo-wide `find` and `grep` are scoped to the folders that matter, never `node_modules` or the 600 MB catalog. **Report it.** A bridge that keeps hundreds of thousands of handles open after the reads are done is a product bug, not a usage mistake. If you hit it, send the numbers above with the thumbs-down feedback in the Claude app. ## What I learned **The program that fails is rarely the program at fault.** The error pointed at Node and OpenSSL. The cause was a tool that wasn't even on the screen. **Measure before you guess.** Three read-only commands turned "something is wrong with the Mac" into two process ids and a name. **Say what you know and what you're inferring.** I measured the handles and the processes. I inferred the cause. Keeping those apart is what makes the report useful to the people who can fix the bridge. I'd spent the previous month learning that GameKal's own design choices were the root of problems I kept patching one symptom at a time. This was the same lesson in miniature. If your Mac starts acting strangely during a day of AI-assisted coding, check the file table before you blame the laptop. ### Locked out by my own fortress URL: https://jodriscoll.com/blog/locked-out-by-our-own-fortress Published: 2026-11-11 Tags: GameKal, Performance, Infrastructure GameKal's Home page went from about 20 seconds to 1.8 by serving a public catalog as files instead of guarding every read. For most of October, GameKal's Home page took about 20 seconds to show the day's releases. Today it shows them in 1.8. Nothing about the data changed. It's the same catalog of 147,000 games, public, read-only, and the same for everyone who asks for it. What changed is how I serve it. I had built that catalog like a vault. Every read went through a bot check, then a serverless function that wasn't allowed to cache anything, carrying the entire catalog inside it. Each of those choices was made to protect the data, and together they made a public calendar behave like a bank login. This post covers how I got there, the evening audit and the cold morning that finally showed me the cause, and how I moved the catalog onto Vercel's CDN. It also covers what happened when the iPhone app switched to the same files and somehow didn't feel any faster. ## What I was guarding GameKal is a game release calendar: an Expo app on iOS, Android, and the web, plus a desktop site at gamekal.com. Under it sits a catalog of about 147,000 titles: - a live file for 2026 and 2027, `releases2026.json`, at 78 MB - 32 year files back to 1998, about 352 MB together - `gridArt.json`, 18 MB of cover art Every visitor sees the same data. Nobody's calendar day is different from anyone else's. Keep that in mind, because it's the detail I managed to ignore for a month. ## Three decisions that looked sensible **Bot protection on every read.** Every catalog request went through Vercel BotID before a byte was served (`server/human.cjs`). The goal was to make scraping it hard. **Never cache.** The very first line of the handler told every cache, including the browser and Vercel's own CDN, not to keep the response: ```js res.setHeader('Cache-Control', 'private, no-store'); ``` Combined with the bot check, that meant every request from every visitor ran a function. **The whole catalog inside the function.** `vercel.json` bundled the live file, the cover art, and every year file into `api/releases.js`. That's a 640 MB function, large enough that it needs `VERCEL_SUPPORT_LARGE_FUNCTIONS=1` just to deploy. A cold instance began each life by parsing tens of megabytes of JSON. ```steps { "title": "Work before the day's games appear", "subtitle": "The website's path for one calendar day. Steps in order, not to scale.", "rows": [ { "label": "Before", "sublabel": "every request", "steps": [ { "title": "Bot check", "detail": "BotID" }, { "title": "/api/releases", "detail": "private, no-store" }, { "title": "Cold start", "detail": "parse tens of MB" }, { "title": "Day's cards", "detail": "JSON answer" } ], "caption": "Every request from every visitor ran the function, and nothing along the way was allowed to keep the answer." }, { "label": "Now", "sublabel": "a file", "group": { "title": "Vercel CDN", "detail": "cached under a stamp" }, "steps": [ { "title": "current.json", "detail": "the stamp" }, { "title": "Day file", "detail": "/data//day/…" } ], "caption": "No function runs. On a second visit the day file comes from the browser's disk cache.", "aside": "Picks, Similar Games, and search still run a function, /api/compute, and the CDN keeps each answer for a day." } ], "note": "Simplified. Phone builds through 0.1.24 still take the old path." } ``` None of these was careless. Each one solved a real problem on the day it was written. The trouble only shows up when you stack them. ## A month of patching symptoms For about a month, every complaint got a local fix: a loader here, a smaller request there, a retry somewhere else. Home kept taking about 20 seconds, and a game page 8 seconds or more. The `no-store` line sat at the top of a file I edited over and over. Nobody asked why public data was going through a function at all. ## November 9: the audit One evening I read every request each page made, in order, and found real waste: - **A game page opened from its address also loaded Home underneath it**, with Home's whole week, its cards, its picks, and its searches. Home now waits until it's actually shown. - **Shelf-wide work ran on every view.** Every shelf card was refreshed and every wishlist title searched, on pages that showed neither. Both now run only where they're seen, and wishlist titles are searched at most once a week. - **Two views asking for the same game sent two requests.** Card answers are now shared by game id for ten minutes. - **A 429 was retried straight back into the rate limit,** which kept the limit closed. The client now honors `Retry-After`. Then I found the one that hurt most. Two tiny data fixes, a Steam page for one game and a working cover for another, grew `releases2026.json` from 78,026,296 bytes to 78,026,355. Every index carries a stamp built from file sizes, so 59 extra bytes made all of them look stale. The API quietly fell back to parsing the full 78 MB file on every request, at 5 to 8 seconds each, and picks returned 503. The deploy had gone out without rebuilding the indexes, and nothing stopped it. The fix had two parts: rebuild the indexes, then make the content editor refuse to deploy a stale one (`requireFreshIndex`, checked before and after it syncs the catalog). After that evening's deploy, Home loaded in 4.62 seconds, then 3.64 on a second visit. Days took 200 to 460 ms each, picks answered in 193 ms instead of failing, and a game page took about 1.5 seconds. Better. But every read still ran a function. ## November 10, 8 a.m.: the cold morning The first load the next morning sat for about 50 seconds. One card request returned a 500 after 4.14 seconds, and the counts request stayed pending for 85. On Home, every request was cut off at about 8 seconds, sent again, and cut off again, for about five rounds. Here's what was happening. The client gave up on any request after 8 seconds and sent it again, but the first request kept running on the server. With Fluid compute, many requests share one Node instance. That's a great deal when requests spend their time waiting on the network, and a bad one when they spend it parsing JSON. Each resend queued behind the one before it until the instance was buried. Why hadn't I seen it the night before? Every one of those tests ran right after a deploy, on warm instances. The quick fix was to wait instead of resending: 45 seconds for catalog requests, 30 for search, and one shared request per URL. The real lesson was obvious by then. This data should never have needed a function. ## Public data belongs on a CDN Store sites serve this kind of data as files from their CDN. In a comparison screenshot, Epic's store home finished in 1.23 seconds. I planned the move in three stages that could each ship on its own. ```before-after { "title": "Every read ran a function. Now most reads are files.", "subtitle": "What answers each catalog request on gamekal.com, before the move and since 10 November 2026.", "panels": [ { "label": "Before", "caption": "One function answered every read, and nothing could be cached.", "metric": "Home: about 20 s", "target": { "icon": "database", "title": "The whole catalog", "detail": "640 MB in the bundle, parsed on a cold start" }, "lanes": [ { "from": { "icon": "user", "title": "People", "detail": "web, iOS and Android" }, "group": { "title": "/api/releases", "detail": "BotID on every read, then private, no-store", "target": true }, "to": [ { "title": "Days and counts", "detail": "the calendar and Release Statistics" }, { "title": "Game pages and cards", "detail": "one game, or a shelf of them" }, { "title": "Picks, Similar Games, search", "detail": "depend on the person or the query" } ] } ] }, { "label": "Since 10 November", "caption": "Public data is files on the CDN. Only what has to be computed runs a function.", "metric": "Home: 1.81 s", "target": { "icon": "database", "title": "Three indexes", "detail": "about 35 MB in the bundle" }, "lanes": [ { "from": { "icon": "user", "title": "People", "detail": "days, counts and games" }, "group": { "title": "Vercel CDN", "detail": "built at deploy, cached forever under a stamp", "variant": "accent" }, "to": [ { "icon": "file", "title": "/data/current.json", "detail": "the stamp, rechecked on each load" }, { "icon": "file", "title": "Day and count files", "detail": "/data//day and /counts" }, { "icon": "file", "title": "Game bucket files", "detail": "8,192 buckets, about 10 KB each" } ] }, { "from": { "icon": "user", "title": "People", "detail": "picks, Similar Games, search" }, "to": [ { "icon": "cpu", "title": "/api/compute", "detail": "answers cached at the CDN for a day", "variant": "accent" } ], "target": true } ] } ], "note": "Simplified. Arrows show what answers each kind of request. Home times are on gamekal.com, 9 and 10 November 2026." } ``` ### Stage 1: days and counts as files `npm run build:web` now ends by writing the catalog into the site's output as static files: | Path | Holds | | --- | --- | | `/data/current.json` | The catalog's stamp, sync time, totals, years, and first and last day | | `/data//day/2026-11-10.json` | That day's cards | | `/data//counts/2026.json` | Release Statistics for every "today" in the year | The stamp is a short hash of the index stamp, so a new catalog gets new paths. That lets everything under a stamp be cached forever. Only `current.json` is ever checked again. The files are built during the Vercel build, from data the deploy already uploads, so the upload (roughly 670 MB of the CLI's 1 GB limit) doesn't grow. A stale index fails the build. In the browser, a 404 means a deploy replaced the stamp, so the client asks for `current.json` again and retries. Any other failure falls back to the API. ### Stage 2: every game as a file There are 147,000 games, which is too many for a file each. Instead, every game id hashes (FNV-1a) into one of 8,192 bucket files, and the server and the browser share the same function, `gameBucket`. A game page fetches one file of about 10 KB. Shelf cards are cut from the same records with `cardOf`, the function the API uses, and a test compares every game's file against the API's answer. ### Stage 3: a small function for what has to be computed Picks, Similar Games, and title search depend on the person or the query, so they can't be files. They moved to `/api/compute`, which carries only the three indexes it reads: about 35 MB instead of 640. Since it doesn't have the catalog files, it can't compute the stamp from their sizes, so it trusts the stamp written inside the search index. A successful answer is cached at the CDN for a day. The same game's Similar Games comes back without running anything. ## What I kept Moving to files didn't mean giving up on protection. It meant putting it where it belongs. - **The functions are still guarded.** `/api/compute`, `/api/releases`, wishlist import, and reports all still check who's asking: BotID for the website, and a signed header for the apps. - **The rate limits stayed.** They now sit in front of the work that actually costs something. - **`/api/releases` still answers.** Phone builds through 0.1.24 call it for everything, so it keeps its full catalog until those builds are gone. What I dropped is the idea that a public, identical-for-everyone catalog needed a vault at all. ## Results on gamekal.com ```bars { "title": "Time until the page finished", "subtitle": "gamekal.com before the audit, after it, and on the CDN. Lower is better.", "unit": "s", "max": 20, "ticks": [ 0, 5, 10, 15, 20 ], "legend": [ { "label": "Nov 9, before", "tone": "muted" }, { "label": "Nov 9, after the audit", "tone": "light" }, { "label": "Nov 10, on the CDN", "tone": "accent" } ], "groups": [ { "label": "Home", "bars": [ { "value": 20, "display": "~20 s", "tone": "muted", "label": "before" }, { "value": 4.62, "display": "3.64–4.62 s", "tone": "light", "dots": [ 3.64, 4.62 ], "label": "after the audit" }, { "value": 1.81, "display": "1.81 s", "tone": "accent", "label": "on the CDN" } ] }, { "label": "Game page", "bars": [ { "value": 8, "display": "8 s+", "tone": "muted", "label": "before" }, { "value": 6.04, "display": "1.5–6.04 s", "tone": "light", "dots": [ 1.5, 6.04 ], "label": "after the audit" }, { "value": 2.28, "display": "2.28 s", "tone": "accent", "label": "on the CDN" } ] }, { "label": "Similar Games", "bars": [ { "value": 4.75, "display": "0.19–4.75 s", "tone": "light", "dots": [ 0.189, 4.75 ], "label": "after the audit" }, { "value": 0.05, "display": "50 ms", "tone": "accent", "label": "on the CDN" } ] } ], "note": "Measured on gamekal.com, November 9 and 10, 2026.\nAfter the audit, dots mark a warm and a cold load. Home's are its second and first visit." } ``` On the CDN, Calendar finished in 1.50 seconds, Journal in 1.33, and Up Next in 1.52. On a second visit, every day and game file came from the browser's disk cache in 3 to 9 ms. None of these pages calls `/api/releases` any more. These were measured on gamekal.com on November 9 and 10, not in a lab, so real devices and networks will vary. The shape of the difference comes straight from the architecture, though: a file on a CDN doesn't have a cold start. ## Then the phone said otherwise The iPhone app switched to the same files and I tested it on a development build. It didn't feel faster, and taps on the menu went missing. The network wasn't the problem any more. A 15 KB day file arrived in 50 ms. Something on the phone itself was blocking the thread that handles taps. A tiny watchdog made it visible. It ticks every 100 ms and logs whenever a tick arrives late (`src/lib/tabSwitchTrace.ts`, development builds only). The first runs logged freezes of 5.8 seconds on Home and 4.7 on Calendar. A 41-second Hermes CPU profile from the phone showed where the time went: - **8.3 of 26 busy seconds were in `matchWishlistCatalog`.** Every catalog change compared each wishlist game on the shelf with every loaded store link, and built two `URL` objects for each comparison. On the phone, `URL` is a slow JavaScript polyfill. Desktop browsers have it built in, which is why the website never showed it. The fix is a one-regex link reader, tested side by side against `new URL`, plus an index built once per catalog list. - **1,411 redraws came from animation listeners.** A shine on the "Out Today!" label and a breathing glow on today's card were redrawn from JavaScript every frame, on every tab kept alive in the background. Both now run on the UI thread with Reanimated, and only on the visible tab. The glow's blurred halo no longer pulses, because animating a blur every frame froze iOS's own UI thread instead. - **A swipe on the menu ate taps.** It kicked in after 8 points of finger drift, cancelled the tap, and then only switched tabs on a long swipe. I removed it. - **Smaller things added up.** Home re-ranked every loaded game for picks on each shelf change, an Activity row could be skipped after a tab switch, and Calendar kept five heavy months drawn instead of three. ```timeline { "title": "Opening a tab on the iPhone", "subtitle": "First open on a development build, before and after the fixes. Lower is better.", "unit": "ms", "max": 1600, "ticks": [ 0, 400, 800, 1200, 1600 ], "rows": [ { "label": "Journal", "sublabel": "before", "segments": [ { "from": 0, "to": 805, "label": "Opening", "tone": "dim" }, { "from": 805, "to": 1573, "label": "Frozen", "tone": "muted" } ], "marks": [ { "at": 805, "label": "805 ms" }, { "at": 1573, "label": "+768 ms" } ] }, { "label": "Journal", "sublabel": "after", "segments": [ { "from": 0, "to": 169, "tone": "accent" } ], "marks": [ { "at": 169, "label": "169 ms" } ] }, { "label": "Up Next", "sublabel": "before", "segments": [ { "from": 0, "to": 716, "label": "Opening", "tone": "dim" }, { "from": 716, "to": 1495, "label": "Frozen", "tone": "muted" } ], "marks": [ { "at": 716, "label": "716 ms" }, { "at": 1495, "label": "+779 ms" } ] }, { "label": "Up Next", "sublabel": "after", "segments": [ { "from": 0, "to": 95, "tone": "accent" } ], "marks": [ { "at": 95, "label": "95 ms" } ] } ], "note": "iPhone, development build, November 10, 2026. The open and the freeze the watchdog logged, shown back to back." } ``` The freeze after today's games load went from 5.8 seconds to about 0.9. Switching tabs afterwards went from 90 to 125 ms, with taps lost, to 36 to 75 ms, with every tap landing. All of this shipped in 0.1.25. ## What it costs Static files use CDN requests and bandwidth, which the old API requests already used, so the move removes function time without adding much. The new cost is storage: about 0.55 GB of catalog files in each deployment Vercel keeps, at $0.10 per GB-month past the included 10 GB. That's roughly $0.10 a month at most, against a 640 MB function bundle that shrinks once old phone builds are gone. Every service and meter is tracked in Cost Analysis. ## What I learned Looking back, four lessons stand out. **Ask what the data is before deciding how to protect it.** Public, read-only, and the same for everyone is a file on a CDN, not a function behind a bot check. **One line can outweigh a month of optimizations.** The `no-store` header sat above everything I tuned. Read the first lines of the hottest handler before tuning anything below them. **Test cold, not just warm.** Every number from the night of the audit came from a freshly deployed instance. The morning was the real test. **Measure the phone on the phone.** The same JavaScript that's instant in a desktop browser can cost seconds in the phone's engine, and a profile finds it faster than any amount of guessing. ## What's next? Two first-draw freezes remain on the phone: Calendar at about 1.5 seconds and Home at about 0.9. They're the next job. Once installed builds through 0.1.24 are gone, `/api/releases` can drop its 640 MB bundle. The catalog was never a secret. It was the same for everyone all along, and now it's served that way. ### Measure before you restyle URL: https://jodriscoll.com/blog/measure-before-you-restyle Published: 2026-11-10 Tags: jodriscoll.com, Performance, Measurement A redesign made the portfolio's Work page 653 KB of HTML. Measuring it found one decoration responsible for 37% of that, and one change cut the page by 29%. Open the Work page on this site tonight and the browser downloads 652,734 bytes of HTML before it fetches a single stylesheet. After one change, it's 463,342. Nothing on the page looks different. The cards still have their scrolling code graphs, the filters still work, and the same sixteen projects are there. The change is live in the portfolio's source and measured on the dev server. Production numbers will be smaller, for reasons covered under results. This post covers how the page got that heavy, how I found the weight, and the one change that removed most of it. It also covers the weight that's still there on purpose. ## Three questions before any restyle When a page feels slow, the stylesheet is rarely the first place to look. I start with what the browser has to download and run. 1. How much arrives before the first paint, and what is it? 2. Which element is the largest paint, and does it have a size? 3. What runs on the main thread when someone clicks? This site was built to make those answers boring. The pages are static HTML and CSS from Astro. There's no component framework in the browser, only small inline scripts, one per behaviour. The project pages, for example, have a script that only listens for the arrow keys: ```js addEventListener('keydown', (event) => { if (event.metaKey || event.altKey || event.ctrlKey) return; if (event.key === 'ArrowLeft' && prevHref) location.href = prevHref; if (event.key === 'ArrowRight' && nextHref) location.href = nextHref; }); ``` Keep the first question in mind. It's the one this redesign failed. ## The redesign Today the site got a new shell: a fixed left rail, isometric line drawings, a 3D line portrait on the home page, and behind every project card, a small code minimap. Each minimap is drawn from the project's language mix, so a WordPress card scrolls purple PHP and a GameKal card scrolls blue TypeScript. They're decoration, and they're drawn on the server as inline SVG, so they cost no JavaScript. That last sentence is where it went wrong. No JavaScript isn't the same as no bytes. ## November 10: the measurement I fetched each page from the dev server and parsed it in the browser, then summed the size of the HTML, every inline script, and every element I'd added in the redesign. **The code graphs were 37% of the Work page.** Sixteen of them came to 241,031 bytes, about 15 KB each, inside a 652,734-byte document. **Each graph was built for clarity, not size.** Every token of fake code was its own `` with five attributes, and every graph was drawn twice so the scroll could loop without a seam. A forty-six-row minimap became a few hundred rectangles, then doubled. **The home page carried the same cost.** Seven code graphs came to 107,172 bytes, out of 451,440. Why hadn't it shown up while building? The pages looked right and loaded fast on a laptop on the same machine as the server. The question was never "does it feel slow" but "what arrives first", and nobody had asked it yet. ## The change The drawing didn't change; the way it's written down did. **One path per colour instead of one shape per token.** A graph has at most six or seven language colours. Each colour is now a single ``, and each token inside it is a short `M x y h w` command: move to a point, draw a horizontal line of a given width. The round caps come from `stroke-linecap`, set once on the group. **Whole numbers instead of decimals.** Token positions are rounded to whole viewBox units. At the size the graphs are shown, the difference is below a pixel. I rendered the old and new versions of the same graph side by side before shipping it. They're the same picture. ## Results The page that was 652,734 bytes is now 463,342, down 29%. The code graphs on it went from 241,031 bytes to 33,123, down 86%, or about 2 KB each. ```bars { "title": "HTML before and after one change", "subtitle": "Uncompressed HTML of each page, and the part of it that was code graphs. Lower is better.", "unit": "KB", "max": 700, "ticks": [0, 175, 350, 525, 700], "legend": [ { "label": "Before, Nov 10", "tone": "muted" }, { "label": "After, Nov 10", "tone": "accent" } ], "groups": [ { "label": "Work page", "sublabel": "whole document", "bars": [ { "value": 652.7, "display": "652.7 KB", "tone": "muted" }, { "value": 463.3, "display": "463.3 KB", "tone": "accent" } ] }, { "label": "Work page", "sublabel": "16 code graphs", "bars": [ { "value": 241.0, "display": "241.0 KB", "tone": "muted" }, { "value": 33.1, "display": "33.1 KB", "tone": "accent" } ] }, { "label": "Home page", "sublabel": "whole document", "bars": [ { "value": 451.4, "display": "451.4 KB", "tone": "muted" }, { "value": 367.2, "display": "367.2 KB", "tone": "accent" } ] }, { "label": "Home page", "sublabel": "7 code graphs", "bars": [ { "value": 107.2, "display": "107.2 KB", "tone": "muted" }, { "value": 14.7, "display": "14.7 KB", "tone": "accent" } ] } ], "note": "Measured on the Astro dev server, November 10, 2026, by fetching each page and parsing it in the browser.\nOne measurement per page: HTML size doesn't vary between loads. Uncompressed bytes, not transfer size." } ``` These are dev-server numbers, and they overstate production. The dev server inlines all 96,767 bytes of CSS into every page and adds its own toolbar script; a production build links the CSS as cached files. Compression will shrink the repeated path commands further still. The ratio is what to notice: one decoration was more than a third of the page. ## What I kept The graphs stayed. They're the most alive thing on the Work page, and at 2 KB each they cost less than one small image. The no-framework rule stayed too. The inline scripts on the home page tonight include a theme switcher at 782 bytes, the footer at 576, tooltips at 2,688 and the contact dialog at 9,412, and none of them depends on another. ## Costs and trade-offs **The portrait is heavy on purpose.** The 3D line portrait on the home page is a 46,358-byte inline script, and almost all of it is the face: 112 rows by 96 columns of depth and brightness, packed as base64. It's the largest single thing on the home page. **The line drawings are still verbose.** The isometric city in the home page's closing section is 44,320 bytes of SVG. It uses one path per face and per window line, which is the same mistake the code graphs made. ## What I learned Looking back, three lessons stand out. **"No JavaScript" isn't a budget.** Inline SVG is free for the main thread and expensive for the network. The first question is about bytes, and it applies to markup too. **Write decoration the way the browser reads it.** A `` per token was easy to reason about and wasteful to send. The same picture as a handful of paths is a seventh of the size. **Measure the page you shipped, not the one you remember.** The graphs were added in an afternoon of design changes. Only the numbers showed what they cost. ## What's next? The isometric drawings get the same treatment as the code graphs, merging their window lines into one path per building. The portrait's data moves out of the HTML into a cached file that loads after first paint. Then I measure again on the production build on Vercel, where the 463 KB number should drop well below this one. The page looks exactly as it did this morning. It's just 29% lighter, because I asked the first question before the next restyle instead of after. ### Icons are a performance budget too URL: https://jodriscoll.com/blog/icons-are-a-performance-budget-too Published: 2026-10-28 Tags: Performance, SVG, CSS, Accessibility Switching to SVG is where icon performance starts, not where it ends. Ten small fixes, sorted by what they actually cost you, and the one rule that decides which of them to use. This site's Work page was once 652,734 bytes of HTML, and 241,031 of them were sixteen small decorative SVGs. They cost no JavaScript, they looked great, and they were more than a third of the page. I wrote that up in [Measure before you restyle](/blog/measure-before-you-restyle). Most icon advice would have called those graphs "done": they were SVG, not PNG. That's the floor. [theSVG's list of icon performance hacks](https://thesvg.org/blog/svg-icon-performance-hacks-2026) makes the same point and collects ten fixes past it, most of them one property or one attribute. This post walks through them the way I'd use them, sorted by what each one protects: bytes and paint, correctness across themes and screen readers, and repeat visits. It also covers the rule that decides whether any of them is worth doing on a given page. ## Bytes and paint These are the fixes that show up in a performance trace. **Stop the shift before the file arrives.** An icon with no size reserves nothing until it loads, then pushes the layout down. Give it a box up front: `width` and `height` attributes on the ``, or a width plus `aspect-ratio: 1 / 1` in CSS. Either way the browser reserves the space on first layout, and the Cumulative Layout Shift goes away for free. **Skip what's off screen.** On a grid of hundreds or thousands of icons, most cards aren't visible. `content-visibility: auto` tells the browser it can skip layout and paint for them until they scroll near the viewport: ```css title="icon-grid.css" .icon-card { content-visibility: auto; /* "auto" remembers the real size once a card has rendered */ contain-intrinsic-size: auto 120px; } ``` The `contain-intrinsic-size` line matters. Without it, skipped cards collapse to zero height, the scrollbar lies, and it jumps as you scroll. With `auto`, the browser uses 120px as a guess until it has measured a card, then remembers the real size. It's not a virtualisation library, but on a long grid it does a lot of the same work. **Know when inline SVG stops being worth it.** Pasting SVG markup into the page is the right call for a handful of icons you style or animate with CSS. At scale, each one is a separate set of DOM nodes your framework has to diff and the browser can't cache. Past a few hundred, `` or a sprite sheet referenced with `` is usually lighter. "Usually" is doing work in that sentence, and the last section is about why. My code graphs were the expensive version of this: one `` per token, every graph drawn twice. Merging them into one `` per colour cut them by 86% without changing a pixel. ## Correct in every theme These don't make a page faster. They stop it from being wrong, which is the more expensive bug. **Let single-colour icons inherit their colour.** If a mark is one flat colour, don't hardcode it. Set `fill="currentColor"` and it follows the text around it: hover, disabled, dark mode, all with no extra files and no JavaScript. **When you can't edit the markup, use it as a mask.** Sometimes you only have the `.svg` file and you need it in a design-token colour. `filter: invert()` and `hue-rotate()` only approximate a colour, and you tune them by eye. A mask gets it exactly, because the icon's shape becomes a stencil over a solid background: ```css title="recolor.css" .icon { width: 24px; aspect-ratio: 1 / 1; background-color: var(--accent); -webkit-mask: url("/icons/check.svg") center / contain no-repeat; mask: url("/icons/check.svg") center / contain no-repeat; } ``` Both of these are for genuinely single-colour marks. A multi-colour brand logo pushed through `currentColor` or a mask comes out as a silhouette. **Ship a real dark variant for anything multi-colour.** `filter: invert(1)` looks right on a black-on-white mark and nowhere else. Inverted brand colours are colours the brand never chose. Store a light and a dark file, and let the browser pick. CSS can't swap an ``'s source, but `` can: ```html title="logo.html" Vercel ``` That handles the system setting. If the site has its own theme toggle, as this one does, the toggle also has to update the `src` when it fires, because `prefers-color-scheme` only knows what the operating system says. **Decide what the icon means to a screen reader.** There are two cases, and mixing them up is the most common icon accessibility bug. An icon next to visible text is decoration: give it `aria-hidden="true"` so it isn't announced as "image" or a file name. An icon that is the only content of a button carries the meaning: put `aria-label` on the button, not on the SVG inside it. ## Repeat visits and input **Cache icons forever, but only once they're fingerprinted.** Published SVGs rarely change, so `Cache-Control: public, max-age=31536000, immutable` lets a returning visitor skip the network completely, with no `304` round trip. The catch is `immutable`: it tells the browser never to check back. On a file whose name doesn't change when its contents do, an edit can stay invisible to visitors for a year. Use it only on hashed filenames. Astro hashes what it bundles into `/_astro/`; files in `public/` keep their names, so they need a shorter policy or a hash of their own. **Debounce the search, not the input.** A live icon search that re-filters thousands of records on every keystroke is fine on a laptop and stutters on a mid-range phone. Keep the text field as plain local state so typing is instant, and delay only the expensive filter until the typing pauses: ```jsx title="IconSearch.jsx" useEffect(() => { const id = setTimeout(() => setResults(search(query)), 180); return () => clearTimeout(id); }, [query]); ``` Somewhere between 150 and 200ms is long enough to skip the work for fast typists and short enough that nobody notices. In React 18 and later, `useDeferredValue` is the other option: it keeps the input responsive by letting the slow render fall behind, rather than waiting a fixed time. ## The rule that picks the fix Every fix above can be applied where it does nothing. `content-visibility` on my Work page's sixteen cards would save almost nothing, because nearly all of them are near the first screen. A 180ms debounce on a list of fifty icons is just 180ms of lag. So the order is always the same. Open the performance panel, or just measure the bytes, find the thing that's actually slow, and pick the fix that matches it. That's how I found the code graphs: not by guessing which decoration was heavy, but by adding up what each one weighed. ## What I learned Looking back, three lessons stand out. **SVG is a format, not an optimisation.** My graphs were SVG and still a third of the page. How the file is written matters as much as what kind of file it is. **The cheapest fixes are about correctness.** `currentColor`, a real dark variant and the right ARIA attribute cost almost nothing and prevent the bugs users actually report. **A fix without a measurement is a guess.** Each of these has a page where it helps and a page where it only adds code. ## What's next? The isometric line drawings on the home page are next. They have the same one-shape-per-line problem the code graphs had, and they get the same treatment and the same before-and-after numbers. The logos on the Stacks page are the other candidate, and they'll get dark variants instead of a filter. The icons on this site were already SVG. Now I check what they cost instead of assuming. ### A redirect is not a DNS record URL: https://jodriscoll.com/blog/a-redirect-is-not-a-dns-record Published: 2026-10-21 Tags: Cloudflare, DNS, Infrastructure www.jodriscoll.com returned Cloudflare error 522 while the bare domain worked. The fix was one Redirect Rule, after a detour through Wrangler error 100117. Open `https://www.jodriscoll.com` one afternoon and Cloudflare answered with error 522, connection timed out. The bare domain, `jodriscoll.com`, loaded fine. Both names were in the same Cloudflare zone, on the same account. It's fixed now. `www` sends a 301 to the bare domain and keeps the path and query string: `www.jodriscoll.com/resume?ref=test` lands on `jodriscoll.com/resume/?ref=test`. The `www` DNS record is exactly as it was. This post covers why a DNS record can't do the redirect on its own, the command-line attempt that failed, and the dashboard steps that worked, with screenshots. ## Why a record can't redirect It feels like `www` should be one line in DNS. It never was. **A DNS record only answers "where is this name?"** An `A` record gives an IP address. A `CNAME` says "this name is another name for that one". Neither one sends a visitor anywhere. A redirect is an HTTP response, a `301` with a `Location` header, and something has to be running to send it. **For years, that something was an old server.** My `www` record was an `A` record pointing at a host from an old web plan. That host answered `www` requests with a redirect, so `www` looked like a DNS feature. It was the server. **When the server stopped answering, so did `www`.** The record is proxied (the orange cloud), so Cloudflare took the request and tried to reach the old host behind it. Nobody answered. That's what 522 means: Cloudflare is fine, and the origin behind it timed out. ![The www A record in Cloudflare: proxied, with the origin address blurred](/posts/a-redirect-is-not-a-dns-record/www-record.webp) ## The command-line attempt The bare domain is served by a Cloudflare Worker, attached as a custom domain in its `wrangler.jsonc`. Adding `www` there looked like the obvious move: ```jsonc title="wrangler.jsonc" "routes": [ { "pattern": "jodriscoll.com", "custom_domain": true }, { "pattern": "www.jodriscoll.com", "custom_domain": true } ] ``` `npx wrangler deploy` attached the first and refused the second: ```text ✘ [ERROR] Trigger configuration for "jodriscoll" was only partially updated: Custom domains: - A request to the Cloudflare API (/accounts//workers/scripts/jodriscoll/domains/records) failed. - Hostname 'www.jodriscoll.com' already has externally managed DNS records (A, CNAME, etc). Delete them first or try a different hostname. [code: 100117] ``` **A Worker custom domain wants to own its DNS record.** It creates the record itself, and it won't share the name with one it didn't make. The way through is to delete the `www` record first. I didn't. This zone also carries mail and other records I can't afford to fumble, and deleting a record by hand to make room for a redirect felt like the wrong trade. I took `www` back out of the Worker's routes and deployed the bare domain alone. Wrangler can't make the redirect either. It manages Workers, not a zone's rules. Doing it from a terminal means creating an API token in the dashboard first and calling the API with it, which is more steps than the dashboard itself. ## The fix: one Redirect Rule A Redirect Rule runs at Cloudflare's edge, before the request is sent to any origin. Because the `www` record is proxied, every `www` request passes through Cloudflare, and the rule can answer it with a 301 without the dead server ever being asked. No DNS change. In the domain's sidebar, it's under **Rules**, then **Overview**, then **Create rule** and **Redirect Rule**. Cloudflare ships a template called **Redirect from WWW to Root**. It opens a form that's already filled in: - **Request URL:** `https://www.*` - **Target URL:** `https://${1}`, where `${1}` is whatever matched the `*` - **Status code:** 301, Permanent Redirect ![The Redirect from WWW to root template: wildcard pattern https://www.* to https://${1}, status 301](/posts/a-redirect-is-not-a-dns-record/redirect-form.webp) **Tick Preserve query string.** It's off by default. Without it, `?utm_source=...` and every other query string is dropped on the way to the bare domain. **Then a warning you can ignore.** Deploying opens a dialog saying the DNS configuration "may not be proxying traffic for www". Mine was proxied. The dialog offers to create a new proxied DNS record instead, which is the one choice here that would have touched DNS. Keep **Ignore and deploy rule anyway** and deploy. ![The warning dialog: deploy anyway, or create a new proxied DNS record](/posts/a-redirect-is-not-a-dns-record/redirect-warning.webp) The rule shows up enabled, first in order: ![Single Redirects: one rule, Redirect from WWW to root, enabled](/posts/a-redirect-is-not-a-dns-record/redirect-active.webp) ## Results - **Before:** `https://www.jodriscoll.com` returned Cloudflare error 522. - **After:** `https://www.jodriscoll.com/resume?ref=test` redirects to `https://jodriscoll.com/resume/?ref=test`, path and query intact. - **Unchanged:** the `www` DNS record, and every other record in the zone. To check it from a terminal, `curl -sI https://www.jodriscoll.com/` prints the status line and the `Location` header without following the redirect. ## What it costs The free plan allows 10 Redirect Rules on a zone. This uses 1. The template only matches `https://www.*`. A plain `http://www.` request only reaches the rule if Cloudflare upgrades it to HTTPS first. ## What I learned Looking back, three lessons stand out. **A redirect needs something running.** DNS points; HTTP redirects. If `www` "just worked" for years, some server was doing the work. **Proxied records let the edge answer.** The orange cloud is what made a no-DNS-change fix possible. With a grey-cloud record, the rule would never see the request. **Read the warning, then read the record.** The dialog's suggestion was the one option that changed DNS. The record list already showed it wasn't needed. ## What's next? Confirm that Always Use HTTPS is on for the zone, so `http://www.` reaches the rule too. Then decide what to do with the other records that still point at the old host. The bare domain was never the problem. `www` had been leaning on a server I'd forgotten was there. ### One source, many front ends URL: https://jodriscoll.com/blog/one-source-many-fronts Published: 2026-06-24 Tags: jodriscoll.com, Architecture, Content The portfolio keeps a single record of the work and translates it for nine front ends: WordPress, Drupal, React, Angular and more. Drafts and private notes never leave. Ask this site for a project as a WordPress post and you get `post_title` and `post_excerpt`. Ask for the same project as a Drupal node and you get `title[0].value` and a `body` with a text format. Ask for it as React props and you get `id`, `tags` and `demoUrl`. It's one record in one JSON file, served in nine shapes, and none of them is a copy. The gateway is live. The Astro front end you're reading uses it, and `/api/content` serves every other shape today. The other eight front ends aren't booted yet, so their shapes are tested by the type checker and the Stacks page, not by a running app. This post covers why the content left the front end, how one request becomes the right shape, and what stops a draft or a private note from leaking. It also covers what this design costs. ## Where the content used to live The previous jodriscoll.com was an Angular 13 app. Every word on it, from the job history to the project descriptions, lived in one translation file, `src/assets/i18n/en.json`, loaded through ngx-translate. A project looked like this: ```json { "Title": "TAK.gov", "Description": "…", "Images": ["…"], "demoLink": "https://tak.gov", "Technologies": ["Ruby", "AWS"] } ``` That made sense at the time. One app, one language file, nothing to synchronise. Keep the capitalised field names in mind, because they come back. ## The problem **The content was shaped like its front end.** `Title` with a capital T, `demoLink` and `githubLink` were Angular template decisions baked into the data. Moving to any other framework meant rewriting the file or writing a converter for it. **There was nowhere to keep a note that wasn't public.** Anything in `en.json` shipped to every visitor in the bundle. A draft project, a client's commercial terms, a half-written role: the only safe place for them was somewhere else entirely. **A portfolio about many stacks only spoke one.** I've shipped on WordPress, Drupal, React, Vue and Angular. The site could only show that as a list of logos. ## The decision: a record, a gateway, and translations The content moved into plain files under `content/`: `profile.json`, `experience.json`, `projects.json` and `technologies.json`, with posts as Markdown. Those files are the record. Nothing renders them directly. Between the record and every front end sits a gateway in `src/gateway`. It's about 31 KB of TypeScript across six files, and every request takes the same path through it. ```steps { "title": "One request through the gateway", "subtitle": "What happens between GET /api/content/projects/gamekal and the JSON that comes back. Not to scale.", "rows": [ { "label": "Request", "sublabel": "?stack=drupal", "steps": [ { "title": "Pick the shape", "detail": "resolveStack: the ?stack= parameter, or the first label of the host name, or astro" }, { "title": "Check the slug", "detail": "Lower-case words and hyphens only. Anything else is a 404" } ], "group": { "title": "The allowlist", "detail": "Only public records, only named fields" } }, { "label": "Response", "sublabel": "200, cached", "steps": [ { "title": "Publish", "detail": "publishProject copies the fields a visitor may see, one by one" }, { "title": "Translate", "detail": "translateProject reshapes them for the requested stack" }, { "title": "Send", "detail": "JSON with public, max-age=60, s-maxage=3600" } ] } ], "note": "From src/gateway/stacks.ts, query.ts, protect.ts, translate.ts and http.ts." } ``` **The shape comes from the host.** `resolveStack` reads `?stack=` first. Without it, the first label of the host name decides, so a request to `drupal.jodriscoll.com` gets Drupal fields and `jodriscoll.com` gets Astro's. An unknown stack is a `400` with `unknown_stack`, not a guess. **Publishing is an allowlist, not a filter.** `publishProject` doesn't delete private fields from a record. It builds a new object and copies `slug`, `title`, `summary`, `featured`, `technologies` and the two links, by name. A field that isn't on that list can't reach a visitor, even one added to the JSON tomorrow. The Sequoia Capital record carries an `internal.notes` field, and it never leaves. **Translation is one function per kind of record.** `translateProject`, `translateRole`, `translatePost` and `translateProfile` each switch on the stack. The Angular case returns `Title`, `Description`, `demoLink` and `githubLink`. Remember those capitals? They're the exact fields the old site's `en.json` used, so the old Angular app could read this gateway without changing a template. **A forgotten stack fails the build.** Each switch ends in a `default` that assigns the stack to a `never`. Add a tenth stack to the `STACKS` list and forget one case, and TypeScript refuses to compile. Nobody finds out from a broken page. ## Drafts stay behind a token Drafts are the reason the record exists, so they get the strictest rule. A post or project marked `visibility: draft` is invisible unless the request asks for `?drafts=1` and sends an `x-content-token` header. `draftsAllowed` compares that header with the `CONTENT_TOKEN` environment variable using `timingSafeEqual`, so a wrong guess takes as long as a right one. Without the token, the answer is a `401`. When drafts are allowed, the response says so. Each draft comes back wrapped as `{ visibility: "draft", record }`, and the cache header switches to `private, no-store`, so a draft can't sit in a CDN. There's a test for this that reads like a tripwire. `content/blog/private-draft.md` says, in its own body, "If this sentence is on the public site, the gateway failed." There's a draft project that says the same thing. ## What I kept The static site didn't get slower for any of this. The Astro pages call the gateway's functions at build time, not over HTTP, so a visitor gets plain HTML and the gateway is never in the way. Only the `/api/content` routes run as functions, with `prerender = false`. The old content survived too. The older project descriptions here, TAK.gov, Sequoia Capital, SageLife, i6tix and Rehab.com among them, started life in `en.json`. They moved, and their fields got lower-case names, but the words are the ones the Angular site shipped. ## What it costs **Every field is written up to nine times.** Adding a field to projects means touching each case in `translateProject`. `translate.ts` is the biggest file in the gateway, at 10,263 bytes, and it's almost all field names. **Images aren't public yet.** `publishProject` always sends `images: []`, because the record didn't hold image paths when the allowlist was written. The shapes are ready for them; the data isn't. **Eight front ends are promises.** The shapes are checked by the compiler and shown on the Stacks page, but no running Next.js, Vue or Drupal app consumes them yet. A real consumer will find something a type can't. ## What I learned Looking back, three lessons stand out. **Content shaped like its framework is a migration you've already scheduled.** The capital-T `Title` cost nothing on day one and blocked every move after it. **Copy fields in; don't strip them out.** An allowlist fails closed. A filter fails open the first time someone adds a field and forgets the filter. **Make the compiler hold the list.** The `never` check turns "did I handle every stack?" from a review comment into a build error. ## What's next? The first host to boot is `next.jodriscoll.com`, reading the Next.js shape from `/api/content` over HTTP rather than at build time. After that, project screenshots join the record and the allowlist, so `images` stops being an empty list in every shape. The record was one file in an Angular bundle. It's still one record. It just doesn't belong to any one front end anymore.