[{"data":1,"prerenderedAt":372},["ShallowReactive",2],{"site-settings":3,"project-castora":76,"project-logs-castora":155,"project-articles-castora":371},{"global":4,"header":11,"footer":27},{"brandName":5,"brandStamp":6,"brandTagline":7,"communityUrl":8,"defaultSeoDescription":9,"defaultOgImage":10},"Useful Labs","est. 2025 · UK","Serious software, built out loud.","https:\u002F\u002Fcommunity.usefullabs.io","A product-led lab building production-grade platforms in the open — Sonic Artistes, Castora and more. A few times a year we take that same craft to a client build. Real software, shipped at product-team pace, shared honestly.",null,{"navLinks":12,"scarcityPulse":10,"showClock":25,"ctaLabel":26,"ctaUrl":8},[13,17,20,23],{"label":14,"url":15,"external":16},"Projects","\u002Fprojects",false,{"label":18,"url":19,"external":16},"Writing","\u002Fblog",{"label":21,"url":22,"external":16},"Tools","\u002Ftools",{"label":24,"url":8,"external":25},"The Hub",true,"Join the community",{"tagline":7,"aboutParagraph":28,"copyrightLine":29,"columns":30,"legalLinks":63},"A UK product lab, built in the open. Real production software, documented honestly — no hype, no highlights reel.","© Useful Labs {year} · Made in the UK with unusual patience.",[31,41,50],{"heading":14,"links":32},[33,36,39],{"label":34,"url":35,"external":25},"Sonic Artistes","https:\u002F\u002Fapp.sonicartistes.com",{"label":37,"url":38,"external":25},"Castora","https:\u002F\u002Fgetcastora.com",{"label":40,"url":15,"external":16},"Archive",{"heading":18,"links":42},[43,44,47],{"label":18,"url":19,"external":16},{"label":45,"url":46,"external":16},"RSS feed","\u002Frss.xml",{"label":48,"url":49,"external":16},"Newsletter","#newsletter",{"heading":51,"links":52},"Elsewhere",[53,54,57,60],{"label":24,"url":8,"external":25},{"label":55,"url":56,"external":25},"GitHub","https:\u002F\u002Fgithub.com\u002Fpaulwilliams-us",{"label":58,"url":59,"external":25},"X \u002F Twitter","https:\u002F\u002Fx.com",{"label":61,"url":62,"external":16},"Email","mailto:hello@usefullabs.io",[64,67,70,73],{"label":65,"url":66,"external":16},"Privacy","\u002Fprivacy",{"label":68,"url":69,"external":16},"Terms","\u002Fterms",{"label":71,"url":72,"external":16},"AI Policy","\u002Fai-policy",{"label":74,"url":75,"external":16},"Colophon","\u002Fcolophon",{"id":77,"slug":77,"name":37,"idLabel":78,"tagText":79,"description":80,"accent":81,"meta":83,"modules":93,"chips":100,"progress":106,"followLabel":109,"followHref":110,"siteUrl":38,"siteLabel":111,"status":112,"isFeatured":25,"isHeroFeatured":25,"publishedIso":113,"modifiedIso":114,"hero":115,"contentHtml":130,"featuredImage":10,"screenshots":131},"castora","02 \u002F CA","92% complete · Shipping August 2026","Multi-tenant audition management platform. Async video submissions, live online and in-person scheduling, collaborative scoring, ATS, live audition rooms and reusable criteria management.",{"token":77,"rgb":82},"245, 158, 11",[84,87,90],{"value":85,"label":86},"40+","Tables",{"value":88,"label":89},"Multi","Tenant",{"value":91,"label":92},"RT","Scoring",[94,95,96,97,98,99],"Async video submissions","Live audition rooms","Collaborative scoring","Hybrid scheduling","Applicant tracking","Criteria engine",[101,102,103,104,105],"Nuxt 4","Supabase","Wasabi S3","Multi-tenant","Claude Code",{"label":107,"percent":108},"Build progress",94,"Follow the build","\u002Fprojects\u002Fcastora","getcastora.com","building","2026-04-23T10:51:08","2026-05-31T10:32:40",{"statusLabel":116,"statusFocus":117,"kickerLabel":118,"kickerValue":119,"description":120,"chips":121,"metrics":122},"Shipping now","live audition rooms","Featured · In progress","88%","Multi-tenant audition management platform — async video submissions, live rooms, collaborative scoring, and a reusable criteria engine.",[101,102,103,104],[123,124,127],{"value":85,"label":86},{"value":125,"label":126},"90%","Complete",{"value":128,"label":129},"Jul","Shipping","",[132,140,147],{"id":133,"image":134,"captionHtml":139},"1d044675-3293-4a0b-b34b-6cc734eaba0a",{"src":135,"alt":136,"width":137,"height":138},"https:\u002F\u002Foffice.usefullabs.io\u002Fassets\u002Fa386773f-e517-4ee8-970a-c6a0556575c6","Castora Login",1903,1359,"\u003Cp>Castora Login Page\u003C\u002Fp>",{"id":141,"image":142,"captionHtml":146},"34713a54-d568-4953-96bf-f3dd618779eb",{"src":143,"alt":144,"width":145,"height":138},"https:\u002F\u002Foffice.usefullabs.io\u002Fassets\u002Fda25c627-7f18-44d8-afe3-c014b3069049","Castora Form Builder Page",1895,"\u003Cp>Form builder page for companies.\u003C\u002Fp>",{"id":148,"image":149,"captionHtml":154},"99255a52-5a2f-4438-aac4-0b795943ea03",{"src":150,"alt":151,"width":152,"height":153},"https:\u002F\u002Foffice.usefullabs.io\u002Fassets\u002Fe06501d4-726f-4091-95e7-298ebe287474","Castora Plans & Billing Page",1905,1356,"\u003Cp>Plans and billing page.\u003C\u002Fp>",[156,163,169,175,181,187,193,199,204,209,214,219,224,229,234,241,247,254,260,266,272,278,284,290,296,302,308,314,320,326,332,338,345,352,359,365],{"id":157,"slug":158,"projectId":77,"kind":159,"title":160,"contentHtml":130,"dateLabel":161,"publishedIso":162,"commitUrl":-1,"image":-1},"13f722ac-c7f9-4b21-9c8b-16d9a4d9b4d4","cas-db-mechanicals-jun26","refactored","Castora's database mechanicals tightened: policies rescoped to authenticated, UUID defaults moved to gen_random_uuid()","today","2026-06-26T15:00:00",{"id":164,"slug":165,"projectId":77,"kind":166,"title":167,"contentHtml":130,"dateLabel":161,"publishedIso":168,"commitUrl":-1,"image":-1},"f740fc06-8116-4d57-97e8-d670ea55d933","cas-mona-sans","shipped","Castora self-hosts Mona Sans and drops the Google Fonts dependency","2026-06-26T14:00:00",{"id":170,"slug":171,"projectId":77,"kind":172,"title":173,"contentHtml":130,"dateLabel":161,"publishedIso":174,"commitUrl":-1,"image":-1},"93831356-2bf5-4d0c-bc43-adf3ed4878de","cas-audit-reconcile","noted","Reconciled every applied DB-audit migration back into version control","2026-06-26T13:00:00",{"id":176,"slug":177,"projectId":77,"kind":159,"title":178,"contentHtml":130,"dateLabel":179,"publishedIso":180,"commitUrl":-1,"image":-1},"50b37290-ed5e-4ba1-9b10-c2689ddce2cd","cas-my-namespace","Performer pages now live under a clean \u002Fmy\u002F namespace","6d ago","2026-06-20T11:00:00",{"id":182,"slug":183,"projectId":77,"kind":166,"title":184,"contentHtml":130,"dateLabel":185,"publishedIso":186,"commitUrl":-1,"image":-1},"57101962-f2c6-4aa3-b85e-087ca05bbb39","cas-268-performer-dashboard-redesign","The performer dashboard, redesigned: zones, priority signals, skeletons and empty-state suppression","1w ago","2026-06-14T11:00:00",{"id":188,"slug":189,"projectId":77,"kind":166,"title":190,"contentHtml":130,"dateLabel":191,"publishedIso":192,"commitUrl":-1,"image":-1},"701fba5b-2f2f-474a-9071-a4c3f30c1fef","cas-267-resource-rls-junction","Rebuilt resource access on a proper RLS junction, with a member-policy gate","2w ago","2026-06-11T11:00:00",{"id":194,"slug":195,"projectId":77,"kind":196,"title":197,"contentHtml":130,"dateLabel":191,"publishedIso":198,"commitUrl":-1,"image":-1},"21e63437-c6fb-4442-b44e-e6dd7c2733ad","cas-266-chip-slug-uuid","fixed","Fixed chip slugs colliding with UUIDs on the forms list","2026-06-11T10:00:00",{"id":200,"slug":201,"projectId":77,"kind":196,"title":202,"contentHtml":130,"dateLabel":191,"publishedIso":203,"commitUrl":-1,"image":-1},"1f00ce4c-35c7-4639-bb82-f849b167e9a2","cas-265-resume-polish","Polished the cross-session upload resume after a round of dogfooding","2026-06-10T17:00:00",{"id":205,"slug":206,"projectId":77,"kind":166,"title":207,"contentHtml":130,"dateLabel":191,"publishedIso":208,"commitUrl":-1,"image":-1},"0207afcf-5499-4e60-aede-5c9a2cb2621a","cas-264-cross-session-resume","Uploads now resume across sessions — close the tab mid-upload and pick up where you left off","2026-06-10T16:00:00",{"id":210,"slug":211,"projectId":77,"kind":166,"title":212,"contentHtml":130,"dateLabel":191,"publishedIso":213,"commitUrl":-1,"image":-1},"664ea595-dfab-4d9a-8ba2-5fdb4ed6d007","cas-263-upload-abort-reclaim","Stalled uploads now abort cleanly and reclaim their orphaned storage keys","2026-06-10T15:00:00",{"id":215,"slug":216,"projectId":77,"kind":196,"title":217,"contentHtml":130,"dateLabel":191,"publishedIso":218,"commitUrl":-1,"image":-1},"a0dbcc9c-c327-4033-893e-9429bd5d13d9","cas-262-pretape-readback","Performers can read back their own pre-tape recordings via a scoped signed URL","2026-06-10T14:00:00",{"id":220,"slug":221,"projectId":77,"kind":166,"title":222,"contentHtml":130,"dateLabel":191,"publishedIso":223,"commitUrl":-1,"image":-1},"e80dd1f7-dc8a-429c-bac0-0384c36ebab4","cas-261-multipart-owner-binding","Every multipart upload is now bound to its owner (key ↔ uploadId ↔ owner)","2026-06-10T13:00:00",{"id":225,"slug":226,"projectId":77,"kind":196,"title":227,"contentHtml":130,"dateLabel":191,"publishedIso":228,"commitUrl":-1,"image":-1},"285a31f6-b57d-4529-92a6-b4c4d677650e","cas-260-upload-uuid-boundary","Closed an intra-tenant hole where an upload key could be written anywhere — keys are now derived server-side","2026-06-10T12:00:00",{"id":230,"slug":231,"projectId":77,"kind":166,"title":232,"contentHtml":130,"dateLabel":191,"publishedIso":233,"commitUrl":-1,"image":-1},"37d0e2d0-8746-489d-8746-95b0ca78dc6a","cas-259-resumable-multipart-uploads","Large audition files upload in resumable multipart chunks","2026-06-09T11:00:00",{"id":235,"slug":236,"projectId":77,"kind":166,"title":237,"contentHtml":238,"dateLabel":239,"publishedIso":240,"commitUrl":-1,"image":-1},"681bd8eb-6d0a-49a3-af79-2d9c10b22af9","a-query-that-asks-every-migration-if-it-actually-ran","A query that asks every migration if it actually ran","\u003Cp class=\"wp-block-paragraph\">After getting burned three separate times by migrations that were committed and pushed but never actually applied to the live database, I stopped trusting git as proof that a schema change happened. So I built a standing reconciliation sweep: a single read-only query with one canary check per migration file — does this table exist, this column, this function, this trigger, this enum value, this policy — including expected-to-be-absent checks for everything that was supposed to be dropped. It returns only anomalies, so an empty result means every migration landed exactly where it should. The first run came back completely clean, which was either reassuring or a little anticlimactic. I added a second tier for the trickier cases — the CREATE OR REPLACE and data-seed migrations where mere existence proves nothing, so those get definition and behavioural checks instead. Naturally the first version had a bug where the sort clause referenced an output alias the inner query had not yet exposed, which felt like a fitting way to be reminded that the tool catching my mistakes can make its own.\u003C\u002Fp>","4w ago","2026-05-29T15:00:00",{"id":242,"slug":243,"projectId":77,"kind":196,"title":244,"contentHtml":245,"dateLabel":239,"publishedIso":246,"commitUrl":-1,"image":-1},"5df7da96-351c-4174-8408-3d74ce9c158d","committed-pushed-and-never-actually-run","Committed, pushed, and never actually run","\u003Cp class=\"wp-block-paragraph\">Chasing down a stubborn coverage warning on the notifications admin page, I found an email template that existed in the codebase, was referenced all over the place, and even had a migration file sitting in the repo, but had simply never been applied to the production database. It turned out to be the third time a migration had been committed but never run, so I also built a read-only reconciliation sweep that checks one canary object per migration against the live database and flags anything that drifted. Lesson firmly relearned: committing a migration and running it are not the same thing, and now there is a query that proves it.\u003C\u002Fp>","2026-05-29T09:00:00",{"id":248,"slug":249,"projectId":77,"kind":196,"title":250,"contentHtml":251,"dateLabel":252,"publishedIso":253,"commitUrl":-1,"image":-1},"78be0bfb-21e3-48f4-ad56-8acb49efea47","we-were-reverse-engineering-the-plan-from-its-features","We were reverse-engineering the plan from its features","\u003Cp class=\"wp-block-paragraph\">The billing page was working out which plan a company was on by looking at which features were switched on: if custom branding was enabled it must be the top tier, and so on. That quietly falls apart the moment you grant a single feature as a one-off override, because a basic-plan company with one bonus flag would suddenly display as a top-tier company. I fixed it at the source so the limits lookup now returns the actual canonical tier name, keeping the feature-flag guessing only as a fallback during the deploy window.\u003C\u002Fp>","1mo ago","2026-05-27T14:00:00",{"id":255,"slug":256,"projectId":77,"kind":166,"title":257,"contentHtml":258,"dateLabel":252,"publishedIso":259,"commitUrl":-1,"image":-1},"442f5350-14c5-4a2d-9d67-c1a4702c9e9d","what-time-is-my-audition-really","What time is my audition, really?","\u003Cp class=\"wp-block-paragraph\">Live auditions get scheduled in the company timezone, but the performer might be three timezones away, so a slot reading “10:15” is genuinely ambiguous. I added a small chip that shows both the schedule local time and the viewer local time side by side, and it hides the second half when the two zones match so you never get the silly “10:15 BST, 10:15 BST your time.” Wired it onto the dashboard and the upcoming-slots widget. Small thing, but timezone confusion is precisely the friction that makes someone miss a slot.\u003C\u002Fp>","2026-05-27T11:00:00",{"id":261,"slug":262,"projectId":77,"kind":196,"title":263,"contentHtml":264,"dateLabel":252,"publishedIso":265,"commitUrl":-1,"image":-1},"ab841637-8006-4377-b8bc-f312d7a64612","an-endpoint-that-trusted-whoever-called-it","An endpoint that trusted whoever called it","\u003Cp class=\"wp-block-paragraph\">Found a real one. The route that saves reviewer assignments was not checking who was calling it, so any logged-in user could have reshaped which reviewers were on a submission, and worse, the caller identity was being read from the request body rather than the session. I locked it behind a proper company-admin membership check, derived the actor on the server instead of trusting the client, and added an audit-log entry so assignment changes now leave a trail. We are pre-production so no harm was done, but this is exactly the kind of thing you want to catch before real users arrive.\u003C\u002Fp>","2026-05-26T18:00:00",{"id":267,"slug":268,"projectId":77,"kind":166,"title":269,"contentHtml":270,"dateLabel":252,"publishedIso":271,"commitUrl":-1,"image":-1},"36230f2c-a06c-4b05-8a4c-9c1ed7d778a8","turning-fake-buttons-into-real-ones","Turning fake buttons into real ones","\u003Cp class=\"wp-block-paragraph\">Spent a session on accessibility debt that had been quietly accumulating. A bunch of clickable slot rows were just divs with a click handler, which looks fine but gives you no keyboard support and no focus ring, so I converted them to actual buttons and got all of that for free. Added proper ARIA to the onboarding progress bar and the locked audition-mode tiles, reordered the team-invite buttons so the destructive one is not the default, and pulled the redundant back arrows off the detail pages. The same sweep brought a mobile filter drawer to the admin tables so filtering at phone width stops being a cramped mess.\u003C\u002Fp>","2026-05-26T16:00:00",{"id":273,"slug":274,"projectId":77,"kind":196,"title":275,"contentHtml":276,"dateLabel":252,"publishedIso":277,"commitUrl":-1,"image":-1},"ceaffad1-51bf-4ed4-845d-af214b8a1b44","buttons-that-promised-things-they-could-not-deliver","Buttons that promised things they could not deliver","\u003Cp class=\"wp-block-paragraph\">Did a pass hunting for places where the UI dangled an action you could not actually take: a “Create audition” button showing to people without permission, an Impersonate button on already-deactivated companies, a 404 that dumped you nowhere instead of routing you somewhere useful. Added a small helper so a not-found state sends logged-in users back to their dashboard and everyone else to the public listings. The one that bugged me most was the schedule and analytics pages showing a silent, infinite skeleton when a fetch failed, quietly masking errors as “still loading” rather than telling you something broke.\u003C\u002Fp>","2026-05-26T13:00:00",{"id":279,"slug":280,"projectId":77,"kind":166,"title":281,"contentHtml":282,"dateLabel":252,"publishedIso":283,"commitUrl":-1,"image":-1},"f711ec46-f3f4-427e-ba00-d1fac9bf6a3a","stop-creating-a-form-just-by-visiting-a-page","Stop creating a form just by visiting a page","\u003Cp class=\"wp-block-paragraph\">For ages, hitting “new form” immediately inserted a draft row into the database, so the moment you clicked away you had left a half-built ghost form sitting in your list. I flipped it so the new-form page just renders a name-and-description input and only writes the row when you actually submit. While I was in there I finally wired up form duplication: it clones the template and all its fields, but deliberately skips the cover image and the submissions, since those belong to the original. Also killed a couple of stat cards that looked clickable but went nowhere.\u003C\u002Fp>","2026-05-26T10:00:00",{"id":285,"slug":286,"projectId":77,"kind":166,"title":287,"contentHtml":288,"dateLabel":252,"publishedIso":289,"commitUrl":-1,"image":-1},"5c4d5f8a-e21e-4767-a357-24874b1c2ea8","forms-can-be-duplicated-from-the-list-and-a-handful-of-dead-ends-are-fixed","Forms can be duplicated from the list, and a handful of dead-ends are fixed","\u003Cp class=\"wp-block-paragraph\">Duplicating an application form is now a one-click action from the list-page dropdown, with an audit entry on the copy so it’s traceable. A handful of UX dead-ends got cleaned up at the same time: the new form page now renders an inline create form instead of silently mounting a draft on load, the Unreviewed stat card no longer navigates to a broken filter state, and the wizard now redirects to the detail page on publish rather than dropping you back at the list. The Today filter on the submissions overview was also wired up — it was visually there but not actually filtering anything.\u003C\u002Fp>","2026-05-26T09:00:00",{"id":291,"slug":292,"projectId":77,"kind":166,"title":293,"contentHtml":294,"dateLabel":252,"publishedIso":295,"commitUrl":-1,"image":-1},"e24c5b1c-dc0b-4ae6-8d9a-7174fcbe170f","auth-errors-are-friendlier-and-the-sign-in-flow-handles-edge-cases-properly","Auth errors are friendlier and the sign-in flow handles edge cases properly","\u003Cp class=\"wp-block-paragraph\">Login, registration, password reset, and magic link confirmation all now route through a shared error formatter that produces readable messages for the cases that actually happen in production — wrong credentials, rate limiting, accounts marked for deletion. The reset-password success state now correctly detects whether you already have a live session before redirecting, which previously produced a confusing dead-end. Sign-out also now clears local state before calling the auth provider, so a failed network call can’t leave you in a half-signed-out limbo where the UI thinks you’re logged out but the session is still live.\u003C\u002Fp>","2026-05-25T09:00:00",{"id":297,"slug":298,"projectId":77,"kind":166,"title":299,"contentHtml":300,"dateLabel":252,"publishedIso":301,"commitUrl":-1,"image":-1},"804f9af8-f666-4bcc-89cf-f60053551f3a","tightened-soft-delete-filtering-and-rate-limiting-across-public-facing-endpoints","Tightened soft-delete filtering and rate limiting across public-facing endpoints","\u003Cp class=\"wp-block-paragraph\">A methodical sweep through the API found several places where soft-deleted records could still be accessed if you knew the right identifier. A shared active-record helper now centralises that filter and is enforced in server-side fetch-by-UUID paths across auditions, forms, pipelines, criteria, and resources — there’s a documented rule in CLAUDE.md so new routes don’t miss it. Rate limits were added to the public-facing endpoints that see the most unauthenticated traffic, and file access was hardened against path traversal and missing deletion-state checks. Nothing dramatic from the outside, but the kind of pass that’s worth doing before volume picks up.\u003C\u002Fp>","2026-05-24T09:00:00",{"id":303,"slug":304,"projectId":77,"kind":159,"title":305,"contentHtml":306,"dateLabel":252,"publishedIso":307,"commitUrl":-1,"image":-1},"f0c1bba0-452b-4056-b4a3-66733b78f3aa","no-more-skeleton-flash-when-you-make-changes-on-the-audition-page","No more skeleton flash when you make changes on the audition page","\u003Cp class=\"wp-block-paragraph\">The audition detail page used to re-fetch noisily after mutations — trigger an action and you’d briefly see a loading skeleton before the data settled back in. This pass extracted silent-refresh variants of the main data fetchers and rewired every realtime event handler to use them, so the UI updates in place rather than blanking out. The rule is now enforced by a CI guard that counts unguarded loading triggers and fails the build if the number goes up — so it can’t quietly regress as new features land on top of it.\u003C\u002Fp>","2026-05-23T09:00:00",{"id":309,"slug":310,"projectId":77,"kind":166,"title":311,"contentHtml":312,"dateLabel":252,"publishedIso":313,"commitUrl":-1,"image":-1},"e7980ddd-9680-4fc9-928f-8ef627dab27b","every-irreversible-action-now-asks-you-to-explain-why","Every irreversible action now asks you to explain why","\u003Cp class=\"wp-block-paragraph\">Deleting an audition, revoking a membership, withdrawing a submission — these all now go through a confirmation dialog that requires a written reason before the action proceeds. It’s a pattern that existed for some admin-only flows already; this audit pass extended it consistently across the whole platform. Role changes got the same treatment, since a demotion is effectively irreversible if the person later needs to be re-invited. The reason text feeds directly into the audit log, so there’s always a paper trail for anything destructive. The consistent dialog component also means the UX is predictable: you always know what you’re about to do and you always have to confirm it in words.\u003C\u002Fp>","2026-05-22T09:00:00",{"id":315,"slug":316,"projectId":77,"kind":166,"title":317,"contentHtml":318,"dateLabel":252,"publishedIso":319,"commitUrl":-1,"image":-1},"252d08b8-3a38-4bfc-ab6d-27198ea8284f","performers-can-join-a-live-waitlist-and-request-reschedules-across-timezones","Performers can join a live waitlist and request reschedules across timezones","\u003Cp class=\"wp-block-paragraph\">Two big workflow additions landed together: a live waitlist that fills cancellation slots automatically using a FIFO queue with priority holds to prevent race conditions, and a performer-initiated reschedule request that goes through a proper handshake with the company before any slot changes hands. Cross-timezone display is handled throughout, so a performer in Tokyo and a company in London both see times in their own locale. Confirmed slots also now generate a .ics calendar file — it’s a small touch but it means a confirmed audition can go straight into your calendar without copying anything by hand.\u003C\u002Fp>","2026-05-02T09:00:00",{"id":321,"slug":322,"projectId":77,"kind":166,"title":323,"contentHtml":324,"dateLabel":252,"publishedIso":325,"commitUrl":-1,"image":-1},"9f8b7e68-14d9-42fc-9f5b-500fe52f81e9","audition-outcomes-can-now-be-shared-directly-with-performers","Audition outcomes can now be shared directly with performers","\u003Cp class=\"wp-block-paragraph\">After an audition slot wraps, companies can now record an outcome — notes, scores, a decision — and choose to share it with the performer through the platform. Performers get notified when feedback lands, can view it in their own dashboard, and the no-show rate feeds into a rolling count on their profile. The outcome panel hooks into the existing cast-publish flow from the application stage, which avoided a lot of duplication. Getting the share toggle to trigger the right notification path — rather than just updating the record silently — was the piece that took the most care to get right.\u003C\u002Fp>","2026-05-01T09:00:00",{"id":327,"slug":328,"projectId":77,"kind":166,"title":329,"contentHtml":330,"dateLabel":252,"publishedIso":331,"commitUrl":-1,"image":-1},"bb172b88-4557-4ea6-a0c3-7f4ee3575574","recording-management-for-live-audition-sessions","Recording management for live audition sessions","\u003Cp class=\"wp-block-paragraph\">The live audition room now supports recording capture and storage — companies can attach recordings to individual slots, control visibility, and share with performers when they’re ready. A state machine handles the recording lifecycle cleanly across slot statuses. Also shipped alongside this: a proper vitest unit-test infrastructure and CI enforcement, so the test tier is a first-class part of the build rather than something that runs locally and gets forgotten. The CI gate fails the PR if unit coverage drops, which changes the development rhythm in a good way.\u003C\u002Fp>","2026-04-30T09:00:00",{"id":333,"slug":334,"projectId":77,"kind":166,"title":335,"contentHtml":336,"dateLabel":252,"publishedIso":337,"commitUrl":-1,"image":-1},"66faadfa-174a-4ec8-ac0b-3878f60183a3","companies-can-require-pre-recorded-material-before-a-live-audition","Companies can require pre-recorded material before a live audition","\u003Cp class=\"wp-block-paragraph\">Some auditions need a self-tape review before inviting performers to an in-person or live session. Companies can now gate slot access behind a pre-tape requirement — performers upload their material, the company reviews it, and only approved submissions unlock booking. The upload sheet handles format validation including a Safari quirk where .mov files arrive without a MIME type. The gating logic lives in a single utility so the rules stay consistent across both the performer-facing and company-facing sides, rather than being duplicated and drifting apart.\u003C\u002Fp>","2026-04-28T09:00:00",{"id":339,"slug":340,"projectId":77,"kind":166,"title":341,"contentHtml":342,"dateLabel":343,"publishedIso":344,"commitUrl":-1,"image":-1},"2cfa703e-2147-4f4d-b624-f787df81bd64","performers-can-now-request-and-confirm-audition-slots","Performers can now request and confirm audition slots","\u003Cp class=\"wp-block-paragraph\">The full slot request lifecycle landed this week — performers browse available slots, send a request, and wait for the company to propose or confirm. Each state transition fires the right notification and clears the right UI state, including the booking handshake on both sides: performers see their confirmation pending, companies see incoming requests and can approve or decline. Getting the cascade right when a slot gets cancelled mid-handshake took a few passes, but it holds up. Venue details were wired in at the same time — the address and location notes stay hidden until a slot is confirmed, so performers only see specifics once they actually have a booking.\u003C\u002Fp>","2mo ago","2026-04-27T09:00:00",{"id":346,"slug":347,"projectId":77,"kind":348,"title":349,"contentHtml":350,"dateLabel":343,"publishedIso":351,"commitUrl":-1,"image":-1},"af74e35e-32e9-471c-9b65-eaae98d5b1bc","broke-prerender-by-flipping-the-nitro-preset","broke","Broke prerender by flipping the nitro preset","\n\u003Cp class=\"wp-block-paragraph\">Changed \u003Ccode>nitro.preset\u003C\u002Fcode> thinking it’d fix a routing edge case. It did — and it also silently disabled the static sitemap output. Reverted. Read the module changelog before flipping presets next time. Re-opened the original routing ticket; the fix sits elsewhere.\u003C\u002Fp>\n\n\n\n\u003Cp class=\"wp-block-paragraph\">\u003C\u002Fp>\n","2026-04-24T16:39:21",{"id":353,"slug":354,"projectId":77,"kind":355,"title":356,"contentHtml":357,"dateLabel":343,"publishedIso":358,"commitUrl":-1,"image":-1},"f6418ea6-b2c0-4131-a2be-533fbf8a59f0","tried-generating-rubrics-from-pdf-briefs","experimented","Tried generating rubrics from PDF briefs","\n\u003Cp class=\"wp-block-paragraph\">Directors want to drop a PDF brief and have Castora extract the criteria straight into the scoring rubric. Spiked with pdf-parse plus some heuristics — got 60% of the way on clean templates, falls apart on scanned PDFs. Parked until there’s actual demand.\u003C\u002Fp>\n","2026-04-24T16:38:16",{"id":360,"slug":361,"projectId":77,"kind":196,"title":362,"contentHtml":363,"dateLabel":343,"publishedIso":364,"commitUrl":-1,"image":-1},"61f2ee51-fd15-483f-9584-704a03eafabb","fixed-the-ats-list-dropping-fresh-submissions","Fixed the ATS list dropping fresh submissions","\n\u003Cp class=\"wp-block-paragraph\">The ATS list was paginating off a cursor that went stale the moment a new submission arrived mid-session. Moved to server-sent events for the row state and killed the cursor. Nicer side effect: the list no longer flickers on each new entry.\u003C\u002Fp>\n","2026-04-24T16:37:10",{"id":366,"slug":367,"projectId":77,"kind":166,"title":368,"contentHtml":369,"dateLabel":343,"publishedIso":370,"commitUrl":-1,"image":-1},"f620ff0b-1654-44c3-9185-87848a6a87ab","live-audition-rooms-are-in","Live audition rooms are in","\n\u003Cp class=\"wp-block-paragraph\">Wired the WebRTC room infra to the shared scoring panel this afternoon. Three adjudicators, one candidate, scores syncing in real-time without anyone having to refresh. First end-to-end test went clean. Left the old async submission path intact as the fallback — not ripping it out until the live flow has a week of real use behind it.\u003C\u002Fp>\n\n\n\n\u003Cp class=\"wp-block-paragraph\">\u003C\u002Fp>\n","2026-04-24T16:36:09",[],1782519037276]