[{"data":1,"prerenderedAt":1056},["ShallowReactive",2],{"profile":3,"work:wedding":71,"work:neighbours:wedding":399},{"title":4,"description":5,"name":6,"role":7,"tagline":8,"location":9,"country":10,"workEligibility":10,"remote":11,"email":12,"availability":13,"summary":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,"jobs@jakearmour.com","Open to senior frontend roles","Senior frontend developer, seven years in Vue and Nuxt. I work where a design system meets production constraints: component libraries other developers build on, rebrands of long-lived sites, and the performance and accessibility work that keeps them healthy.",[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.",{"title":72,"description":73,"tagline":74,"year":75,"status":76,"role":77,"featured":11,"order":78,"stack":79,"highlights":85,"slug":89,"body":90},"The Armour Wedding","We got married in May 2026, and the whole run-up went through a site I built for it: the invitation,\nwho was coming, what each of them wanted to eat, the songs they wanted played, and — on the day\nitself — the photos they took. Guests come in English and Polish, so it shipped in both. The site is\nretired now that the day has passed; the screenshots here come from a local copy seeded with\nfictional guests.","RSVPs, menu choices, song requests and shared photos — a platform for our own wedding",2026,"archived","Sole developer — frontend, data, storage, auth, deployment, and a second-language edition",2,[24,26,80,81,82,83,84],"Supabase","AWS S3","Cognito Identity Pool","AWS Lambda","Spotify API",[86,87,88],"No application server — the browser is the backend","Scan a table card, land in the photo uploader","Real RSVPs from real guests, in two languages","wedding",{"type":57,"children":91},[92,96,109,118,125,168,173,179,192,200,205,213,225,233,239,260,289,297,303,323,331,343,349,362,370,376,381,394],{"type":60,"tag":61,"props":93,"children":94},{},[95],{"type":65,"value":73},{"type":60,"tag":61,"props":97,"children":98},{},[99,101,107],{"type":65,"value":100},"The constraint I set was that one person should be able to run it with ",{"type":60,"tag":102,"props":103,"children":104},"strong",{},[105],{"type":65,"value":106},"almost no server to\nmaintain.",{"type":65,"value":108}," A single small Lambda handles the password gate; everything else is the browser talking\nstraight to managed services.",{"type":60,"tag":61,"props":110,"children":111},{},[112],{"type":60,"tag":113,"props":114,"children":117},"img",{"alt":115,"src":116},"The day at a glance: a live countdown, the timeline, and the menu everyone would later choose from.","\u002Fwork\u002Fwedding\u002Fhome.webp",[],{"type":60,"tag":119,"props":120,"children":122},"h2",{"id":121},"no-server-just-the-browser-and-managed-pieces",[123],{"type":65,"value":124},"No server, just the browser and managed pieces",{"type":60,"tag":61,"props":126,"children":127},{},[128,130,134,136,140,142,146,148,153,155,159,161,166],{"type":65,"value":129},"There is no application backend. The Vue SPA reads and writes ",{"type":60,"tag":102,"props":131,"children":132},{},[133],{"type":65,"value":80},{"type":65,"value":135}," — Postgres with an\nauto-generated REST layer — directly from the client for everything relational: parties, guests,\nmenu, songs. Photos go to ",{"type":60,"tag":102,"props":137,"children":138},{},[139],{"type":65,"value":81},{"type":65,"value":141},", with the browser getting temporary upload credentials from a\n",{"type":60,"tag":102,"props":143,"children":144},{},[145],{"type":65,"value":82},{"type":65,"value":147}," rather than a presign endpoint I'd have to run. Song search hits the\n",{"type":60,"tag":102,"props":149,"children":150},{},[151],{"type":65,"value":152},"Spotify Web API",{"type":65,"value":154},". The one gate — the password on the private pages — is an ",{"type":60,"tag":102,"props":156,"children":157},{},[158],{"type":65,"value":83},{"type":65,"value":160}," behind\na Function URL. The whole thing is served as a static build from ",{"type":60,"tag":102,"props":162,"children":163},{},[164],{"type":65,"value":165},"AWS Amplify Hosting",{"type":65,"value":167},".",{"type":60,"tag":61,"props":169,"children":170},{},[171],{"type":65,"value":172},"That shape is the point: it's cheap, it deploys as static files, and there's no server process to\nkeep alive for a site that only had to survive a few months of real traffic.",{"type":60,"tag":119,"props":174,"children":176},{"id":175},"the-rsvp",[177],{"type":65,"value":178},"The RSVP",{"type":60,"tag":61,"props":180,"children":181},{},[182,184,190],{"type":65,"value":183},"Every guest belongs to a ",{"type":60,"tag":185,"props":186,"children":187},"em",{},[188],{"type":65,"value":189},"party",{"type":65,"value":191}," with a short code. Enter the code — or arrive on a link that\ncarries it — and the app loads that party and its people, and asks each of them, individually, for a\nstarter, a main and a dessert. Children get their own path: a half-sized adult portion or a set\nkids' menu.",{"type":60,"tag":61,"props":193,"children":194},{},[195],{"type":60,"tag":113,"props":196,"children":199},{"alt":197,"src":198},"One party, every guest: each person picks their own three courses, and the children get a kids' menu.","\u002Fwork\u002Fwedding\u002Frsvp.webp",[],{"type":60,"tag":61,"props":201,"children":202},{},[203],{"type":65,"value":204},"Song requests are part of the same flow. A search box queries Spotify live from the browser, and the\nchosen track travels with the RSVP so it lands in the same place as everything else.",{"type":60,"tag":61,"props":206,"children":207},{},[208],{"type":60,"tag":113,"props":209,"children":212},{"alt":210,"src":211},"Song requests search Spotify directly from the browser; the chosen track rides along with the RSVP.","\u002Fwork\u002Fwedding\u002Fsongs.webp",[],{"type":60,"tag":61,"props":214,"children":215},{},[216,218,223],{"type":65,"value":217},"Behind it, a password-gated dashboard reads every party and guest and aggregates it ",{"type":60,"tag":102,"props":219,"children":220},{},[221],{"type":65,"value":222},"entirely on\nthe client",{"type":65,"value":224}," into the numbers a caterer actually needs: who's confirmed, and how many of each dish\nto cook.",{"type":60,"tag":61,"props":226,"children":227},{},[228],{"type":60,"tag":113,"props":229,"children":232},{"alt":230,"src":231},"The admin view aggregates every response in the browser — who's coming, and how many of each dish to make.","\u002Fwork\u002Fwedding\u002Fdashboard.webp",[],{"type":60,"tag":119,"props":234,"children":236},{"id":235},"a-qr-code-thats-really-a-magic-link",[237],{"type":65,"value":238},"A QR code that's really a magic link",{"type":60,"tag":61,"props":240,"children":241},{},[242,244,249,251,258],{"type":65,"value":243},"The physical-to-digital bridge is my favourite part. Each printed code encodes a URL rather than an\nid. A guest's card carries a link that ",{"type":60,"tag":102,"props":245,"children":246},{},[247],{"type":65,"value":248},"deep-links straight to their own prefilled RSVP",{"type":65,"value":250}," — the\nsame ",{"type":60,"tag":252,"props":253,"children":255},"code",{"className":254},[],[256],{"type":65,"value":257},"?code=",{"type":65,"value":259}," the form reads, so scanning it skips the code entry entirely.",{"type":60,"tag":61,"props":261,"children":262},{},[263,265,271,273,279,281,287],{"type":65,"value":264},"The photos card goes one better. Its code encodes ",{"type":60,"tag":252,"props":266,"children":268},{"className":267},[],[269],{"type":65,"value":270},"…\u002Fphotos?password=…&lang=…",{"type":65,"value":272},", and the router\ntreats a ",{"type":60,"tag":252,"props":274,"children":276},{"className":275},[],[277],{"type":65,"value":278},"?password=",{"type":65,"value":280}," in the URL as an instruction to unlock. So a guest scans the card on the table,\nand lands directly in the unlocked, correct-language photo uploader — no code, no password prompt,\nnothing to type. The codes themselves are drawn with ",{"type":60,"tag":252,"props":282,"children":284},{"className":283},[],[285],{"type":65,"value":286},"qr-code-styling",{"type":65,"value":288},", rounded and centred on our\ninitials.",{"type":60,"tag":61,"props":290,"children":291},{},[292],{"type":60,"tag":113,"props":293,"children":296},{"alt":294,"src":295},"A printed code that's really a magic link — scan it and land in the unlocked, correct-language uploader.","\u002Fwork\u002Fwedding\u002Fqr.webp",[],{"type":60,"tag":119,"props":298,"children":300},{"id":299},"uploading-the-day",[301],{"type":65,"value":302},"Uploading the day",{"type":60,"tag":61,"props":304,"children":305},{},[306,308,313,315,321],{"type":65,"value":307},"The photos page lets guests upload straight from their phones. Because the browser holds Cognito\ncredentials, each file goes ",{"type":60,"tag":102,"props":309,"children":310},{},[311],{"type":65,"value":312},"directly to S3",{"type":65,"value":314}," with no upload server in the middle. There's an\nin-browser camera too, capturing video with a ",{"type":60,"tag":252,"props":316,"children":318},{"className":317},[],[319],{"type":65,"value":320},"MediaRecorder",{"type":65,"value":322}," codec fallback chain (VP9, then WebM,\nthen MP4) so it works across the phones people actually brought.",{"type":60,"tag":61,"props":324,"children":325},{},[326],{"type":60,"tag":113,"props":327,"children":330},{"alt":328,"src":329},"Guests uploaded straight to S3 from their phones; no app server sat in the middle.","\u002Fwork\u002Fwedding\u002Fphotos.webp",[],{"type":60,"tag":61,"props":332,"children":333},{},[334,336,341],{"type":65,"value":335},"Everything lands in a shared gallery with a lightbox. Downloading uses the ",{"type":60,"tag":102,"props":337,"children":338},{},[339],{"type":65,"value":340},"Web Share API",{"type":65,"value":342}," where a\nphone supports it, with a fallback that staggers the downloads one second apart — because Android\nChrome quietly blocks a burst of simultaneous ones.",{"type":60,"tag":119,"props":344,"children":346},{"id":345},"two-languages",[347],{"type":65,"value":348},"Two languages",{"type":60,"tag":61,"props":350,"children":351},{},[352,354,360],{"type":65,"value":353},"It was a UK–Polish wedding, so there are two full editions — English and Polish — sharing one\nSupabase project. The Polish site is the same RSVP over the same guest data, reading its own\n",{"type":60,"tag":252,"props":355,"children":357},{"className":356},[],[358],{"type":65,"value":359},"menu_items_pl",{"type":65,"value":361}," table so the courses come through translated.",{"type":60,"tag":61,"props":363,"children":364},{},[365],{"type":60,"tag":113,"props":366,"children":369},{"alt":367,"src":368},"The Polish edition: the same RSVP, a separate build, reading its own menu table.","\u002Fwork\u002Fwedding\u002Frsvp-pl.webp",[],{"type":60,"tag":119,"props":371,"children":373},{"id":372},"what-id-change",[374],{"type":65,"value":375},"What I'd change",{"type":60,"tag":61,"props":377,"children":378},{},[379],{"type":65,"value":380},"Two things, both from hindsight rather than regret.",{"type":60,"tag":61,"props":382,"children":383},{},[384,386,392],{"type":65,"value":385},"The Polish edition should have been proper localisation inside one codebase, not a second repository.\nForking it was simply faster under a fixed date, and the cost showed up later as two builds to keep\nin step by hand — exactly what an ",{"type":60,"tag":252,"props":387,"children":389},{"className":388},[],[390],{"type":65,"value":391},"i18n",{"type":65,"value":393}," layer and a single deploy would have removed.",{"type":60,"tag":61,"props":395,"children":396},{},[397],{"type":65,"value":398},"And the RSVP submit writes one guest at a time in a loop. It's fine at a wedding's scale, but it's a\nbatched upsert wearing the wrong clothes: one round trip would be correct where several do the job\ntoday.",{"previous":400,"next":615},{"title":401,"description":402,"tagline":403,"year":404,"status":405,"role":406,"featured":11,"order":407,"stack":408,"links":414,"highlights":418,"slug":422,"body":423},"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",1,[409,30,410,411,412,413,29],"Nuxt 4","Stripe","AWS Amplify","AppSync \u002F DynamoDB","AWS SES",[415],{"label":416,"href":417,"primary":11},"junglelandbooking.com","https:\u002F\u002Fjunglelandbooking.com",[419,420,421],"Real customers, real payments","Multi-step flow that survives a refresh","Staff-managed dates and capacity","jungleland",{"type":57,"children":424},[425,429,434,440,445,456,464,483,491,497,505,525,530,535,541,558,566,574,580,585,593,598,606,610],{"type":60,"tag":61,"props":426,"children":427},{},[428],{"type":65,"value":402},{"type":60,"tag":61,"props":430,"children":431},{},[432],{"type":65,"value":433},"It has been running and taking bookings ever since.",{"type":60,"tag":119,"props":435,"children":437},{"id":436},"the-booking-flow",[438],{"type":65,"value":439},"The booking flow",{"type":60,"tag":61,"props":441,"children":442},{},[443],{"type":65,"value":444},"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":446,"props":447,"children":451},"pre",{"className":448,"code":450,"language":65},[449],"language-text","\u002Fbook\n  → \u002Fbook\u002F[date]\n    → \u002Fbook\u002F[date]\u002F[timeslot]\n      → \u002Fdeposit\u002F[id]\n        → \u002Fconfirmation\u002F[id]\n",[452],{"type":60,"tag":252,"props":453,"children":454},{"__ignoreMap":4},[455],{"type":65,"value":450},{"type":60,"tag":61,"props":457,"children":458},{},[459],{"type":60,"tag":113,"props":460,"children":463},{"alt":461,"src":462},"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":465,"children":466},{},[467,469,473,475,481],{"type":65,"value":468},"Booking state lives in a ",{"type":60,"tag":102,"props":470,"children":471},{},[472],{"type":65,"value":30},{"type":65,"value":474}," store persisted to ",{"type":60,"tag":252,"props":476,"children":478},{"className":477},[],[479],{"type":65,"value":480},"localStorage",{"type":65,"value":482},", 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":484,"children":485},{},[486],{"type":60,"tag":113,"props":487,"children":490},{"alt":488,"src":489},"The details step. The £2 deposit is stated plainly before anyone reaches a card field.","\u002Fwork\u002Fjungleland\u002Fdetails.webp",[],{"type":60,"tag":119,"props":492,"children":494},{"id":493},"capacity-is-the-hard-part",[495],{"type":65,"value":496},"Capacity is the hard part",{"type":60,"tag":61,"props":498,"children":499},{},[500],{"type":60,"tag":113,"props":501,"children":504},{"alt":502,"src":503},"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":506,"children":507},{},[508,510,516,518,523],{"type":65,"value":509},"Everything else follows from not overselling a slot. The data model is deliberately narrow —\n",{"type":60,"tag":252,"props":511,"children":513},{"className":512},[],[514],{"type":65,"value":515},"Date → TimeSlot → Booking",{"type":65,"value":517}," in ",{"type":60,"tag":102,"props":519,"children":520},{},[521],{"type":65,"value":522},"AppSync\u002FGraphQL over DynamoDB",{"type":65,"value":524}," — 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":526,"children":527},{},[528],{"type":65,"value":529},"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":531,"children":532},{},[533],{"type":65,"value":534},"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":119,"props":536,"children":538},{"id":537},"payments-and-mail",[539],{"type":65,"value":540},"Payments and mail",{"type":60,"tag":61,"props":542,"children":543},{},[544,546,550,552,556],{"type":65,"value":545},"Deposits go through ",{"type":60,"tag":102,"props":547,"children":548},{},[549],{"type":65,"value":410},{"type":65,"value":551},", 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":102,"props":553,"children":554},{},[555],{"type":65,"value":413},{"type":65,"value":557},". Both are\nboring, on purpose — this is somebody's actual revenue.",{"type":60,"tag":61,"props":559,"children":560},{},[561],{"type":60,"tag":113,"props":562,"children":565},{"alt":563,"src":564},"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":567,"children":568},{},[569],{"type":60,"tag":113,"props":570,"children":573},{"alt":571,"src":572},"Deposit paid, balance on arrival, and the one instruction that actually gets forgotten.","\u002Fwork\u002Fjungleland\u002Fconfirmation.webp",[],{"type":60,"tag":119,"props":575,"children":577},{"id":576},"admin",[578],{"type":65,"value":579},"Admin",{"type":60,"tag":61,"props":581,"children":582},{},[583],{"type":65,"value":584},"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":586,"children":587},{},[588],{"type":60,"tag":113,"props":589,"children":592},{"alt":590,"src":591},"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":594,"children":595},{},[596],{"type":65,"value":597},"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":599,"children":600},{},[601],{"type":60,"tag":113,"props":602,"children":605},{"alt":603,"src":604},"Both limits, editable on the slot itself.","\u002Fwork\u002Fjungleland\u002Flimits.webp",[],{"type":60,"tag":119,"props":607,"children":608},{"id":372},[609],{"type":65,"value":375},{"type":60,"tag":61,"props":611,"children":612},{},[613],{"type":65,"value":614},"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":616,"description":617,"tagline":618,"year":75,"status":619,"role":620,"featured":621,"order":622,"stack":623,"highlights":627,"slug":631,"body":632},"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","building","Creator",false,3,[24,624,26,625,626],"Vite","vue-component-meta","Shiki",[628,629,630],"Stories are real Vue templates","Controls derived from your props","A playground non-developers can use","vitro",{"type":57,"children":633},[634,638,643,655,661,666,679,687,693,706,893,901,914,920,948,956,962,993,998,1006,1012,1039,1045,1050],{"type":60,"tag":61,"props":635,"children":636},{},[637],{"type":65,"value":617},{"type":60,"tag":61,"props":639,"children":640},{},[641],{"type":65,"value":642},"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":644,"children":645},{},[646,648,653],{"type":65,"value":647},"The CLI is ",{"type":60,"tag":252,"props":649,"children":651},{"className":650},[],[652],{"type":65,"value":631},{"type":65,"value":654},". Vite is the builder — not wrapped, not shimmed, just Vite.",{"type":60,"tag":119,"props":656,"children":658},{"id":657},"who-it-turned-out-to-be-for",[659],{"type":65,"value":660},"Who it turned out to be for",{"type":60,"tag":61,"props":662,"children":663},{},[664],{"type":65,"value":665},"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":667,"children":668},{},[669,671,677],{"type":65,"value":670},"That is what makes the shareable-URL state and the ",{"type":60,"tag":252,"props":672,"children":674},{"className":673},[],[675],{"type":65,"value":676},"figmaUrl",{"type":65,"value":678}," 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":680,"children":681},{},[682],{"type":60,"tag":113,"props":683,"children":686},{"alt":684,"src":685},"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":119,"props":688,"children":690},{"id":689},"stories-are-just-vue",[691],{"type":65,"value":692},"Stories are just Vue",{"type":60,"tag":61,"props":694,"children":695},{},[696,698,704],{"type":65,"value":697},"The format is a ",{"type":60,"tag":252,"props":699,"children":701},{"className":700},[],[702],{"type":65,"value":703},".story.vue",{"type":65,"value":705}," file containing ordinary Vue markup:",{"type":60,"tag":446,"props":707,"children":711},{"className":708,"code":709,"language":710,"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",[712],{"type":60,"tag":252,"props":713,"children":714},{"__ignoreMap":4},[715,737,771,801,842,859,876],{"type":60,"tag":716,"props":717,"children":719},"span",{"class":718,"line":407},"line",[720,726,732],{"type":60,"tag":716,"props":721,"children":723},{"style":722},"--shiki-default:#24292E;--shiki-dark:#E1E4E8",[724],{"type":65,"value":725},"\u003C",{"type":60,"tag":716,"props":727,"children":729},{"style":728},"--shiki-default:#22863A;--shiki-dark:#85E89D",[730],{"type":65,"value":731},"template",{"type":60,"tag":716,"props":733,"children":734},{"style":722},[735],{"type":65,"value":736},">\n",{"type":60,"tag":716,"props":738,"children":739},{"class":718,"line":78},[740,745,750,756,761,767],{"type":60,"tag":716,"props":741,"children":742},{"style":722},[743],{"type":65,"value":744},"  \u003C",{"type":60,"tag":716,"props":746,"children":747},{"style":728},[748],{"type":65,"value":749},"Story",{"type":60,"tag":716,"props":751,"children":753},{"style":752},"--shiki-default:#6F42C1;--shiki-dark:#B392F0",[754],{"type":65,"value":755}," title",{"type":60,"tag":716,"props":757,"children":758},{"style":722},[759],{"type":65,"value":760},"=",{"type":60,"tag":716,"props":762,"children":764},{"style":763},"--shiki-default:#032F62;--shiki-dark:#9ECBFF",[765],{"type":65,"value":766},"\"Button\"",{"type":60,"tag":716,"props":768,"children":769},{"style":722},[770],{"type":65,"value":736},{"type":60,"tag":716,"props":772,"children":773},{"class":718,"line":622},[774,779,784,788,792,797],{"type":60,"tag":716,"props":775,"children":776},{"style":722},[777],{"type":65,"value":778},"    \u003C",{"type":60,"tag":716,"props":780,"children":781},{"style":728},[782],{"type":65,"value":783},"Variant",{"type":60,"tag":716,"props":785,"children":786},{"style":752},[787],{"type":65,"value":755},{"type":60,"tag":716,"props":789,"children":790},{"style":722},[791],{"type":65,"value":760},{"type":60,"tag":716,"props":793,"children":794},{"style":763},[795],{"type":65,"value":796},"\"Primary\"",{"type":60,"tag":716,"props":798,"children":799},{"style":722},[800],{"type":65,"value":736},{"type":60,"tag":716,"props":802,"children":804},{"class":718,"line":803},4,[805,810,815,820,824,829,834,838],{"type":60,"tag":716,"props":806,"children":807},{"style":722},[808],{"type":65,"value":809},"      \u003C",{"type":60,"tag":716,"props":811,"children":812},{"style":728},[813],{"type":65,"value":814},"AppButton",{"type":60,"tag":716,"props":816,"children":817},{"style":752},[818],{"type":65,"value":819}," variant",{"type":60,"tag":716,"props":821,"children":822},{"style":722},[823],{"type":65,"value":760},{"type":60,"tag":716,"props":825,"children":826},{"style":763},[827],{"type":65,"value":828},"\"primary\"",{"type":60,"tag":716,"props":830,"children":831},{"style":722},[832],{"type":65,"value":833},">Save changes\u003C\u002F",{"type":60,"tag":716,"props":835,"children":836},{"style":728},[837],{"type":65,"value":814},{"type":60,"tag":716,"props":839,"children":840},{"style":722},[841],{"type":65,"value":736},{"type":60,"tag":716,"props":843,"children":845},{"class":718,"line":844},5,[846,851,855],{"type":60,"tag":716,"props":847,"children":848},{"style":722},[849],{"type":65,"value":850},"    \u003C\u002F",{"type":60,"tag":716,"props":852,"children":853},{"style":728},[854],{"type":65,"value":783},{"type":60,"tag":716,"props":856,"children":857},{"style":722},[858],{"type":65,"value":736},{"type":60,"tag":716,"props":860,"children":862},{"class":718,"line":861},6,[863,868,872],{"type":60,"tag":716,"props":864,"children":865},{"style":722},[866],{"type":65,"value":867},"  \u003C\u002F",{"type":60,"tag":716,"props":869,"children":870},{"style":728},[871],{"type":65,"value":749},{"type":60,"tag":716,"props":873,"children":874},{"style":722},[875],{"type":65,"value":736},{"type":60,"tag":716,"props":877,"children":879},{"class":718,"line":878},7,[880,885,889],{"type":60,"tag":716,"props":881,"children":882},{"style":722},[883],{"type":65,"value":884},"\u003C\u002F",{"type":60,"tag":716,"props":886,"children":887},{"style":728},[888],{"type":65,"value":731},{"type":60,"tag":716,"props":890,"children":891},{"style":722},[892],{"type":65,"value":736},{"type":60,"tag":61,"props":894,"children":895},{},[896],{"type":60,"tag":113,"props":897,"children":900},{"alt":898,"src":899},"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":902,"children":903},{},[904,906,912],{"type":65,"value":905},"No abstract ",{"type":60,"tag":252,"props":907,"children":909},{"className":908},[],[910],{"type":65,"value":911},"args",{"type":65,"value":913}," 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":119,"props":915,"children":917},{"id":916},"controls-you-didnt-have-to-write",[918],{"type":65,"value":919},"Controls you didn't have to write",{"type":60,"tag":61,"props":921,"children":922},{},[923,925,930,932,938,940,946],{"type":65,"value":924},"Controls are derived from the component's own props via ",{"type":60,"tag":252,"props":926,"children":928},{"className":927},[],[929],{"type":65,"value":625},{"type":65,"value":931},". A variant with no\nslot content auto-renders ",{"type":60,"tag":252,"props":933,"children":935},{"className":934},[],[936],{"type":65,"value":937},"\u003CComponent v-bind=\"state\" \u002F>",{"type":65,"value":939},", so a component you have just written\ngets a working playground with zero configuration. Manual ",{"type":60,"tag":252,"props":941,"children":943},{"className":942},[],[944],{"type":65,"value":945},"Hst*",{"type":65,"value":947}," 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":949,"children":950},{},[951],{"type":60,"tag":113,"props":952,"children":955},{"alt":953,"src":954},"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":119,"props":957,"children":959},{"id":958},"three-views",[960],{"type":65,"value":961},"Three views",{"type":60,"tag":61,"props":963,"children":964},{},[965,970,972,977,979,984,986,991],{"type":60,"tag":102,"props":966,"children":967},{},[968],{"type":65,"value":969},"Canvas",{"type":65,"value":971}," for the component, ",{"type":60,"tag":102,"props":973,"children":974},{},[975],{"type":65,"value":976},"Source",{"type":65,"value":978}," for Shiki-highlighted markup, ",{"type":60,"tag":102,"props":980,"children":981},{},[982],{"type":65,"value":983},"Docs",{"type":65,"value":985}," for autodocs —\nprop, event and slot tables pulled from the type signatures, including JSDoc descriptions. If a\n",{"type":60,"tag":252,"props":987,"children":989},{"className":988},[],[990],{"type":65,"value":676},{"type":65,"value":992}," 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":994,"children":995},{},[996],{"type":65,"value":997},"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":999,"children":1000},{},[1001],{"type":60,"tag":113,"props":1002,"children":1005},{"alt":1003,"src":1004},"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":119,"props":1007,"children":1009},{"id":1008},"built-output",[1010],{"type":65,"value":1011},"Built output",{"type":60,"tag":61,"props":1013,"children":1014},{},[1015,1021,1023,1029,1031,1037],{"type":60,"tag":252,"props":1016,"children":1018},{"className":1017},[],[1019],{"type":65,"value":1020},"vitro build",{"type":65,"value":1022}," produces a static, relative-path ",{"type":60,"tag":252,"props":1024,"children":1026},{"className":1025},[],[1027],{"type":65,"value":1028},"dist\u002F",{"type":65,"value":1030}," with per-story chunks that will serve from\nany subpath. It also emits a Storybook-compatible ",{"type":60,"tag":252,"props":1032,"children":1034},{"className":1033},[],[1035],{"type":65,"value":1036},"\u002Findex.json",{"type":65,"value":1038},", 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":119,"props":1040,"children":1042},{"id":1041},"status",[1043],{"type":65,"value":1044},"Status",{"type":60,"tag":61,"props":1046,"children":1047},{},[1048],{"type":65,"value":1049},"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":1051,"props":1052,"children":1053},"style",{},[1054],{"type":65,"value":1055},"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);}",1785932992056]