MAIN - #17280
MAIN#17280niteeshkanna-sh wants to merge 288 commits into
Conversation
|
Hi @niteeshkanna-sh! Thank you for your pull request and welcome to our community. Action RequiredIn order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you. ProcessIn order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA. Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks! |
The push filter listed the workflow file among its paths, so merging it would have run a real deploy immediately. The dry run is only reachable through workflow_dispatch, so that first run would have written to the server before anyone had seen what it intended to do -- the opposite of the point. Triggering on the panel's own files only. The workflow is now started by hand the first time, which is where the dry run lives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Deploy the admin panel to Hostinger over FTPS
Two dry runs failed with "getaddrinfo ENOTFOUND", which reads like the server is missing rather than like the value has a prefix on it. hPanel displays the host as ftp://1.2.3.4, and pasting that whole string makes the client look up a hostname of "ftp://1.2.3.4". The same error hides a trailing space, an appended :21, or the surrounding label text coming along with a copy. Rejects those up front with a message naming the actual problem, and reports the value's length so a stray character shows up without the value being printed. Nothing here prints the secret or anything that reconstructs it -- the repository is public, so the logs are public. The lookup applies to hostnames only. Checking an IP with getent is a reverse lookup, which fails whenever the address has no PTR record: testing caught this rejecting the correct answer, since 8.8.8.8 and 1.1.1.1 have PTR records and the Hostinger address does not. Verified against a bare IP, a hostname, an ftp:// prefix, a trailing space, an appended port, and the hPanel label pasted whole. Only the bare IP passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Check the shape of FTP_SERVER before handing it to the FTP client
The FTPS deploy puts everything tracked in git into the subdomain's web root, so sql/001_schema.sql became fetchable -- the whole shape of the database, every table and relationship, handed to anyone who asked for the URL. The same went for README.md, SECURITY.md and the tools/ scripts. install.php reads the schema from disk with glob(__DIR__ . '/sql/*.sql'), not over HTTP, so denying it to the web does not affect installation. tools/ is denied by default, with one exception. create-user.php and clear-enquiry-throttle.php already refuse to run outside the command line, but test-auth.php and test-money.php do not -- they expect $argv and would run for anyone who loaded them. reset-password.php is explicitly allowed: its browser mode is the way back in when the plan has no SSH, which is exactly the case where a locked-out owner cannot reach the CLI tools. Blocking it would remove the only route it exists to provide, and it already ships inert, demands HTTPS, expires an hour after upload and stops answering after five wrong tokens. config.php is denied too. PHP executes it rather than printing it, so this changes nothing while PHP is healthy; it matters on the day PHP is disabled mid-upgrade and .php files are served as text, which is precisely when that file should be least reachable. Each rule is given in both Apache 2.4 and 2.2 form, guarded by IfModule. Checked every file in the panel against the patterns: index.php, dashboard.php, logout.php, install.php and the css and js are all still served. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every word on the home page was a string literal in a component, so changing one meant a code change. The eight sections -- hero, services, highlights, why us, how it works, open road, areas served and the closing call to action -- now come from content, edited at admin/content.php. Only overrides are stored. Each section ships a default in my-app/src/content/defaults.json, and a section nobody has touched has no row at all, so the table starts empty and "reset to original" is a DELETE rather than a restore. Sections are replaced whole rather than merged field by field: a deep merge would quietly put back a list item someone had just removed. The build pulls the content in before Vite runs rather than the browser fetching it per visit. A visitor's page load stays one request to a CDN instead of a second one to shared hosting, the public site keeps working when the panel does not, and there is no empty flash while copy arrives. If the panel cannot be reached the committed defaults are used and the build says so -- a site that will not deploy because a shared host had a bad minute is worse than one that deploys last week's wording. A section whose shape does not match the default it replaces is refused, so one malformed row cannot empty a section of the live site. Migrations had no route to an installed site: the runner lived in install.php, which refuses to run once an account exists. Moved to src/migrate.php so the installer and a signed-in admin share one definition, and content.php offers to run pending migrations when the table is missing. The glob inside it is now relative to the parent directory, since the file sits a level deeper. The panel never receives this repository, so it carries its own copy of the defaults to prefill the form. The build refuses to run if the two differ, which is the only thing keeping them honest. Content editing is a page of its own rather than another dashboard tab: dashboard.php is driven by admin.js and would need real surgery to hold a form this shape. Plain form posts, no JavaScript -- a repeater renders its rows server-side with one blank row on the end, which is enough to add an item. Verified: 13 tests over the content layer, including that the schema and the defaults agree in both directions; the SQL parser still handles semicolons in strings, comments and backticks after being moved; an edit served by a stub endpoint reaches the built site, a malformed section is refused, drift between the two defaults files fails the build, and an unreachable panel falls back without failing it. With nothing edited the page renders exactly as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Make the home page's wording editable from the admin panel
vehicles.ts, enquiry.ts and fetch-content.mjs all called https://admin.niteshacars.in/admin/api/... The panel's document root is the subdomain root, so those files are served without an admin segment -- every one of those three requests was a 404. Confirmed against the live server: /api/public-vehicles.php answers, /admin/api/public-vehicles.php does not. Nothing reported it. useFleet treats a failed request as "no cars" and falls back to the empty cars.ts, which is the right behaviour for a rental site whose panel is briefly down, and exactly wrong for a URL that was never going to work: the fleet pages have been showing their empty state since the endpoint was written. The enquiry form was posting into the same void. The mistake came from the repository layout. public_html/admin.niteshacars.in/ admin/ maps onto the document root, so admin/api/x.php in git is /api/x.php on the server -- the folder name is not part of the URL. The README said so all along; the code did not. README corrected to match, with a note on why the two paths differ, since the next person to add an endpoint will make the same assumption otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Drop the /admin/ segment that kept the fleet empty
The deploy cannot tell an empty directory from the admin panel. Pointed at the wrong path it does not fail -- it writes a second copy of all 57 files into a folder nobody serves, reports success, and leaves the real panel untouched and working. That has now happened twice, and the only symptom was a 404 on a page that had supposedly just deployed. config.php is the fingerprint: it holds the database password, it is gitignored, and no deploy has ever written it, so it exists in exactly one place -- where the panel was really installed. Checking for it before uploading anything turns a silent wrong-folder deploy into a refusal that names the problem, and prints what is in the folder instead so the mistake is recognisable. Whether an FTP path is read from the login directory or the filesystem root depends on the account, so both are tried before concluding the folder is wrong. Credentials go to a mode-0600 temp file rather than the command line, where the password would appear in the process list. Verified against a real FTP server, running the script extracted from this workflow rather than a copy: a folder holding config.php passes; a folder with index.php and dashboard.php but no config.php fails and lists what it found; a path that does not exist fails saying so. Only --ssl-reqd was dropped for the local run, the test server being plain FTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Refuse to deploy the panel into a folder that is not the panel
The check discarded curl's exit code and stderr, so a refused login, a failed TLS handshake and a blocked data connection all produced an empty listing -- indistinguishable from an empty directory. It then reported that the folder was not the panel and "may not exist". Its first run against the real server said exactly that, about a path the deploy had uploaded 57 files to an hour earlier. Those cannot both be true, and the check was the thing that was wrong: it never got far enough to look. Now it keeps the exit code, and separates "this folder is not the panel" from "could not tell". The second prints what curl actually said, which is the difference between changing a secret and fixing a connection. Still refuses to deploy when it cannot verify. Deploying into the wrong folder leaves a copy of the panel where nobody serves it, which is the failure this exists to prevent, and an unverified folder is not evidence against that. Verified against a real FTP server: config.php present passes; reachable folder without it fails and lists what is there; a plain-FTP server against the FTPS requirement reports "Requested SSL level failed"; a dead port reports "Couldn't connect to server". Each of the last three previously produced the same misleading message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Report why the panel check failed instead of blaming the folder
The check required a certificate curl would validate. The FTP host presents one it will not, so the check failed the handshake and -- before the previous commit taught it to say so -- reported the folder as missing. It has blocked every admin deploy since, including ones that would have succeeded. A guard stricter than the thing it guards blocks work without protecting anything. The deploy step completes FTPS against this host regardless, so the upload already carries these credentials over a connection whose certificate nobody verified. Matching that is not a new exposure; it is the check stopping pretending to a standard the pipeline does not meet. The fix that would let this be strict is a valid certificate on the FTP host, which is Hostinger's to provide, and the comment says so where someone would look. Verified the three outcomes still hold with the flag in place: the panel folder passes, a folder without config.php fails and lists what is there, an unreachable host reports curl's error. The certificate case itself could not be reproduced here -- this sandbox blocks generating a self-signed certificate to test against -- so that path rests on curl's documented behaviour and the next real run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Let the panel check accept the same certificate the deploy already does
With the certificate no longer in the way, the check gets a real answer from the server: "Server denied you to change to the given directory". That is useful but not actionable on its own -- it does not say what the account can reach instead, and the path that failed is the one File Manager displays. An FTP path is walked from the login directory, which is not always the filesystem root. A path that is correct as an absolute filesystem path can be unreachable over FTP for that reason alone, which is the likeliest explanation for a deploy client that reaches this folder while curl cannot. So on failure, list the login directory and print it. That replaces another round of guessing with a list of names to build the path from. Directory names are not secrets; the credentials are, and they are not printed. Reproduced the real failure against a local server whose account lands above the panel: the path from File Manager gives the same curl error, the listing then shows "logs" and "public_html", and the path built from that passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Show the FTP login directory when the path cannot be reached
api/public-content.php required src/content.php, which required content-defaults.json. Both are new, so a panel updated by hand has the endpoint and neither of its dependencies, and the request dies on the require. That is what it was doing: uploading the one file produced a PHP error, not a working endpoint. The dependency existed only to merge defaults with overrides before sending them. The site already ships every default in src/content/defaults.json and merges them itself, so the server was assembling a document the caller could build from what it already had. Sending overrides alone removes the requires. What remains is db.php and http.php, both on the server since the panel was installed, so the endpoint is now a single file anyone can drop in beside public-vehicles.php. It also reports whether the content table exists rather than failing when it does not. A panel that has not run the migration has nothing overridden, which is a fact worth stating, not an error -- and the build now says so instead of falling back for an unexplained reason. Verified both sides: the endpoint returns overrides when the table exists and ready:false when it does not; the build applies an edit, keeps every untouched section, refuses a malformed one, and prints the migration hint. Lint and build clean, 11 routes prerender. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Let the content endpoint stand on files the server already has
The Hostinger deploy has never reached the panel. FTP_REMOTE_DIR was meant to carry the path, but an FTP path is walked from the account's login directory, not the filesystem root -- so the path hPanel's File Manager shows cannot be used as-is, and there is no way to convert one into the other from outside the server. That would have been a small problem if it failed loudly. It does not: SamKirkland's action creates directories it cannot find, so a wrong path produces a complete second copy of the panel in a folder nobody serves, and the run reports success. That happened three times. So stop guessing the path and go look for it. config.php is the fingerprint -- it holds the database password, it is gitignored, and no deploy has ever written it, so it exists in exactly one place: where the panel was really installed. find-panel-dir.sh walks out from the login directory looking for it and prints the folder it is in, which the deploy then writes to. It refuses rather than guesses. No config.php anywhere fails with the login listing attached; more than one (the stale copies earlier deploys left) fails and names them. FTP_REMOTE_DIR stays on as an optional hint for exactly that case, matched on its tail so its format no longer matters. Paths are percent-encoded before they reach curl. Without that, a folder named with a space makes curl reject the URL as malformed, and the search reports the panel missing when it never looked -- and the web root this account lands in has "background car.webp" sitting in it. find-panel-dir.test.sh covers the shapes seen on the real host: panel one level down, at the login directory, three levels down, absent, duplicated with and without a hint, shadowed by a subfolder, and named with a space. Eight cases, run against a local pyftpdlib server. All pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
"Not found" and "gave up looking" are different results, and the failure message reported both the same way. A completed search proves the panel is not reachable from this account; a truncated one only says it has not turned up yet -- and acting on the first when the truth is the second sends someone off to reconfigure an FTP account that was fine. Same class of mistake as the earlier version of this check reporting a failed listing as an empty folder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
"None of them contains config.php" is true and nearly useless. It does not say whether the search was even in the right part of the filesystem, and twice now that has been the actual question. So the failure path now lists every directory it looked in, with a file count and a note when the contents look like this panel -- index.php beside api/, src/, sql/, tools/. A row like that means the panel's files are present and only config.php is absent, which is exactly what a copy made by a deploy looks like, since config.php is excluded from every upload. That distinction is not guessable from "not found"; it is obvious from the map. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The home page was six identical text tiles, three text steps and a row of chips. Nothing on it was worth looking at, which on a rental site is a problem: people decide whether to enquire from a page they skim. So every card now opens with a drawn scene -- a road, a motorcycle, a decorated car, a tempo traveller, a calendar and key, a flight path, the cape's lighthouse -- built from the brand palette on the same navy sky. Drawn rather than photographed, deliberately. Six stock photographs never share a light source, a colour cast or a horizon, and the grid reads as a scrapbook; vector scenes share all three by construction. They also add no network request, no layout shift while a file loads and no broken frame if one goes missing, and they stay sharp on a phone and a 5K display alike. Real photographs of the actual fleet will beat these on every card they replace, so SectionArt takes a `photo` prop that swaps one in behind the same frame and overlay -- these are the floor, not the ceiling. Cards got the treatment that makes a grid feel built rather than laid out: a gradient edge that fades in, a lift on hover, and the illustration pushing in slightly behind it so the movement reads as depth. All of it is opacity and transform only, and all of it sits inside the existing prefers-reduced-motion guard. The focus ring is outside that guard, because a keyboard user needs it whatever their motion setting. Areas served gained the coast panel beside the town list, and the steps became cards rather than loose paragraphs. Four things were wrong at full size and are fixed rather than shipped: the motorcycle read as a bicycle against copy that says "scooters and motorcycles", the palms read as bare spokes because a stroked line tapers to nothing at its base, the wedding heart floated unattached where a bow on the bonnet belongs, and the sea's filled crescents came out as dark olive lozenges -- gold at low alpha over near-black subtracts more than it adds, so the water is bright thin glints now. Checked at 1280 and 390 with a real browser: no horizontal overflow, and the scroll reveals fire on every new section. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
You asked for real photographs. I cannot fetch any: this environment's network policy refuses every image host (unsplash, wikimedia, picsum all answered 403 at the proxy), and I am not going to ship drawings while calling them photographs. So the next best thing that is actually within reach -- build the plumbing so your photographs work the moment they exist, and keep the drawings as the floor until then. photo-manifest.mjs scans public/photos at build time and records what is there. Drop in cars-hero.jpg and the Cars banner is a photograph on the next build; no code change, no registration, nothing to remember. A slot with no file keeps its drawing, which is what lets a half-finished set of photographs still look like a finished site. It has to be a build-time scan rather than a runtime check, because an <img> whose file is missing does not fall back -- it renders a broken frame on a live page. The only thing that can answer "is there a photograph here" before render is the filesystem. PageHeader now carries that banner, so every inner page gains one at once. Photographs and drawings get different treatment rather than a shared one: a photograph can be any brightness, so it fills the band under a scrim heavy enough to hold white type over a white sky (measured at its lightest point against a pure white image: title 12.0:1, intro 8.3:1). A drawing is already dark and on-palette, and the same scrim reduced it to a smudge -- and a banner is about 5:1 where the scenes are 2:1, so filling the width cropped the subject in half. Inset on the right and faded into the navy, it reads as an illustrated band instead, and is hidden below lg where it would sit behind the words rather than beside them. public/photos/README.md lists every slot, what each shot should show, and the things that actually go wrong: too small, midday sun, number plates you would rather not publish, 4MB straight off a phone. Checked all eleven routes in a real browser at 1280px and 390px: no horizontal overflow, no broken images, no console errors. The photograph path was verified with a real file in the slot, then the file removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Find the admin panel by its config.php, and illustrate every page
Every failure here collapsed into one silent fallback, so an unreachable
endpoint and a panel with nothing in it produced the identical page. Those
need completely different fixes -- one is plumbing, one is data -- and
telling them apart meant asking someone to open a URL by hand. That cost
real time today.
Now each state says which it is, in the browser console:
unreachable a warning naming the URL, the reason fetch gave, and the
suggestion to open that URL directly
zero vehicles an info line saying the panel answered fine and every
vehicle is either not Available or has no rate card dated
today or earlier -- which is exactly what the endpoint
filters on
vehicles unchanged
A visitor still never sees a broken listing or a diagnostic; the fallback
renders as before. AbortError is excluded, because navigating away
mid-request is not a fault and reporting it would train people to ignore
the warnings that matter.
Verified all three paths in a real browser with the response stubbed:
unreachable warns, empty informs, and one vehicle still renders.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Say why the fleet is empty instead of failing silently
The panel needs to be reachable at niteshacars.in/admin. It is PHP, and
GitHub Pages serves static files only, so that address cannot exist while
the domain points at Pages. The site moves to Hostinger, where the panel
already lives.
That is not just a relocation. Same origin removes the CORS allowlist, the
second TLS certificate, and the failure mode they produced together: a
browser refuses to read across origins from a host whose certificate it
does not trust, and it fails silently. That is what has been emptying the
fleet, and no amount of fixing the deploy would have touched it.
It also unblocks the deploy that has been stuck all day. The FTP account
that works logs in at the website's web root -- the one place both the site
and admin/ need to be written. The account that could never be created is
the one for the subdomain we are no longer using.
- deploy-hostinger.yml builds and uploads the site over FTPS, using the same
three secrets the panel deploy uses. It refuses if it finds config.php in
the login directory, which would mean the account had been re-pointed at
the panel and the upload was about to overwrite it.
- public/.htaccess: HTTPS (checking X-Forwarded-Proto, since Hostinger
terminates TLS upstream and %{HTTPS} alone would loop), /admin handed
straight to PHP before the site's routing can swallow it, and a real 404
rather than a soft one. Each route is already a prerendered directory, so
Apache serves them itself and the per-route titles survive the move.
- lib/api.ts resolves the endpoints relative to the site, overridable with
VITE_API_BASE for local work against a panel elsewhere.
deploy-my-app.yml is left in place on purpose: until DNS moves, Pages is
still serving the live site, and it is the way back if this goes wrong.
Verified after the change: all eleven routes clean at 1280px and 390px, and
the fleet's three states still report correctly against the relative URL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Folders rather than boxes, stepped down and up across the row, in the four colours the site already owns -- navy, cream, gold, bronze -- with the stamp turning slowly in the gap between the middle two. It carries the line the logo used to have under the name, which is where it came from. What they say is what the owner asked for: trusted 100%, cleaned before pickup, 1000+ customers, 20+ vehicles. Those last two are claims that change, so they are content rather than markup -- a fleet of twenty is twenty until it is thirty, and a number written into a component is a number nobody can correct without a deploy. Which meant the panel needed a second page. The schema was one page deep in shape but only ever held "home", and content.php already read ?page= and saved under it, so what was missing was a way to get there: a row of two chips above the sections, links rather than script, so a page is somewhere you can be sent and somewhere the back button returns to. The stagger is margin rather than a transform. These cards are revealed on scroll and the reveal animates transform to nothing at its last frame, so a translate here was undone the moment each card arrived -- which is why the first attempt was a flat row. Checked against the real panel: the switcher lists both pages, the about page prefills from the defaults, an edit saves and comes back, the home page is untouched by it, the public endpoint carries the edit under about, and reset puts the shipped wording back. On the site: the cards step, the colours are the four, and the page walk, accessibility names, how-it-works, footer, services, FAQ, reveals and no-JavaScript suites are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Twenty-eight pixels of hairline on a coloured card is a smudge, and four of them were four smudges. The mark is forty pixels now, on a disc of its own: gold on the navy card, bronze on the cream one, navy on the gold one and cream on the bronze, so the row carries four colours rather than one gold repeated four times. Each one draws itself as its card arrives -- the outline first, the detail after it -- and then keeps a slow float, offset a beat per card so the row breathes rather than pulsing in time. Under a pointer the disc grows and tilts a little. pathLength="1" on every shape, so one dash rule draws all of them: measured in user units, the shield outline would crawl while the tick was over before anybody saw it. The undrawn state is keyed on data-js, like the reveals: with no script there is nothing to add .is-visible, and an icon left undrawn is an icon nobody sees. Reduced motion gets them drawn from the first frame, with no float and no hover. Checked: the marks start at a dash offset of 0.999 and finish at 0, four badges at 72px with four different colours and four different mark colours, the mark itself 40px, and -- the two that matter -- drawn from the start under reduced motion and drawn with no script at all. The page walk, accessibility names, reveals and no-JavaScript suites are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every icon on both was a hairline in one colour, drawn a piece at a time in whatever component needed one -- a phone, a pin and an envelope in identical grey say nothing about which is which before you read the line beside them. One recipe now, in two places because the two share no build: a plate with a diagonal gradient, a gloss across its top half, a shadow underneath and a rim inside the edge -- the four things that make a flat shape read as an object -- with the mark in white on top. Six tones: gold, bronze, navy, green, plum, slate, so a row of icons carries colour rather than one gold repeated. On the site (my-app/src/components/Icon3d.tsx): the footer's four contact rows, the social row, the WhatsApp bubble and the back-to-top button. In the panel (admin/src/icons.php): all eight sidebar entries, each on its own colour, and the five counters on the dashboard, where the plate sits top right and out of the way of the number. Not everywhere, deliberately. The chevron beside a menu, the tick inside a chip, the bin on a table row: a plate at sixteen pixels is a coloured square with something indistinct on it, and those are clearer as hairlines. The four marks on the about page keep their own discs, which already draw themselves. Ids are suffixed per instance in both. Six plates on one page all defining "plate" would every one of them paint with whichever gradient the browser read last -- the failure is silent and looks like a colour choice. Checked: 14 checks over both -- plates in the footer and the buttons with two gradients and a shadow each, none under 38px, no two definitions sharing an id, eight sidebar plates at 22px in four or more colours, five counters at 34px with none sitting on its number, and nothing thrown either side. The page walk, accessibility names, footer, benefits, services, how-it-works, FAQ and no-JavaScript suites are unchanged, as are the panel's nav, UI and records suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The about page's four cards, and one colour icon recipe across site and panel
Two cards and then a form centred in its own band meant that on a desktop the page was a column down the middle with a screen of nothing either side, and the phone number was a scroll away from the form by the time anybody had read it. Side by side, both are on screen at once. On the left: phone, WhatsApp, email and the address, each on the plate its kind of contact uses everywhere else, then the opening hours and the map -- which belongs on the page somebody opens to find us rather than only at the foot of every other one. All of it the panel's: the address, the hours and the map pin come from the footer section, so there is still one place to change them. On the right: the form. Enquiry is a card the page places now rather than a section that centres itself, which is the whole of the change to it -- same fields, same handler. The id stays on the card, because /contact#enquire is linked from half the site and has to land on the form rather than on the column holding it. Under a thousand pixels they stack, details first: most people opening this page on a phone want the number, not the form. Checked: four ways on the left, the form to the right of them and level with them, 780px of room for it, the anchor landing on the form, every field still posted, one map, stacked in that order on a 390px phone with no sideways scroll, and a silent console. The page walk, accessibility names, the icon plates and the no-JavaScript pass are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Contact: the ways to reach us on the left, the form on the right
Stacked one under another, the five blocks were a screen and a half of scrolling to reach a phone number: five links, then four more, each with a 40px tap target under it, before "Contact us" even appeared. The grid is two columns from the narrowest screen up now. The two lists of links share a row, and the three blocks that need the width -- the name and its line, the ways to reach us, and the map -- span both. Nothing changes above 1024px, where it is still five across. Checked at 390 and 360 pixels: two columns, the link lists side by side, the brand and contact blocks spanning both, no sideways scroll, and the whole footer 1084px tall where the two lists alone used to take more than that. At 1440 the five blocks are still on one row. The footer, page walk, accessibility name and contact suites all pass unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
A brand's mark is not ours to recolour. Instagram was on the house plum plate, which is a purple circle with the Instagram outline on it -- close enough to be read as the wrong icon rather than as a choice. It has Instagram's own gradient now: five stops, yellow at the bottom left through orange and pink to blue at the top right, which is the direction the real one runs. That meant the plate recipe had to take more than two colours and, where a brand's gradient runs its own way, its own pair of corners -- both optional, so every other plate is unchanged. The shadow of a five-stop plate is cast in the middle colour, because neither end is what the eye reads the plate as. Facebook goes with it, to its own blue. One of the two in brand colours and the other in ours would read as a fault in the row rather than as restraint. Checked: the Instagram plate has five stops starting #feda75 and the Facebook one two starting #3b8bf5, with the social row filled in temporarily to see them and the defaults put back empty afterwards. The page walk, accessibility names, the footer, its two-column phone layout and the icon plates on both site and panel are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Six cards in a three-wide grid is two rows, and the second row is under the fold on a laptop and a long way down on a phone -- so half of what the business does was seen only by somebody who kept scrolling. They are all in one gesture now: four across on a desktop with the fifth running off the edge, one and a peek on a phone, and the card half off the right is what says there is more. Native scrolling rather than a slider. The browser already does momentum, touch, trackpads, shift-wheel, the keyboard and the scrollbar; a library would reimplement all six and get one of them wrong. Snap points so a flick lands on a card rather than between two, smooth behaviour so the arrows glide rather than jump, and scroll-padding matching the page's gutter -- without that last one the first card snapped against the window edge and the rail sat 48 pixels scrolled before anybody had touched it, with a "back" arrow live and nothing behind it. The arrows are hidden until the page says it has a script, like the reveals: they are scrollBy() and nothing else, and a button that does nothing is worse than no button. They are also gone on a phone, where the gesture is the affordance, and each is disabled at its own end of the rail. The rail itself takes focus and is announced as a region with the section's name, so a keyboard can reach it and arrow-scroll it. Checked at 1440: six cards on one row, wider than the window, snap points and smooth behaviour, the back arrow disabled at rest and live after a press, a press moving one card and landing on a card edge, the forward arrow dead at the end, arrow keys scrolling it, and a silent console. At 390: no arrows, no sideways scroll on the page itself. The page walk, accessibility names, the services page, the phone footer and the no-JavaScript pass are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
A scrolling rail for "What we hire", a two-column footer on phones, and the social marks in their own colours
Two things about the rail were wrong. It ran to the window edge, which made it the one band on the page that ignores the column everything else lines up to. And it only moved when somebody moved it, which for a row with a card half off the edge is an invitation nobody was taking. It is inside the page's own padding now -- starting where the heading starts and ending where it ends -- with the cards fading out at both ends rather than being chopped, so it reads as something passing through the column rather than as a grid that overflowed it. And it crawls: twenty-six pixels a second, the list rendered twice and the position wrapped at the halfway mark, which is what makes the loop seamless -- at the wrap, the copy under the frame is pixel for pixel what was there a moment before. The second set is scenery: hidden from screen readers and out of the tab order, because six more identical links say nothing new. It gets out of the way rather than fighting anyone. Hover and focus stop it; a touch, a wheel or an arrow press stands it down for two and a half seconds and it picks up from wherever it was left; a background tab stops it; and prefers-reduced-motion turns it off altogether. Two things had to go for the crawl to work at all, and both are worth knowing. Scroll snapping: mandatory snap and a crawl are two things deciding where the rail should be, and the snap wins every frame -- the rail inches forward and is yanked back. And scroll-behavior: smooth, which animates every assignment to scrollLeft, including the crawl's own fraction of a pixel, which is still easing when the next frame overwrites it. The arrows ask for smooth scrolling themselves, which is the one place it is wanted. The position is also kept in a variable rather than read back from the element each frame: a browser stores the scroll offset in whole device pixels, so scrollLeft += 0.4 reads back as the number it already was, and the rail sits still at any speed under about thirty pixels a second. Checked at 1440: twelve cards with six in the tab order and six hidden, the rail starting and ending exactly where the heading does and inside the page padding, moving 26 pixels a second on its own, stopping under a pointer and starting again when it leaves, wrapping at the halfway mark, an arrow moving it about a card, and nothing on the console. With reduced motion it does not move at all. At 390 there are no arrows and the page still does not scroll sideways. The page walk, accessibility names, the services page, the reveals and the no-JavaScript pass are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The rail sits in the page's column, and moves on its own
Google shows a business's posts above the fold on its own name, and a post without a picture is a grey box of text nobody reads. This is the picture: the logo, the headline, the three numbers the site already stands behind, and the phone number in a band that runs off both edges. It is an HTML page rendered to a JPEG rather than a file from a design tool. The logo, the photograph, Poppins and the brand colours are all in this repository already, so a poster built out of them cannot drift away from the website, and changing a claim is a text edit and one command. Two sizes off the one page: 1200x900, which is what Google asks for, and 1080x1080 for WhatsApp status and Instagram, so the wording is written once and cannot disagree with itself. JPEG at quality 92 rather than PNG, because most of the card is a photograph -- a fifth of the bytes with nothing visible to tell them apart, which is the whole wait when the post is uploaded from a phone. marketing/ is source material, not the site, so it gets what assets-original/ gets: a 403 from .htaccess and a Disallow in robots.txt. Hostinger deploys the whole repository into the web root and only the site itself belongs there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every date field showed two calendars. Ours sat open under the box with the taken days marked in gold; the browser's opened on top of it when the box was tapped, and that one -- the one anybody actually used -- knew nothing about which days are already spoken for. On a phone it is worse still: a date input opens the operating system's picker on touch whatever we do to it, so our grid could only ever be a second opinion nobody asked for. A visitor picked a day that was gone, and the first they heard of it was the call back. So there is one calendar now, and it is ours. The field is a button; it opens the grid, and the grid is the only way in -- which is what lets a booked day be drawn as booked and refused when it is pressed. Booked days are tinted and struck through rather than filled solid, because a solid gold day read as the day you had chosen rather than the day you cannot have; the chosen day is navy. The line through the number carries it for anyone who cannot see the colour, and the reading is in the day's label too. The cost is that a date can no longer be typed. That is a real loss for somebody who knows their dates, and it buys the thing that matters more: on the one calendar anybody sees, the days we cannot give them are struck out before they ask. The field reads the date back as "Tue 6 Oct 2026" -- named month, because 06/10 is the sixth of October here and the tenth of June to half the people who will read it. Smaller, too. The grid is only in the page while it is open, so the contact form's two dates are two single-line fields where they used to be two whole months, and the banner card can afford the same picker -- which is why the dates in the banner now mark the booked days as well. Where it opens is measured from the field before it is drawn: under it when there is room, over it when there is not, pinned to the top of the window when there is neither. It hangs at the end of the page rather than inside the field, because the banner's section clips what overflows it and was cutting the last week off. And the arrows walk the days, across month boundaries, with one cell in the tab order at a time -- the keyboard route the date input used to provide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
One calendar on the dates, and a post card for the Google Business Profile
"Also worth asking about" and the open-road banner are gone, with the components, their entries in the content panel and the image slot the banner used. The first was three cards for monthly hire, NRI bookings and wedding cars. All three are already on the page: the rail above it links to those same pages along with the three it did not repeat, so this band said the same thing a second time in a different shape, a screen further down. The second was a photograph with "No driver. No timetable." over it. It argued for self drive to somebody who had scrolled past the fleet, the reasons to hire from us and the four steps of booking -- an advertisement for a decision already made, between two sections that now sit next to each other without it. Nothing is orphaned by either: /monthly, /nri and /wedding-cars are still linked from the home page by the services rail, and from the menu. The panel loses the two sections from Website content and loses the "Open road band" upload. Any file already uploaded there is untouched, it simply has nowhere left to appear. Stored copy for the two sections is ignored rather than needing a migration -- mergeContent walks the shipped defaults, so a section the site no longer ships is a section it no longer reads. The scrim the banner used goes with it; the note on the inner-page scrim compared itself to it and now says what it does on its own terms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The two pages this business is searched for by name were served to a crawler as "Fetching the current fleet…" and "Loading the rate card…". The vehicles live in the panel's database and the site asked for them from the browser, so /cars had no cars in it and /tariff had no rates. Googlebot does render JavaScript, on a second pass, days later, at its own discretion; nothing else that reads a page does it at all. So scripts/fetch-fleet.mjs pulls the list before Vite runs and writes it into the bundle, the same trade fetch-content.mjs already makes for the copy: never fails the build, falls back to what is committed, and says so in the log. useFleet starts from that baked list instead of from a loading state, and the browser's fetch still replaces it, so a car added this morning appears this morning. /cars goes from 145 words to 304 with all six vehicles named; /tariff from 632 to 697 with the table filled in. A rate of 0 now prints "On request" rather than ₹0. A tariff is a promise and a figure nobody at the business typed is not one they can keep, which is exactly what would have been published otherwise -- the committed snapshot carries the models and their specifications, not invented prices. On top of that, a page each for the six vehicles people ask for by name, at /cars/<slug>. Somebody deciding between a Swift and an Innova is not searching for "self drive car rental"; they are searching for the car. Each page is written from its own entry in models.json -- what it is like on these roads, who it suits, and a "worth knowing" paragraph saying what it is bad at, because six pages that are one page with the name swapped are doorway pages and Google treats a site that has them as less trustworthy overall. 618 to 662 words each, three questions each, and every page links to the other five. Structured data to match: Car on each car page, FAQPage from the same questions the page renders, and breadcrumbs through /cars. Plus Organization and WebSite on the home page, which were the two missing pieces -- the business block says what is done here, those say who does it and what this domain is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
"Bike rental Kanyakumari" and "bike rental Nagercoil" are different searches made by people wanting different things -- a sunrise run along the coast, and a morning of errands through heavy traffic. One page covering both answers neither. So six pages, each nested under the service it belongs to: bikes in Nagercoil and in Kanyakumari, wedding cars in both, monthly hire in Nagercoil, tourist vehicles in Kanyakumari. 553 to 624 words each, with their own Service and FAQPage data and a breadcrumb through the service. Deliberately not a matrix. Four services across twelve towns is forty-eight pages that are one page with two words swapped, which is what Google means by a doorway page and is treated as a reason to trust the whole site less. Hiring a scooter in Kanyakumari really is a different job from hiring one in Nagercoil; hiring a car by the month in Kanyakumari rather than Nagercoil is not, so that page does not exist. And the blog has five articles instead of "Nothing here yet" -- the documents you need, a Kanyakumari day with honest timings, self drive against a driver, what KM limits and deposits actually mean, and what the roads are like through the monsoon. Each one answers something people ask on the phone before they book, which is the only reason worth writing one: an article that exists to carry a phrase reads like it. The body is blocks of data rather than a string of HTML, which is why the renderer is short and why nothing in posts.json can put anything into the page but words. BlogPosting on each, with dateModified that means it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
main.tsx called createRoot().render(), which does not hydrate: it empties #root and renders the whole page again. So every prerendered page was discarded the moment its JavaScript ran -- the content already on screen vanished, the page collapsed to the Suspense fallback while the route's lazy chunk arrived, and came back a third of a second later. Measured on a throttled phone that is 0.19 CLS on every inner page, which is "needs improvement" on the one Core Web Vital that is entirely a rendering decision. It also meant the prerender bought nothing at all for anybody who runs JavaScript. hydrateRoot reuses the markup, and React keeps server-rendered content on screen while a suspended boundary resolves. CLS is now 0.000 on every page measured. It hydrates only when the markup belongs to the page: the build stamps data-route on #root, because 404.html is a copy of the home page and hydrating that as some other route is a mismatch React recovers from by re-rendering anyway, with a console error nobody can act on. The route's chunk is also preloaded now, from a Vite build manifest, so it is in the module map before React looks for it. A route missing from that map only loses its preload, which is what every route had before, so drift costs speed and never correctness. Images: the banner photograph is 1920 wide and was being sent whole to a 390-point phone to paint the largest thing above the fold -- which is to say the thing this page's loading speed is measured on. It now has 800 and 1280 wide copies and a srcset. The header lockup is 1209 wide, drawn at about 110, fetched at high priority on every page, and on the home page competing with the banner for bandwidth; it has 400 and 800 wide copies. Both carry width and height so the space is reserved. Only the committed files -- a photograph uploaded in the panel is served by the panel, and inventing -800w addresses for it would be inventing 404s. Home page LCP 4080ms to 3068ms and 941KB to 740KB; inner pages about 80KB lighter each. LCP is still short of the 2500ms it should be, and what is left is the 344KB entry bundle rather than anything about the images. Internal linking, while here: the fleet listing and the tariff table now lead to the car pages, town pages lead to the vehicles and to the services written up for that town, service pages lead to their town pages, and the tariff leads to the two articles that explain its own figures. Every page now has at least three other pages pointing at it and nothing is orphaned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
/places was the same bug /cars had: the list is fetched in the browser, so the page a crawler was served said "The list is on its way" -- on the page most likely to be found by somebody searching for things to do around Kanyakumari, who is exactly the person who then needs a car. Same fix: scripts/fetch-places.mjs bakes the panel's list into the build and keeps what is committed when the panel cannot be reached. The committed list is twelve real places with approximate road distances from Nagercoil, because a snapshot of nothing is the bug it replaced. Then the pages the audit called thin. /cars was the banner and six cards, most of which is "5 seats · Petrol · Manual" six times; it now says which vehicle suits which trip and what every hire includes, which is the conversation that happens on the phone anyway. /about says how a hire is actually run and lists the towns. /contact says what happens after the form is sent, which is the question a form leaves unanswered and the reason people close the tab. Nothing invented: the about page's warning against inventing a founding year or a fleet size still stands, and everything added is true of the business as the rest of the site already describes it. Two titles were two characters over what a search result shows, and the town hub said "About 30 km from Nagercoil" twelve times, which made the name of the base town nearly eight per cent of the page. It is said once, above the grid, where it was always the more useful place for it. All of which is now checked rather than asserted. npm run seo reads the built files -- what a crawler is served is the file -- and exits non-zero on a page with no canonical, a title that would be cut, a duplicate description, a loading message in the markup, an image with no alt, fewer than 250 words, a term over eight per cent of the page, or two pages of the same family more than 45% alike. Every one of those is a rule something broke here at some point. It reports 43 pages, 255 words at the shortest and 503 on average, and nothing flagged. The closest two town pages are 33.7% alike, the closest two car pages 16.3%, the closest two service-in-a-town pages 3.3%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
SEO: crawlable fleet and rates, 17 new pages, and a check that keeps them honest
The check rebuilt the site and failed if republishing changed anything. That could not work, and had not worked since the panel came online: a build embeds the copy, the fleet and the places the panel is serving at the moment it runs, and the workflow has no way to reproduce those -- the copy lands in live.json, which is not committed, and the rest changes whenever the owner edits anything in the panel. build.json also carries a fresh timestamp every run. So the rebuild differed every single time, the check was red on every pull request and on main for as long as anyone can remember, and a check that is always red is a check nobody reads. It was reporting nothing while looking like it was reporting something, which is worse than not having it. It compares a fingerprint of the source instead -- a hash of the tracked files the build output depends on, written into build.json by publish-site.mjs and recomputed by the workflow. That is the question the check was always trying to ask: was this site built from this source? It is deterministic, needs no install and no build, and takes about a second instead of a minute. What it no longer claims to know is whether the copy baked into the HTML is the newest the panel has. Nothing could tell you that from a checkout, and it matters little: the browser asks the panel on load and replaces it, so stale baked copy costs a crawler one build's worth of freshness and costs a visitor nothing. The hash covers admin/ as well as my-app/, because copy-admin-panel.mjs copies the panel into the output verbatim and it reaches the server by the same route. It excludes the two files that do the checking rather than the assembling: without that, editing the checker would report the site as stale, which is exactly the sort of false failure that got the old one ignored. Verified three ways: it passes immediately after a publish, it fails with the two fingerprints and the fix when a source file changes without one, and it stands down with an explanation on a build published before the fingerprint existed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Make "Site is current" able to pass
Ten cream cards on a dark photograph read as a stack of paper dropped on the page: the section fought the banner behind it and won, which is not what a photograph is for. The panels are the band's own navy now, lit from within, with a gold hairline and a chevron that turns over rather than a plus that becomes a minus -- the rotation says "this opens downwards", which is what it does. And six questions rather than ten, three in each column. Ten on a home page is a wall: the last of them sat a scroll and a half below the heading, on the page somebody is still deciding whether to keep reading. The other four are not gone -- they are on the tariff page, which is where a person with a detailed question already is, and the home page links there. Nothing is deleted from the panel. All ten are still edited in one place and all ten still appear on /tariff; the home page takes the first six. The count lives in one shared module that the component and the build both read, because Google asks that FAQPage data match what the page displays, and a page claiming ten answers while showing six is the kind of mismatch that gets a site's rich results turned off rather than improved. Checked both ways round: six questions and six FAQPage entries on the home page, ten and ten on the tariff page. Contrast measured against the brightest pixel actually painted behind the text rather than against the intended colour: 19.6:1 on a question and 11.0:1 on an answer, so it holds whatever photograph ends up in the band. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The check I added yesterday went red on its own next pull request, which is the best thing it could have done. sourceHash listed tracked files. Publishing happens before committing, so a change that introduces a new source file hashed everything except that file -- and the hash then changed the moment the file was committed and became tracked. Every commit that added a file would have failed the check, telling the author to republish a site that was not stale. The previous pull request passed only because the two files it added were the two the hash excludes. It lists tracked files and files that are not ignored but not yet added, which is exactly the set `git add -A` would commit. On a clean checkout -- which is what the workflow sees -- that is the same set as before. A tracked file missing from disk is now skipped rather than hashed as a placeholder, for the same reason in the other direction: an uncommitted deletion should count the same before the commit and after it. Checked by reproducing the failure in a fresh clone of the pushed commit, finding that the two trees held the same 232 files with identical contents, and then running the case that was broken end to end: publish, add a new source file, publish, commit, check. It passes now and it failed before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Three and three on the FAQ, and fingerprint what will be committed
The bundle was going over the wire uncompressed. .htaccess listed
application/javascript in the compression filters, but IANA reregistered
.js as text/javascript years ago and Apache's mime.types followed, so a
host on a recent build serves it under the name that was not on the list.
The largest file on the site, on every page, at full size.
Both spellings are listed now. Measured on a throttled phone -- four times
CPU, about 1.6 Mbps, 390 by 780 -- against the same build served with the
old rules and the new:
home page a car page
transferred 592 KB -> 301 KB 499 KB -> 203 KB
page load 3295ms -> 1860ms 2885ms -> 1421ms
LCP 1656ms -> 1476ms 1040ms -> 944ms
The entry bundle alone is 350 KB against 107 KB. Nothing else on the site
came close to that as a saving, and it is one line.
Two smaller things while measuring. The header lockup is drawn 83 points
wide on a phone, so a two-times screen wants about 166 pixels and was
being sent the 400-wide file at 27 KB; there is a 240-wide copy now at
17 KB, and srcset still hands a three-times screen the larger one. And
Poppins at weight 600 is what font-semibold asks for -- every card title,
every button, every nav link -- so it was fetched on every page anyway,
just late enough to swap after the other two had settled. It is preloaded
with them now.
Two things I tried and reverted rather than ship, because measuring said
they did nothing. Splitting the home page out of the entry bundle made the
entry larger, because its components are shared and stayed there. Loading
the banner drawing only at desktop widths saved nothing either: four other
components import that module statically, so Vite folded it into the entry
instead, and the page ended up the same size with a slower drawing on
desktop.
Suites: site walk clean, 0 unnamed links, no-JavaScript 20/20, reveals
5/5, FAQ 41/41, dates 35/35, contact 11/11, rail 18/18, SEO audit 43 pages
nothing flagged. CLS stays 0.000 everywhere.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Compress the JavaScript, which the server was not doing
The list stacked three things in every cell -- name over phone, vehicle
over registration, dates over duration -- so each booking stood three
lines tall. Four of them filled the screen, and the reader had to work
out that the number under a name was that customer's phone rather than
the next row starting.
Each cell now reads across, with a middot between the parts. The line
wraps rather than forcing the table sideways: nowrap on the whole row
would make each column's minimum its full length, which comes to about
1390px across eight columns -- a horizontal scrollbar on anything
narrower. Wrapped, a row is one line wherever it fits and two where it
does not, and never the three it was.
Three things had to give for it to fit at all:
- Dates say what repeats once. "14 Oct 2026 -> 16 Oct 2026" is the
same month and the same year twice; it is now "14-16 Oct", with the
year back the moment a hire is not in this one and both years in
short form across New Year.
- The records screens are let out of the 1180px reading measure. That
width keeps a form or a paragraph comfortable and is the wrong width
for an eight-column table; the record itself, and every other
screen, keeps it.
- The list drops the "Upcoming" schedule chip. The chip is there to
show a booking whose real position has drifted from its recorded
status -- overdue, due back, not collected. "Upcoming" is the
absence of drift, and next to a Confirmed booking it says the same
thing twice, for ninety pixels. The record and the dashboard card
have the room and still show it.
Measured in the panel's own content column, with the sidebar, on the two
bookings from the screenshot: row height 116px -> 61px at 1680 and
73-83px at 1440, and on a phone the table is 348px wide where it was
407px, with the rows the height they already were.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Bookings list: one booking, one line
No description provided.