Ship log
Changelog
What is new, what shipped, what changed. Products, updates, writing, and projects, newest first. Most of it derives straight from the site's own data, so it stays current on its own.
The Entytle work gets a page again: their four solutions on the landing with the three that are Rahul's marked, 23 frames and 37 screens across the executive dashboard, Asset Lifecycle Management and Installed Base Health Check, the 2024 Newton AI panel, and the comments on the file where the spec got argued with.
- The route had redirected to / since August 2026, so the first job was deleting the redirect that would have shadowed the new page.
- Tabs on a hire artifact are usually a trap; this one reuses the component that already solved why.
A deliberate exception to the no-employer-write-ups rule, asked for and taken.
CLAUDE.md retired the Entytle and Synclo studies in August 2026 and says not to add one back without asking. Rahul asked. What earns it is the sequence rather than the screens: the same idea shows up in five surfaces, which is a claim about product judgment and not about visual polish.
The summary line is the query, and that is the argument the order makes.
Top 30 high value opportunities is not a heading, it is a control that cuts 965 records to 30, checks them and points Bulk Send Email at the result. The unassigned summary does the same for 43 records and swaps the actions to Assign To. Contacts grade themselves Unknown, Bad or Verified from the row. Newton names its fields and admits it can be wrong. Five surfaces, one habit: make the uncertainty visible, then give the reader something to do about it.
Five tabs: a landing, the dashboard, then the three products that are actually his.
Rahul asked for a tab per product plus a landing over them, on the grounds that someone who wants the Quote Agent should not have to scroll the dashboard to learn it exists. The recommendation had been to wait for a second product with captures, because tabs with nothing in them advertise what is missing. He overruled it, and the landing is what makes it work: what Entytle sells, what he did there, the one number he owns (retention up 7 percent), then the four solutions as a list.
The product names come from Entytle, and one claim on the site was wrong.
Rahul sent entytle.com's current Solution menu: Installed Base Intelligence, Quote Agent, Asset Lifecycle Management, Installed Base Health Check. He worked on three of those four, and Installed Base Intelligence is not one of them, which the work atlas entry had been claiming since it was written. Corrected. The tabs now use Entytle's wording rather than the CV's older AI Quote Agent and Health Check, so a hiring manager who looks the company up finds the same words. Installed Base Intelligence sits on the landing unmarked and unlinked: showing the whole product line with only three of four claimed is more credible than a list that happens to contain just his own work.
One caveat, traceable through four screens, is the best evidence on the page.
The Excel cell in the before frame reads "Up to Every three to six years?", question mark included, because the manual gives a range and the answer depends on operating conditions. That exact phrase then turns up as a Remark field in the create wizard, as an editable field on the part in the manage flow, and as the remark a rep reads beside a six thousand dollar line item. It never gets flattened into a single number. Four separate captures carry it, which is why the claim can be made at all: nothing on this page asserts design judgment that the screenshots do not show.
Asset Lifecycle Management is now the deepest sequence on the site.
Seven frames: the spreadsheet and the manuals it was copied from, the four step Create Asset Lifecycle wizard as a six cell grid, one location's 28 machines, one machine with the Today-to-twenty-years slider that turns a maintenance schedule into a revenue forecast, all 6,963 assets with asset price beside lifecycle value (fifty thousand dollars against 1.2 million, which is the aftermarket argument in one column), the manage flow as a four cell grid, and myOEM. Thirty two captures came in for the two flows and ten are shown; a reader does not need every state of a four step form, and the rest stay staged in Create Flow/ and Manage Flow/. The create and manage grids are deliberately not adjacent: create sits after the spreadsheet it replaced, manage after the list it operates on. Rahul confirmed that separation.
The competitor is on the page, labelled, which the rule normally forbids.
Competitor UI stays off this site, which is why the Competitor References folder in the Executive Dashboard tree is unreferenced. myOEM is the exception Rahul asked for, and it earns it by being the alternative architecture rather than craft to admire: theirs is telemetry first, live sensor curves and last connected and a dynamic warranty, where Entytle reads the same machine from ship date, service history and the manufacturer's intervals. Naming what Entytle chose is only meaningful with the other option on screen. It is called a competitor in the first three words of its caption, in its alt text and in its filename, so it can never be read as his.
Installed Base Health Check turned out to be the other half of ALM.
The checklist screens were filed under ALM until Rahul pointed out they belong to Health Check, which reframed two half-empty tabs as one loop: ALM says a machine is due a periodic inspection and what it is worth, and Health Check is where that inspection is authored as a form, scheduled against the serial, and handed back filled in with answers, attachments and the inspector's comments. Same asset, SA110254-J, end to end. Its blurb had been an inference about account health and is now grounded in what the captures show.
Asset Lifecycle Management stopped being an empty tab.
One capture, and it is the right one: the Equipment tab of a customer location, where the service recommended today sits next to what that service is worth, so a rep reads revenue rather than a task list. The donut and the stacked bars are the same 28 items cut by stage and by model, which answers how much work is due and which machines are driving it at once. The quiet detail is a Competitor column in the model chart: the installed base counts machines the customer did not sell, because that is where the displacement opportunity is. Confirmed as demo data before it went up, which mattered: unlike the ACME dashboard screens this one carries account names and equipment serials, and it sat unreferenced until Rahul confirmed.
Every Entytle asset moved under one folder, and the typos went with it.
"Inter Active Dashboard" and "Entytle Password UX" were two top-level folders for one company, and the filenames inside were dashbaord-full, Componernts and Executive Dashbaord. Now public/portfolio/Entytle/ holds Executive Dashboard (Shipped, Iterations, Hackathon, Competitor References), Asset Lifecycle Management and Password Creation UX. Six one-file Iteration N folders flattened into Iterations with the real numbering kept, so the missing 05 still reads as missing. 30 files moved as git renames, 46 path references rewritten across five source files, and docs/assets.md regenerated rather than hand-edited. Files are kebab-case now, which also retires the %20 in the password study's paths.
The executive dashboard is filed as a surface, not a fifth product.
It is the only panel with captures and it spans all four solutions rather than belonging to one, which Rahul confirmed. Calling it a product would have invented something Entytle does not sell; leaving it out would have hidden the only work on the page. So the panel type carries a kind of product or surface, and it is the one surface.
The tabs are borrowed, not built, and that is what makes them safe here.
CaseStudyTabs moved out of /password-ux into src/components and gained a storageKey prop, so the two pages cannot decide each other's reading mode. It already answered the objection to tabs: inactive panels are hidden=until-found so find-in-page reaches them and opens the owning tab, panel ids make /entytle#quote-agent a real link, and a reading-mode toggle flips all five panels into one scroll. The h2s render in both modes, so long mode has separators and SectionNav has a five-item rail. One thing it was missing: the hash effect only ran on mount, so a link from one panel to another moved the hash and left the wrong tab open. It listens for hashchange now, which is what makes the landing's product list work and also fixes the back button between two deep links on /password-ux.
Every caption describes a screen that was opened and read first.
Nine captures were reviewed frame by frame before any copy was written, which is why the date control is described as four presets plus a calendar and an explicit Apply, and why the bulk-send caption talks about merge fields staying visible as tokens. Nothing in the feed claims a feature that is not in the picture above it.
The comments sheet is the frame a finished screen cannot replace.
Rahul's own notes on the file: numbering the bulk send flow from step zero, asking what high value actually means and who sets the condition, phasing a customisable top X into a second release. It shows the arguing, which is what product thinking looks like when it is still in progress.
Two components came out of CollectionFeed so nothing had to be written twice.
FeedFigure now owns what one slot looks like, including the portrait height cap and the sizes string that stops a capped frame fetching three times the pixels it renders; RelatedWork owns the hand-off to /password-ux, which keeps its own promoted URL. The flat feed on /ai-design, /healthcare and /devison renders exactly as before. The bare /entytle redirect was deleted, /projects/entytle repointed from / to the real page, the work atlas entry got the href it never had, and the wiki career line now links to it. Unlisted from the landing on purpose: the Aug 2026 call about the UI Work tab stands.
ProjectEntytleNine slug pages, an index and a research page retired, the explode embed moved to /md, and two YouTube counts that were the wrong way round.
- Deleting the /crpko tree was the small part; the cascade through four data files was the work.
- The wiki gained a contribution graph and lost two subscriber counts that had been transposed.
The artifacts survive the pages that showed them.
work-atlas and equicom-timeline keep both CRPKO entries with their summaries and proof video, losing only the dead hrefs. href became optional on the timeline type, so the view hides the View details affordance when there is nowhere to go. redirects.ts dropped 11 rules and repointed 2 that had been aimed at /work, which does not exist.
The explode embed moved to /md with the exposure understood.
It still carries the Crpko name, the inline logo and the header design, and /md is shared directly with recruiters. That was an explicit call, and reverting it is two lines.
Two subscriber counts were the wrong way round.
@yamparalarahul claimed 429 and has 57; @yamparalamedia claimed 56 and has 428. Verified against the live channels, along with Superteam's 2,630 across 47 submissions and 11 wins, which was already right.
The contribution graph reads the library, not its own API route.
A Worker isolate self-fetching over HTTP is the exact failure that broke the old OG template, so the card imports the shared lib directly. The label says contributions rather than public contributions, because neither the GraphQL calendar nor the profile scrape can tell whether private work is counted.
Carrot DeFi is no longer described as archived.
The protocol shut down on 30 April 2026 after the 285M dollar Drift exploit, but the write-up is live on Medium, so the page says what happened instead of filing itself away.
UpdateSiteA third clip on /trade-lab: the command palette's market card, whose sparkline draws itself in and then scrubs under the cursor.
- Search opens over the terminal as one list, and the market card inside it is where the motion is.
- The clip is square, so it pillarboxes inside the 16:9 frame that every hosted clip on the site shares.
The card scrubs, and the numbers roll with it.
A crosshair tracks the cursor along the sparkline while the price, change and timestamp roll digit by digit to the hovered point. Move off the card and it settles back to live.
The caption describes the clip, not clips in general.
It was watched frame by frame first, so the copy names the recent markets, open positions, trades and quick settings the palette actually lists.
1612x1612, which the frame was built for.
Probed with ffprobe and declared as its own ratio, so R2VideoPlayer centres it at true aspect and fills the pillarbox with the synced blurred copy. Three clips on the page now disagree on shape, which is the point of that frame.
UpdateTrade LabEverything below the QR on a handset is the AgentUX block, and its spacing was decided against a two-column layout phones never see.
- Five things only went wrong once the grid collapsed, all of them scoped below the breakpoint.
- Desktop is byte-for-byte unchanged: every hunk sits behind sm, lg or a coarse-pointer query.
The headline was glued to the QR.
The plate's bottom edge and the headline's cap height measured to the same pixel on a 393px handset. It gets margin and a hairline, because this is the one boundary on the phone where the page changes subject, from who Rahul is to what he built.
The five source marks named nothing.
Their names live in a hover tooltip, and the tooltip provider is disabled on coarse pointers by design, so Binance, Coinbase, OKX, Jupiter and Robinhood sat as five 18px logos with a bite out of each. The tuck comes off below sm and an 11px legend names them, derived from the same SOURCES array so the two cannot drift.
The artwork's only tap affordance was a cursor nobody has.
Zoom worked, but cursor-zoom-in advertises it to a pointer a phone does not have. A pill on the MacBook's base bar, gated on hover: none, with pointer-events off so it cannot swallow the tap it advertises.
The iOS shot was 76px wide.
21 percent of a 720px stage is 151px; the same percentage of the 361px a handset gives is a speck. 27 percent below sm puts it at 97px, still the smaller of the two devices.
Superteam reached the panel a recruiter actually reads.
The URL had been in the repo in three places, but the only surface consuming it was the 404 tile. The fintech summary on /cv claims 11 bounty wins, and until now that claim sat there with nothing to check it against.
UpdateSiteThirty-six review notes over /cv: dead deployment claims out, a pronouns toggle in, and a WhatsApp code encoded at build time.
- Every deployed live line came out, along with twelve Vercel URLs that were links into a payment wall.
- The pronouns switch moves the preview and both download links together, because eight PDFs are generated rather than four.
UpdateResume- The 404 puts the way out at the top
Back to home was the last thing on the page, after the snapshot, three sector cards and six contact rows.
- Someone who hit a dead link wants to leave, and scrolling past everything to find the only exit treats the detour as compulsory.
- The entrance stagger moved with it, or the nav would have faded in after the sections below it.
Order is now header, way out, then everything for whoever stays.
The page ends cleanly on the contact section instead of leaving the nav orphaned at the foot.
The delay utilities already covered the reshuffle.
The nav carried delay-400 because it was last. It is delay-100 now, with snapshot, sectors and contact at 200, 300 and 400, so no new CSS.
The comment documented the arrangement being replaced.
It read One way out, and it comes last. Rewritten rather than left to contradict the code under it.
The button is 39px tall, under the 44px guideline.
Pre-existing and shared with the page's other pills, so it is left alone, but it matters more now that it is the primary action near the top.
UpdateSite Read-only share links are renamed Public URLs, the section loses its bottom stroke, and the architecture SVG stops flickering on first paint.
- The map gained two visuals, Public URLs and Four designers, and swapped the MCP and team icons to match.
- Read-only share links described the mechanism; Public URLs describes what a reader gets.
The section's bottom stroke came off.
One more rule under a page that already closes on its own edges.
The architecture SVG no longer paints its first frame wrong.
First-paint behaviour on that diagram was showing state it should have resolved before rendering.
UpdateAgentUXInfinite stopped being true once the wall became a staff pick, so the dive is renamed, the URL moves, and the page shows the surface before arguing for it.
- The previous rename argued for gallery and let infinite survive unexamined, and it says the opposite of the point.
- Three paragraphs about retrieval used to sit ahead of the only place the page showed the thing.
Wall carries the editorial claim without implying an order.
It is the word the page's own copy already reached for. An S-tier framing was considered and dropped for exactly that reason: the notes said nothing is ranked.
The clip got the column, and a rail of one earned a full variant.
A rail of one has nothing for the next card to peek in from, and at 475px in a 768px column the only place the page showed the surface was the smallest thing on it. Now 768x432, opt-in, so the other six callers are untouched. No bleed variant: at 100vw the 16:9 frame is 814px tall, and a wall you take in at a glance should not be taller than the glance.
Deleting the Why section would have deleted a decision.
How the screens get picked was stated only in the third note. The hero carries it now, in one word: a wall of 27 hand-picked screens.
Four comments were left pointing at deleted content.
One cited the third note above, one credited the notes with disclaiming a ranking, one described a spelled-out count serving prose that had gone, and a docblock claimed both sections stacked a heading over a paragraph. All four now describe the files as they are.
The R2 filename stays Infinite Canvas.mp4, for the third rename running.
It is a stored object rather than a label, and renaming it would break the link for no reader-visible gain.
UpdateAgentUXA new MCP ingestion leaf and two illustration pairs both exposed the same thing: the detail rail sets the canvas height, so anything that changes it moves the whole map.
- The three original captures were all 1936x1472, so a fixed width gave them one height by coincidence.
- Width now derives from each asset's own ratio, holding height constant instead of width.
The new leaf is written from what the repo already states.
Ingest over MCP, under Capture rather than Share, because it is an ingestion path and the needs-review guard is another leaf's story. Both halves cite existing pages rather than being written fresh.
Leaves with no illustration reserve the slot and hold it empty.
Without that, the rail lost the figure's entire height on every text-only leaf, and the map jumped as the pointer travelled.
The actions row reserves itself with a hidden copy of the link.
A hand-tuned 16px was wrong: the real row measures 26px, so linked and unlinked leaves still differed by 10px. A copy of the element cannot drift from it.
Connector coordinates snap to the half-pixel grid.
A 1px stroke centred on a whole coordinate paints as two grey rows instead of one dark one, and the middle leaf of a three-leaf branch measured 167 against its parent's 166.5, emitting a half-pixel jog exactly where the bracket should read as one straight line.
Both illustration pairs arrived with light and dark swapped.
They are wired by content rather than filename, on the rule that a floating surface matches its theme rather than fighting it.
UpdateAgentUXEvery other dive describes a part of AgentUX that stopped moving. This one is live and unfinished, so it ends on the questions it is genuinely open on.
- The decisions page already set the precedent that this case study says when it is unsure.
- Infinite Canvas became Infinite Gallery here, because the surface hangs a picked subset rather than everything.
It ends on open questions, not a conclusion written to sound like one.
Structure runs why search is not enough, the clip, then what is still undecided.
Gallery frees canvas to keep meaning a surface you draw on.
Which is what it means at the architecture board, and it stops this being the third infinite canvas on the site.
It reuses the shared hero, section and footer rather than adding styles.
Both sections are a heading over a paragraph, which is what those components were built for.
ProjectAgentUXThe preview and the downloaded PDF were two different documents, with different titles, summaries, sections and employer names.
- The page rendered from the data file and the generator carried its own hardcoded copy, and they had drifted.
- Worse, sector sorting put Entytle 2022 to 2024 above Equicomtech 2025 to present on the B2B preview.
183 lines of duplicated resume copy left the generator.
It imports the record now, and the education and recognition lines are shared, so the two renderers cannot word the closing sections differently. Experience is reverse-chronological on every sector.
The letter stays HTML rather than becoming a fourth PDF.
It changes per application, and generating one per keystroke means either shipping the PDF renderer to the client or rendering on the Worker, which is the same size cap that forced the OG cards to text only. So: copied text for forms, and a print stylesheet for Save as PDF.
Printing produced three pages, two of them blank.
visibility: hidden hides elements but keeps their boxes. display: none with :has() sparing the letter's ancestors gives one clean page in either theme.
Every details row copies the destination, not the label.
A handle is useless pasted into an application form.
Extracted PDF text matches the rendered page word for word.
On all three sectors, which is the check the drift would have failed.
UpdateResumeJob portals were dropping pronouns from the parsed profile because the resume never stated them, and an unlabelled He/Him gets discarded as noise.
- One field on the record feeds the PDFs, the web resume, the details card, both llms files and the JSON-LD.
- /cv was rebuilt as two columns in the same pass.
Labelled, because the label is what makes it survive parsing.
Pronouns: He/Him/His in the header contact row of all four PDFs, plus the PDF keyword metadata. schema.org has no pronoun field, so the JSON-LD carries it as an additionalProperty.
The PDF content stream was decompressed to confirm it extracts cleanly.
As normal kerned text, the same way the email field does, and not as the letter-spaced failure the generator already warns about.
It is visible to the person reading the page, not only the machine parsing the file.
The details card carries it too.
UpdateResume- The 404 becomes a snapshot, and three pages come out of hiding
A dead end is still a visit, and often a first one. The apology is one line now and the space goes to the work.
- The page it replaces spent an eight-storey headline saying the one thing the reader already knew.
- A route audit found three pages registered in some places and not others, so nobody could reach them.
UpdateSite It shipped stating both 2,530 and 2,630 USD, and both 112 and 137 AgentUX releases, across the same three pages. And it had quietly become four.
- Splitting the resume record into one file left three generator-only strings restating figures that file owns.
- Page counts are now counted from the rendered bytes and enforced, not trusted.
The generator is not downstream, it is a sibling.
The data file's docblock warned about everything downstream of it, which is precisely why the stale numbers survived beside it. Its docblock now names this failure mode so the next correction pass greps for it.
Correcting one count pushed the file to four physical pages.
While its footers still read Page 1 of 3 through Page 3 of 3, the spilled fourth page carried no footer at all, and /cv offered it as a three-page document.
One sentence out, chosen because the next one makes the same point with evidence.
The removed line asserted that the work ships as code; the line after it says an order matching engine was written to learn exchange mechanics. Nothing unique is lost, and it is script-only, so the one-pagers are untouched.
The guard writes the files anyway when it fails.
Seeing the long one is how you work out what to trim. Tested in both directions: restoring the removed sentence makes it exit non-zero with expected 3, got 4.
UpdateResumeSuperteam was 11 wins and 2,630 USD, not 10 and 2,530. AgentUX was 137 releases, not 112. Both had reached the PDFs a recruiter opens.
- Same shape of bug twice: one file gets corrected, the others do not, and the generated PDFs go out stale.
- The wiki was right and the CV rebuild propagated the stale figure into four more files.
The count that warned about itself went stale anyway.
A comment tracking AgentUX releases against the product's own changelog had said 137 since 30 July, and recorded the path: 112, then 123, then 137. That same comment warns in writing that a hardcoded count here goes stale quickly.
137 releases since March 2026, not in five months.
A count plus a duration is two things that go stale. Anchoring to the start date leaves one, and the reader does the arithmetic.
The fix that prevents a third round is a note, not a number.
The data file now names its source for that count, lists what travels downstream, and states that the PDFs must be regenerated. The figure had no stated provenance, which is how it sat wrong through an entire CV rebuild.
UpdateResumeOne password held two kinds of writing: a management practice worth showing, and private deliberation about identifiable people.
- The gate is a single shared password handed to recruiters, so gated does not mean private here.
- On a three person team, swapping names for roles does not anonymise anyone.
Two entries were rewritten, three were pulled.
The hiring-at-volume entry is kept as is. Two firing entries and the compensation entry are gone; the two that only had a person as their example were rewritten to keep the thinking.
The compensation entry argued against him to the one reader who had it.
It states that he feels underpaid and is already 100 percent up from his last role. A recruiter reading that learns how little they need to offer and that it will probably be accepted.
Deletion rather than de-naming, deliberately.
Anonymising the negative entries while the positive ones carry real names would identify people by contrast: on a three person team, the one who is not named is the one being discussed.
One clause in the leading-team note moved too.
It judged a person. It now describes the role drifting into implementation, and the reasoning that follows, which is the actual leadership signal, is untouched.
WritingNotesEvery project URL on the site answered 402, including three hirahul.xyz subdomains. Fifteen links met a payment wall.
- The work those links point at is real and the outage is temporary, so they are marked rather than deleted.
- A removed link is harder to restore than a marked one.
One host list drives it.
Restoring a deployment is deleting a line, not editing six pages. The link component renders a plain link when the host is not listed, so no call site can be forgotten and left showing a stale warning.
The note goes into the accessible name, not just the tooltip.
Tooltips are skipped when a screen reader walks a page by links, which is the pass where this warning most needs to survive.
Controls were checked, so this is the account and not the network.
The live site, example.com and vercel.com all answered 200.
An inert control stopped animating a press it never acts on.
The drawn map button on the architecture board cards has a hardcoded pressed state and no handler. Its whole response to a click was a scale down and back, which reads as a click that failed to land.
UpdateSiteThe detail panel showed a title, a body, then a column of empty space, because its height is set by the tree beside it.
- Three nodes now fill that column with real captures rather than diagrams of themselves.
- Two pair labels lost an arrow that only appeared once in the whole map.
The arrow repeated the icon and implied a conversion.
That node's icon is already a two-way arrow, and the pair is a choice between two views rather than a conversion between them. Both labels take the same mark, so the map does not carry two conventions for one idea.
Noted rather than hidden: the marks read inverted from convention.
A pipe conventionally means or and a colon binds a key to a value. It stays legible because the body text beside it spells out the choice in words.
UpdateAgentUX- Five retirements in one day
/hire, the Synclo and Entytle case studies, /sim, the two never-written chapter routes, and six components nothing imported. Published routes went from 39 to 33.
- The philosophy note already said it: this is a builder's portfolio, and the live product is the case study.
- Every retired URL was in sitemap.xml, so crawlers have them and each one has to keep answering.
/hire went because /cv and /md are shared directly.
Around 490 lines, including 187 of data. Its 404 CTA was an absolute link to /hire, so the 404 page would have sent people to a 404.
hireProfile survived the page it was written for.
It reads like page copy, but /llms.txt builds its positioning, methodology and open-to lines from it in six places. It is the machine-readable profile, not one page's text.
The employer case studies were the carve-out, so they went.
431 lines, 351 of them data driving both /projects/[slug] and the vanity URLs. Pubkey stays as the one long-form write-up left, because it is contract work on a product that shipped publicly.
Redirect ordering was load-bearing for the page that was kept.
The password study had to be declared before the bare parent rule, or the shorter source swallows it and the page just rescued lands on the landing instead of itself. Two other rules would have become redirects into a 404.
Dead components were found by resolving imports, not by grepping.
Grep cannot tell an import from a mention in a comment, and that mattered: the first pass flagged two components that turned out to be named only in comments explaining why they were parked. 944 lines, and a translation block, a type and a docs row died with them.
A nav nobody rendered was making /wiki look load-bearing.
Its single link pointed at /wiki labelled Home, while nothing on the site rendered the nav at all. Removing it makes the real link graph legible.
UpdateSite A flow-label edit that lost concurrent writes, thumbnails a day from deletion, an MCP schema hoist, and a measurement that killed a plan which then shipped anyway.
- All four go into What broke, and why, whose section copy widens to cover a wrong conclusion alongside broken software.
- Only the hoist gets a table, and the new metrics field is documented as rare on purpose.
A row that deliberately does not move is what earns the table.
Cost roughly halved across three call types while the tool surface an agent sees stayed byte-identical at 33,179 bytes. As prose that is a clause you skim.
Most changelog numbers measure different things in different units.
Shared columns imply a comparability that is not there, so those stay as prose attached to their own unit.
The table refuses a tempting mix of measurement sets.
A later entry says three of four were over the ceiling, but that set reflects a separate change. Using only the matched before and after pairs means the body says two of three, which is what these numbers show.
It is real table markup, with real header scopes.
It reuses an existing look but reaches a screen reader as headers rather than as a grid of divs. Number cells are nowrap, because at 375px the byte count was breaking after the comma and reading as two numbers, on the one row the table exists to show.
The page was also corrected about itself.
Its description said fifteen decisions with thirteen present, and its card said five things broke where nine had.
UpdateAgentUXEvery stage carried a stand-in glyph: a microphone for Granola, a kanban board for Linear, a sparkle for Claude and Codex. They carry their own logos now.
- The source art arrived as 800x800 artboards with background rects that would have rendered as bright tiles in dark mode.
- Path data came down 49 percent, and the result was checked by rasterising original against rounded.
UpdateNotesFour things in that block were sized against the desktop gutter and carried straight down to a 361px screen.
- Measured at twelve widths from 320 to 1512 in both themes; everything from 640 up is unchanged.
- The phone copy is a held-back subset of the desktop strings, not a second copy that can drift.
The subline broke after the source marks.
So line two opened on a lowercase participle with a stranded comma above it. Below the breakpoint the noun phrase collapses and the motive clause drops, holding one line from 360px up.
Two badges measuring 211px and 222px could never share a 361px row.
Split into a fact and a qualifier they measure 81px and 92px and sit on one line.
A flat blur over half the artwork stops reading as separation.
The glow was authored against a 720px shot, so on a phone it read as a grey panel behind the laptop. The wide stop is expressed in container units against the stage now, which is exactly as wide as the image.
Weight was doing the separating on its own.
The heading sits one step above its subline, 18px over 14px, so 650 made two wrapped lines read as a slab. 550 still clears the 400 subline; 500 starts to sit level with it.
text-balance was making the heading narrower than the line under it.
It pulled a full 361px box in to 245px and 217px to even the lines up, leaving the heading visibly inset against a 291px subline. Letting it wrap squares the block off.
UpdateSiteSeven project tabs become one block, in the right-hand gutter, with the desktop and iOS hero and the catalogue's five sources as marks.
- The block takes the headshot reel's own band, and blurs back when the reel or the bio tooltips open.
- The tabs and their data stay in the tree unrendered, for the day the focus widens again.
Two columns start exactly where the reel turns on.
So the gutter never exists without something able to occupy it, and that space reads as one surface being replaced rather than two things overlapping.
The spring tooltip came back from the dead.
One box that seeds at the cursor, lags, tilts with velocity and morphs between adjacent targets. Its pattern doc recorded it as stranded after a notation pass took that path over, and five marks in a row is the case its morph was built for.
Its placement is forced, not chosen.
The box is position: fixed, and the block takes a blur filter when the reel opens, which would make that block its containing block. So the provider sits at the page root.
Five icons arrived at 900x900 and 1.04MB.
Kept as 144px webp only: 10.6KB the lot, rendered at 18px with a 5px overlap.
The heading base rule moved into a layer, and 69 headings changed weight.
Unlayered, it beat every Tailwind weight utility in the codebase, so a heading asking for a different weight silently got 700. Both consequences were intended.
UpdateSiteThe access field was write-only, so a mistyped password could only be found by retyping it. Four unrelated fixes from one feedback pass.
- Only the field became a client component; the page still awaits params server-side and the form still works with no JS.
- type=button is load-bearing: the default inside a form is submit, which would post the gate on every peek.
The research hub and its studies lost their header rule.
Scoped to that section so the hub matches the pages it links into. The same header pattern is deliberately left in place on five other pages.
The Pubkey carousel stage went transparent.
It is a full-bleed band, and an opaque fill under it read as a slab across the page. Only the base goes; the accent halo already faded to nothing.
A loop-back arrow came off the voice-to-ticket diagram.
The sentence already says feedback goes back to Linear, so the glyph restated it.
UpdateSite- One OG endpoint replaces eighty route files
Every social card on the site now renders from one template with the text changing per page, GitHub style, and pages opt in with a one-liner.
- Wired into 41 routes using the exact copy recovered from the 80 files the migration deleted, so no card silently reworded.
- Text only is the design: a hero panel needs either a filesystem the Worker does not have, or a self-fetch over HTTP.
The homepage went out with no card at all.
Next replaces openGraph and twitter wholesale rather than merging them. That page sets its own block, so spreading the helper after it dropped the block and spreading before dropped the images. A per-route check caught it, and it is now documented in two places.
Eleven prebuilt PNGs and their regeneration script are gone.
They went stale on any copy edit and had to be rebuilt by hand. Cards now derive from the page's own title and excerpt, so the dynamic routes cannot drift at all.
The root layout gained a fallback, so there is never a missing preview.
/cv and /wiki had no card before and now do.
It was verified against the built Worker, not just the build.
An earlier edge-runtime attempt built clean and 500'd at runtime, so the checks run in workerd.
Headroom went from 881 KiB to 81 KiB.
The image renderer costs 791 KiB of the 3 MiB free-plan cap. A deliberate trade for cards that cannot go stale, now guarded by a CI size check that fails with the measured number instead of an opaque error code.
UpdateSite A voice memo becomes a scoped ticket becomes a merged change. Granola captures, Linear triages, Claude and Codex execute, and the changelog writes itself.
WritingProcessA new note with a five-stage pipeline diagram, plus a rework so NDA-covered notes are listed rather than filtered out of existence.
- The diagram is HTML and CSS, not one wide SVG, because a fixed-width diagram needs sideways scrolling on a phone.
- Gated notes now sit under the public index with titles readable, and open a locked sheet.
Nothing signalled the deeper employer work existed at all.
They were filtered out of the rail, the sitemap and llms.txt.
The only body text there is written for the purpose, never the excerpt.
The real excerpts name a team and a subject. It reaches the client through a narrowed projection, so a component physically cannot render more than title, category, read time and gist.
Redaction works because the bracketed text is the stand-in.
The real term is never in the data, the HTML or the bundle. Nothing is hidden with CSS, because CSS comes off in one click and the text would still be there.
Un-gating one note meant editing all three places that have to agree.
Doing one without the others either leaks the page or lists a note that redirects away. Leak-checked on the index: every sensitive term returns zero.
The signpost capsule was an order of magnitude too thick.
A 3D rounding helper took a radius of 0.432 against a depth of 0.065, so the radius drove the depth instead of the reverse and a thin board rendered as a fat pill. Extruding a 2D shape makes depth mean depth.
WritingNotes- The site moves to Cloudflare Workers
www.hirahul.xyz was answering 404 and the other subdomains 402, so the site was down. It runs on Workers via OpenNext now, on the free plan.
- Cloudflare serves a matching static asset without invoking the Worker, so the NDA gate was bypassed entirely for 109 protected files.
- Fitting the 3 MiB script cap meant removing the image renderer, the visit counter, Redis, and 96 MB of unreferenced assets.
The security hole could not have surfaced in any local test.
Vercel ran middleware ahead of static serving, so it had no equivalent there. 100 images under one gated folder, 6 under another, and 3 gated prototypes were reachable. Fixed by scoping worker-first handling to exactly those paths, then verified live on both hosts.
Two things that look like fixes are not, and both are written down.
Prerendering the 80 card routes removes the work but not the code, since the renderer still ships. Static metadata images are worse: Next base64-inlines them, growing the handler from 9.4 MB to 26 MB.
The first build missed the cap by 13 KiB.
Removing the image renderer outright freed around 600 KiB, landing at 2157 KiB against 3072.
Two platform-forced changes, neither of them a choice.
Next 16.1.6 to 16.3.0, because OpenNext's peer range excludes the window in between. And the proxy file convention back to middleware, because Next 16 pins proxy to the Node runtime and OpenNext rejects Node middleware. The gate only uses edge-safe APIs, so it is a filename change.
Deploys run from Actions with a readable size check.
Replacing Vercel's Git integration. The gate password stays a Worker secret, and wrangler leaves existing secrets alone, so a deploy cannot clear it.
Asset deletion was checked against literal paths and webp siblings.
The repo builds no asset paths by template literal, so there were no dynamic false positives, and the PNG siblings the cards read were protected.
UpdateSite The case study reworked into a single carousel with a fullscreen screenshot modal, then polished after visual QA.
- Side-card clicks centre the card, a centred-card click opens the modal, and vertical scroll leaves the carousel alone.
- Assets moved into old-ui and new-ui folders, with every project and media reference repointed.
UpdatePubkeySigned, notarized, installed from a private page. The surfaces table gained a status for that, the two browser extensions split apart, the capture section shows one of its five paths working, and a note covers the build.
- One extension row could not honestly carry two browsers once their statuses diverged.
- "Notarized, direct install" is a real state, and none of the four existing statuses could say it.
Notarized is not shipped, and it is not sideloaded either.
Signed with a Developer ID, notarized by Apple, and installed from a private page inside the app. That installs on a teammate's Mac without Gatekeeper stopping it, which an unpacked extension cannot claim, and it is not a store listing either. None of Live, TestFlight, Built or Dormant says that, so it got its own status.
Safari and Chromium are separate rows now.
They were one row reading "Chromium and Safari builds exist; neither is in a store". That was true until one of them entered review. A single row can only carry the lower claim, which would have buried the news, so they split: Safari in review, Chromium loaded unpacked and submitted nowhere.
The section copy was making a claim that had just expired.
It said the extension "has never been published to a store", which is still true and was hiding the more interesting fact. Now: nothing here is in a store, the Safari build is signed and notarized for direct install, and everything else is live on the web or handed out by hand.
And the build story is a note, not a longer section.
About 400 words on why Safari at all, one codebase with two builds, and the three things that cost time: code signing off means Safari silently lists nothing, a wrong developer ID survived two weeks because Xcode signs with whatever it can find, and Safari has no way to open an extension popup from code, so saves queue and the toolbar badges instead. A button under the walkthrough goes to it.
Notes also stopped sharing one social card.
Every note fell back to the /notes index card, so any of them posted anywhere showed a generic "Notes / Index" image that never named the post. Each note renders its own now. It cannot use generateStaticParams alongside the edge runtime the card template needs, so it renders per request like the other twenty OG routes.
The Capture section now shows a path instead of only naming five.
A 67-second walkthrough on /agentux/ux: signed in from the Safari toolbar, right-click an image or drop one into the popover, and it lands in the catalogue with its source and when it was saved. Its own figure rather than a card in a rail, because every clip in those rails runs 5 to 13 seconds and this is a walkthrough, and because at 2046x1776 it is the one nearly-square thing in a row of 16:9 cards.
And the heading counts its own rows.
It read "Ten ways in" while the array held ten, which the split would have made wrong the moment it landed. Derived now, which is the third heading on this site to need that: adding an item is exactly when nobody re-reads the sentence above the list.
UpdateAgentUXSuperteam totals to 11 wins, 2,630 USD and 47 submissions, the Hobba fifth place added, and the YouTube counts refreshed.
- The copy had been verified in June and drifted from the current public data.
- Checked against the live Superteam profile, the Hobba listing, the Medium feed and the YouTube channels.
This is the entry the later corrections trace back to.
These figures were right here first, and a CV rebuild eight days later propagated the older ones into four more files before being caught.
The verification date moved with the numbers.
So the page states when it was last checked rather than implying it is always current.
UpdateSiteThe catalogue's particle orb and falling grid join the interaction deep dive, with attribution to Jakub Antalik stored on the clip rather than in the page's prose.
- A filtered catalogue's loading state is one of its most-seen screens and usually its least designed.
- The orb is someone else's work, so the credit lives in the clip data and shows up wherever the clip does.
It says what it is doing, not that it is busy.
"Matching filters..." rather than "Loading". The screen names the work in progress, which is the part worth putting on an interaction page: it is a product decision, not a motion flourish.
It joins the completions rail, from the other end.
That section is called "Different actions deserve different endings" and held Save, Delete and Thanos Snap, all of them what an action looks like when it finishes. A loading state is the same argument about what one looks like while it is still going, so it needed no new section.
The credit is a field on the clip, not a sentence on the page.
This data feeds the carousel on /agentux/design and the full library on /agentux. Attribution written into one page's copy would be missing from the other, so it sits on the clip and renders under the description in the lightbox on both.
And it needed its own poster frame.
The clip opens on the landing page, which is already its own clip in the same rail, so at the default 0.5s freeze both cards showed the same image. Clips can now name the second to freeze; this one uses 3s, where the orb is actually on screen.
UpdateAgentUXA clip of the bottom bar collapsing from four labelled tabs to a single puck, placed between the static screenshot and the capture path.
- The page had one still screenshot and no motion, on a page about an app whose navigation is the interesting part.
- Catalogue, Videos, Links, Search collapse to the active icon once a reference opens.
The bar earns its space back.
The catalogue is screenshots, so the screen is the product. A tab bar parked permanently at the bottom spends the most valuable space on a control you have already used, so it contracts to its active icon while you read and returns when you need it.
It sits between the still and the capture path, not at the end.
The order now reads: here is the app, here is how it moves, here is how things get into it, here is the decision behind it. Motion directly after the screenshot of the same screen.
Probed, not assumed: 820x460.
That is 1.783 against 16:9's 1.778, so the shared player's frame holds it with about seven pixels of letterbox top and bottom and the blurred backdrop barely shows. Verified in the browser at readyState 4 with the inner player measuring 744x417, which is the clip's true ratio rather than a stretched fit.
UpdateAgentUX- Hand-drawn icons out, Phosphor in
A wrong copy glyph turned out to be one of eight icons drawn by hand. All of them are Phosphor now. The settings button also shows the active theme, and the eight build tools fit on one line.
- The copy icon was a hand-drawn polyline whose back sheet spiralled inward, so it read as a rectangle with a stray tab.
- The tool row wrapped because eight chips need 879px and the column is 768. It bleeds right now instead.
The copy icon was not a Phosphor icon at all.
It was a hand-authored polyline, and its back sheet ran [5,4 15,4 15,7 8,7 8,17]: top edge, then a retrace LEFT along the front sheet's own top edge, then a drop at x=8, one unit from the front sheet's left edge at x=9. Two of its four legs landed on top of the front rectangle and the third became a doubled hairline. For months it read as a rectangle with a stray tab.
So all eight hand-drawn glyphs are gone.
Chevron, menu, copy, expand, settings, play/pause, sound, download: every one was a polyline drawn by hand on a 24-unit grid, which is why one could be wrong for months without anyone noticing. Eleven call sites now use Phosphor, the same set as the rest of the site, and the file that held the paths is deleted.
The cost is the morph, and it is a real cost.
Interpolating between two shapes needs the same point count in the same order. Phosphor glyphs are arcs and curves authored independently, so there is no honest morph from a copy icon to a checkmark. One shared IconSwap crossfades instead: both icons always mounted and absolutely positioned, so the box never resizes and nothing beside it shifts. Less clever, and it cannot be wrong about what a copy icon looks like.
One inversion fell out of the swap.
The diagram's expand button reads "Close expanded diagram" when expanded, and was showing the corners-OUT glyph while saying it. It shows corners-in now, so the mark and the label agree on which direction the click goes.
The tool row was arithmetic, not taste.
Eight chips need 879px of track and the reading column is 768, so flex-wrap split them. Bleeding right the way the clip and deep-dive rails already do gives 1140px at 1512 and 896 at 1024, so it is one row down to about a laptop and scrolls below. The chips keep full size: the tool names are the part worth reading.
The settings button carries the active theme.
Sun, sun-on-horizon, moon-and-stars, or moon, reusing the marks the theme rows already use so the trigger and the highlighted row cannot disagree. It reads the resolved theme rather than the stored preference, because under Auto the question a corner glyph answers is what am I looking at.
Which introduced a hydration bug, then fixed it properly.
The theme is read off the document by a lazy useState initializer, which runs during the hydration render, so server and client disagreed. React does not patch a differing attribute; it warns and leaves the server's value. The first attempt gated only the glyph and left aria-label reading "Day theme" on a night page. One gate now covers both, and the console is clean.
UpdateSite The notes signpost was redesigned by pointing at it: volute curls, a circular base, real glow, and boards that fan around the pole instead of touching.
- A 3D scene cannot be annotated by DOM tools, so a playground copy of the signpost learned to name its own parts on click.
- Every change was tuned on dials there and ported back as one JSON of numbers.
The iron curls were circles pretending to be scrolls.
A wrought-iron curl is a volute, a spiral tightening toward its own centre, and the old constant-radius arc read as subtly wrong in a way that was easy to misread as squashed. The curls are log spirals now, the cap pair is gone per the reference lamp, and the foot pair rises from the mount, one curling up and one down.
The base stopped being an ellipse under a circle.
The skirt shipped scaled to a 1.6:1 ellipse while the ring and cap above it were true circles, which is why it felt off without being nameable. Circular now, at its widest radius, so it keeps its stance.
The halo became glow instead of a ball.
It was a solid sphere with additive blending, which has a hard silhouette: a crisp-edged orange ball around the bulb. It is a camera-facing sprite with a radial falloff to zero now, at roughly double the size and four times the strength.
Boards fan around the pole, each at its own angle.
Seven per-sign yaw angles, tuned by hand: negative swings a board toward the camera, positive away. The pivot is the pole's own axis, so the arm still meets the pole and the hub still wraps it, which a plain depth offset would have broken. A gentle phase-spread sway sits on top, skipped under reduced motion.
The playground is the tool, and it ships with the repo.
public/htmls/notes-signpost.html is a vanilla three.js transcription of the scene with sixteen dials, a click-to-copy part picker, and a Copy values button. The port back was pasting one JSON. It stays in sync: its defaults now equal what production renders.
UpdateSiteThe tree's lines were drawn by an animation, so a stalled reveal left it genuinely half-drawn. It is a static diagram now: the lines are always whole, and hovering marks one title.
- A reported half-drawn tree turned out to be the entrance animation, not the geometry.
- Every moving part came off. The diagram now has exactly one interactive state.
The trunk stopped halfway to two of its four branches.
Which is what identified it: the lines were painted by stroke-dashoffset 1 to 0, running outward from the root, so a line's resting appearance was invisible and every visible line depended on an animation having finished. Where one had not, the trunk stopped partway and the leaf brackets lost their tail. A diagram whose job is to show a structure cannot have a state where it shows a different one.
So the reveal is gone, and the replay button with it.
No scroll-triggered draw, no staggered fade-up from scale(0.92), no visibility gating that kept nodes unfocusable until their turn. Zero animations run in the map now. The failure mode is not fixed so much as removed: there is no longer a mechanism that can leave the tree partial.
The lines were also under 1.7:1 in all five themes.
The stroke used --border-color, tuned for 1px card borders where near-invisibility is the point. Measured per theme against its own background: 1.17 default, 1.23 afternoon, 1.70 evening, 1.38 dark and night. Mixed halfway to --text-secondary it reads 2.15 to 3.63 across the same five.
Hover was doing six things; now it does one.
Gone: recolouring the active branch's lines and thickening them, dimming every other line, dimming every unrelated node, sliding the hovered node 3px sideways, scaling its icon 1.12, and a per-branch rotate or translate on four icons. What remains is the title under the cursor taking the accent colour. Also cut on the way: a highlight that travelled out from the root, built and measured over 63 frames the same day it was removed, because motion on a structure is noise.
And the root's line started behind its own name.
It sits in a grid column that resolves to exactly its 104px floor while "AgentUX" needs 116px, so the label spills 12px past its box. The anchor read offsetWidth, which cannot see spilled content. max(offsetWidth, scrollWidth) gives it the 7px clearance every other node had.
UpdateAgentUXAgentUX already had a Chrome extension. Getting the same thing into Safari took an afternoon of code and two weeks of waiting for a Mac.
WritingTechnicalThe AgentUX deep-dive cards became a rail with the clip carousel's grammar, both sets of arrows moved onto the reading column, and four diagrams gave back the space they were padding.
- The dive grid's column count was hardcoded, so every new dive orphaned the last card on its own row.
- The carousel underneath it had already solved this. The fix was to stop inventing a second answer.
The first attempt was measured, correct, and still wrong.
Cards that grew to fill the row, on a 220px floor picked from container widths of 1448 / 1216 / 1088 / 960, so the rail only scrolled when it had to. It worked. It also sat on a centred bleed, which put the first card at x=32 while the section's text starts at 372, so the rail began 340px out in the left margin.
The carousel had the right answer the whole time.
Its breakout keeps the scroll container's left edge on the text column and bleeds only right, so a card scrolling out is cut exactly on that line and nothing ever appears in the left gutter. Matching it meant giving up grow-to-fit, because the container is 1140px rather than 1452px. Worth it: a permanently cut card at the right edge is what makes a rail read as scrollable.
2px of padding was enough to break the alignment.
Left padding on the scroll container put the first card at 370 against text at 372. The horizontal axis is zero now, and the vertical padding the hover state needs is cancelled by a negative margin so it adds no height.
The arrows were technically right-aligned and practically unreachable.
On one page the row ran 32 to 1480 while the heading ended at 1044. They sit on the reading column now, which takes two boxes because no single caller knows both facts, and because that column is 42rem in the AgentUX module and the full 48rem shell elsewhere.
Most of the reported diagram height was not the diagram.
One was 304px of content sitting in a 384px box, on its own min-height floor. In the sequence diagram every child of a step is absolutely positioned, so the 68px floor was the step's entire height while its deepest content ended at 47px: 21px dead per step, 189px across nine. 726px down to 606px with nothing clipped.
Below 640px the cards stay a vertical stack.
On a phone they are the section's navigation, and navigation should be scannable without swiping to find it. Same reason the arrows are pointer-only.
UpdateAgentUXA sixth deep dive for the native client: what ships on TestFlight, how share-sheet capture works, and why the old Shortcut still does.
- The iOS app was a hover tooltip on the hub's hero and nothing else. Now it is a page.
- Every claim on it reads out of the architecture page's verified surface list, so the two cannot drift apart.
The most interesting thing about it is a decision, not a screen.
Shipping a native app is the moment it would be easy to switch the old iOS Shortcut off. Doing that would break every Shortcut already sitting on someone's phone, for a change they never asked for. So the endpoint stays live and the app only replaced the Shortcut's setup screen.
One library, and the phone writes into it.
The app is a share target rather than a second client with its own store. A screenshot routes through the same endpoint the Telegram bot uses and lands in the catalogue the web app reads, so there is no mobile library to reconcile later.
Nothing on the page is retyped from the product.
The status line and the Shortcut quote are looked up from the surface list that /agentux/architecture already publishes. When the app leaves TestFlight, one file changes and both pages follow.
The hub heading now counts its own cards.
It read "the product from three angles" while four cards sat under it, because adding a dive is the one moment nobody re-reads the sentence above it. It derives the number now, and the card grid auto-fits instead of orphaning the last card on its own row.
ProjectAgentUXThree layered, theme-aware shadow steps, after an audit found 30 distinct box-shadow values and no token.
- 90 hand-written shadow values, 30 of them distinct, replaced by three themed steps.
- Roughly half the old set was locked to literal black, so it did nothing on the dark themes.
One light source.
Every step is straight down. The old set implied light from below and from the right on the same page, which is what made surfaces read as stickers rather than as things at different heights.
Layered, not one big blur.
Each step stacks two or three shadows whose offset and blur roughly double, so a surface reads as a contact shadow plus its falloff. A single large gaussian reads as fog.
Tinted, not black.
The shadow hue is a per-theme token. Pure black over warm paper reads as dirt while a tinted shadow reads as shade, and a strength multiplier lifts the alpha on dark themes where the same shadow would otherwise vanish.
Focus rings, inset hairlines and the brutalist offset were left alone.
They use box-shadow for jobs that are not elevation, and folding them in would flatten three separate visual languages into one.
UpdateDesign systemOne event per request instead of scattered console.log, starting with the visits endpoint. Not yet exercised in production.
- One structured event per request instead of scattered console.log, starting with the visits endpoint.
- Not yet exercised against production traffic, and no drain is configured.
The visits endpoint was the loudest blind spot in the app.
Without Upstash credentials it failed with a URL parse error that named neither the route nor the cause, and returned a silent 500.
It now emits the request id, the action, the failing step, the parsed error with status, and the duration.
Errors are logged and re-thrown, so the HTTP behaviour is unchanged and only the observability is new.
Environment prefers VERCEL_ENV over NODE_ENV.
Vercel builds preview and production both as production, so only the former separates them.
Still open.
No production traffic has exercised it, events go to stdout and age out, and both peer ranges are unmet.
UpdateToolingEight build tools, each opening a panel with what it is and the job it does, instead of a row of logos.
UpdateAgentUXA reusable hook that reveals and hides document.title character by character. The first version froze the home page.
- A reusable hook that reveals and hides the tab title character by character.
- The first version froze the home page four seconds after load.
The brief mostly shipped intact.
Durations fixed regardless of string length, 730ms to reveal and 530ms to hide, characters appearing sequentially, a full exit before the next title enters, cancellation mid-flight, and no libraries.
requestAnimationFrame was the wrong clock.
A title has only as many states as it has characters, so at 60fps it rewrote the same slice several frames running: about 76 writes per cycle, every four seconds, for as long as the tab stayed open. Stepping once per character costs one write per visible change.
A MutationObserver on the document head locked the main thread.
It wrote the title back so route metadata could not flash in mid-animation. Writing the title from inside an observer that watches the title re-enters the callback on every write, and React re-asserting its own title turns that into a ping-pong neither side wins.
A real route title can now briefly appear during navigation.
That is what the observer existed to hide, and a far better trade than a frozen page.
UpdateInteractionThirteen decisions from four months of shipping: what I removed, what broke and why, and the one rule that never moved.
- Thirteen decisions from four months of shipping AgentUX, edited down from 137 dated releases.
- Grouped by the three things that cannot be faked: what I removed, what broke, and what never moved.
The other five pages made it read as something that arrived fully formed.
They explained what the system is and how it works, but none of them showed it was built over time, argued with, and corrected.
Subtraction leads, with four features deleted in one release.
144 files and 28k lines. Almost nobody publishes that, which is exactly why it is worth publishing.
Each failure mode has a stated cause.
Led by a cleanup tool that read its in-use file list through a single capped request, so every file past the cap looked orphaned and could have been deleted.
No release counter anywhere on the page.
A count is a vanity metric. The decisions carry it better.
ProjectAgentUX- Annotate the preview, not just localhost
The Agentation toolbar now rides along on Vercel preview deploys, so a change can be marked up on a real phone.
- The Agentation toolbar now rides along on Vercel preview deploys, not just localhost.
- Previews are public unless deployment protection is on, so anyone with the link sees it.
UpdateTooling Tapping the headshot on mobile now opens a full-screen reel with a card rail, collapsing to a mini player that keeps playing.
- Tapping the headshot on mobile now opens a full-screen reel with a card rail.
- It collapses to a mini player that keeps playing while the page becomes readable again.
The old version wasted half the screen.
A bare centred square left 380px of empty scrim split into two useless halves, with no pause, no seek, and a clip that looped forever.
The rail follows playback and yields the moment you swipe.
Four cards: who, six years, the resume, and a way to get in touch as the reel ends.
Full and mini differ by transform alone.
The video element is never reparented across that travel, because moving it between containers tears down the media and a 27MB clip would re-download and restart. Animating width instead would relayout a decoding video every frame.
Only the mini player carries a cross.
That makes minimising the cheap gesture and dismissal the deliberate one.
UpdateInteractionWhy my team reviews live HTML prototypes instead of static mocks, and how a link that never changes tightened the feedback loop.
WritingProcessA tool to annotate HTML so AI agents read a screen the way a designer does.
- Annotating HTML so an agent reads a screen the way a designer does.
- Built because an agent shown a page sees markup, not the hierarchy a designer sees in it.
The gap is not vision, it is emphasis.
An agent can read every element on a page and still not know which three matter, because nothing in the markup says so.
So the annotation goes in the HTML itself.
Not a side file that drifts, and not a screenshot an agent has to interpret.
ProjectTool- Feedback Capture & Share
Capture feedback and share it as a linkable artifact.
ProductIn dev - Fintech Icon Collection
Icon CDN for internal fintech & crypto products.
ProductExperiment Evidence-based catalogue of how products design for AI agents, not just humans.
- A catalogue of how real products design for AI agents, not just for people.
- Evidence-based: every claim points at a captured screen rather than at a best practice.
Most writing about agent UX is assertion.
The catalogue exists so a pattern can be argued from screens that shipped, which is a different kind of claim.
It became the project the rest of the case study grew out of.
Five pages now, covering AI access, interaction design, team UX, architecture, and the decisions behind them.
ProjectResearch- Simulator App
Order-type simulator for Spot & Futures.
ProductPrivate - Crpko Graphics App
Graphics sharing for dev handoff, across mobile & web.
ProductPrivate Early notes on YDex and the problem of retrieving the right context at the right time.
WritingProduct- Family Gallery
Private family memory archive.
ProductPrivate - Telegram + Finance
Personal finance ledger via Telegram.
ProductPrivate Reusable UI components and utilities.
ProductExperimentPersonal links, notes, and references.
ProductExperimentBackground generation utility.
ProductExperimentInteractive playground for concept exploration.
ProductExperimentCreative canvas and interaction experiment.
ProductExperiment