The guesthouse earns less from every reservation that flows through an OTA. Platform commissions are far higher than most guests realize, and a booking redirected through the platform hands margin away for no additional service to the guest. The counter-strategy is to make direct booking the obviously better option: richer information, personal service, packages that can only be arranged through a host who knows the property. BBQ group sessions and birthday surprise arrangements extend that strategy with a structural advantage. A twelve-person BBQ with fresh seafood delivered to the courtyard, or a birthday cake ordered from a local Hengchun shop and ready at check-in — neither exists on any OTA booking flow. They require a direct conversation, and that conversation converts to loyalty no platform can redirect. Adding the pages formalized what would otherwise happen by phone.
The build: Astro 5, SSR-first with export const prerender = true on all public routes, manual i18n routing so admin endpoints don’t get locale-processed, inline stylesheets for FCP, deployed on Railway. npm run build runs image conversion, astro build, then audit-orphan-classes.mjs — all three must exit zero for CI to pass. The four new pages (zh and en for each service) took a few hours. Getting the build clean took longer.
Three bugs surfaced that session. None were introduced that session.
The apostrophe English carries structurally
The first failure was a parse error on a line I read three times without finding the problem. The offending string: 'Ocean's Best (Seafood Only)' — a BBQ vendor package name defined in the JS frontmatter of the English BBQ page. A single-quoted JS string literal terminates at the first embedded apostrophe: 'Ocean' is the complete string, s Best (Seafood Only) is unexpected tokens, the trailing ' opens a new unterminated string. The parser’s error lands several tokens downstream from the actual fault, which is why repeated reading didn’t land on it.
The zh locale had been building clean for weeks. Chinese prose structurally never contains apostrophes — contractions don’t exist in the character set. Single-quoted string literals in zh frontmatter are safe by default. English carries apostrophes inherently in this kind of content: vendor names with possessives, marketing copy with ampersands, package descriptions with contractions. Every string literal that wraps English prose with single quotes is a latent syntax error. The zh files had exercised the build pipeline for weeks; the en files hadn’t, and this was the first session writing real English-language frontmatter data.
The one-character fix — swap the outer delimiter to double quotes — took thirty seconds. The convention is the actual change: English prose in frontmatter now uses double-quoted string literals as a default. The rule doesn’t need to be remembered. Double-quoting is consistent with how most JS data is written; single-quoted strings become an anomaly that code review catches on sight.
Copying markup is how you maintain parity — and how you lose it
The orphan-class audit runs after astro build completes. It walks every HTML file in dist/, extracts class names from markup, and verifies each one has a matching CSS rule in that page’s inline or external stylesheets. No rule, the script exits non-zero, the build fails.
The new service pages were scaffolded from an existing component: I copied the markup structure, didn’t carry over all the CSS. Four classes appeared across four locale files — zh and en for each service page — with no corresponding stylesheet rule: .bbq-hero-content, .pkg-card--highlight, .pkg-group, .bday-hero-content.
This is the predictable cost of parallel locale trees. Content varies per locale; layout markup is supposed to be identical, so you copy it. The class-to-rule mapping then drifts: add a class during scaffolding and forget the rule, delete a rule and leave three locale files still referencing it. Visual QA across every page in every locale doesn’t scale — attention runs out before the full matrix. The audit converts that class of silent failures into build failures. Two minutes to fix the missing rules. Zero minutes of half-styled pages reaching a client.
The audit is mandatory rather than opt-in because it lives inside npm run build, not as a separate command someone has to remember to invoke. If it exits non-zero, the build fails.
A global ::after that bet on context stability
The third bug was a watermark: a ::after pseudo-element on .protected-image, centered absolutely on its container, rotated 45°, at rgba(255, 255, 255, 0.03) — near-invisible on a white background. The BBQ and birthday pages use dark gradient heroes. When .protected-image elements were in proximity to those sections, the overflowing pseudo-element painted across the dark background as a faint but perceptible artifact.
Root cause: .protected-image declared position: relative; display: inline-block but no overflow: hidden. The watermark text was font-size: 3rem; letter-spacing: 0.5em; white-space: nowrap — wide enough to escape the image bounds on any page where the containing block is narrower than the rendered text width. The failure had probably existed for weeks; light-background pages absorbed the overflow silently because rgba(255,255,255,0.03) on white is invisible. Dark backgrounds made it visible.
I removed the ::after rather than adjusting the offset. Adjusting the offset patches this page combination only. The next dark section or different containing block produces a different overflow with the same root cause — a second debugging session, a different number, the same decision point. More precisely: a decorative watermark at 0.03 opacity doesn’t justify a global ::after participating in every page’s stacking context and overflow behavior. When you ask whether the element earns its footprint, the answer here is no. The fix also added overflow: hidden to .protected-image as a structural guarantee for whatever content appears inside it in the future.
Same session: service cards on the homepage now link to the BBQ and birthday pages, contact CTAs in RoomCard and Footer were tightened, one room’s copy was updated in both locale files inside the same commit. That last detail is what gives the orphan audit its coverage. The audit operates per-build — it catches drift within a build but can’t see divergence accumulated across separate commits. Keeping zh and en changes in the same commit closes that gap. Split them and the tooling has a blind spot it cannot reach.
The new pages didn’t introduce any of these bugs. They removed the conditions that had kept them hidden.