[{"data":1,"prerenderedAt":1650},["ShallowReactive",2],{"profile":3,"experience":71,"work":183},{"title":4,"description":5,"name":6,"role":7,"tagline":8,"location":9,"country":10,"workEligibility":10,"remote":11,"timezone":12,"email":13,"availability":14,"links":15,"skills":22,"education":41,"languages":53,"slug":55,"body":56},"","I build interfaces for products people use every day — mostly in Vue and Nuxt, and mostly at the\npoint where a design system meets real production constraints.","Jake Armour","Senior Frontend Developer","Vue 3 · Nuxt · TypeScript","Telford, United Kingdom","UK",true,"UTC−2 to UTC+2","jobs@jakearmour.com","Open to senior frontend roles",[16,19],{"label":17,"href":18},"GitHub","https:\u002F\u002Fgithub.com\u002Fjake-armour",{"label":20,"href":21},"LinkedIn","https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fjakearmour",{"core":23,"other":32},[24,25,26,27,28,29,30,31],"Vue 3","Nuxt","TypeScript","JavaScript","HTML\u002FCSS","Tailwind CSS","Pinia","Figma",[33,34,35,36,37,38,39,40],"Design systems","Component libraries","Headless CMS","Core Web Vitals","Unit testing","CI\u002FCD","Data visualisation","Git",[42,48],{"institution":43,"qualification":44,"grade":45,"start":46,"end":47},"The University of Huddersfield","MEng Computer Science","Pass with Distinction",2014,2019,{"institution":49,"qualification":50,"grade":51,"start":52,"end":46},"Telford College of Arts and Technology","BTEC Information Technology","D*D*D*",2012,[54],"English (fluent)","profile",{"type":57,"children":58},"root",[59,66],{"type":60,"tag":61,"props":62,"children":63},"element","p",{},[64],{"type":65,"value":5},"text",{"type":60,"tag":61,"props":67,"children":68},{},[69],{"type":65,"value":70},"I'm currently a senior frontend developer at Spamhaus, where I've led two company rebrands and\nbuilt the Vue 3 component library the rest of the team ships on. Outside work I build tools I\nactually need: a component workshop for Vue, a zero-knowledge password manager, and a monitor\nthat watches the containers running in my house.",[72,129,143,156,169],{"title":4,"description":4,"company":73,"role":7,"start":74,"end":75,"detail":76,"order":77,"stack":78,"slug":81,"body":82},"Spamhaus","2023-09",null,"full",1,[24,25,26,29,31,79,80],"Directus","PayloadCMS","spamhaus",{"type":57,"children":83},[84],{"type":60,"tag":85,"props":86,"children":87},"ul",{},[88,94,99,104,109,114,119,124],{"type":60,"tag":89,"props":90,"children":91},"li",{},[92],{"type":65,"value":93},"Led two full company rebrands from a frontend perspective, translating third-party UI\u002FUX\ndesigns into scalable, production-ready code in Vue 3, Nuxt and Tailwind CSS.",{"type":60,"tag":89,"props":95,"children":96},{},[97],{"type":65,"value":98},"Architected and maintained a reusable Vue 3 \u002F TypeScript component library, establishing\ndesign consistency across applications and significantly increasing team development velocity.",{"type":60,"tag":89,"props":100,"children":101},{},[102],{"type":65,"value":103},"Gave the non-technical marketing team a hands-on playground for components they had otherwise\nonly seen as Figma frames, so they could try the real thing with real props rather than signing\noff from a design file.",{"type":60,"tag":89,"props":105,"children":106},{},[107],{"type":65,"value":108},"Served as the primary frontend developer on multiple major customer-facing launches, taking\nfeatures from concept to production in a high-autonomy environment.",{"type":60,"tag":89,"props":110,"children":111},{},[112],{"type":65,"value":113},"Optimised web performance and Core Web Vitals, improving load times, accessibility and overall\nuser experience across the primary platforms.",{"type":60,"tag":89,"props":115,"children":116},{},[117],{"type":65,"value":118},"Integrated headless CMS solutions (Directus, PayloadCMS) with Nuxt, developing custom schemas\nand dynamic UI components so marketing could build and update content independently.",{"type":60,"tag":89,"props":120,"children":121},{},[122],{"type":65,"value":123},"Partnered with marketing to build flexible, CMS-driven page templates, streamlining campaign\nlaunches while holding brand consistency across core platforms.",{"type":60,"tag":89,"props":125,"children":126},{},[127],{"type":65,"value":128},"Worked with backend engineers and DevOps to integrate REST APIs and streamline automated CI\u002FCD\ndeployment pipelines.",{"title":4,"description":130,"company":131,"role":132,"start":133,"end":74,"detail":134,"order":135,"slug":136,"body":137},"I contributed to the rewrite of the user platform from Vue 2 to Vue 3, including a custom\ncomponent pack, then redesigned and rewrote the admin interface to align with the new v2\ninterface. I introduced a code coverage tool and conventional commits to make the change history\neasier to track, and worked with client services on reported bugs, feeding progress back through\nour ticketing system.","Awin","Frontend Developer","2022-05","compact",2,"awin",{"type":57,"children":138},[139],{"type":60,"tag":61,"props":140,"children":141},{},[142],{"type":65,"value":130},{"title":4,"description":144,"company":145,"role":146,"start":147,"end":133,"detail":134,"order":148,"slug":149,"body":150},"One of a three-person frontend team building Kahu, a desk and room booking system. I implemented\na full redesign of the user application to streamline the booking journey, and helped raise test\ncoverage — maintaining both the e2e and unit suites, and reviewing as much code as I submitted.","Vanti","Web Application Developer","2021-03",3,"vanti",{"type":57,"children":151},[152],{"type":60,"tag":61,"props":153,"children":154},{},[155],{"type":65,"value":144},{"title":4,"description":157,"company":158,"role":159,"start":160,"end":147,"detail":134,"order":161,"slug":162,"body":163},"My graduate role, on an internal web development team spread across Singapore, Crawley and Texas.\nI maintained the intranet the whole company used daily, and helped research and launch a new wiki\nto centralise the company's information in one place.","CGG","Web Software Developer","2019-06",4,"cgg",{"type":57,"children":164},[165],{"type":60,"tag":61,"props":166,"children":167},{},[168],{"type":65,"value":157},{"title":4,"description":170,"company":171,"role":172,"start":173,"end":174,"detail":134,"order":175,"slug":176,"body":177},"A placement year from my degree, maintaining the school's management information system (iSAMS),\nsupporting staff and students day to day, and imaging teacher and student laptops ready for\nrollout. I also built a security application for the campus gate team, letting them check the\nperson at the main gate against the registration plates parents had registered.","The British School of Brussels","Web Technician","2016-07","2017-08",5,"british-school-of-brussels",{"type":57,"children":178},[179],{"type":60,"tag":61,"props":180,"children":181},{},[182],{"type":65,"value":170},[184,404,842,1003,1288,1514],{"title":185,"description":186,"tagline":187,"year":188,"status":189,"role":190,"featured":11,"order":77,"stack":191,"links":197,"highlights":201,"slug":205,"body":206},"Jungleland","Jungleland is a children's play centre in Telford. Until 2021 it had no booking system at all —\nfamilies simply turned up. Social distancing ended that: entry had to be capped per session, so\nthe whole thing had to be built from nothing. Timed slots with hard capacity limits, a deposit\ntaken at the point of booking, and a way for staff to open and close dates themselves without\ncalling me.","Timed-slot booking and deposits for a children's play centre",2021,"live","Sole developer — frontend, payments, backend, deployment",[192,30,193,194,195,196,29],"Nuxt 4","Stripe","AWS Amplify","AppSync \u002F DynamoDB","AWS SES",[198],{"label":199,"href":200,"primary":11},"junglelandbooking.com","https:\u002F\u002Fjunglelandbooking.com",[202,203,204],"Real customers, real payments","Multi-step flow that survives a refresh","Staff-managed dates and capacity","jungleland",{"type":57,"children":207},[208,212,217,224,229,241,250,270,278,284,292,312,317,322,328,345,353,361,367,372,380,385,393,399],{"type":60,"tag":61,"props":209,"children":210},{},[211],{"type":65,"value":186},{"type":60,"tag":61,"props":213,"children":214},{},[215],{"type":65,"value":216},"It has been running and taking bookings ever since.",{"type":60,"tag":218,"props":219,"children":221},"h2",{"id":220},"the-booking-flow",[222],{"type":65,"value":223},"The booking flow",{"type":60,"tag":61,"props":225,"children":226},{},[227],{"type":65,"value":228},"A parent booking a party is usually doing it one-handed. The flow is deliberately linear — pick a\ndate, pick a slot, pay a deposit, get a confirmation — and each step is its own route:",{"type":60,"tag":230,"props":231,"children":235},"pre",{"className":232,"code":234,"language":65},[233],"language-text","\u002Fbook\n  → \u002Fbook\u002F[date]\n    → \u002Fbook\u002F[date]\u002F[timeslot]\n      → \u002Fdeposit\u002F[id]\n        → \u002Fconfirmation\u002F[id]\n",[236],{"type":60,"tag":237,"props":238,"children":239},"code",{"__ignoreMap":4},[240],{"type":65,"value":234},{"type":60,"tag":61,"props":242,"children":243},{},[244],{"type":60,"tag":245,"props":246,"children":249},"img",{"alt":247,"src":248},"Step one. Only dates staff have opened are selectable — everything else is greyed out before a parent can get it wrong.","\u002Fwork\u002Fjungleland\u002Fdate.webp",[],{"type":60,"tag":61,"props":251,"children":252},{},[253,255,260,262,268],{"type":65,"value":254},"Booking state lives in a ",{"type":60,"tag":256,"props":257,"children":258},"strong",{},[259],{"type":65,"value":30},{"type":65,"value":261}," store persisted to ",{"type":60,"tag":237,"props":263,"children":265},{"className":264},[],[266],{"type":65,"value":267},"localStorage",{"type":65,"value":269},", so a half-finished booking\nsurvives a refresh, a backgrounded tab, or a phone that decides to lock mid-payment. That single\ndecision removed the most common support complaint from the first version.",{"type":60,"tag":61,"props":271,"children":272},{},[273],{"type":60,"tag":245,"props":274,"children":277},{"alt":275,"src":276},"The details step. The £2 deposit is stated plainly before anyone reaches a card field.","\u002Fwork\u002Fjungleland\u002Fdetails.webp",[],{"type":60,"tag":218,"props":279,"children":281},{"id":280},"capacity-is-the-hard-part",[282],{"type":65,"value":283},"Capacity is the hard part",{"type":60,"tag":61,"props":285,"children":286},{},[287],{"type":60,"tag":245,"props":288,"children":291},{"alt":289,"src":290},"Slots for a Saturday, with the SEN session first. Green, red and grey are the only states that matter: available, sold out, gone.","\u002Fwork\u002Fjungleland\u002Ftimeslots.webp",[],{"type":60,"tag":61,"props":293,"children":294},{},[295,297,303,305,310],{"type":65,"value":296},"Everything else follows from not overselling a slot. The data model is deliberately narrow —\n",{"type":60,"tag":237,"props":298,"children":300},{"className":299},[],[301],{"type":65,"value":302},"Date → TimeSlot → Booking",{"type":65,"value":304}," in ",{"type":60,"tag":256,"props":306,"children":307},{},[308],{"type":65,"value":309},"AppSync\u002FGraphQL over DynamoDB",{"type":65,"value":311}," — and capacity is two limits on\nthe timeslot rather than one: a group limit and a space limit, because a party of eight is one\ngroup and eight spaces. A slot has to be under both to be bookable.",{"type":60,"tag":61,"props":313,"children":314},{},[315],{"type":65,"value":316},"What has actually been consumed is summed from the slot's bookings at read time, skipping anything\ncancelled or not yet confirmed. That keeps the write path free of counters to keep in step, at the\ncost of a number that is only ever as fresh as the last read — fine at this volume, and worth\nrevisiting if two parents were regularly racing for the last place in a session.",{"type":60,"tag":61,"props":318,"children":319},{},[320],{"type":65,"value":321},"Abandoned bookings expire on their own through DynamoDB TTLs rather than needing a cleanup job, so\na parent who bails at the payment step releases their slot without anyone intervening.",{"type":60,"tag":218,"props":323,"children":325},{"id":324},"payments-and-mail",[326],{"type":65,"value":327},"Payments and mail",{"type":60,"tag":61,"props":329,"children":330},{},[331,333,337,339,343],{"type":65,"value":332},"Deposits go through ",{"type":60,"tag":256,"props":334,"children":335},{},[336],{"type":65,"value":193},{"type":65,"value":338},", with the checkout session created server-side in a Nitro route so\nno key is ever exposed to the client. Confirmations are sent through ",{"type":60,"tag":256,"props":340,"children":341},{},[342],{"type":65,"value":196},{"type":65,"value":344},". Both are\nboring, on purpose — this is somebody's actual revenue.",{"type":60,"tag":61,"props":346,"children":347},{},[348],{"type":60,"tag":245,"props":349,"children":352},{"alt":350,"src":351},"Ten minutes to pay or the slot goes back. The countdown is the user-facing half of the expiry.","\u002Fwork\u002Fjungleland\u002Fdeposit.webp",[],{"type":60,"tag":61,"props":354,"children":355},{},[356],{"type":60,"tag":245,"props":357,"children":360},{"alt":358,"src":359},"Deposit paid, balance on arrival, and the one instruction that actually gets forgotten.","\u002Fwork\u002Fjungleland\u002Fconfirmation.webp",[],{"type":60,"tag":218,"props":362,"children":364},{"id":363},"admin",[365],{"type":65,"value":366},"Admin",{"type":60,"tag":61,"props":368,"children":369},{},[370],{"type":65,"value":371},"Staff get a JWT-authenticated admin area to create bookable dates, set slot capacity, and see\nwhat has been booked. It is intentionally plain: the people using it are running a play centre,\nnot learning software.",{"type":60,"tag":61,"props":373,"children":374},{},[375],{"type":60,"tag":245,"props":376,"children":379},{"alt":377,"src":378},"Pick a date, pick a slot, see who is coming. Groups and spaces are counted separately because a party of eight is one group and eight spaces.","\u002Fwork\u002Fjungleland\u002Fadmin.webp",[],{"type":60,"tag":61,"props":381,"children":382},{},[383],{"type":65,"value":384},"Capacity is two numbers, not one, and staff can change both per slot without involving me — which\nwas the whole point.",{"type":60,"tag":61,"props":386,"children":387},{},[388],{"type":60,"tag":245,"props":389,"children":392},{"alt":390,"src":391},"Both limits, editable on the slot itself.","\u002Fwork\u002Fjungleland\u002Flimits.webp",[],{"type":60,"tag":218,"props":394,"children":396},{"id":395},"what-id-change",[397],{"type":65,"value":398},"What I'd change",{"type":60,"tag":61,"props":400,"children":401},{},[402],{"type":65,"value":403},"The repo has no automated tests and no linting — it grew from something built quickly under a\nreopening deadline and never had that debt paid down. If I picked it up again today, capacity\nlogic is the first thing I'd put under test, because it is the part that costs real money when\nit is wrong.",{"title":405,"description":406,"tagline":407,"year":408,"status":409,"role":410,"featured":11,"order":135,"stack":411,"highlights":415,"slug":419,"body":420},"Vitro","Vitro is a component workshop for Vue 3 — the thing you open to build a component in isolation,\nbefore it has a page to live on.","A Vite-native component workshop for Vue 3",2026,"building","Creator",[24,412,26,413,414],"Vite","vue-component-meta","Shiki",[416,417,418],"Stories are real Vue templates","Controls derived from your props","A playground non-developers can use","vitro",{"type":57,"children":421},[422,426,431,443,449,454,467,475,481,494,679,687,700,706,734,742,748,779,784,792,798,825,831,836],{"type":60,"tag":61,"props":423,"children":424},{},[425],{"type":65,"value":406},{"type":60,"tag":61,"props":427,"children":428},{},[429],{"type":65,"value":430},"It exists because writing Storybook stories was always the part of the job I put off. Authoring\nfriction was the problem I actually wanted to solve; format churn came a close second, because a\nstory format that keeps changing underneath you turns migrations into routine work. Histoire, the\nobvious alternative, had stalled.",{"type":60,"tag":61,"props":432,"children":433},{},[434,436,441],{"type":65,"value":435},"The CLI is ",{"type":60,"tag":237,"props":437,"children":439},{"className":438},[],[440],{"type":65,"value":419},{"type":65,"value":442},". Vite is the builder — not wrapped, not shimmed, just Vite.",{"type":60,"tag":218,"props":444,"children":446},{"id":445},"who-it-turned-out-to-be-for",[447],{"type":65,"value":448},"Who it turned out to be for",{"type":60,"tag":61,"props":450,"children":451},{},[452],{"type":65,"value":453},"It has earned its keep in a way I did not plan for. A lot of my work at Spamhaus is building\ncomponents that the marketing team have only ever seen as a Figma frame — approved in a design\nfile, never touched. Giving non-technical stakeholders somewhere hands-on to poke at instead\n(change the props yourself, with the Figma frame it came from on the same page as the real\nrendered component) has been worth more than any amount of written documentation.",{"type":60,"tag":61,"props":455,"children":456},{},[457,459,465],{"type":65,"value":458},"That is what makes the shareable-URL state and the ",{"type":60,"tag":237,"props":460,"children":462},{"className":461},[],[463],{"type":65,"value":464},"figmaUrl",{"type":65,"value":466}," embed the two features I would\nkeep above all the others: they turn \"here is the component\" from a meeting into a link.",{"type":60,"tag":61,"props":468,"children":469},{},[470],{"type":60,"tag":245,"props":471,"children":474},{"alt":472,"src":473},"Autodocs for a component, with the Figma frame it was designed from directly underneath. Props, events and the design source in one view.","\u002Fwork\u002Fvitro\u002Fdocs.webp",[],{"type":60,"tag":218,"props":476,"children":478},{"id":477},"stories-are-just-vue",[479],{"type":65,"value":480},"Stories are just Vue",{"type":60,"tag":61,"props":482,"children":483},{},[484,486,492],{"type":65,"value":485},"The format is a ",{"type":60,"tag":237,"props":487,"children":489},{"className":488},[],[490],{"type":65,"value":491},".story.vue",{"type":65,"value":493}," file containing ordinary Vue markup:",{"type":60,"tag":230,"props":495,"children":499},{"className":496,"code":497,"language":498,"meta":4,"style":4},"language-vue shiki shiki-themes github-light github-dark","\u003Ctemplate>\n  \u003CStory title=\"Button\">\n    \u003CVariant title=\"Primary\">\n      \u003CAppButton variant=\"primary\">Save changes\u003C\u002FAppButton>\n    \u003C\u002FVariant>\n  \u003C\u002FStory>\n\u003C\u002Ftemplate>\n","vue",[500],{"type":60,"tag":237,"props":501,"children":502},{"__ignoreMap":4},[503,525,559,589,629,645,662],{"type":60,"tag":504,"props":505,"children":507},"span",{"class":506,"line":77},"line",[508,514,520],{"type":60,"tag":504,"props":509,"children":511},{"style":510},"--shiki-default:#24292E;--shiki-dark:#E1E4E8",[512],{"type":65,"value":513},"\u003C",{"type":60,"tag":504,"props":515,"children":517},{"style":516},"--shiki-default:#22863A;--shiki-dark:#85E89D",[518],{"type":65,"value":519},"template",{"type":60,"tag":504,"props":521,"children":522},{"style":510},[523],{"type":65,"value":524},">\n",{"type":60,"tag":504,"props":526,"children":527},{"class":506,"line":135},[528,533,538,544,549,555],{"type":60,"tag":504,"props":529,"children":530},{"style":510},[531],{"type":65,"value":532},"  \u003C",{"type":60,"tag":504,"props":534,"children":535},{"style":516},[536],{"type":65,"value":537},"Story",{"type":60,"tag":504,"props":539,"children":541},{"style":540},"--shiki-default:#6F42C1;--shiki-dark:#B392F0",[542],{"type":65,"value":543}," title",{"type":60,"tag":504,"props":545,"children":546},{"style":510},[547],{"type":65,"value":548},"=",{"type":60,"tag":504,"props":550,"children":552},{"style":551},"--shiki-default:#032F62;--shiki-dark:#9ECBFF",[553],{"type":65,"value":554},"\"Button\"",{"type":60,"tag":504,"props":556,"children":557},{"style":510},[558],{"type":65,"value":524},{"type":60,"tag":504,"props":560,"children":561},{"class":506,"line":148},[562,567,572,576,580,585],{"type":60,"tag":504,"props":563,"children":564},{"style":510},[565],{"type":65,"value":566},"    \u003C",{"type":60,"tag":504,"props":568,"children":569},{"style":516},[570],{"type":65,"value":571},"Variant",{"type":60,"tag":504,"props":573,"children":574},{"style":540},[575],{"type":65,"value":543},{"type":60,"tag":504,"props":577,"children":578},{"style":510},[579],{"type":65,"value":548},{"type":60,"tag":504,"props":581,"children":582},{"style":551},[583],{"type":65,"value":584},"\"Primary\"",{"type":60,"tag":504,"props":586,"children":587},{"style":510},[588],{"type":65,"value":524},{"type":60,"tag":504,"props":590,"children":591},{"class":506,"line":161},[592,597,602,607,611,616,621,625],{"type":60,"tag":504,"props":593,"children":594},{"style":510},[595],{"type":65,"value":596},"      \u003C",{"type":60,"tag":504,"props":598,"children":599},{"style":516},[600],{"type":65,"value":601},"AppButton",{"type":60,"tag":504,"props":603,"children":604},{"style":540},[605],{"type":65,"value":606}," variant",{"type":60,"tag":504,"props":608,"children":609},{"style":510},[610],{"type":65,"value":548},{"type":60,"tag":504,"props":612,"children":613},{"style":551},[614],{"type":65,"value":615},"\"primary\"",{"type":60,"tag":504,"props":617,"children":618},{"style":510},[619],{"type":65,"value":620},">Save changes\u003C\u002F",{"type":60,"tag":504,"props":622,"children":623},{"style":516},[624],{"type":65,"value":601},{"type":60,"tag":504,"props":626,"children":627},{"style":510},[628],{"type":65,"value":524},{"type":60,"tag":504,"props":630,"children":631},{"class":506,"line":175},[632,637,641],{"type":60,"tag":504,"props":633,"children":634},{"style":510},[635],{"type":65,"value":636},"    \u003C\u002F",{"type":60,"tag":504,"props":638,"children":639},{"style":516},[640],{"type":65,"value":571},{"type":60,"tag":504,"props":642,"children":643},{"style":510},[644],{"type":65,"value":524},{"type":60,"tag":504,"props":646,"children":648},{"class":506,"line":647},6,[649,654,658],{"type":60,"tag":504,"props":650,"children":651},{"style":510},[652],{"type":65,"value":653},"  \u003C\u002F",{"type":60,"tag":504,"props":655,"children":656},{"style":516},[657],{"type":65,"value":537},{"type":60,"tag":504,"props":659,"children":660},{"style":510},[661],{"type":65,"value":524},{"type":60,"tag":504,"props":663,"children":665},{"class":506,"line":664},7,[666,671,675],{"type":60,"tag":504,"props":667,"children":668},{"style":510},[669],{"type":65,"value":670},"\u003C\u002F",{"type":60,"tag":504,"props":672,"children":673},{"style":516},[674],{"type":65,"value":519},{"type":60,"tag":504,"props":676,"children":677},{"style":510},[678],{"type":65,"value":524},{"type":60,"tag":61,"props":680,"children":681},{},[682],{"type":60,"tag":245,"props":683,"children":686},{"alt":684,"src":685},"The Source view. The story is the file on disk, highlighted with Shiki — nothing is generated from an intermediate format.","\u002Fwork\u002Fvitro\u002Fsource.webp",[],{"type":60,"tag":61,"props":688,"children":689},{},[690,692,698],{"type":65,"value":691},"No abstract ",{"type":60,"tag":237,"props":693,"children":695},{"className":694},[],[696],{"type":65,"value":697},"args",{"type":65,"value":699}," object standing in for a template. You write the markup you would actually\nwrite, which means a story is readable by anyone on the team and stays honest about how the\ncomponent is really used. The format is a semver-protected, additive-only contract — the\nmigration churn is the thing I was trying to escape, so committing to never causing it was the\npoint.",{"type":60,"tag":218,"props":701,"children":703},{"id":702},"controls-you-didnt-have-to-write",[704],{"type":65,"value":705},"Controls you didn't have to write",{"type":60,"tag":61,"props":707,"children":708},{},[709,711,716,718,724,726,732],{"type":65,"value":710},"Controls are derived from the component's own props via ",{"type":60,"tag":237,"props":712,"children":714},{"className":713},[],[715],{"type":65,"value":413},{"type":65,"value":717},". A variant with no\nslot content auto-renders ",{"type":60,"tag":237,"props":719,"children":721},{"className":720},[],[722],{"type":65,"value":723},"\u003CComponent v-bind=\"state\" \u002F>",{"type":65,"value":725},", so a component you have just written\ngets a working playground with zero configuration. Manual ",{"type":60,"tag":237,"props":727,"children":729},{"className":728},[],[730],{"type":65,"value":731},"Hst*",{"type":65,"value":733}," controls are there when you want\nto override, and control changes update the preview live without remounting — state survives, so\nyou can tweak a prop mid-interaction.",{"type":60,"tag":61,"props":735,"children":736},{},[737],{"type":60,"tag":245,"props":738,"children":741},{"alt":739,"src":740},"The Auto variant. Nobody wrote those controls — variant and disabled are read off the component's own prop types.","\u002Fwork\u002Fvitro\u002Fcontrols.webp",[],{"type":60,"tag":218,"props":743,"children":745},{"id":744},"three-views",[746],{"type":65,"value":747},"Three views",{"type":60,"tag":61,"props":749,"children":750},{},[751,756,758,763,765,770,772,777],{"type":60,"tag":256,"props":752,"children":753},{},[754],{"type":65,"value":755},"Canvas",{"type":65,"value":757}," for the component, ",{"type":60,"tag":256,"props":759,"children":760},{},[761],{"type":65,"value":762},"Source",{"type":65,"value":764}," for Shiki-highlighted markup, ",{"type":60,"tag":256,"props":766,"children":767},{},[768],{"type":65,"value":769},"Docs",{"type":65,"value":771}," for autodocs —\nprop, event and slot tables pulled from the type signatures, including JSDoc descriptions. If a\n",{"type":60,"tag":237,"props":773,"children":775},{"className":774},[],[776],{"type":65,"value":464},{"type":65,"value":778}," is set on the story, that frame embeds under the tables — and because the URL carries a\nnode id, each story points at its own frame rather than the whole file.",{"type":60,"tag":61,"props":780,"children":781},{},[782],{"type":65,"value":783},"The full UI state — story, variant, every control value, viewport, background, theme — is encoded\nin the URL. Any view you are looking at is a link you can paste to a designer.",{"type":60,"tag":61,"props":785,"children":786},{},[787],{"type":60,"tag":245,"props":788,"children":791},{"alt":789,"src":790},"Dark theme and canvas background, both reached by changing the URL rather than the UI. ?bg=dark&theme=dark is the whole of it.","\u002Fwork\u002Fvitro\u002Fdark.webp",[],{"type":60,"tag":218,"props":793,"children":795},{"id":794},"built-output",[796],{"type":65,"value":797},"Built output",{"type":60,"tag":61,"props":799,"children":800},{},[801,807,809,815,817,823],{"type":60,"tag":237,"props":802,"children":804},{"className":803},[],[805],{"type":65,"value":806},"vitro build",{"type":65,"value":808}," produces a static, relative-path ",{"type":60,"tag":237,"props":810,"children":812},{"className":811},[],[813],{"type":65,"value":814},"dist\u002F",{"type":65,"value":816}," with per-story chunks that will serve from\nany subpath. It also emits a Storybook-compatible ",{"type":60,"tag":237,"props":818,"children":820},{"className":819},[],[821],{"type":65,"value":822},"\u002Findex.json",{"type":65,"value":824},", so existing visual-regression\ntooling — Lost Pixel, Playwright — works against it without adaptation. Adopting a new workshop\nshould not mean rewriting your VR pipeline.",{"type":60,"tag":218,"props":826,"children":828},{"id":827},"status",[829],{"type":65,"value":830},"Status",{"type":60,"tag":61,"props":832,"children":833},{},[834],{"type":65,"value":835},"Everything described here works today — the story format, derived controls, all three views, URL\nstate, and the static build. Hosted publishing is the next piece: somewhere to put a built\nworkshop so a designer can open a link without running anything locally.",{"type":60,"tag":837,"props":838,"children":839},"style",{},[840],{"type":65,"value":841},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":843,"description":844,"tagline":845,"year":408,"status":409,"role":410,"featured":11,"order":148,"stack":846,"highlights":851,"slug":855,"body":856},"Cubby","Cubby is a self-hosted password manager — a Bitwarden replacement with a web dashboard and a\nFirefox extension. The constraint that shapes every decision in it: the server never sees\nanything it could decrypt. Your master password and vault keys do not leave the client.","A zero-knowledge password manager",[192,26,847,848,849,850],"Web Crypto","Postgres","Docker","WXT",[852,853,854],"Server only ever holds ciphertext","Shared vaults without breaking the model","Proven by test, not by claim","cubby",{"type":57,"children":857},[858,870,875,881,901,909,917,925,931,936,944,950,955,963,968,976,982],{"type":60,"tag":61,"props":859,"children":860},{},[861,863,868],{"type":65,"value":862},"Cubby is a self-hosted password manager — a Bitwarden replacement with a web dashboard and a\nFirefox extension. The constraint that shapes every decision in it: ",{"type":60,"tag":256,"props":864,"children":865},{},[866],{"type":65,"value":867},"the server never sees\nanything it could decrypt.",{"type":65,"value":869}," Your master password and vault keys do not leave the client.",{"type":60,"tag":61,"props":871,"children":872},{},[873],{"type":65,"value":874},"That is easy to claim and easy to get subtly wrong, so it is asserted by a test that talks to a\nlive Postgres, registers a user, creates an item, and fails if any row holds anything but\nciphertext. The property is enforced by CI rather than by my good intentions.",{"type":60,"tag":218,"props":876,"children":878},{"id":877},"where-the-crypto-lives",[879],{"type":65,"value":880},"Where the crypto lives",{"type":60,"tag":61,"props":882,"children":883},{},[884,886,892,894,899],{"type":65,"value":885},"Key derivation happens in the browser. The symmetric key stays in memory only — reload the page\nand you get the unlock screen back, master password required. The API deals almost exclusively in\nopaque blobs; it can tell you ",{"type":60,"tag":887,"props":888,"children":889},"em",{},[890],{"type":65,"value":891},"that",{"type":65,"value":893}," an item changed and ",{"type":60,"tag":887,"props":895,"children":896},{},[897],{"type":65,"value":898},"when",{"type":65,"value":900},", never what it says.",{"type":60,"tag":61,"props":902,"children":903},{},[904],{"type":60,"tag":245,"props":905,"children":908},{"alt":906,"src":907},"The unlock screen. Reload the tab and you are back here, because the key was only ever in memory.","\u002Fwork\u002Fcubby\u002Funlock.webp",[],{"type":60,"tag":61,"props":910,"children":911},{},[912],{"type":60,"tag":245,"props":913,"children":916},{"alt":914,"src":915},"The vault. Names, usernames and TOTP codes are all decrypted client-side; the rows they came from hold nothing but ciphertext.","\u002Fwork\u002Fcubby\u002Fvault.webp",[],{"type":60,"tag":61,"props":918,"children":919},{},[920],{"type":60,"tag":245,"props":921,"children":924},{"alt":922,"src":923},"An item open, with the password revealed and a live TOTP code counting down beside it.","\u002Fwork\u002Fcubby\u002Fitem.webp",[],{"type":60,"tag":218,"props":926,"children":928},{"id":927},"sharing-without-cheating",[929],{"type":65,"value":930},"Sharing without cheating",{"type":60,"tag":61,"props":932,"children":933},{},[934],{"type":65,"value":935},"Shared vaults are where zero-knowledge designs usually quietly compromise. Cubby gives every user\nan RSA key-pair, with the private key wrapped under their own symmetric key. A shared vault has\nits own key, delivered to each member encrypted to that member's public key. Invite by email,\naccept, and everyone can read and edit — while the server still holds nothing but ciphertext and\na set of per-member wrapped keys it cannot open.",{"type":60,"tag":61,"props":937,"children":938},{},[939],{"type":60,"tag":245,"props":940,"children":943},{"alt":941,"src":942},"Inviting someone to a shared vault. Accepting is what delivers them the vault key, encrypted to their public key — the server never handles an openable copy.","\u002Fwork\u002Fcubby\u002Fshared.webp",[],{"type":60,"tag":218,"props":945,"children":947},{"id":946},"the-rest-of-it",[948],{"type":65,"value":949},"The rest of it",{"type":60,"tag":61,"props":951,"children":952},{},[953],{"type":65,"value":954},"Revision-based delta sync against an encrypted local cache, so a client fetches what changed\nrather than the whole vault. Folders with encrypted names. Live TOTP codes. A configurable\ngenerator with look-alike exclusion. Client-side Bitwarden JSON import — decrypted, re-encrypted\nand uploaded without the plaintext touching the wire. Account recovery via a one-time key issued\nat signup, because \"we can reset it for you\" is precisely what a zero-knowledge system cannot\noffer.",{"type":60,"tag":61,"props":956,"children":957},{},[958],{"type":60,"tag":245,"props":959,"children":962},{"alt":960,"src":961},"The generator. Look-alike exclusion is the toggle that stops a password containing characters you cannot tell apart when reading it aloud.","\u002Fwork\u002Fcubby\u002Fgenerator.webp",[],{"type":60,"tag":61,"props":964,"children":965},{},[966],{"type":65,"value":967},"Password health runs entirely on the client, because it has to — the analysis needs the plaintext,\nand the plaintext only exists in the browser. Weak and reused passwords are found locally; the\nbreach check sends a five-character hash prefix and matches the rest here.",{"type":60,"tag":61,"props":969,"children":970},{},[971],{"type":60,"tag":245,"props":972,"children":975},{"alt":973,"src":974},"Password health. Reuse is exact-match across the vault; the breach check is HIBP k-anonymity, so no password or full hash leaves the client.","\u002Fwork\u002Fcubby\u002Fhealth.webp",[],{"type":60,"tag":218,"props":977,"children":979},{"id":978},"running-it",[980],{"type":65,"value":981},"Running it",{"type":60,"tag":61,"props":983,"children":984},{},[985,987,993,995,1001],{"type":65,"value":986},"One ",{"type":60,"tag":237,"props":988,"children":990},{"className":989},[],[991],{"type":65,"value":992},"docker compose up",{"type":65,"value":994}," brings up Postgres, the API, and a Caddy proxy handling auto-TLS and\nrouting, plus a scheduled ",{"type":60,"tag":237,"props":996,"children":998},{"className":997},[],[999],{"type":65,"value":1000},"pg_dump",{"type":65,"value":1002}," backup with retention. The Firefox extension is built with\nWXT and is the piece still in progress.",{"title":1004,"description":1005,"tagline":1006,"year":408,"status":409,"role":410,"featured":11,"order":161,"stack":1007,"highlights":1011,"slug":1015,"body":1016},"Beacon","I run a handful of Docker containers at home that are reachable from the internet. Beacon exists\nbecause I kept finding out one of them had fallen over by trying to use it.","Uptime monitoring for the containers I self-host",[192,26,1008,848,849,1009,1010],"Drizzle","Cloudflare Tunnel","Vitest",[1012,1013,1014],"Watches from outside the box","Correctness enforced by the database","292 tests against a real Postgres","beacon",{"type":57,"children":1017},[1018,1022,1028,1038,1050,1056,1068,1076,1081,1090,1098,1104,1109,1121,1127,1147,1152,1158,1163,1171,1176,1184,1190,1201,1206,1212,1217,1223,1283],{"type":60,"tag":61,"props":1019,"children":1020},{},[1021],{"type":65,"value":1005},{"type":60,"tag":218,"props":1023,"children":1025},{"id":1024},"the-one-rule",[1026],{"type":65,"value":1027},"The one rule",{"type":60,"tag":61,"props":1029,"children":1030},{},[1031,1036],{"type":60,"tag":256,"props":1032,"children":1033},{},[1034],{"type":65,"value":1035},"Beacon must not share a fate with the things it watches.",{"type":65,"value":1037}," Its entire job is to notice when\nsomething has stopped answering, which it cannot do if one failure takes them both out.",{"type":60,"tag":61,"props":1039,"children":1040},{},[1041,1043,1048],{"type":65,"value":1042},"My first instinct was a separate box in the house, and that is not enough. A power cut, a dead\nrouter or an ISP outage takes down the services ",{"type":60,"tag":887,"props":1044,"children":1045},{},[1046],{"type":65,"value":1047},"and",{"type":65,"value":1049}," the monitor — and that is precisely the\nfailure you most want to hear about. So it runs off-premises on a free-tier VM with its own power\nand its own connection, and reaches the services over the public internet exactly as anyone else\nwould. It is testing the path in, not just the process.",{"type":60,"tag":218,"props":1051,"children":1053},{"id":1052},"one-question-per-page",[1054],{"type":65,"value":1055},"One question per page",{"type":60,"tag":61,"props":1057,"children":1058},{},[1059,1061,1066],{"type":65,"value":1060},"The dashboard answers ",{"type":60,"tag":887,"props":1062,"children":1063},{},[1064],{"type":65,"value":1065},"is anything wrong right now",{"type":65,"value":1067}," — in a sentence, before any table. If\nsomething is down it says which service and for how long; if nothing is, it says so and gets out\nof the way. It should be readable from a phone in a few seconds.",{"type":60,"tag":61,"props":1069,"children":1070},{},[1071],{"type":60,"tag":245,"props":1072,"children":1075},{"alt":1073,"src":1074},"The dashboard: a plain-language verdict, then services grouped by the machine they run on.","\u002Fwork\u002Fbeacon\u002Fdashboard.webp",[],{"type":60,"tag":61,"props":1077,"children":1078},{},[1079],{"type":65,"value":1080},"Services are grouped by host — the machine they run on. That is not cosmetic. It is what makes\n\"the NUC is down\" one line rather than six identical alerts, and it is the unit history is kept\nagainst.",{"type":60,"tag":230,"props":1082,"children":1085},{"className":1083,"code":1084,"language":65},[233],"host          a machine — the NUC under the stairs, a Pi, a VPS\n └── monitor  a service on it, checked on its own schedule\n      ├── check_result   one row per probe, pruned at 30 days\n      ├── incident       one open per monitor per kind\n      └── daily_uptime   the rollup that survives pruning\n",[1086],{"type":60,"tag":237,"props":1087,"children":1088},{"__ignoreMap":4},[1089],{"type":65,"value":1084},{"type":60,"tag":61,"props":1091,"children":1092},{},[1093],{"type":60,"tag":245,"props":1094,"children":1097},{"alt":1095,"src":1096},"The host list. Environment is a label for my own benefit — it changes nothing about how often anything is checked.","\u002Fwork\u002Fbeacon\u002Fhosts.webp",[],{"type":60,"tag":218,"props":1099,"children":1101},{"id":1100},"deciding-what-counts-as-down",[1102],{"type":65,"value":1103},"Deciding what counts as down",{"type":60,"tag":61,"props":1105,"children":1106},{},[1107],{"type":65,"value":1108},"A single failed check is not an outage. The default is two consecutive failures before an incident\nopens, which at 60-second checks means roughly two minutes to notification — long enough that a\ndropped packet or a container restart never emails anyone, short enough to be useful. One failure\nwould make the whole thing something you learn to ignore, which is the actual failure mode for\nalerting tools.",{"type":60,"tag":61,"props":1110,"children":1111},{},[1112,1114,1119],{"type":65,"value":1113},"Recovery closes the incident and sends a second email. Alerts are written to the database ",{"type":60,"tag":887,"props":1115,"children":1116},{},[1117],{"type":65,"value":1118},"before",{"type":65,"value":1120},"\nthe send is attempted, so a crash mid-send leaves the row unsent and the next cycle retries it.\nAt-least-once delivery is the right trade here: a duplicate email beats a missed outage.",{"type":60,"tag":218,"props":1122,"children":1124},{"id":1123},"correctness-that-does-not-depend-on-my-code-being-careful",[1125],{"type":65,"value":1126},"Correctness that does not depend on my code being careful",{"type":60,"tag":61,"props":1128,"children":1129},{},[1130,1132,1137,1139,1145],{"type":65,"value":1131},"Two concurrent check cycles must not both open a \"down\" incident for the same service. Rather than\nguard that in application logic, it is a ",{"type":60,"tag":256,"props":1133,"children":1134},{},[1135],{"type":65,"value":1136},"partial unique index",{"type":65,"value":1138}," — one open incident per monitor\nper kind, ",{"type":60,"tag":237,"props":1140,"children":1142},{"className":1141},[],[1143],{"type":65,"value":1144},"WHERE resolved_at IS NULL",{"type":65,"value":1146},". The second writer gets a constraint violation from\nPostgres. The rule holds regardless of what the application does next.",{"type":60,"tag":61,"props":1148,"children":1149},{},[1150],{"type":65,"value":1151},"Raw checks are pruned at 30 days and rolled up into daily uptime first, because 20 services at\n60-second intervals is about 10.5 million rows a year, and \"uptime over the last 90 days\" should\nstill be one cheap read.",{"type":60,"tag":218,"props":1153,"children":1155},{"id":1154},"the-other-question-has-this-been-happening",[1156],{"type":65,"value":1157},"The other question: has this been happening",{"type":60,"tag":61,"props":1159,"children":1160},{},[1161],{"type":65,"value":1162},"Each host has a history page — 90 days of uptime as a strip, response times, and the incidents\nbehind them. A missing day renders as a third state rather than as zero, because a day with no\ndata is not a day with an outage, and colouring it red would invent one.",{"type":60,"tag":61,"props":1164,"children":1165},{},[1166],{"type":60,"tag":245,"props":1167,"children":1170},{"alt":1168,"src":1169},"A host's history: 90 days per service, with days that were never monitored kept visually distinct from days that were down.","\u002Fwork\u002Fbeacon\u002Fhistory.webp",[],{"type":60,"tag":61,"props":1172,"children":1173},{},[1174],{"type":65,"value":1175},"Months are also rendered as a printable page rather than a generated PDF, so there is no headless\nbrowser in the container and the browser's own \"Save as PDF\" does the work.",{"type":60,"tag":61,"props":1177,"children":1178},{},[1179],{"type":60,"tag":245,"props":1180,"children":1183},{"alt":1181,"src":1182},"A month as a print-styled page.","\u002Fwork\u002Fbeacon\u002Freport.webp",[],{"type":60,"tag":218,"props":1185,"children":1187},{"id":1186},"nothing-dials-in",[1188],{"type":65,"value":1189},"Nothing dials in",{"type":60,"tag":61,"props":1191,"children":1192},{},[1193,1195,1199],{"type":65,"value":1194},"Both ends use a ",{"type":60,"tag":256,"props":1196,"children":1197},{},[1198],{"type":65,"value":1009},{"type":65,"value":1200},", in opposite directions. The services at home are reachable\nwithout a single inbound port open, no port forwarding and no certificates to renew. Beacon's own\ndashboard is published the same way, which on a cloud VM means never touching a firewall — and\nOracle Cloud has two independent ones that disagree with each other, so avoiding both is worth\nmore than it sounds.",{"type":60,"tag":61,"props":1202,"children":1203},{},[1204],{"type":65,"value":1205},"The tunnel dials out; nothing dials in.",{"type":60,"tag":218,"props":1207,"children":1209},{"id":1208},"tested-against-a-real-database",[1210],{"type":65,"value":1211},"Tested against a real database",{"type":60,"tag":61,"props":1213,"children":1214},{},[1215],{"type":65,"value":1216},"292 tests, and the integration ones run migrations against Postgres compiled to WASM rather than a\nmock. That matters for this app specifically: a mocked database would happily accept the duplicate\nincident the partial unique index exists to reject, so the test would pass and the constraint\nwould be decoration.",{"type":60,"tag":218,"props":1218,"children":1220},{"id":1219},"configuration-is-load-bearing",[1221],{"type":65,"value":1222},"Configuration is load-bearing",{"type":60,"tag":61,"props":1224,"children":1225},{},[1226,1228,1234,1236,1242,1244,1249,1251,1257,1259,1265,1267,1273,1275,1281],{"type":65,"value":1227},"A note I left in the deployment guide for my future self, after it bit me twice: every application\nvariable must be ",{"type":60,"tag":237,"props":1229,"children":1231},{"className":1230},[],[1232],{"type":65,"value":1233},"NUXT_",{"type":65,"value":1235},"-prefixed. Nitro bakes ",{"type":60,"tag":237,"props":1237,"children":1239},{"className":1238},[],[1240],{"type":65,"value":1241},"runtimeConfig",{"type":65,"value":1243}," at build time and re-reads it at\nruntime ",{"type":60,"tag":887,"props":1245,"children":1246},{},[1247],{"type":65,"value":1248},"only",{"type":65,"value":1250}," from ",{"type":60,"tag":237,"props":1252,"children":1254},{"className":1253},[],[1255],{"type":65,"value":1256},"NUXT_*",{"type":65,"value":1258},". Set ",{"type":60,"tag":237,"props":1260,"children":1262},{"className":1261},[],[1263],{"type":65,"value":1264},"ADMIN_PASSWORD_HASH",{"type":65,"value":1266}," instead of ",{"type":60,"tag":237,"props":1268,"children":1270},{"className":1269},[],[1271],{"type":65,"value":1272},"NUXT_ADMIN_PASSWORD_HASH",{"type":65,"value":1274}," and\nsign-in returns a 503 while ",{"type":60,"tag":237,"props":1276,"children":1278},{"className":1277},[],[1279],{"type":65,"value":1280},"docker compose exec",{"type":65,"value":1282}," shows you a perfectly correct-looking value in\nthe environment. The container starts, the dashboard loads, and the one thing you need it to do\nfails silently.",{"type":60,"tag":61,"props":1284,"children":1285},{},[1286],{"type":65,"value":1287},"The helper script that generates the hash was printing the unprefixed name — walking people\ndirectly into the trap its own documentation warned about. Small operational details like that are\nmost of what running your own infrastructure actually is.",{"title":1289,"description":1290,"tagline":1291,"year":408,"status":409,"role":410,"featured":1292,"order":175,"stack":1293,"highlights":1295,"slug":1299,"body":1300},"Job Search","Applying for work is mostly reading. Fifty postings that use the same words to describe\nmaterially different jobs, and the part that decides yes or no — is this remote really, is\n\"lead\" a title or two direct reports — is usually one clause somewhere in the middle.","An LLM-assisted pipeline for reading job postings honestly",false,[192,26,1294,1008,848,1010],"Anthropic API",[1296,1297,1298],"Model output is never trusted raw","Degrades to a working app with no key","Two models, chosen per task","job-search",{"type":57,"children":1301},[1302,1314,1319,1325,1330,1338,1343,1349,1394,1399,1405,1410,1418,1423,1428,1433,1452,1460,1466,1477,1482,1490,1496,1509],{"type":60,"tag":61,"props":1303,"children":1304},{},[1305,1307,1312],{"type":65,"value":1306},"Applying for work is mostly reading. Fifty postings that use the same words to describe\nmaterially different jobs, and the part that decides yes or no — is this remote ",{"type":60,"tag":887,"props":1308,"children":1309},{},[1310],{"type":65,"value":1311},"really",{"type":65,"value":1313},", is\n\"lead\" a title or two direct reports — is usually one clause somewhere in the middle.",{"type":60,"tag":61,"props":1315,"children":1316},{},[1317],{"type":65,"value":1318},"So this is a tracker with an LLM in the loop: capture a posting, score it against what I\nactually want, and keep the pipeline visible. The interesting engineering is not the prompting.\nIt is everything around the model.",{"type":60,"tag":218,"props":1320,"children":1322},{"id":1321},"what-i-want-as-prose",[1323],{"type":65,"value":1324},"What I want, as prose",{"type":60,"tag":61,"props":1326,"children":1327},{},[1328],{"type":65,"value":1329},"Fit scoring needs something to score against. That is a written profile, not a form:",{"type":60,"tag":61,"props":1331,"children":1332},{},[1333],{"type":60,"tag":245,"props":1334,"children":1337},{"alt":1335,"src":1336},"The preference profile. Prose rather than checkboxes, because the things that decide a yes or no are not checkboxes.","\u002Fwork\u002Fjob-search\u002Fpreferences.webp",[],{"type":60,"tag":61,"props":1339,"children":1340},{},[1341],{"type":65,"value":1342},"Checkboxes cannot express \"React acceptable if the team is strong on fundamentals\" or \"product\ncompany over agency\", and those are exactly the criteria that settle most decisions. Prose in,\nprose compared — the model is good at that, and a filter is not.",{"type":60,"tag":218,"props":1344,"children":1346},{"id":1345},"the-model-output-is-never-trusted-raw",[1347],{"type":65,"value":1348},"The model output is never trusted raw",{"type":60,"tag":61,"props":1350,"children":1351},{},[1352,1354,1360,1362,1368,1370,1376,1378,1384,1386,1392],{"type":65,"value":1353},"Every call constrains output with a JSON schema — ",{"type":60,"tag":237,"props":1355,"children":1357},{"className":1356},[],[1358],{"type":65,"value":1359},"output_config: { format: { type: 'json_schema' } }",{"type":65,"value":1361},"\n— so the response is shaped before it arrives. That is necessary but not sufficient, so each\nresponse then goes through a ",{"type":60,"tag":237,"props":1363,"children":1365},{"className":1364},[],[1366],{"type":65,"value":1367},"coerce*",{"type":65,"value":1369}," function at the boundary: ",{"type":60,"tag":237,"props":1371,"children":1373},{"className":1372},[],[1374],{"type":65,"value":1375},"coerceScore",{"type":65,"value":1377}," clamps to 0–100\nand normalises the arrays, ",{"type":60,"tag":237,"props":1379,"children":1381},{"className":1380},[],[1382],{"type":65,"value":1383},"coerceSummary",{"type":65,"value":1385}," shapes the signals object, ",{"type":60,"tag":237,"props":1387,"children":1389},{"className":1388},[],[1390],{"type":65,"value":1391},"coerceExtraction",{"type":65,"value":1393}," maps\nthe sentinels a model returns for \"not stated\" onto real nulls.",{"type":60,"tag":61,"props":1395,"children":1396},{},[1397],{"type":65,"value":1398},"Those coercers are the most-tested code in the project, and they are pure functions taking a\nplain object — so they are unit-tested with no key, no network and no mocking of the SDK. Of 57\ntests, none of them call the API. The parts that could be wrong in a way that matters are the\nparts that don't need it.",{"type":60,"tag":218,"props":1400,"children":1402},{"id":1401},"a-score-is-worthless-without-its-reasoning",[1403],{"type":65,"value":1404},"A score is worthless without its reasoning",{"type":60,"tag":61,"props":1406,"children":1407},{},[1408],{"type":65,"value":1409},"A number on its own invites you to either trust it or ignore it. So a score arrives with the\nrationale, what matched, what is missing, and the stack it found:",{"type":60,"tag":61,"props":1411,"children":1412},{},[1413],{"type":60,"tag":245,"props":1414,"children":1417},{"alt":1415,"src":1416},"The AI panel: a score, the reasoning behind it, matches and gaps, and the extracted stack.","\u002Fwork\u002Fjob-search\u002Fai-panel.webp",[],{"type":60,"tag":61,"props":1419,"children":1420},{},[1421],{"type":65,"value":1422},"The gaps are the useful half. \"On-call expectations unclear\" is not a reason to reject a posting\n— it is the question to ask on the call, which is a better output than a verdict. And when the\nrationale is visible, a bad score is obviously bad, which is the only practical way to keep\ntrusting the good ones.",{"type":60,"tag":61,"props":1424,"children":1425},{},[1426],{"type":65,"value":1427},"Scores are also stamped with the profile they were scored against, so editing what I want does\nnot silently leave stale numbers looking current.",{"type":60,"tag":218,"props":1429,"children":1431},{"id":1430},"two-models-chosen-per-task",[1432],{"type":65,"value":1298},{"type":60,"tag":61,"props":1434,"children":1435},{},[1436,1438,1443,1445,1450],{"type":65,"value":1437},"Scoring, extraction and summarising run on ",{"type":60,"tag":256,"props":1439,"children":1440},{},[1441],{"type":65,"value":1442},"Haiku",{"type":65,"value":1444}," — they are high-volume, structured, and\ncheap by design. Drafting a cover letter runs on ",{"type":60,"tag":256,"props":1446,"children":1447},{},[1448],{"type":65,"value":1449},"Sonnet",{"type":65,"value":1451},", because that is prose someone will\nread and the difference shows. Both are environment-overridable, because pinning a model in\nsource is how you end up on last year's model forever.",{"type":60,"tag":61,"props":1453,"children":1454},{},[1455],{"type":60,"tag":245,"props":1456,"children":1459},{"alt":1457,"src":1458},"The board, sorted by fit. Scores are colour-banded, so triage is a glance rather than a read.","\u002Fwork\u002Fjob-search\u002Fboard.webp",[],{"type":60,"tag":218,"props":1461,"children":1463},{"id":1462},"it-has-to-work-with-no-key-at-all",[1464],{"type":65,"value":1465},"It has to work with no key at all",{"type":60,"tag":61,"props":1467,"children":1468},{},[1469,1475],{"type":60,"tag":237,"props":1470,"children":1472},{"className":1471},[],[1473],{"type":65,"value":1474},"isAiEnabled()",{"type":65,"value":1476}," is a presence check on the API key, and every AI surface is behind it. With no\nkey the app is a complete tracker: capture, kanban, timeline, reminders, documents, search. The\nAI controls simply are not rendered.",{"type":60,"tag":61,"props":1478,"children":1479},{},[1480],{"type":65,"value":1481},"That is not politeness about optional dependencies. An app that half-works without its API key\nis one that fails confusingly the first time a key expires — and it made the whole AI layer\ntestable without one, which is why the coercers have the coverage they do.",{"type":60,"tag":61,"props":1483,"children":1484},{},[1485],{"type":60,"tag":245,"props":1486,"children":1489},{"alt":1487,"src":1488},"The dashboard: funnel, applications per week, and what needs chasing.","\u002Fwork\u002Fjob-search\u002Fdashboard.webp",[],{"type":60,"tag":218,"props":1491,"children":1493},{"id":1492},"capture-in-three-tiers",[1494],{"type":65,"value":1495},"Capture, in three tiers",{"type":60,"tag":61,"props":1497,"children":1498},{},[1499,1501,1507],{"type":65,"value":1500},"Paste a URL and it tries the cheap thing first: schema.org ",{"type":60,"tag":237,"props":1502,"children":1504},{"className":1503},[],[1505],{"type":65,"value":1506},"JobPosting",{"type":65,"value":1508},", then OpenGraph, and only\nthen the model on the raw text. Most postings are structured data and cost nothing to read\nproperly. A pluggable scraper framework (RSS and generic HTML) feeds a staging inbox with dedup,\nso nothing auto-enters the pipeline without being looked at.",{"type":60,"tag":61,"props":1510,"children":1511},{},[1512],{"type":65,"value":1513},"Ordering those by cost rather than by capability is the whole trick. Reaching for the model first\nwould work, and would be slower and more expensive on the majority of inputs that never needed\nit.",{"title":1515,"description":1516,"tagline":1517,"year":408,"status":189,"role":410,"featured":1292,"order":647,"stack":1518,"highlights":1521,"slug":1525,"body":1526},"Blinds Measure","I moved into a new house that needed blinds in every room, and I spent an evening walking round\nwith a tape measure and my phone's notes app. Six numbers per window — three widths, three heights\n— and by the third room the note was an undifferentiated wall of digits. I could no longer tell\nwhich figures belonged to which window, so the next day I started again.","Recess blind sizing for a house full of windows",[24,412,30,1519,848,849,1520],"Fastify","Cloudflare Access",[1522,1523,1524],"The tightest point sets the size","Out of square, drawn not described","Built while measuring the house it was for","blinds-measure",{"type":57,"children":1527},[1528,1532,1537,1542,1554,1574,1584,1590,1595,1600,1609,1614,1620,1632,1640,1645],{"type":60,"tag":61,"props":1529,"children":1530},{},[1531],{"type":65,"value":1516},{"type":60,"tag":61,"props":1533,"children":1534},{},[1535],{"type":65,"value":1536},"Blinds Measure is the thing I should have had on that first evening. Houses contain rooms, rooms\ncontain windows, windows carry six measurements, and the app gives you back the size to order.",{"type":60,"tag":218,"props":1538,"children":1540},{"id":1539},"the-tightest-point-sets-the-size",[1541],{"type":65,"value":1522},{"type":60,"tag":61,"props":1543,"children":1544},{},[1545,1547,1552],{"type":65,"value":1546},"A recess blind hangs inside the opening, so it has to clear the narrowest point of that opening.\nRecesses are rarely square — plaster bows, frames settle, sills are not level — which is why you\nmeasure the width at the top, middle and bottom, and the height at the left, centre and right.\nThe size you order is the ",{"type":60,"tag":256,"props":1548,"children":1549},{},[1550],{"type":65,"value":1551},"smallest of each",{"type":65,"value":1553},". Order to the average and the blind jams on the way\ndown.",{"type":60,"tag":61,"props":1555,"children":1556},{},[1557,1559,1565,1567,1572],{"type":65,"value":1558},"The rule itself is a ",{"type":60,"tag":237,"props":1560,"children":1562},{"className":1561},[],[1563],{"type":65,"value":1564},"Math.min",{"type":65,"value":1566}," over each trio, and it is about six lines with a test to hold it\nin place. Making it ",{"type":60,"tag":887,"props":1568,"children":1569},{},[1570],{"type":65,"value":1571},"visible",{"type":65,"value":1573}," was the interesting part.",{"type":60,"tag":61,"props":1575,"children":1576},{},[1577],{"type":60,"tag":245,"props":1578,"children":1583},{"alt":1579,"src":1580,"className":1581},"Rooms as sheets, each with a title block and its window count. The blind size is the largest thing in the row, because it is what you came for; a window whose measurements disagree by more than 10mm carries a warning.","\u002Fwork\u002Fblinds-measure\u002Frooms.webp",[1582],"phone",[],{"type":60,"tag":218,"props":1585,"children":1587},{"id":1586},"drawing-the-window",[1588],{"type":65,"value":1589},"Drawing the window",{"type":60,"tag":61,"props":1591,"children":1592},{},[1593],{"type":65,"value":1594},"Every measurement form I have used treats six numbers as six numbers. But the six describe a\nshape, and a shape can be drawn — so the add-window form draws it, to scale, as you type: an\noutline of the recess with the widths stacked as dimension lines underneath it and the heights\ndown the left, the governing measurement in accent with arrowheads, the other four muted.",{"type":60,"tag":61,"props":1596,"children":1597},{},[1598],{"type":65,"value":1599},"A window out of square comes out visibly out of square. That immediately raised an honesty\nproblem: 4mm of bow on a 1200mm window is a third of one percent, and at true scale it is\ninvisible — an accurate drawing that shows you nothing. So deviation from the governing figure is\namplified, and capped so one mistyped entry cannot distort the drawing beyond the frame. A diagram\nthat exaggerates silently is a lying diagram, so it says \"deviation exaggerated\" whenever it is\ndoing it, and the real figures are always printed on the lines.",{"type":60,"tag":61,"props":1601,"children":1602},{},[1603],{"type":60,"tag":245,"props":1604,"children":1608},{"alt":1605,"src":1606,"className":1607},"A bay window measuring 1204 wide at the top, 1200 across the middle and 1206 at the bottom. The outline is visibly waisted where it is tightest, and the two governing figures — 1200 and 1450 — are the ones carrying arrowheads while the four larger ones stay muted.","\u002Fwork\u002Fblinds-measure\u002Fdiagram.webp",[1582],[],{"type":60,"tag":61,"props":1610,"children":1611},{},[1612],{"type":65,"value":1613},"The geometry lives in a pure function with no Vue and no DOM in it, and the component is a\nfew dozen lines of SVG over the top. Boundaries symmetric about the centre, each edge a three-point\nprofile, so the outline pinches exactly where the measurement is tightest.",{"type":60,"tag":218,"props":1615,"children":1617},{"id":1616},"the-shape-of-the-thing",[1618],{"type":65,"value":1619},"The shape of the thing",{"type":60,"tag":61,"props":1621,"children":1622},{},[1623,1625,1630],{"type":65,"value":1624},"A Vue 3 SPA with Pinia over a Fastify API and Postgres. The SPA and the API build into a single\nimage — the API serves the built frontend — with Postgres alongside it, so the whole thing comes up\non one ",{"type":60,"tag":237,"props":1626,"children":1628},{"className":1627},[],[1629],{"type":65,"value":992},{"type":65,"value":1631},". Results export as CSV, or print as an order sheet — title block, rooms\ngrouped, tabular sizes, and the out-of-square warnings carried through as text, because a supplier\nneeds to know that.",{"type":60,"tag":61,"props":1633,"children":1634},{},[1635],{"type":60,"tag":245,"props":1636,"children":1639},{"alt":1637,"src":1638},"The order sheet on a laptop: measuring happens on a phone in the room, but the sheet you hand to a supplier is easier to check and print from a desk.","\u002Fwork\u002Fblinds-measure\u002Forder-sheet.webp",[],{"type":60,"tag":61,"props":1641,"children":1642},{},[1643],{"type":65,"value":1644},"Authentication is the part worth mentioning, because there isn't any. The app sits behind a\nCloudflare tunnel with an Access policy in front of the hostname, and has no login of its own. That\nis a real decision rather than a shortcut, and it is only safe under conditions that are easy to\nbreak by accident later — so the README states them: never publish the origin port, never add a\nsecond ingress that bypasses Access, and add app-level auth first if it ever moves off the tunnel.\nA security posture you cannot see is one you will violate in six months.",{"type":60,"tag":61,"props":1646,"children":1647},{},[1648],{"type":65,"value":1649},"It is the smallest thing on this page, and it exists because measuring a houseful of windows into a\nnotes app is a genuinely bad way to spend an evening.",1785763488406]