01 — Color
Eight schemes, and not one color is typed into a component. Each scheme sets eight values; every rule on the site reads one of those, or one of the three grays derived from --key-rgb underneath them. That is what lets a control be written once and work in all eight. Adding a scheme is a :root[data-theme=…] block, a row in THEMES, and the literal list in the small <head> script that paints before the first frame — three places, and check-browser.mjs fails if the last two disagree.
Pick a scheme — everything below repaints
The colors this scheme sets
The three grays derived from them
There is an eighth value that has no swatch: --key-rgb, which is --key again as three bare numbers. It exists only so the grays below can be built with rgba(), and it has to be kept in step by hand — a scheme whose --key-rgb disagrees with its --key gets body copy in one color and headings in another, with nothing to say why.
Don't write a hex into a component rule, and don't reach for a gray that isn't one of these three. A fourth gray is a fourth thing to check in eight schemes.
02 — Contrast, measured live
Computed from the rendered page, so these numbers describe what is actually on screen right now. Small text needs 4.5:1; a control's edge or a shape needs 3:1. Anything failing is a real readability problem, not a style preference — this site is read in cars, in Texas, in daylight.
| Pair | What it is | Ratio | Needs |
|---|
The worst result across all eight schemes
| Pair | Worst scheme | Ratio | Needs |
|---|
A row failing here fails for somebody, in some scheme, today. Fixing it usually means changing a token rather than a component — which is the point of having tokens.
03 — Type
Three faces, one job each. Bungee is the voice and appears only where the site is speaking as itself. Overpass is everything you read. Overpass Mono is for anything a machine produced — a count, an ID, a date, a label that names a field rather than describing it. A number the reader is meant to compare goes in the mono face so the digits line up.
The mark lives inside the name, not beside it. .imk is one declaration and it serves every place the name is printed — the headline, the sticky bar, the phone nav panel and a document page's bar — because everything in it sizes off 1em. The span reserves cap height and the mark overflows it evenly, so the line box never grows; set a height on the span instead and the whole masthead moves down. Only the gap is set per place: .0603em at the headline, a flat 6px in a bar. .wm-space is a real space at no size, so the heading's own text is still Texas Find 'Em to a screen reader and to a copy-paste.
Find 'Em in the name is --accent, never --accent-text, at every size. It is the brand name, and WCAG 1.4.3 exempts a logotype from the contrast floor by name — text that is part of a logo or brand name has no minimum contrast requirement. That exception is easy to miss from the arithmetic: the bar's lockup is 21px and Bungee's computed weight is 400, so it reads as small text to a checker and was migrated to the lighter tint once. The result was the same two words in two different reds depending on how big they were. A wordmark does not change color with its size.
Werks and Yer are spelled that way on purpose. Both have been "corrected" and reverted once. The one place the joke stops is inside Yer Stuff, where the settings block is headed Settings and not Yer Settings — a second Yer inside the first one is a tic rather than a voice.
Don't set Bungee on anything that is merely important — it is the site's own voice, not an emphasis level. And don't put a comparable number in the proportional face; digits that don't line up can't be scanned.
04 — Controls
.act is the only button shape on this page, Yer Stuff included: 2px border, uppercase, wide tracking, hover lighting the border to the accent, 34px tall and 44 on a phone. Yer Stuff used to have its own smaller, dimmer variant, which made the same kind of thing look like two kinds of thing depending which half of the page you were on. Both of those classes are gone and they don't come back.
.act — resting, and pressed
.seg — a group where exactly one is on
.cbx — the chain picker
.feat — an amenity toggle
.seg as a tab bar — Yer Stuff's three panels
Not a new control — .seg is how this page already says "pick exactly one of these", five times over. What differs is that this one switches a panel, so underneath it is a real tablist: arrow keys, aria-selected rather than aria-pressed, and a roving tabindex so the group is one Tab stop.
.stuff-row — a saved bookmark. Every row is this height.
A store number, not a street: sixty-three H-E-Bs in Houston means the town alone cannot tell two apart. An amenity count, not the list. Both lines truncate rather than wrap, because uniform height is the whole point and a narrow screen is the one thing that breaks it.
.locate, .showbtn, .findem — the ones with a job of their own
.sb-btn / .jumplink — the section links, in both places they appear
One rule, two places. The masthead and the sticky bar carry the same three destinations, and they used to wear different clothes for it — underlined accent text with a down arrow at the top of the page, bordered pills on the bar that follows you down it. A reader who learns one shape should not have to learn another for the same links, so .jumplink and .sb-btn are one declaration and sit hard right of the mark in both. Same idiom as .act, worn by the one piece of furniture that follows you down the page. aria-current marks the section you are in — the topmost one that is open and on screen below the bar. Nothing open means nothing marked, which is right: you are in the search. Below 1100px these lose their borders and become a full-height tab row, because three full-size links are 393px and a 390px phone has 354px of bar to put them in.
.stop-hours — the four things a card can say about hours
Open and shut are the same weight. A closed store is not a warning, it is the other half of the answer, so neither gets a color the other doesn't and neither gets an icon. “Regular hours” is not decoration. Not one of these chains publishes a holiday schedule anywhere this map can read, so every one of these is a claim about an ordinary week and has to say so — a bare “Open now” on Thanksgiving morning is the failure this caveat exists to prevent. “Hours not published” is a finding, not a blank. A chain that publishes nothing is saying something, and the card says it back rather than leaving a gap that reads as shut.
.hchip — the bookmark row's three states, and the week behind it
| Monday | 6 AM – 11 PM |
|---|---|
| Tuesday | 6 AM – 11 PM |
| Wednesday | 6 AM – 11 PM |
| Thursday | 6 AM – 11 PM |
| Friday | 6 AM – 11 PM |
| Saturday | 6 AM – 11 PM |
| Sunday | — |
The chip is a fixed width in all three states, and that is the whole point of it. A bookmark row is one height whatever the chain publishes — rows used to swing between 78 and 138px, and fixing that is why the closed row carries a store number and an amenity count rather than a street and a list. A chip that grew with its word would put that back, so the times are behind the disclosure with the address and the amenities, where the rest of the detail already lives. It is a caption and not a control, so it wears a pill's clothes rather than an action's. Seven rows, always, so a day nobody published is visibly missing rather than quietly absent.
.trip-when — what an itinerary asks, and .trip-open — what each stop answers
The fourth state is the one that matters. Two Shipleys publish Monday-to-Friday hours and nothing at all for the weekend, and the first build of this rendered that as “Closed” on a Sunday. A chain that published nothing has not told you it is shut, and the page must not put that word in its mouth — so an unpublished day says so, in the same italic as “Hours not published” on a card. The control stores nothing. Not the date, not the time, not the minutes — it is something you run over an itinerary, and it starts at now on every load. The date is read off the itinerary and never written back, because an itinerary's id is from_to_date: writing a date would move the drive rather than edit it. Two of the three numbers behind an arrival are measured and one is not. The route's duration is OSRM's own, costed segment by segment; the stop's distance along it is measured; the pace between them is assumed even, which is why every line says “about”. The minutes at each stop are yours, not the site's — six stops at thirty compounds into three hours, and that is far too big a term for the page to invent.
#openNow — a control that must never become a link
Same clothes as Coming soon, because it does the same job. It is deliberately not in the query string, not in a bookmark and not in a shared link: results that change by the hour make a link that lies to whoever opens it tomorrow. The shareable version of this idea is “open 24 hours”, which is a stable fact about twenty specific stores and lives in the amenities. It also hides itself once the hours are past ninety days old, because by then the rest of the page has stopped claiming anything about right now and this would be promising what nothing else will say.
Don't introduce a second button style for one section, and don't reach for a smaller or dimmer variant to signal that something is secondary — the words already do that. If a control genuinely isn't a control, it's a pill; see below.
05 — Pills are captions, actions are controls
The one thing on a card you cannot press used to wear almost exactly the clothes of the only things you can. A pill is flat: a tint, no border, no hover. An action has a border and lights up. The CSS has to keep saying which is which, because a reader decides what to touch from shape alone.
.pill — a caption. Nothing happens.
.act — a control. Something happens.
A card never says a store is shut when nobody said so. Hours not published and Closed are different sentences and the page must not collapse them. A stop with no hours has told you nothing, which is not the same as telling you it is closed — and the first is far more common than people expect.
06 — The result card
Three columns, and all three are the height of the card: the chain stripe, the content column holding the store block and the actions row, and the distance. That is what makes the stripe on the left and the rule on the right start and stop at the same two points. The distance column is a sibling of the content column, not a child of it — it used to sit two levels deeper and centered itself against the top two thirds of a card it was only part of.
The card is a <div>, not a button. The part you press to light the dot is button.stop-hit inside it; .stop-actions sits beside that button and never inside it, because a link inside a button is invalid HTML and because tapping Directions must not also toggle the dot.
Don't put the distance column back inside the press target, and don't let anything but a coordinate reach a map link — three Buc-ee's in Angleton and sixty-three H-E-Bs in Houston mean a mapping app handed a store's name picks one of them confidently and without saying which. verify-links.mjs asserts that rather than trusting it.
The mark on a card is a bookmark, not a star. Starring reads as rating, and nothing here rates anything; what the button does is put a stop somewhere you can find it again. It is an inline SVG path filled currentColor, because an emoji arrives at a weight nobody chose and won't take the accent when the button is pressed.
A saved row shows a store number, not a street — and an amenity count, not the amenity list. Both exist to buy one thing: every row the same height, whatever a chain happens to publish. The full address and the full list are on the card itself.
07 — Disclosures
This page has one disclosure idea: a <details>. The browser already has the keyboard behavior, the state a screen reader announces, and a find-in-page that can open a closed section. Every one opens with a + and closes with a −, one size and one color, drawn from CSS rather than swapped in markup so the two places that render a disclosure cannot drift apart. Set in the mono face, so the two glyphs are the same width and the row doesn't shift when you open it.
Yer Stuff
Open it and the contents sit here.
The theme picker and the amenities panel keep their carets — a menu and a panel toggle are a different idiom. And there are no rules in or under a section, open or shut. The only rule left on the page is the sign-off's, and it is the width of the content column. A rule is structure, not prose.
A tab is a view, not a preference. Which panel you were last on is never stored. The saved shape is { theme, map } and nothing else, so two devices merge cleanly — a tab in there would mean one device's browsing quietly changing another device's screen.
Every fold link opens the fold it points at — on click, on hashchange, and once on load. Otherwise a shared link lands somebody on a shut row and looks broken.
08 — The numbered path
Used in exactly two places, and both are a sequence you follow rather than a list of facts: the steps in How It Werks, and the stops inside a saved itinerary. A 28px disc filled --accent and numbered --accent-ink — a pressed button made round — joined by a dotted line that stops at the last one. A path running on past its end would promise a step that isn't there.
The numbers are a CSS counter, not typed, so a step inserted in the middle renumbers itself. list-style:none takes list semantics with it in Safari, so the <ol> carries role="list" to put them back.
- Pick a mode. Near me measures from a town, a ZIP, or wherever you are.
- Look at what came back. Closest first, or in drive order on a roadtrip.
- Build an itinerary. Nothing is saved until you press Save itinerary.
The disc colors come out of the theme rather than being typed, and that is load-bearing. Filling with --key breaks three schemes whose accent is their key color; filling with --accent-ink renders as an outline in the two whose ink is their background. check-browser.mjs measures the worst of all eight rather than trusting any of it.
09 — Brands and map dots
One color per chain, and it does three jobs: the stripe down a card, the eyebrow above the store name, and the dot on the map. These are the chains' colors, not the scheme's, so they are the one set of values that doesn't change when you switch schemes.
Those three jobs are not one contrast test, and this table used to treat them as one. A dot is a shape and its visible edge is the --key rim every dot carries — so the rim is what has to clear the map ground, not the fill inside it. An eyebrow is small text, so it answers to 4.5:1. Judging the fill against the ground failed a dozen rows that were never the thing keeping a dot visible, on a page that had already written the rim down two paragraphs below.
The rim, which is what makes a dot visible
| Pair | What it is | Ratio | Needs |
|---|
The fills, and where each one is read rather than seen
| Chain | Color | Dot on land | Eyebrow on panel | Reads against |
|---|
Dot on land is information, not a test — the rim above is the test.
Eyebrow on panel is the honest problem on this page, and it is a decision rather than an oversight. .stop-brand on a result card, and .tfe-wr and .tfe-sid on a document page, print the chain color as text at 11px and under. Against 4.5:1 that clears for Buc-ee's, Rudy's and P. Terry's and does not for H-E-B, Shipley or Torchy's — eighteen of the forty-eight chain-and-scheme combinations.
The colors are not moving, and the reason is that they are not chosen to be read against a background. A chain color is chosen to be told apart from the other five, on eight different grounds, which is a different problem with a different answer — H-E-B's red is theirs, Rudy's white is the only value that holds on all eight lands, and Shipley's brown exists because Rudy's gave up the caramel. Lightening them for legibility would undo that.
What carries the chain when the eyebrow doesn't is the 6px stripe down the card's left edge and the rimmed dot beside it, both in the same color at a size where 3:1 is the right bar and both clearing it. The label repeats what the stripe already said. That is the trade, it is taken knowingly, and the last column above names it row by row rather than printing a verdict this page has decided not to act on.
Nothing on the map is labeled by hand. Town labels are placed by measuring, not estimating, and a label that would collide is removed rather than nudged. Don't replace either with a hand-kept list of the majors — the point is that zooming in can always undo a hiding.
A dot carries a thin --key rim so it stays visible where its own color doesn't clear the map ground. Don't remove it to tidy the SVG, and don't reach for the fill to make text legible — a chain color is chosen to be told apart from the other chains, which is a different problem from being read against a background.
10 — The document pages
The chain pages are the second thing this site is, and they are built on a second stylesheet: css/page.css, written by scripts/build-pages.mjs out of the same tokens the app uses. Every class there is prefixed tfe-, and every specimen below is rendered by that file — each one is real markup in a <template> beside an iframe that links css/page.css. Nothing is copied here, so nothing here can go stale. The frames link that file relatively, so they draw the same off the disk as they do over HTTP — a root-absolute /css/page.css resolves to file:///css/page.css and every specimen below comes up bare.
A document page runs no JavaScript at all, and that is the whole design constraint rather than a nicety. The crawlers these pages exist for — GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot — do not execute it, so anything a script would have drawn is simply not there when they read. The map is inline SVG, the hours are text, the amenities are text, and the interaction budget is spent on links.
The sticky bar, and the one button that leaves
Same shape as the app's: the mark on the left, the buttons hard right. Four of them move you down the page and are outlined; .tfe-btn-go is filled.
Filled means primary, and there is exactly one per page — not “filled means it leaves”, which is what the rule said first. The five buttons in Find More in Texas leave too, and filling those made a stack of five red slabs that shouted down the one action the page is for. Everything else is outlined, whether it moves you down the page or off it.
It sticks with CSS alone. .tfe-bar is a sibling of <header> rather than a row inside it, because position:sticky only holds while the element's containing block is on screen — a sticky child of a 90px header unsticks the moment the header scrolls past, which reads as the bar breaking rather than as markup. Its containing block is <body>, which is as tall as the page. The app gets the same effect with a scroll listener; a document page has no script and needs none.
The labels are one word each. These sit in a bar of fixed width, and The Facts and Every Location were the headings wearing their full names somewhere that only has to say where the button goes. The headings keep theirs.
.tfe-bar / .tfe-barin / .tfe-jump / .tfe-btn / .tfe-btn-go
The two lines the app and the documents share
Under the H1, above the answer sentence: Data Freshness and Truly Texas, and both are printed verbatim from index.html — the date from the UPDATED block, the Truly Texas line from v on the chain's row in CHAINS, which is the same string the card in The Places shows. The date is set in mono because the app sets it in mono; one fact should not change typeface between two pages of one site. verify-pages.mjs check 11 fails if either drifts.
.tfe-fresh / .tfe-truly
Data Freshness: August 24, 2026
Truly Texas: Wander from the massive, 18-wheeler-free sea of gas pumps to the store and experience a sprawling jerky wall, towering displays of Beaver Nuggets, fresh-chopped brisket, and notoriously spotless bathrooms.
The answer sentence
The first thing in <main>, always, and the one paragraph an assistant is most likely to lift whole. It carries the count, the spread and the date it was checked, so a sentence quoted somewhere else still says how old it is. Nav lives in <header> rather than <main> for exactly this reason — a row of buttons above it would make the first paragraph of the document a list of links.
.tfe-answer
There are 37 Buc-ee's locations in Texas, across 31 towns. Every one of them is open 24 hours a day.
The facts, one card each
A bullet list was underselling these. Every one of them is something the build worked out across the whole chain — the town with the most, the share that have a thing, the earliest opening in the state — which is the part nothing else on the internet has. The left border is --chain, set once on <body>, so a page is tinted by the chain it is about.
.tfe-facts
- Angleton has more of them than any other town, with 3.
- 8 of 37 have a car wash.
- Every one is open 24 hours a day, which is Buc-ee's own answer on its FAQ.
The map, in the app's own box
The same .sign treatment the app's map sits in, double border and all, and the dots are the chain colors off BRANDS. Under it a hairline, the legend on the left and the way out on the right — the app's .maprow, one for one. The whole card is a link, so the call to action is prose rather than a button: this is a document, and a button that does nothing without JavaScript is a lie about what the page can do.
.tfe-card / .tfe-maprow / .tfe-legend / .tfe-cta
The table, which is the page's reason for existing
Every store the chain publishes, carrying what a result card carries: the number that tells two stores in one town apart, the address with the town under it, the hours, the amenities as the same flat pills the app uses, and a way to drive there. The columns are given percentage widths because auto layout handed the leftovers to the pills and squeezed the name column until a chain's own name broke at its hyphen. The box scrolls sideways; the page never does. No <caption>: the section's H2 says “Every H-E-B in Texas” eight pixels above it, and the caption was that heading again in smaller type.
.tfe-table / .tfe-sid / .tfe-town / .tfe-hrs / .tfe-pill / .tfe-map-link
| Store | Where | Hours | What it has | |
|---|---|---|---|---|
| Bastrop Buc-ee's#28 | 1700 Highway 71 EastBastrop, TX | Open 24 hours | Ethanol-free fuelDEFTesla Supercharger | Directions |
| Alvin Buc-ee's#14 | 780 Hwy-35 N BypAlvin, TX | Open 24 hours | — | Directions |
Questions, and the rest of the map
The questions sit above the table rather than under it: they are the part a search engine quotes, and a thousand rows between the heading and the answers buries them. Every page ends by naming the others, which is how six documents that nobody links to become six documents that link to each other — and each row carries both ways in, the name to that chain's page and the button to its search. A sixth row reading “search every chain at once” used to sit under them and is gone: five buttons that each land somewhere specific say it better than one that doesn't.
.tfe-said / .tfe-qa / .tfe-conf / .tfe-subhead / .tfe-links
Answers from what people actually say about Buc-ee's online.
What's the best food at Buc-ee's?
The Texas Cheesesteak Burrito has exceptionally strong current support. Multiple recommendation threads independently converge on it.
Strong consensus
What Beaver Nuggets flavor should I try?
Original and Sea Salt are established favorites, and Salted Caramel is also frequently recommended.
Common recommendation
Detailed Facts about H-E-B
- H-E-B with a tortillería 4 locationsLook →
- H-E-B with no pharmacy 32 locationsLook →
- H-E-B in Texas 345 locationsFind 'Em →
- Torchy's in Texas 81 locationsFind 'Em →
.tfe-route / .tfe-osm
The route a roadtrip is measured against is a real driving route, fetched once from OSRM and baked into this page. Map data © OpenStreetMap contributors, under the Open Database License.
The line is --accent rather than a chain color, because a drive belongs to no chain, and the dots sit above it because the stops are the answer and the route is the question. The attribution is not optional: the geometry is OpenStreetMap data under the ODbL, and verify-pages.mjs fails any page that draws .tfe-route without carrying the app's own attribution sentence word for word.
.tfe-foot / .tfe-sig / .tfe-copy
The last two lines are the app's own, read out of index.html at build time and printed on every document page — and the year inside the copyright is stamped from the system clock by build.mjs, so it rolls over on its own. verify-pages.mjs fails a page whose copy of either has drifted.
Don't add a script to a document page, and don't move the nav into <main> to get it above the heading. verify-pages.mjs fails on either — the first because a page that needs JavaScript to say what it says is invisible to the readers it was built for, the second because it would put a row of buttons where the answer belongs.
11 — Breadcrumbs
Every page on this site that isn't the map opens with one, and until now the pattern was on nine pages and in no document — which is how a tenth page ends up with a lookalike. It is .tfe-crumb in css/page.css, and this page is wearing it above.
The rules, and every one of them has a reason
- It starts at Home and ends on the page you are on. The last crumb is plain text, never a link to itself — a link that goes nowhere is a link that has to be tried before it can be ruled out.
- The separator is › in its own span, marked aria-hidden. A screen reader announcing "Home right angle quotation mark H-E-B" is reading punctuation aloud. Hiding it leaves the words and the order, which is the whole of what a breadcrumb says.
- It sits above the <h1>, inside the header, never in the bar. The bar is where you can go; the crumb is where you are.
- Mono face, 12px, --faint. It is machine-produced structure rather than prose, which is what the mono face means everywhere else on this site.
The half a crawler reads
The visible trail is only one of the two, and shipping one without the other is the mistake worth naming. Every page carrying a crumb also carries a BreadcrumbList in JSON-LD saying the same thing in the same order, because these pages exist to be read by assistants that never see the markup. They have to agree — a visible trail that says one thing and structured data that says another is worse than either alone.
The same trail, as structured data
{"@type":"BreadcrumbList","itemListElement":[
{"@type":"ListItem","position":1,"name":"Texas Find 'Em","item":"https://www.texasfindem.com/"},
{"@type":"ListItem","position":2,"name":"H-E-B in Texas","item":"https://www.texasfindem.com/heb/"},
{"@type":"ListItem","position":3,"name":"H-E-B in Austin","item":"https://www.texasfindem.com/heb/austin/"}]}
Don't add a crumb without its JSON-LD, and don't let the two drift. And don't put one on the map itself — the map is Home, and a crumb pointing at the page you are already on is furniture.
The mockups, which are free inside and identical at the edges
Each proposal under /roadmap/ ships a mockup, and a mockup is meant to look unlike the others: it is trying something, it is built for one argument, and holding its content to a house style would defeat the point of building it. Its chrome is a different question. These are public pages, and somebody moving between two of them is reading one site — so the breadcrumb and the footer are the same in every mockup, byte for byte, and the only part that differs is the project's own name at the end of the trail.
The mockups are self-contained on purpose — none of them loads css/page.css — so the shared block is genuinely copied into each one, and a copy that nothing checks is a copy that drifts. Three of them had, and every difference came from the same habit: the block was pasted, then quietly edited in place. One kept the app's own footer rule and squeezed its two lines to 9px where the others gave 18. One carried a second <footer> of leftover prose above the real one. One wrote the separator as a literal › where the others used the entity. None of that is visible in the file you are editing. It is only visible with two of them open side by side, which is how a reader meets them and never how they are written — so verify-docs.mjs compares them instead.
The shared block, in every mockup
<p class="tfe-crumb"><a href="/">Home</a> <span aria-hidden="true">›</span> <a href="/roadmap/">Roadmap</a> <span aria-hidden="true">›</span> Project name</p> <footer> <p class="tfe-crumb">Texas Find 'Em is independent and is not affiliated with, endorsed by, or sponsored by any of these companies.</p> <p class="tfe-crumb"><a href="/roadmap/">Back to the roadmap</a> · <a href="/how-we-know/">Where this comes from</a> · <a href="/">The map</a></p> </footer>
The footer reuses .tfe-crumb rather than inventing a class, because faint small underlined links is exactly what those are. It sits inside the page's content column, not as a child of <body> — two of them escaped it once and ran the full width of the window with their text against the edge while everything above sat centred.
Don't restyle a mockup's breadcrumb or footer to suit the mockup. If the shared block genuinely needs to change, change it in every mockup in the same commit — the check compares them against each other, so one file moving on its own is what fails.