Releases: cjimti/drink
Release list
v1.6.1
The All segment on the Menu is now grouped by the bottle each drink is mostly made of, and the Info tab, humans.txt and README lose a paragraph and a line of credits. The menu is still 185 drinks from 50 ingredients.
What did not change
No drink id, shelf bit, brand id, storage key, share parameter or route changed, and retired_bits is still empty. Every ?s= link and every /drink/<id>/ link sent before this release opens what it opened. No recipe, code, build, method, family, taste or history changed. cocktails.json, bar.json, notation.json and kin.json are the same files as in v1.6.0.
All, by leading bottle
All used to list Stirred, Shaken and Built, with the printed families under each. It now lists one heading per bottle, in the order the Bar tab shows them, with the drinks in card order under each and no blurb (#125). The Stirred, Shaken and Built segments still show the card's own filing, and Shapes is unchanged.
The leading bottle is worked out from the build when the app loads:
- A spirit outranks everything else in the glass. The French 75 is under Gin, not Champagne, and the Air Mail under Aged rum.
- With no spirit, the largest vermouth or liqueur leads. The Bamboo is under Sherry and the Bitter Giuseppe under Cynar.
- A tie goes to the pour written first. The Vieux Carré is under Rye.
- Juice, syrup, bitters and mixers lead only a drink with nothing else in it, and then its first pour does, so no drink is left off All.
The printed-card section on each drink is not changed and is not used for this. So So Cocktail is still filed under Apple Brandy on the card and appears under Gin in All; Corpse Reviver No. 1 appears under Cognac.
Headings on the full menu:
| Bottle | Drinks | Bottle | Drinks |
|---|---|---|---|
| Gin | 49 | Mezcal | 6 |
| Bourbon | 21 | Cachaça | 5 |
| Rye | 18 | Sweet vermouth | 3 |
| Scotch | 8 | Dry vermouth | 5 |
| Cognac | 12 | Campari | 4 |
| Apple brandy | 5 | Absinthe | 1 |
| Aged rum | 9 | Cynar | 1 |
| Light rum | 14 | Sherry | 5 |
| Tequila | 7 | Fernet | 1 |
| Vodka | 11 |
A shelf, a shared menu, search and the bottle chips filter the list first, and only the headings with a drink left in them print. Print and Big type follow the same grouping when All is selected.
Info tab, humans.txt, README
In #124:
- The Info tab's This site section drops its paragraph about how the site was written.
humans.txtreadsSoftware: a text editor and a printed card.- The README's How it is built section lists the rules only, and the checks section no longer points to the instructions file.
The gate
scripts/app.test.mjs gains one case, 24 in all: the leading bottle for seven builds, including a juice-only one, every drink appearing exactly once under All, and Stirred keeping its family headings. make verify passed on the branch and the deploy on the tag, which runs the checks first, passed.
Confirmed on the live site after this tag's deploy:
index.htmlloadsapp.js?v=v1.6.1, andsw.jscarriesv1.6.1.- The served
app.jscontains the leading-bottle code. humans.txtcarries the new Software line, and the front page has no paragraph about how the site was written.
Upgrading
Nothing to do. An installed home-screen app picks up the new worker when it comes to the foreground. Your shelf, brand ticks, menu title and print ticks are untouched.
Not verified
- The WebKit print split. Safari and every iOS browser cut the printed list into two halves by an estimated height per row, and that estimate still counts each heading as having a blurb, so the halves may be slightly uneven. Print was checked in Chrome only.
- Safari and iOS on screen, and VoiceOver on the new headings.
- Light and dark at 390px were checked in headless Chrome, not on a phone.
Pull requests
- Info tab, humans.txt and README copy by @cjimti in #124
- Group the All segment by leading bottle instead of by method by @cjimti in #125
Full Changelog: v1.6.0...v1.6.1
v1.6.0
The second 12 September 2026 deep QA, of v1.5.0, filed eighteen findings under #99. This release closes all of them, #100 to #117, and the tracking issue #99. There are no new drinks: the menu is still 185 drinks from 50 ingredients. The work is search, first paint and caching, history and taste copy, the front page's title and description, drink page links and dates, a test suite for app.js, and the gate.
What did not change
No drink id was removed or renamed, no shelf bit moved, retired_bits is still empty, and no brand id, storage key, share parameter or route changed. Every ?s= link and every /drink/<id>/ link anyone has sent still opens what it opened. No recipe, code, build or method changed: the only drink fields that changed are taste (10 drinks) and history (71 drinks), 78 drinks in all. In bar.json, eleven brands changed tier or display name, and no price changed. In notation.json, the h glass gained a label for built drinks, and no glass letter changed.
Search
Search reads bottle names and shelf words (#102). It used to read only each bottle's short name, so vermouth found 4 drinks and bitters, syrup and liqueur found none. It now reads the drink name plus each bottle's name, short and shelf word, accent-folded. Counts under All bottles:
| Search | v1.5.0 | v1.6.0 |
|---|---|---|
vermouth |
4 | 78 |
bitters |
0 | 70 |
liqueur |
0 | 60 |
syrup |
0 | 48 |
lemons |
0 | 38 |
benedictine |
18 | 18 |
creme, crème |
0 | 6 |
This closes the one criterion v1.5.0 missed: creme now finds the Racquet Club. There are still no synonyms, so whiskey does not find bourbon. A code still matches in the case typed. Single letters now also match inside the longer names, so Q goes from 97 drinks to 113 and q from 64 to 82.
Front page title and description
- The title is
few bottles: classic cocktails from a small home bar, in the title tag,og:titleandtwitter:title. - The description is
My printed menu, decoded. Tick what you have, see what you can pour tonight, and which bottle to buy next.It is the same sentence in the meta, og and twitter descriptions, the JSON-LD, README line one,humans.txt,llms.txtand the manifest.Cocktail maxxing on a budget.is now only the Info tab's tagline. - The Menu view's visually hidden h1 reads
Classic cocktails from a small home bar. The section is still labelled Menu. - The README carries the current figure from
make stats, says four JSON files, uses the names the app shows (Shapes, Related), names Cloudflare Web Analytics, adds a How it is built section and four screenshots, and points to CLAUDE.md for the checks. It has no em dash. The screenshots live inassets/readme/, which is not deployed.
Analytics statement
Cloudflare Web Analytics injects a cookieless beacon at the edge (#100). The Info tab used to say Google Analytics was the whole list. It now names both, says Cloudflare's script is added at the edge and is not in the source, and humans.txt lists both. If the beacon is switched off in the Cloudflare dashboard, those sentences need to come back out.
History and taste copy
Corrections (#103). Prose only, no recipe changed:
- Scofflaw: Henry Irving Dale, not Shaw. The contest ran in late 1923 and the word was announced in January 1924.
- Bee's Knees: Brooklyn Standard Union, 22 April 1929, matching the Stingless Bee.
- Cock 'n' Bull Special: Bottoms Up, without the apostrophe.
- Vodka Martini: the 1951 Bottoms Up, not 1952.
- Barbary Coast: Gaige's Cocktail Guide and Ladies' Companion, 1941, not 1945.
- Pegu Club: Craddock's lime is a teaspoonful. Rose's is gone.
- Whiskey Sour: the 1826 date stays and is now attributed to the Oxford Companion.
- Negroni Sbagliato adds that Bar Basso itself says 1972. The Ritz history says "printed menu" instead of "printed card".
Closers and taste lines (#104). The fourteen histories built on one "pre-1970 manuals" sentence and eighteen "keeps X, yet it pours Y" closers were rewritten by hand, and 33 more "This menu" closers went in families where every page ended the same way. Histories ending in a "This menu" sentence went from 171 to 109, and no family's pages all end that way now. Nine taste lines that described a pour by what it is not, plus the Barbary Coast's cacao line, now give the amount and what it tastes like. No fact, date or source moved.
Bar tab
In #115:
- Tiers. Campari, Luxardo Maraschino, Bénédictine, St-Germain, Cynar 33 Proof and Fernet-Branca move to Solid, so no quality heading sits over a single bottle. Chartreuse stays Excellent.
- Names.
El Dorado 12 Year,Sazerac 6 Year Rye,Old Forester Rye 100 Proof,Rittenhouse Rye 100 Proof,Lucid Absinthe Supérieure. Brand ids are unchanged, so brand ticks carry over. - Alternatives has one line under its heading: "A different bottle that does the same job. The drink changes and still pours."
- Next bottles leads with "The bottles that add the most drinks, and what each one opens."
Drink pages
- Menu rows link to their pages (#112). Each row's name links to
/drink/<id>/, so the front page links to all 185. A plain click on the name still expands the row, and a modifier click opens the page. A screen reader and a keyboard still meet one control per row: the row text names the button, and the link is out of the tab order. - Dates (#112). Recipe JSON-LD carries
datePublished,dateModifiedandtotalTime(PT3M stirred and shaken, PT2M built). The sitemap carrieslastmodand dropschangefreqandpriority. Dates come from the generateddata/dates.json, keyed on a hash of each drink and seeded once from git history.make pagesdates an edit to the day it runs, andmake verifyrefuses a stale entry without asking git. - What the site is, and where corrections go (#106). Under the code: "The code is the shorthand off my printed menu; the Key tab decodes every letter." The all-drinks line says what the menu does with your bottles. Every drink page,
404.html,offline.htmland the Info tab end with "A wrong measure or a bad date is an issue at github.com/cjimti/drink/issues." These lines are hidden in print. - Highball or flute (#116). A built drink in glass
his always a Champagne top, so the Champagne Cocktail, Death in the Afternoon and the Seelbach now read "Highball or flute (no ice, sparkling wine on top)" in the app, and "Highball or flute" on their pages, in the JSON-LD steps, inllms-full.txtand on their three redrawn cards. The Gin Fizz keeps its label.
First paint, data and caching
- Intro copy in the HTML (#110). The first-visit strip and the rail's three steps are static markup, so the page paints words before
app.jsruns.app.jsisdefer. A visitor with a shelf, a dismissed strip or a shared link has the strip hidden before the data arrives. - Shapes after first paint (#110).
kin.jsonis fetched after the first repaint. Until it arrives, the Shapes segment is disabled and says "Shapes are loading", or "Shapes did not load" if the fetch fails. A Related link opened in the gap lands on Related once the data is in. - Glass art in one file (#110). The 20 glass SVGs are served as
assets/glasses/all.json(11 KB), written bymake glasses: one request instead of up to twenty. - Data fetches carry the version (#109). The four data files are fetched with
?v=<tag>, so a new script never reads the last release's menu off the edge. The worker revalidates them withno-cache, stores them by path, and on activation copies them from the old cache before deleting it, so the first open after a release with no signal still has a menu. A failed load reads "The menu did not load. Check the connection and reload." with no exception text. - Tool plates (#111). The Info tab's twelve plates are palette PNGs, 938 KB down to 188 KB, and are no longer precached with the shell. They load network-first like the glasses.
Lighthouse, live, mobile. Three runs on https://fewbottles.com/ after this deploy, Lighthouse 13.0.3, default mobile throttling: performance 70, 74, 73; LCP 6.2 s, 5.8 s, 6.3 s; TBT 90 to 100 ms; CLS 0. Accessibility, best practices and SEO scored 100 in every run. The LCP element is now the intro paragraph from the HTML, which is what #110 set out to do. The score did not move, though: #99 measured v1.5.0 at 74 and 85. The local branch runs in #122 (81, and 93 to 99 with gzip) did not carry over to the live edge. Lighthouse now names app.css as the render-blocking resource, about 990 ms of estimated savings. Google Tag Manager and gtag are still about 280 KB on the wire, which is a recorded decision.
Boot and engine
- A tab tap during loading (#108). The
hashchangelistener used to be registered before the data arrived, so tapping a tab while the menu loaded threw. It is now registered after boot's firstroute(). - Share links (#117). Opening a shared link goes through
selectMenu('shared'), so the gate flags are written in one place. - Next bottles sets (#117). Sets are scored with the same gain as single bottles, so a stand-in now counts there too. That made the set search slower on a near-empty shelf (about 2.5 ms to about 9 ms in node), so
nextBottlesis memoised on the shelf code. - Stale shelf ids (#117). A stored shelf entry naming no bottle is dropped and saved back without it.
- Generators (#117).
pages.pyandcards.pyrefuse a drink id that is not a slug, andpages.pyremoves the folder an orphaned page leaves behind.
Accessibility and papercuts
In #116:
- Skip links.
Skip to contentand, on a phone,Skip to tabs. They move focus without changing the route, and the target takestabindex="-1"only while it holds focus. - Chips are 32px tall. The
With <bottle>chip was a pressed button no click handled, and is now a label out of the...
v1.5.0
The 12 September 2026 deep QA of v1.4.0 filed seventeen findings under #69. This release closes all of them, #70 to #86, plus four items that pass had left unticketed (#94 to #97). It also adds eleven drinks, bringing the menu from 174 to 185.
What did not change
No drink id was removed or renamed, no shelf bit moved, and no storage key, share parameter or route changed. Every ?s= link and every /drink/<id>/ link anyone has sent still opens what it opened. The recipe changes are listed under The menu below: one recipe changed (the Ritz), two drinks changed method (the Ritz and Fernet and Ginger), and bit 41 is now called Olive brine in a recipe.
The menu
Eleven drinks, no new bottle (#87). Every one of them is poured from bottles the shelf already held. Tonic, sherry, olives, sugar, cream and grapefruit were each used by five drinks or fewer.
- Highballs: Vodka & Tonic, Campari & Tonic, Vermouth & Tonic, Sherry & Tonic.
- Beside the Bamboo: Sherry Cobbler, Coronation No. 1.
- Beside the Martini: Fifty-Fifty, Dirty Martini.
- Also: Salty Dog, Blinker, Brandy Milk Punch.
Each has a taste line, a sourced history, a drink page and a share card.
| Bottle | Drinks before | Drinks after |
|---|---|---|
| Tonic water | 1 | 5 |
| Sherry | 2 | 5 |
| Olives | 3 | 5 |
| Sugar | 4 | 5 |
| Cream | 4 | 5 |
| Grapefruit | 4 | 6 |
The Dirty Martini puts brine in the build. Bit 41 is now called Olive brine in a recipe and Olives on the shelf, the same way lemon already worked, and it is still one bit. The Salty Dog files under Highball through an override, because the ratio alone would call spirit plus juice with no sugar a Sour. The Coronation is stirred where the Savoy shakes it, since it has no juice, and the Brandy Milk Punch takes grated cinnamon, the shelf's spice, where the classic is nutmeg. Both notes say so.
The Ritz gets its Champagne back (#85). The card had 3/4 oz cognac and no top, which made it the smallest drink on the menu. That 3/4 oz is Dale DeGroff's own measure for a drink finished with Champagne, and every published Ritz is his. It is now Q,h,q,q,3,2,cfo, shaken, with 2 oz Champagne, spelled the way the French 75 already was. It files under Sparkling. The taste line is rewritten and the history says the top was restored. The demerara and Angostura stay as the card has them.
Fernet and Ginger is built (#71). It was filed as stirred with four ounces of ginger beer over ice, so the app told a guest to stir a highball and strain it. A new checker rule refuses a mixer poured into a glass that already holds ice under any method but built. The Gin Fizz still passes, because it is shaken and strained into an empty highball, which is what h rather than H on its serve token means.
Copy (#80, #86). Twenty-one taste lines that described a pour by what it is not now give the amount and the taste. The base-spirits paragraph on the Bar tab is three plain sentences. The Twelve-bottle bar holds fourteen bottles before the staples, so it is now the Fourteen-bottle bar; its id stays twelve. The README says four tabs and lists every step for adding a drink, including make pages and make cards.
Next bottles leads to the drinks it counts
Tapping a +N figure (#70), on a Next bottles row or a shelf row, now opens the Menu showing exactly the drinks that bottle would open: your shelf plus that bottle, filtered to the new ones. A selected With <bottle> · N chip shows the state. Before, it landed on My Shelf filtered by a bottle the shelf lacked, which showed nothing. On the shelf, the +N is its own button with a label saying how many drinks it opens. Changing menus, Clear, or any shelf change drops the filter. On a shared shelf the count is against that shelf.
Routing and focus
In #81:
- A hash that names neither a view nor an existing drink (
#foo,#drink/,#drink/MARTINI,#drink/nope) is replaced with#menuinstead of staying in the address bar. - A shared link that names a drink the sender cannot pour selects All bottles and keeps the shared banner, saying
<drink> is not on their shelf. - A drink open in the side panel that a filter or search then hides is closed, and the tab title resets.
- Closing the panel puts focus on the drink's row if it is in view, otherwise on the search box. Switch to My Shelf puts focus on the My Shelf row or chip.
Search
- 16px (#77). The search box was 15px, and iOS Safari zooms any focused input under 16px. This shell has no page scroll, so the zoom left the filter row and tab bar off screen. The line height is set explicitly, so the row is the same height it was.
- Its own row on a wide screen (#77). Beside the segments it had as little as 70px at desktop widths and showed
Name, ingredie. It now takes a full row, as on a phone. - Accents fold (#75).
benedictine,BÉNÉDICTINEandBénédictinefind the same drinks, and so dovieux carre,champs-elyseesandcachaca. Codes still match in the case typed, soQandqremain different searches.
One acceptance criterion was not met: creme does not find the Racquet Club. The search reads each bottle's short name (cacao), so Crème de cacao never reaches it. The same is why vermouth matches only one drink and juice, syrup and bitters match none. Changing what the search reads is a separate decision.
Contrast
Three grey tokens now clear 4.5:1 against every background they sit on (#76):
| Token | Was | Is | Worst-case ratio |
|---|---|---|---|
light --faint |
#A29E96 |
#767268 |
2.51 → 4.52 |
light --muted |
#73706A |
#5F5C56 |
4.65 → 6.28 |
dark --faint |
#837D72 |
#928C81 |
3.86 → 4.72 |
--faint colours the tab-bar labels, the search placeholder, every greyed in 12 on the shelf and the key glosses. check_style.py now computes every text token against both backgrounds in both themes, fails under 4.5:1, and prints the ratios on each run.
Screen readers
In #83:
- The Menu and Bar views each have a live status line that survives repaints. A tick on the Bar tab announces the new drink count once. The Menu announces
N of 185 shownafter a chip tap, and after a search once typing pauses. - Menu, Bar and Info each have one visually hidden
h1. The printed menu title is a paragraph now, and looks the same on paper and in Big type. - Drink pages had
aria-labelon a paragraph, which ARIA ignores. That is fixed on all 185 pages, and the checker refuses the pattern. - Printed drinks the shelf is short of now print at full ink, with the line naming the missing bottle.
Drink pages, cards and the agent dump
- One method line (#72). Drink pages printed the section heading as the instruction, so the Champagne Cocktail page said to build a dry flute over ice. Each method in
cocktails.jsonnow has ahowline (andbuiltahow_dry), and the app, the pages andllms-full.txtall read it. - Counts read correctly (#79).
1 drinks you can pouris now1 drink. An unmeasured pour prints the bottle on its own instead ofone Egg whiteorone Mint. - Cards go stale only when the drawing changes (#78). Each card hashed the whole of
cards.py, so editing a comment staled all 185. The hash now covers the drink, the glass art and aDRAWconstant bumped by hand.
Layout
- Segments under 340px (#94). At 320px the five segments came out unequal, with STIRRED and SHAKEN touching their borders. Tighter letter spacing below 340px makes them equal, with room around every label. 375px and up is unchanged.
- Amount column (#95).
2 barspoonswas wider than its column: 82.5px in 74px on screen, 70.4px in 69.3px on paper. The column is now 84px on screen and 54pt in print, wide enough for the longest amount label. - Landscape on a notched phone (#96). Every page uses
viewport-fit=cover, but nothing read the left and right safe-area insets, so the brand, filter row, list and outer tabs sat under the notch.--padis now at least 20px and at least each side inset, and the tab bar takes the insets as padding. With no inset, as on a desktop or on paper, nothing moves.
Two tabs open at once
Each tab read storage once at boot (#97). Ticks in one tab did not appear in another, the stale tab's next tick wrote its old shelf over the new one, and an Undo there could restore a shelf from before the other tab's change. A storage listener now reloads the shelf, brands, menu title, print ticks and first-run flag when another tab changes them, drops any Undo, and repaints. It never writes, so two tabs cannot loop. A shared menu open in the second tab stays the shared menu.
Deploy and caching
- The site, not the repo (#82).
scripts/stage.pycopies only theSERVEDpaths into_site, and the deploy uploads that.CLAUDE.md, the Makefile and the checkers are no longer on fewbottles.com.check_assets.pyfails if the site links anything outsideSERVED.make stage V=v9.9.9 DEST=<empty dir>rehearses a release locally. - Version queries on the asset tags (#73). Cloudflare holds js and css for four hours, so a visitor with no service worker could get the new page against the old script. The staged copy stamps
?v=<tag>on theapp.cssandapp.jstags inindex.html,404.html,offline.htmland every drink page, alongside__BUILD__insw.jsand__VERSION__inapp.js. Any stamp that does not land exactly once fails the deploy before upload. The worker matches shell files by path alone, so a stamped URL still finds its cached copy offline. - Gate cleanup (#74). The worker caches only a 200. A glass drawing no serve token asks for fails the build, and
rocks-ice.svgis gone. A passingmake verifyno longer printsWORKERlines.
Confirmed on the live site after this tag's deploy: /CLAUDE.md and /Makefile return 404, index.html loads app.js?v=v1.5.0, sw.js carries v1.5.0, and /drink/ritz/ shows the new code.
The gate
The checkers them...
v1.4.0
The 11 September 2026 deep test of v1.3.0 filed twelve findings under #47. This release closes all twelve, and settles the one decision that test deferred.
Nothing on the menu moved. The same 174 drinks, the same 50 bottles, the same shorthand codes, the same marginal-gain figures, and no id, shelf bit, route or storage key changed, so every link anyone has sent still opens what it opened. What changed is everything around the menu: what happens when the signal is gone, when the reader is on a keyboard or a screen reader, when the tap was a mistake, and when the phone decides to help with the typing.
Offline is now a first-class state
The type travels with the site. Montserrat, Lato and DM Mono were loaded from Google's CDN, and the worker skips every cross-origin request, so the faces were never in the cache. A phone that added the site to its home screen and opened it a day later offline got the swap fallbacks: the letterspaced caps in Helvetica, the shorthand in Courier. The printed menu is the thing this project preserves, and it does not get set in the wrong type because the signal went. All twelve cuts are now served from this origin and ride in the worker's shell.
Weight of assets/fonts |
180.3 KB |
| Budget the checker holds | 200 KB |
Nothing about the type itself changes. Lato and DM Mono are the same files at the same pinned weights. Montserrat is one variable file per cut where Google was serving four rules pinned at 500, 600, 700 and 800, and a sample string renders to an identical width between the two forms at every weight from 400 to 900. Two things come free: the Info tab's claim that nothing here knows who you are is now true of the fonts as well, and a page view is no longer a request to a third party before a letter renders.
A navigation with nowhere to go gets a page. A drink link tapped with no signal, for a page this phone had never opened, missed the network, missed the cache, and resolved to nothing, which is the browser's own error page with the site's name on it. offline.html now rides in the shell and is what a missed navigation gets: one line saying the page is not saved on this phone, that the menu is, and a link back to it. Every path in it is absolute, because it is served at whatever address was asked for. It loads no font and no script, so a page that exists because the network is gone makes no request of its own.
An installing worker can no longer pin the previous release. The install path fetched the shell with cache: 'reload', which skips the browser's cache and nothing else. Cloudflare and GitHub's edge both hold a file for ten minutes after a deploy, so a worker installing inside that window cached the old release under the new name and served it until the next tag. The shell is now fetched with ?v=<tag> on the end, which no edge has seen. Everything outside the shell, meaning drink pages, glasses, share cards and data, is network-first with the cached copy as the fallback, and only a response that said ok is ever stored.
The shelf has a way back
Stock everything, Clear the shelf and Make this my shelf each replace the whole shelf in one tap, and a hand-built shelf of thirty bottles went the same way as a shelf of three. The decision from the September review stands: a destructive shelf action gets Undo, never a confirm dialog. So the tap still lands, and a strip above the tab bar holds the shelf as it was for twenty seconds, reading Shelf cleared., Everything stocked. or Shelf replaced.
Both stores go back, brand ticks included. Undoing an adopt also restores the link somebody sent, the banner and the ?s= parameter, so their shelf stays readable exactly as it was before the tap. The snapshot is session-only and there is one of it, so a second bulk write replaces the first. A single tick, a starter shelf, the next bulk write and Undo itself all take the strip away. It stands down in Big type the way the tab bar does, it is hidden on paper, and Undo hands focus back to the button that did the write.
Keyboard and screen reader
Focus survives a repaint. Every list is written back as innerHTML, so the control that had focus was destroyed and focus fell to the body. Tab to a row, press Enter, and the next Tab started again from the top bar. On the Bar tab that was one tick per trip down the page, and a shelf is fifty of them. Focus is now recorded by the data- attribute the control carries plus the nearest id above it, and restored after the repaint with preventScroll, so there is no jump and a text field gets its caret back with it.
The search box keeps what you typed. The input was re-rendered from the folded, trimmed term the matcher uses, so typing Gin and tapping Stirred left the field reading gin with the caret and the focus gone. What was typed and what is matched are now two separate values written in one place.
Selected state is said out loud. The segmented control, the bottle chips, the shape chips and the shelf chips carried a class and nothing else, so with VoiceOver the Stirred segment read exactly like the Shaken one beside it. Every one of those buttons now writes aria-pressed in the same string that writes the class, and the tab bar takes aria-current="page" on the view that is shown. The check mark a shelf chip prefixes moved into an aria-hidden span, so the chip announces as All bottles · 174 rather than with a stray glyph in front of it. No visual treatment changed.
Escape closes Big type, and puts focus back on the button that opened it. Escape inside a text field still does nothing.
A drink page scrolls with a wheel in Firefox and Safari. The page turned the app shell off on body but left the main element as a scroll container that could not scroll, and those browsers do not chain the wheel through one. Chrome did, which is why it took a second pass to find.
Search: a code keeps the case you typed
The shorthand spends case instead of inventing a second letter. q is a quarter and Q is three quarters; h is a half ounce and H a highball packed with ice. Search folded the name, the ingredient line and the code into one lowercased haystack, so Q and q returned exactly the same drinks. Seventy-nine codes on this menu carry a three-quarter pour and thirty-two carry a quarter, and there was no way to ask for one without the other.
A name and an ingredient still fold, so rye finds Rye. The code is now compared with the case you typed. The search box also carries autocapitalize and autocorrect off, which is the half that makes the other half worth having: a text field capitalises its first letter by default, so q,h,q typed on a phone would have arrived as Q,h,q, which is a different drink. The Info tab gains a Finding a drink topic saying so.
The one behaviour that is deliberately worse: a code typed in the wrong case now finds nothing. That is the point of the change, and on the device where it would happen by accident it cannot.
On WebKit the two columns are cut by hand, because multicol has never paginated there. The cut is weighed per row, a row weighs more when Recipe, Taste or History is ticked, and the weighing only happened when the list rendered. Ticking Recipe after that toggled a body class and nothing else, so the cut stayed where it was struck for one-line rows and the second column ran long by however much every row had grown. A tick that changes a row's height now recuts. Icon and the shorthand key leave the rows alone and do not. Chrome and Firefox never recut, because real multicol balances per sheet.
A long menu title also used to run off the right edge in Big type and be cut by the edge of a Letter sheet. Both headings now wrap and keep their letterspacing, and a title is truncated on the way in as well as on the way through the input, because it can arrive from an older build or a hand-edited store.
Copy, and one recipe reading
One pour, one word. The garnish entry for 3 said drops, while the same pour written into the build decoded to dashes, so the Brass Rail read 3 dashes Angostura bitters ON TOP and, two lines down, GARNISH 3 drops of bitters. An Angostura dasher bottle does not do drops, and the Key tab's own rule says a bare number beside bitters is dashes. Both now say dashes. The 3 did not move, in the serve token or in the build.
Card out of the visitor's copy. Two strings said card where a visitor reads menu, and both show under All bottles, where a first visit lands. The site also said select in some places and tick in others for the same action, and the Bar tab's controls are checkboxes, so tick is now the one verb across the rail, the Bar note, the first-run strip, the empty-menu line, the Info tab and the README.
A name on the voice. The About block is written in the first person and nothing on that screen said who "I" was. It now ends with a signature, and a new About me section tells the story behind the bar: the thirty years, the two years off that turned out to be the palate cleanser, the four books that built the shelf, and why the menu went online this year.
Installing on Android
The manifest offered one 180px icon and an SVG. Chrome's install criteria ask for a 192 and a 512, and Android's launcher wants a maskable icon or the mark sits in a white circle. All four PNGs are now drawn from the same mark in the same grid and supersampled, so the bowl's taper is no longer stepped at 512, and the portrait lock is gone, so an installed app on a tablet can use the laptop layout. The maskable sheet is the coupe alone, centred, because a circular mask slices the corners off a rectangular frame and that reads as a fault rather than a border.
The gate
make verify is still the whole pipeline, and it grew by everything above. The checker suite went from 66 cases to 96, each one breaking a rule on purpose to prove the rule still fails. The tally its...
v1.3.0
Version 1.3.0 gives every drink a page of its own and a card to unfurl into, so a drink shared by text or on social media arrives as a recipe rather than the site's front-page image, and a bookmark carries the drink's name.
Sharing a drink
A drink has an address. The Share tab on every recipe now hands out fewbottles.com/drink/<id>/, a page rather than a hash. A hash never leaves the phone: iMessage, Messages, X, Facebook, Slack, Discord and Googlebot all fetch the address before the # and read the meta tags they find there, with no JavaScript run. Before this, every shared drink previewed as the front page with the generic glasses picture, and its tab read the site's title. Now the link unfurls into that drink's card, with its name and recipe in the preview text. The old #drink/<id> form still opens the drink expanded and always will; those links live in other people's message threads and an id is a stable key.
The card. Each drink's preview image is a 1200 by 630 card in the site's dark palette: the name in tracked caps over the heavy rule, the ingredient lines in italic with the amounts decoded, method and glass in grey, the shorthand in mono, and the glass the serve token calls for, drawn from the same art the menu uses. A Vieux Carré shows a rocks glass with ice and a twist; a Newark, a Nick & Nora. The name shrinks to fit a long one and the lines shrink to fit a six-ingredient build, so every drink on the menu sits on one card.
The page. The address is a real page, readable with nothing running. It carries the recipe as a list, the method sentence and the serve line, the taste and history with their references, and the six nearest drinks of the same shape, each a link to its own page, so a reader and a crawler both move drink to drink. An Open in the menu button leads into the app with that drink expanded. The page does not redirect, because a redirect is what makes a crawler index the front page instead of the drink.
Who pours it. Every page names its author. The title is Newark, few bottles, the meta tags carry the author and creator, and a Recipe block of JSON-LD sits on the same #person and #website ids the front page declares, with the ingredient list, the instructions, the yield and the card as its image, plus a breadcrumb back to the menu. The sitemap now lists the front page and all 174 drink pages. robots.txt and llms.txt say where the pages are, and each drink's line in llms.txt links to its page rather than to the hash.
The tab says which drink. In the app, the browser tab title follows the one drink that is open and goes back to the site's title when it closes or another tab is chosen. The address bar is unchanged: #drink/<id> is still a way in, not state.
Nothing here. An address nothing answers to, a mistyped one or a drink dropped from the menu, lands on a 404 page in the same shell, with the way to the menu.
Under the hood
scripts/pages.py(make pages) writesdrink/<id>/index.htmlfor every drink andsitemap.xmlover the lot.--checkrefuses a stale, missing or orphaned page, the same askin.pyrefuses a stalekin.json.scripts/cards.py(make cards) drawsassets/cards/<id>.png, reusing the Pillow and rsvg pipeline inmake-og.pyand the amount decoder inllms.py. The 174 cards are committed and total about 5 MB as 32-colour palette PNGs. Each carries a hash of everything it was drawn from, including the script, in a PNG text chunk, so--checkreads 174 headers and refuses a stale card with nothing installed. CI needs no Pillow; rendering does, likemake icons.make verifyruns both checks undermenu.check_style.pynow walks all 175 static pages, the drink pages and the 404, for alt text, a title, a description, exactly onemain, unique ids and em dashes, andtest_checks.pybreaks each new rule on purpose.check_assets.pycounts404.htmlamong the well-known files.app.cssgains abody.pageblock so the static pages share the app's shell, tokens and print rules while scrolling as a page; the top tabs show from 640px and hide on a phone, where the brand is the way back and the button under the recipe is the way in.
Pull requests
- #46 Give every drink a page of its own and a share card
Full changelog: v1.2.0...v1.3.0
v1.2.0
Version 1.2.0 reworks how you choose which menu you are looking at, keeps the Bar tab's count on screen, and turns the Info tab into a help page with the tools and glasses the bar runs on.
Menu
One row of menus. The last row of chips over the list is now a single choice of which shelf the list is gated on. All bottles is the whole card and where every visit lands. My Shelf is what your own bottles pour. The third chip is Starter Shelves until you load one, and then it is that shelf, so the row reads All bottles, My Shelf, Gin shelf and the three switch freely. A shared link adds Shared menu for the session. The chips select rather than toggle, so there is always a way off a menu, and Clear drops the other filters while keeping the menu you chose. Print, Share and Big type follow the same choice: under All bottles the sheet is the whole card and the link is the bare address.
All bottles is the card as printed. Under All bottles nothing is dimmed, nothing is struck through and nothing is lit in brass. The shelf only colours the list when a shelf chip is on. Before this, a loaded starter shelf greyed rows under All bottles and the expanded recipe struck ingredients against a shelf the chip did not name.
The count is on the Menu tab. The badge counting drinks you can pour moved from the Bar tab to the Menu tab, since it counts drinks. The Bar tab gets its own badge counting the bottles the selected menu is made from, so under All bottles the two read 174 and 50, and on a shelf what it pours over what it holds.
A cleaner list. The last drink under each family heading no longer draws a hairline, since the next heading opens with its own rule and the two were doubling up. The bottle filter reveal is now a chip with a dashed border and a chevron, and the shelf row carries a small Shelf label.
Bar
The count stays put. On a phone the count went sticky, held for about two hundred pixels, and then scrolled away with its wrapper. It now stays pinned for the whole length of the shelf, which is the point of the tab: watching the number move under your thumb as you tick bottles.
Starter Shelves is one row. Five ways to replace your shelf were on screen every visit. They are now behind one closed Starter Shelves row that says how many are inside, in the same reveal treatment Share menu and Print menu use.
One more bottle says why it is grey. While a named shelf is loaded, the suggestions sit under a veil naming the shelf you are reading, with the way back to My Shelf. The figures stay readable because they are true of that shelf; what they are not is buyable. The same lock box now appears on the Menu rail's One more bottle. The line beside the disabled checkboxes now says the actual next move: switch back to My Shelf to select and deselect bottles.
Info
A help page, in sections. The Info tab was one stack of paragraphs. It is now About, Using it, Tools, Glassware, Analytics and Version under a sticky chip index on a phone, and a rail beside the column on a wide screen, on the same two tracks the Menu and Bar use. Each section has an address: fewbottles.com/#info/tools opens the tab at Tools and then reads #info, the same way a drink link works. The index follows the scroll. Using it, and How it works on the Key tab, run in two columns on a wide screen.
Tools. The nine tools the whole menu is made with, each with a line drawing and a plain paragraph on what it is and what it does: Boston shaker, Japanese jigger, Hawthorne strainer, barspoon, citrus peeler, paring knife, dasher bottles, pourers and bar mat.
Glassware. The three glasses that pour every drink on the menu: the rocks glass, the highball, and a Nick & Nora, with the case for the Nick & Nora as the one glass that serves anything chilled.
Under the hood
scripts/make-tools.py(make tools) turns a generated plate into bone ink on a transparent ground at half size, painting out the generator's corner letter. One token flips the ink for light mode and paper. It is idempotent, so a fresh export followed bymake toolsis the whole workflow.check_assets.pyfails on a plate that is still raw, a plate no row shows, or a plate missing from the worker's cache list, with cases intest_checks.py. The worker precaches the plates so the Info tab works offline the first time it opens.- The two shelf-gate flags are written in one place,
selectMenu. The delegated click handler lost its menu branches to a named function and its budget ceiling came down to match. - Tracking gains a
bar_startersevent and asectionparameter oninfo_section. - The element showing the running version is now
#version-label.
Pull requests
- #41 Move the count to the Menu tab, put the starter shelves behind one row, and give the list a way back to My Shelf
- #42 Keep the count on screen for the whole shelf
- #43 Put All bottles, My Shelf and the starter shelf on one row, and badge the Bar tab
- #44 Head the shelf row, make the filter reveal a chip, and keep All bottles the card as printed
- #45 Rebuild the Info tab as a help page with Tools and Glassware
Full changelog: v1.1.0...v1.2.0
v1.1.0
The Bar tab stops taking your bottles away from you, and the panel that tells you what to buy next stops running out of answers. Two pull requests, #39 and #40, on top of v1.0.0.
A shelf you can look at without losing your own
Tapping one of the named shelves used to write its bottles straight onto yours. That is fine from nothing and wrong from anywhere else: you tapped Gin shelf to see what it pours and the shelf you had ticked by hand was gone under it.
A named shelf is a view now. My Shelf sits at the top of the list holding what is ticked, and it is kept whatever else you are looking at. Tapping a named one opens two buttons. Switch shelves reads the menu gated on that set and writes nothing, so what is saved on your phone does not move until you ask it to. Add bottles does what the tap used to do, and now says so first.
The figure beside a named shelf is what that shelf pours on its own rather than what it would add to yours, which was a number that read zero for anyone who already owned the bottles.
A look never becomes an edit
Selecting a bottle while looking at another shelf used to switch you quietly back to My Shelf and tick it there, which is a tick you never watched land. While a named shelf or a shelf someone sent is up, every control that would add a bottle stands down: the shelf boxes, the brand ticks inside them, the suggestion in the Menu rail, the buy rows, and Stock everything with Clear the shelf. They grey out rather than disappear, because the shelf is still worth reading, and the figures beside them stay in full ink because they are true of the shelf on screen.
The rail says Looking at Gin shelf, and a line above the bottles repeats it with a Back to My Shelf button, since on a phone the rail is a screen above the boxes.
A shelf someone sent you had the same hole. The Bar tab has always shown the sender's ticks while a shared menu was up, and a tick there wrote to your own shelf invisibly. It is read-only now, and Back to My Shelf drops the shared gate. Make this my shelf is still the only thing that writes.
This closes #35 by removing the path that lost a shelf rather than adding an undo over it.
Choosing a shelf chooses a menu
The chip over the list said My menu whatever shelf the list was gated on, so switching to the Gin shelf left My menu sitting beside the Gin shelf's count. It reads My Shelf now, or the name of the shelf you are looking at, with that shelf's count.
Picking a shelf also gates the menu on it, so the Menu tab opens on the drinks that shelf pours without a second tap. A shelf that pours nothing gates too, because the list says Nothing yet in words and 174 drinks beside a Bar tab saying Staples only is the two tabs disagreeing.
What to buy next never runs out
One more bottle went quiet as soon as no single bottle opened anything, which happens early. Gin and tonic pour the Gin and Tonic, and from there not one bottle on the shelf is worth a drink on its own, so the one panel whose job is to say what to buy said nothing.
The rows that single bottles leave empty now go to the smallest sets that do open something, and a set is selected in one tap like a bottle. The candidates are the drinks themselves, since a set worth naming is exactly what some drink is short of. Ranking is fewest bottles, then the biggest unlock, then what the rest of the menu wants most, which is the tie-break that matters: from one spirit almost every pair opens exactly one drink, and it is the difference between being offered lime and simple syrup or Benedictine and bourbon.
| shelf | what it offers |
|---|---|
| gin | Tonic water +1, Honey syrup + Lemons +1, Limes + Simple syrup +1 |
| gin, tonic | Honey syrup + Lemons +1, Limes + Simple syrup +1, Dry vermouth + Orange bitters +1 |
The heading reads One more bottle while every row is one bottle and What to buy next once a row names a set.
Plain words
Visitor copy called the printed menu a card 189 times, and the controls were named in house jargon. Taste and history say menu now, and the eight labels a guest reads are named for what they do: Shorthand key, Related, Shapes, Big type, One more bottle, and Start from a kind of bottle. Every drink's history line was touched, and not one id, code, build, serve token, family or method moved.
Fixes
- The count stayed on screen. The Bar tab's count scrolled off the top at the end of a long shelf. The rail is sticky, and a sticky box cannot leave its containing block, which ends at the tail under every view, so a rail 40px taller than the room left there got shoved up by the difference on the last screen. At the very bottom of the tab the rail top was 12px with the count clipped, and is 52px with the count on screen. The Menu tab's recipe rail shared the defect.
- A suggestion selects the bottle. Tapping a One more bottle row used to scroll you to that row on the shelf and ask for a second tap on the same thing.
- Stock everything and Clear the shelf moved out of the rail into a Reset the shelf section at the foot of the shelf, which is where a reset belongs and not beside the count.
Nothing you have sent anyone broke
No cocktail id, no shelf bit and no shorthand changed, so every ?s= shelf link and every #drink/ link handed out under v1.0.0 opens exactly what it opened before.
Under the hood
Vanilla JS, no build step and no package.json, same as it ever was. make verify runs the whole pipeline: JSON, syntax, two linters, 54 checker tests, every code against its build, kin, the agent dumps and the assets. The Bar tab still counts drinks unlocked rather than recipes mentioning a bottle; the set search costs a nearly empty shelf about 4ms per menu repaint, which is inside the search box's per-keystroke budget, so nothing was traded for a cached usage count. CLAUDE.md describes the shelf views, the read-only rule, the rail height and the set rows.
What's Changed
- Call the printed menu a menu, and name the eight labels for what they do. by @cjimti in #39
- A shelf you can look at without losing your own, and a buy list that never runs out of answers by @cjimti in #40
Full Changelog: v1.0.0...v1.1.0
v1.0.0
The first tagged release of fewbottles.com, and the first version of this repo that publishes from a tag rather than from main.
It is one home bar's cocktail list: a static site with no build step, no framework, no database and no package.json. Everything on screen renders from three JSON files.
The two ideas
The house shorthand is decodable. Every drink on the printed card is written in a contextual code that reads left to right in build order, 2,1,q,3,10,2b,R. The same token means different things in different slots: a bare number is ounces beside a spirit and dashes beside bitters, h is a half ounce everywhere except the last token where it is the tall glass, and the last token carries glass plus garnish packed together. The app resolves each amount against the ingredient it belongs to, which is what turns a margin note into a recipe without anyone typing the recipe twice. Every drink carries both the transcribed code and the spelled-out build, and scripts/check_menu.py regenerates one from the other and refuses any drink where the two disagree.
The menu is a function of the shelf. The organising constraint is range from few bottles, so the Bar tab is not a checklist for its own sake. The number beside an unopened bottle is drinks unlocked, computed by adding that bottle to the shelf and re-counting, not how many recipes mention it. A bottle used in twelve drinks that unlocks none reads in 12, greyed, and that is the honest answer. Tapping the number opens the list it counts.
By the numbers
- 174 drinks: 86 stirred, 69 shaken, 19 built, across 15 printed sections.
- 50 ingredients: 12 base spirits, 2 vermouths, 14 modifiers, 3 bitters, 4 syrups, 3 juices, 3 mixers, 8 garnishes.
- 9 kin families: Martini, Sour, Daisy, Negroni, Old-Fashioned, Fancy, Vermouth, Sparkling, Highball.
- 33 notation entries in the key: 9 amounts, 6 counts, 5 glasses, 13 garnishes.
- From a starter shelf of 14 bottles you can pour 9 drinks, and the best next bottle is not a spirit at all: orange liqueur at +8, then Bénédictine at +6, then maraschino at +5.
What is in it
Menu. The list, filterable by method, by bottle, by search, or by what your shelf actually pours. Tap a drink for the recipe, the taste, the history and its references.
Bar. Every bottle any drink can call for, with drinks unlocked beside each one you do not have, house recipes for the syrups, and a brand list per type graded from solid to exceptional. Next bottles lifts the same figure into the three best unopened bottles. stand_in softens the gate where soft is honest: the card writes the Old-Fashioned with demerara, and a shelf holding simple pours it.
Key. The whole notation, decoded letter by letter, read from the same table the decoder reads from.
Kin and Families. A second filing of the same menu by shape rather than by printed section. The Martini and the Manhattan share almost no ingredients and share almost the whole pour. scripts/kin.py works that out and names the bottle that changed between neighbours.
Sharing. A menu leaves the phone as one number, fewbottles.com/?s=281474976710655. The shelf is a bitmask read with BigInt, brands never travel, and a bit is a stable key that is assigned once and never moved. Opening somebody's link is reading, not adopting: their shelf is held for the session behind a banner, and your own is untouched until you tap Make this my shelf. A single drink shares the same way from fewbottles.com/#drink/<id>.
Printing. US Letter, two columns, pure black and white with the light palette forced, a QR on the bottom of the sheet. Ticks decide whether the glass icon, the recipe, the taste, the history and the bar instructions come with it. On WebKit the list is pre-split into two floated halves, because WebKit has not honoured CSS multicol on paper since 2007.
Offline. A service worker caches the shell, so a phone propped against the back bar needs no signal. Add it to the home screen and it keeps working.
How it is built
Vanilla JavaScript, no bundler, no dependency, mobile first. One accent colour, brass, carrying every earned state. Every colour is a token with a light counterpart, because light mode is the printed page rather than an inversion of the dark one.
make verify is the whole pipeline, since there is no build step for it to hide behind: JSON parses, node --check and py_compile on the sources, two linters, a test suite that breaks every linter rule on purpose to prove the linters still fail, the menu checks, and an asset check. scripts/check_code.py is what this repo has instead of eslint. scripts/check_style.py enforces the house rules and three contracts the app would otherwise break silently: an analytics parameter the dataLayer does not reset, a click branch whose data- attribute is missing from the delegation selector, and the shelf code read as anything but a BigInt.
Shipping, as of this release
Pushes to main run make check and deploy nothing. Pushing a v* tag runs the same checks, stamps the tag into sw.js and assets/app.js, deploys to GitHub Pages, and then cuts the release with needs: deploy, so a release cannot exist for something that never went live. The tag is the only place a version is written: no VERSION file, nothing in the manifest. The app prints it in the top bar and on the Info tab, and an unstamped working copy reads dev.
This release also retired the About modal in favour of a fourth Info tab, which is where the shelf, sharing, printing, offline and analytics are now explained.
Agent files
llms.txt is the map and llms-full.txt is the whole menu spelled out, both generated from the same JSON the app reads, so an agent does not have to run the decoder.
What's Changed
- Stand-ins on the shelf, and Next bottles on the Bar tab by @cjimti in #25
- A standard for verify to hold the code to, and an address for a drink by @cjimti in #26
- Desktop layout and Tonight: a rail that answers, not a wider list by @cjimti in #27
- Mixers, a Built method, and twenty long drinks by @cjimti in #28
- Thirteen classics, an absinthe section, and a method line that reads the glass by @cjimti in #29
- Give the thin bottles nine more drinks to justify themselves by @cjimti in #30
- Ship from tags, and give About a tab to grow into by @cjimti in #32
Full Changelog: https://github.com/cjimti/drink/commits/v1.0.0