Skip to content

MAIN - #17280

Open
niteeshkanna-sh wants to merge 350 commits into
react:mainfrom
niteeshkanna-sh:main
Open

MAIN#17280
niteeshkanna-sh wants to merge 350 commits into
react:mainfrom
niteeshkanna-sh:main

Conversation

@niteeshkanna-sh

Copy link
Copy Markdown

No description provided.

@meta-cla

meta-cla Bot commented Aug 27, 2026

Copy link
Copy Markdown

Hi @niteeshkanna-sh!

Thank you for your pull request and welcome to our community.

Action Required

In 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.

Process

In 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 CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

niteeshkanna-sh and others added 29 commits September 16, 2026 15:58
Put the site at the top level, where the deploy actually lands
That message means the CSRF check failed, and the CSRF check fails when
the token stored in the session on one request is not there on the next.
Which is almost never about tokens: it is the session not surviving
between the page loading and the form being submitted. Four quite
different faults produce it and they look identical from the outside,
which is exactly the shape of problem that has cost the most time here.

session-check.php does what signing in does -- stores a token, renders it
in a form, checks it when the form comes back -- and reports what
happened alongside the settings that decide it: whether the browser
returned a cookie, whether session storage is writable, whether the
cookie is marked Secure, and whether the visitor is actually on HTTPS.

It singles out the one combination that silently eats every session: a
cookie marked Secure served over plain HTTP, which the browser accepts
and then refuses to send back. That check outranks the round-trip result,
because browsers exempt localhost from the Secure rule -- a local copy
round-trips happily on settings that lose every session on a real domain.
Without that ordering the page reported success and named a fault in the
same breath, which is worse than reporting neither.

Two real mismatches found while reading the path, both in what install.php
writes:

  https_only came from $_SERVER['HTTPS'] alone. This host terminates TLS
  upstream, so PHP is handed a plain HTTP request for a visitor who
  arrived over HTTPS -- the site's own .htaccess says exactly that about
  %{HTTPS} and redirects on X-Forwarded-Proto instead. It now reads both,
  and REQUEST_SCHEME as a third opinion. This is the value that decides
  whether the session cookie carries Secure.

  session_minutes was written; the panel reads session_idle_minutes. A
  hardcoded fallback was doing the work, so changing the value in
  config.php had no effect at all.

Neither is proven to be the cause of what is happening on the server --
that is what the new page is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Say why the sign-in page reports "Session expired"
The Add Vehicle form had no way to attach a picture. Photographs only
worked by dropping a file into the repository under a filename matching
the car, which is no use to the person who actually knows which car is
which.

Pick a file, press Save, and it appears on the website's fleet card.

Where the file goes matters more than it looks. Uploads are written under
storage_path, above the document root, because this host deploys by
rebuilding the web root from the repository -- a photograph written
inside it would survive until the next push and no longer. config.php was
lost exactly that way, and a customer-facing photo vanishing during an
unrelated deploy would be the same bug in different clothes. Being
outside the web root also means Apache cannot serve it, so photo.php
reads and streams it: a PHP process per image, in exchange for nothing in
that directory ever being executed.

What decides whether an upload is an image is the file's own contents,
via getimagesize -- never its name or the type the browser claims, both
chosen by whoever is uploading. Verified over a real multipart POST:

  a real PNG                          accepted as .png
  a PHP script named car.jpg,
    declared image/jpeg               refused

The stored name is ours, never the uploader's, because a filename from a
browser can contain path separators. photo.php then only answers for names
matching the exact shape this generates, which is a better rule than
trying to enumerate the ways a path can escape a directory. Every attempt
returns 404:

  ../secret.txt, ../../nitesha-config/config.php, ..%2Fsecret.txt,
  v7-../../../etc/passwd, a well-formed name for a file that does not
  exist, and an empty name

The URL is built against the panel's root rather than used as returned.
The endpoint sends "photo.php?f=...", relative to the panel; in an <img>
that would resolve against the page instead, so the fleet page would ask
for /cars/photo.php and every photograph would 404 on some pages and work
on others. Confirmed in a browser: the card requests /admin/photo.php and
gets 200 image/png, and a vehicle with no photograph still falls back to
the drawing.

The upload is a second request after the save, because a photograph is
stored against a vehicle id and a vehicle being created has none until
the save returns one. The form hides that. Remove marks the photograph
for deletion but changes nothing until Save, so Remove then Cancel leaves
the vehicle as it was.

Not verified: anything touching the database. This sandbox has no MySQL,
so the migration, the UPDATE, and the audit entries are unexercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Add a photograph to a vehicle from the panel
The document root caches every .js and .css for a year as immutable. That
is right for the site's build output, where a changed file is a changed
filename because the name carries a content hash. It is wrong for the
panel: admin.js, admin.css, api.js, car-data.js and booking-data.js have
fixed names, so a browser that loaded the panel once would not ask for
them again until 2027 -- and every change to the panel would reach nobody
who had ever used it, with no error and nothing to notice. The vehicle
photograph upload merged minutes ago would have been the first casualty.

Two parts, because one alone does not finish the job. admin/.htaccess
overrides the header for this folder, which fixes it for browsers that
ask -- and a browser already holding the file will not ask. So the tags
now carry the file's modification time: a changed file is a different
URL, and the cached copy cannot match it.

asset() lives in its own src/assets.php. It went into http.php first,
which was wrong in a way worth recording: http.php is the JSON endpoints'
plumbing, and no page that renders HTML loads it. dashboard.php,
index.php and content.php all require csrf.php and nothing else, so
asset() was defined precisely where it was never called from and every
panel page would have died on an undefined function. Caught by loading
the pages rather than by reading them -- php -l passes on a file that
calls a function that does not exist.

Verified: asset() returns admin.css?v=<mtime> and falls back to ?v=0 for
a file that is not there; the require precedes the first call in all
three pages; index.php reaches its database call, which is as far as
anything gets here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Stop the panel's own scripts being cached for a year
Saving a vehicle with a photograph failed, and the reason was worse than
the symptom: the column the photograph feature needs had never been
created, on any installation.

migrate() ran from exactly two places -- install.php, which refuses once
an account exists, and a button on the content page that nobody has a
reason to press. So a deploy could add a migration and nothing would ever
apply it. The code shipped and the database did not, and the first sign
was a feature failing with a SQL error.

It now runs on dashboard load. migrate() already records what it has
applied and skips those, so the cost is one small SELECT per load. A
failure is reported in a banner rather than thrown: a migration that
cannot run is worth knowing about, and is not a reason to refuse to show
a panel that otherwise works.

The public fleet endpoint had the same problem from the other side, and
it mattered more. It selects a narrow list of columns by name, and one of
those was the new one -- so between a deploy and an admin next signing
in, niteshacars.in/cars would have been asking for a column that did not
exist. A change made entirely inside the panel could empty the fleet on
the live site, for visitors, with nobody signed in to notice. It now
names the column only when it is there and selects NULL otherwise, so the
site does not depend on anyone having opened the admin. Both forms of the
query were checked for validity.

The upload endpoint says what to do rather than returning a SQL error, in
the window where the column is genuinely missing.

Still unverified: the migration actually applying. There is no MySQL
here. What was checked is that the file parses to exactly the one ALTER
statement intended, and that every new function is defined by the require
chain the pages use -- the last change defined a helper in a file no page
loads, which php -l cannot catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Run pending migrations when the panel loads
The photograph upload told the user to "open the Dashboard tab once and
try again". They did, and it could not have worked: the panel switches
tabs in JavaScript without loading the page, so the migration that runs
on page load never ran. An instruction that only works if you know that
is not an instruction, it is a trap. That message was mine and it was
wrong.

So the schema is brought up to date in api_guard instead. Every endpoint
in the panel goes through it, which means a deploy that adds a column has
it created by the first action taken after that deploy, whatever the
action is and whichever page the person is on. Nothing to be told, and
nothing to remember.

migrate() is cheap but not free, and the panel makes several API calls per
page, so migrate_if_needed() decides whether to bother. What it compares
is the newest migration filename: a deploy that adds one changes that
string, so the next request applies it and every request after skips.
There is no version number anyone has to remember to bump.

A failure is returned rather than swallowed, and deliberately not
remembered -- recording it would mean the session gave up retrying for as
long as it lasted. The dashboard shows it in a banner; the upload now
says the database could not be updated and that the database user may not
be allowed to change tables, which is the actual remaining cause once the
migration has genuinely been attempted.

Exercised with migrate() stubbed, since there is still no MySQL here:

  first call                 runs once, records 002_b.sql
  two further calls          skipped, nothing re-run
  a new file appears         runs again, records 003_new.sql
  the call after that        skipped
  migrate() throws           error returned, marker left unset
  the call after that        retries, and succeeds

The first version of that test reported ran=0 for every case and looked
like the feature was dead. The harness was wrong -- __DIR__ inside the
eval'd copy pointed at the temp directory, so the glob found no
migrations at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Apply pending migrations on any panel action, not on a page load
…t does not

The photograph upload returned 503 from its own "column is missing"
branch, which means table_has_column() said no on a database where the
column had almost certainly just been created.

It asked with "SHOW COLUMNS FROM `t` LIKE ?". This connection uses real
prepared statements -- ATTR_EMULATE_PREPARES is false -- and a
placeholder in a SHOW statement is not something MySQL accepts there. The
catch below it then turned that failure into a confident "no", for every
column, every time. An exception that becomes a plausible answer is worse
than one that escapes, so the failure is logged now as well as caught.

The same function gates the public fleet endpoint, which is the part that
would have gone unnoticed: it was selecting NULL for every photograph, so
no picture would ever have reached the website either, with nothing
failing anywhere to say so.

It now asks information_schema.columns -- an ordinary SELECT, so it
prepares, and both the table and the column bind as parameters rather
than being interpolated into SQL at all.

That diagnosis cannot be run here, and there is a second possible cause
for the same 503: the migration simply failing. Rather than pick one
again, the endpoint now reports what the database actually said.
api_guard runs migrate_if_needed() and discards its result, so an
endpoint finding a column missing could not tell "never attempted" from
"failed, and here is why"; last_migration_error() carries that across.
The two previous messages here each named one plausible cause, were wrong
about which, and neither carried the one fact that would have settled it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Ask information_schema whether a column exists, and report why when it does not
Four things, three of which were the same mistake in different places:
something existed in the code and not in the form.

Body type offered Hatchback, Sedan and SUV. The database ENUM and the
API's validator both accept MUV and Other as well, so a seven-seater
could not be recorded as one -- an Innova is filed as a hatchback on the
live site right now. Nothing warned, because a choice you are not offered
is not a bug anyone reports. The lists lived in three places; the form's
copy had fallen behind. It and the API now read one list in src/vocab.php,
and the form renders its options rather than spelling them out. The
schema's ENUM is still its own declaration, since changing it means a
migration, but two of the three copies are now one.

Photographs are framed before upload and saved at 1200x750, which is the
shape the website's cards use. That is what stops the fleet looking
ragged: the page is no longer cropping pictures of different proportions
and hoping, it is laying out identical rectangles. A 4 MB phone
photograph also arrives at around 150 KB, which matters on a page that
fetches one per car.

The framing window is that same shape, so what is inside it is what a
customer sees. Drag to choose what shows, a slider to zoom, and the
image is held so it always covers the window -- an empty corner cannot be
framed. Zoom works about the middle rather than the corner, which is
where the subject is. Written by hand rather than with a cropping
library: the panel has no build step, and every script in it is a plain
file the browser loads.

The admin card thumbnail had no fixed shape at all, so a tall photograph
made its card taller than its neighbours. It is 16:10 now, with the
drawn illustration fitted and a photograph covering.

And a new photograph did not appear on the site because the fleet
endpoint was cached for five minutes. Changing a picture and not seeing
it reads as the upload having failed, and the natural response is to
upload it again. One minute still absorbs any burst worth absorbing.

Checked in a browser: the body type list offers all five, the framing
window measures 358x223 (1.605 against 16/10's 1.600, from integer
widths), and the export is exactly 1200x750 image/webp. The geometry was
exercised with the same arithmetic the panel uses rather than by running
admin.js itself, which needs the whole dashboard around it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Frame photographs to one size, and offer every body type
It turned continuously beside the logo. A mark that never stops moving
competes with the navigation next to it for attention it does not need,
and the rotation was the only reason the stylesheet needed a
reduced-motion exception for it at all. Nothing moves now, so there is
nothing to exempt.

The drawing stays as a placeholder. SnakeMark already prefers a real
image when one exists -- photoFor('snake') -- so dropping a file named
snake into my-app/public/photos replaces it with nothing else to change.

Verified in a browser: animation-name computes to none, and the
element's transform is identical 1.5 seconds apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Both were drawn in code, replaceable only by committing a file to the
repository under the right name. That is not something the person who
owns the brand should have to do, and it was the reason a cobra pasted
into a chat could not become the site's mark.

Website content now has a Logo and mark section: choose a file, upload,
and it is on the site within a minute. "Use the drawn one" puts the
drawing back. Nothing is destroyed until the replacement is safely
stored -- the old file is deleted only after the row points at the new
one.

The files live under storage_path, above the document root, exactly as
vehicle photographs do: this host rebuilds the web root from the
repository on every deploy, so a logo written inside it would last until
the next push. brand.php reads them back, since Apache cannot reach
above the root.

Validation is vehicle_photo_check(), the same function the car
photographs use, so there is one answer to "is this an image" rather than
two that can drift apart. It reads the file's own contents rather than
its name or the type the browser claims. Confirmed over a real multipart
POST: a PNG is accepted, a PHP script named snake.png and declared
image/png is refused.

brand.php answers only for names matching the shape it generates. Every
escape attempt returns 404 -- ../brand-secret.txt,
../../nitesha-config/config.php, the URL-encoded form,
blogo-../../../etc/passwd, a vehicle photograph's name, a well-formed
name for a file that does not exist, and an empty name.

The site reads them from the request it already makes for the wording,
rather than a second round trip for two short strings, and resolves them
against the panel rather than the page -- used as returned, the fleet
page would ask for /cars/brand.php and the logo would break on some
pages only. Checked in a browser: with nothing uploaded both stay drawn
and no request is made; with both uploaded they resolve to
/admin/brand.php and two requests go out.

Not verified: the database half. There is no MySQL here, so the new
table, the INSERT ... ON DUPLICATE KEY UPDATE and the audit entries are
unexercised. The migration parses to the one CREATE TABLE intended and
applies itself on the first panel action after this deploys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Upload the logo and the header mark from the panel
…ogle

Every page banner is now an upload slot alongside the logo and the mark:
eleven of them, named for the places the site already looks. Choose a
file in Website content and it is on that page within a minute. The site
resolves one lookup -- uploaded first, then a file committed under
public/photos -- so the panel and the repository are the same question
with one answer.

On the SEO side, three things, one of which was a mistake worth
recording.

Banner photographs were alt="" and hidden from assistive technology.
That is right for an ornament and wrong for these: it is a photograph of
the thing being hired, on a site that wants to be found for "self drive
car Nagercoil". An empty alt is an image Google cannot read and a screen
reader is told nothing about. Each page now says what its banner shows,
in words someone would actually search. They also carry width and height
so the browser reserves the space -- without them the heading jumps as
the banner arrives, which is unpleasant and a ranking signal Google
measures directly.

sitemap.xml gains image entries with captions, for the routes whose
banner exists. Google finds an <img> by crawling, but a sitemap is how a
picture reaches image search promptly, and for a rental business people
search for what a car looks like as often as for its price. Slots with
no file are left out: a sitemap full of 404s is worse than a short one.

And the mistake. I said the site had no structured data and added an
AutoRental block -- the grep that went looking covered src/ and scripts/
and not index.html, which has carried one for a while. The build then
put two AutoRental entities with different names on every page, which is
worse than having none: it asks Google to decide which of two businesses
this is. The prerender step now edits the block that exists, adding the
banner photographs and the logo as absolute URLs, and leaves it untouched
if it ever stops being valid JSON.

Uploads reach the sitemap too. They already reach visitors the moment
they are uploaded, because the browser asks the panel on every page load,
but a sitemap is written once at build time -- so fetch-content.mjs,
which already talks to the panel before every build, now writes down
which images were uploaded and prerender-seo merges them over the
committed ones, in the same order the site resolves them.

Checked by building against a panel that reports two uploads: the slots
land in brand.json, the /cars entry names its banner with its caption,
and the structured data lists the banner and the logo -- in exactly one
block per page.

Two things this does not fix. The names disagree: index.html says
"Nitesha Cars" and seo.json says "NiteSha Cars & Bikes", and one business
with two names is a thing local search notices. Changing which one is
correct is not mine to decide. And an image only enters the sitemap at
the next build, so an upload is visible to people immediately and to
image search at the next deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Upload page banners from the panel, and put the images in front of Google
…o edit it

People planning a trip to Kanyakumari search for what is there before they
search for a car. A page answering that question is worth having on its
own, and it is the only page on this site that gives someone a reason to
arrive who was not already looking for a rental.

Three parts. The home page gets a short section -- three places and a way
through to the rest, rendering nothing at all when the list is empty,
because a heading that says "where people go" above white space is worse
than no heading. /places is the full list, grouped by the categories the
panel sets rather than a list fixed in code. And Places to visit in the
panel adds, edits, reorders, hides and deletes them, each with a
photograph and a map link.

Content rather than code, deliberately. The glass bridge is the case that
makes the point: it opened recently, and a hardcoded list would have
missed it until somebody noticed and asked.

Seeded with nineteen places so the page is useful the moment it exists --
the coast, the temples, the forts, the waterfalls and the quieter beaches
-- every one editable and deletable like any other row. Their map links
are plain Google Maps search URLs built from the name, which keep working
when a place is renamed; the long share links with session parameters in
them do not.

The map link is validated as http or https with a host, and again on the
way out in the browser. A link field that accepts anything is stored XSS
the first time somebody pastes a javascript: URL into it, and the person
pasting need not be hostile, only careless with something they copied.
Checked: javascript:, data:, //evil.com and ftp:// are all refused, and
an empty field stays allowed.

Photographs follow the pattern the rest of the panel uses -- stored above
the document root where a deploy cannot erase them, validated by the same
function the car photographs use, and read back through a script that
only answers for names of the shape it generates.

Driven in a browser against a stub panel: three cards and a "View all 5
places" button on the home page, five cards in four groups on /places,
every photograph loading, Directions opening Google Maps in a new tab
with rel="noopener noreferrer", and no page errors.

That test first showed three broken images and looked like a bug in the
photo URLs. The URLs were fine -- the 2x2 PNG I had been using as a
fixture all day is corrupt, and PIL refuses it too. It passes PHP's
getimagesize because that reads the header and does not decode, which is
worth knowing about the upload check: a file with a valid PNG header and
corrupt data will be accepted and then not display. The preview in the
form shows that immediately, so it is visible rather than silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Places to visit in Kanyakumari: a page, a home section, and a panel to edit it
The site called itself two things. index.html said "Nitesha Cars" in its
title, its og:site_name and its structured data; seo.json said "NiteSha
Cars & Bikes"; two page titles used a third, shorter form; and the alt
text wrote it out as "NiteSha Cars and Bikes". For local search that is
weaker than any one of them would have been on its own -- the name is one
of the things Google matches a business against, and a business with four
spellings matches less well than a business with one.

Fixed at the source rather than by hand, so it cannot drift again. The
prerender step now writes og:site_name and the structured data's name
from seo.json, which is the file that already holds the canonical
details. Whatever the template says about the name stops mattering.

The literals went too: index.html, the About and Contact pages, the two
odd titles, and the panel's own pages, which had both spellings between
them.

Checked in a browser rather than in the markup, because &amp; is correct
in an attribute and wrong if it reaches the page as text: the tab reads
"Self Drive Cars in Nagercoil — NiteSha Cars & Bikes", og:site_name
reads "NiteSha Cars & Bikes", and so does the wordmark in the header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
One business name: NiteSha Cars &amp; Bikes
… can hit

The panel's page furniture lived inside dashboard.php, and its card styles
lived inside a <style> block in content.php. places.php used the same class
names as content.php and loaded none of them, so it rendered with no
container, no card and no max width -- every field stretched the full width
of the monitor, which is the misalignment in the screenshot.

The fix is one shell rather than three copies of one:

* src/shell.php renders the frame for dashboard.php, content.php and
  places.php -- the same navigation, bar and widths on all three. places.php
  had no header at all before this; now it has the same one.

* The row of pills above the page becomes a column beside it. On a phone that
  row showed two of its six destinations and the other four were reachable
  only by dragging a strip that gave no sign it could be dragged. The column
  folds into a drawer under 1000px, opened from the bar, and all eight
  destinations are 44px tall and hittable.

* Every section is a card, and on content.php the cards fold. That page was a
  single scroll about 14,800px long with no way to find a heading in it; shut,
  it is under 2,000 on a desktop and 1,140 on a phone.

* A tick box was getting width:100% and a text field's padding from the
  .field-group rule, which is why "Shown on the site" had its box floating
  away from its word. Tick boxes and radios are now excluded, and the label
  wrapping one is a 44px row that is itself the target.

* Buttons were between 31 and 38px tall -- "Delete" on a place was 31. Under
  700px, or on any device without a pointer, they are 44.

On the public site the footer's nine page links were 16px lines with 8px
between them, and the menu toggle was 42px. Both are 44 now. Links inside a
sentence are deliberately left the size of their words: padding one would
open a gap in the line it sits in.

Verified in Chromium at 320, 390, 768 and 1280px: no horizontal scroll on any
admin or public page, no control under 40px, the drawer opens and every entry
in it is hittable, and the dashboard's six panels still switch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
admin: one shell for every page, cards that fold, and targets a thumb can hit
Three things the card was getting wrong.

**The daily price is a band.** A car goes out at 1,600 midweek and 1,800 in
season, and naming one figure either undersells it or surprises the customer.
vehicle_rates gains rate_daily_max, and the card prints "₹1,600 – ₹1,800 / day"
when it is set and one figure when it is not.

rate_daily keeps its meaning exactly: it is what a booking is charged at,
copied into booking_charges and multiplied by the number of days. A range
cannot be multiplied, so the upper figure is a second, display-only column
rather than a widening of the first. Nothing in the billing path reads it.

The panel gets a "Daily, up to" box beside "Daily", with a note saying what
leaving it empty does. An upper rate below the daily rate is refused -- entered
the wrong way round it would print on the site exactly as typed.

**"₹38,000 / day on monthly hire" now reads "₹38,000 on monthly hire".** The
field is documented as a per-day rate for month-long hires, but nobody enters
it that way, and the card was advertising thirty-eight thousand rupees a day.

**The interpuncts between the KM line's three facts are gold rules**, matching
the rule under the logo bar. They are aria-hidden: a divider read aloud as a
character is noise, and the three facts are separate elements, which is what
carries the grouping.

Both new columns are named in SQL only when they exist -- the same guard
public-vehicles.php already had for vehicles.photo_file, after selecting that
one unconditionally emptied the fleet on the live site.

Also fixes the check that decides whether a save writes a new rate row. It
trimmed trailing zeros off both sides as strings, and the sides are not the
same shape: MySQL returns "1800.00" and trims to "1800", the validator returns
1800 and trims to "18". Every rate ending in a zero looked changed on every
save and wrote a redundant rate row -- the one thing the check exists to
prevent. Now compared as numbers, to half a paisa.

Verified: the card renders "₹1,600 – ₹1,800 / day", "₹38,000 on monthly hire"
and gold rules, and a car with no upper rate still shows one price; the rate
INSERT builds valid SQL with matching placeholder and parameter counts both
with the column and without; the comparison was tested against nine cases
covering both shapes, nulls and a tenfold change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
**"Browse the fleet" and "Check availability" did nothing.** They pointed at
#fleet and #enquire, and neither section is on the home page -- the fleet is on
/cars and the enquiry form is on /contact. Clicking either was a no-op.

They now point at /cars and /contact#enquire. Because the destination is typed
into the panel, the element has to match what was typed: ContentLink picks a
router Link for a path, a plain anchor for a #hash on the page already open,
and a new tab for http/tel/mailto. Rendering everything as <a href> was what
let a route be entered that could never work.

A #hash is only honoured by the browser on a real page load, so a link across
pages used to land at the top of the destination. ScrollToTop now scrolls to
the target on the frame after the new page paints, and anchor targets carry a
scroll-margin so they clear the sticky header instead of hiding behind it.

**The snake was showing on phones.** `.snake-mark` set `display: block`, and
Tailwind's utilities sit in a cascade layer that unlayered CSS outranks -- so
it beat the `hidden` on the element. The header's own comment says the snake is
held back until 640px because "a fourth thing pushes the business name onto two
lines"; that is exactly what was happening, and at 320px it also pushed the
menu button off the right edge. The rule keeps only object-fit; the element's
classes decide whether it is shown.

**Labels no longer wrap.** "NiteSha Cars & Bikes" was breaking after "Cars",
which reads as two businesses. In the panel, "+ Add Deposit" and "Mark
Completed" were two lines tall, which makes a button look like a paragraph --
.btn is now nowrap, so it is the row that wraps and never the words.

**Alignment.** A section heading and its buttons shared one row with
space-between, so "Customer & Rental Details" kept shrinking to leave room and
came out three words tall beside a staggered column of buttons. The heading now
takes its own line and the buttons line up two to a row below it, with an odd
one at the end spanning both columns. The same for the fold headings on Website
content: name and chevron across the top, count underneath.

Modal actions are side by side again rather than stacked. Two 44px buttons fit
across a 320px phone, and stacking put Cancel directly under the primary action
where a thumb reaching for one finds the other.

Verified in Chromium at 320, 390, 430, 768 and 1280px: both hero buttons reach
their pages and the enquiry form lands 70px clear of the header; the business
name is one line at every width and nothing overflows 320px on any public or
admin page; every button label measures one line; the modal's two actions share
a row at all three widths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
A daily price band, working hero buttons, and labels that stay on one line
claude and others added 30 commits October 5, 2026 09:37
"Who can sign in" described what the page is for, which is what a tooltip
is for. In a sidebar beside Dashboard, Bookings, Vehicles and Reports it
was the only entry that was a sentence, and the one word everybody was
going to call it anyway was Users.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Six boxes and three paragraphs on every vehicle, for something most cars
do not have.

The posting goes with it rather than being left behind. It was driven by
the dashboard being opened, and with no form there is no way to describe a
loan, change one, or close one -- so a car already carrying a loan row
would have gone on writing an expense every month with nothing anywhere
to stop it. A thing that writes to the books and cannot be seen is worse
than a form nobody wanted.

What stays: the vehicle_loans table and the loan_id on expenses. Any EMI
already written is real money that was really paid, and the row it points
at is the only explanation of it. EMI also stays an expense category, so
it can still be entered by hand like any other cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Take the Loan / EMI block out of the vehicle form
The home page listed twelve town names as twelve equal chips. What people
actually ask for is narrower and more specific than a town: the railway
station, the town station, the bus stand. "Self drive car at Nagercoil
railway station" is a different search from "car rental Nagercoil", and
the page said only the second.

So the ten places we hand vehicles over at are named, with the kind of
place each one is drawn beside it, and grouped under the town they sit in.
Flat, ten chips would read as ten towns and overstate how far apart they
are; under a heading each, the three Nagercoil spots read as what they are
-- three doors into the same town.

Every chip still leads somewhere: the town page it belongs to, or the list
of towns for the two we do not have a page for yet.

The twelve towns stay. They are the service area, they feed their own
pages and the sitemap, and dropping them off the home page would quietly
cut the one internal link each of those pages has from it -- so they are
underneath, smaller, in a line rather than a row of chips.

Parvathipuram and Muttom are named on the page, so they go into
areaServed as well. A page that claims a place to the reader and not to
Google is the same page disagreeing with itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The stops are a route now rather than three headings with chips under them:
a gold line down the left, a point on it per town, and it draws itself
downward as the section arrives. A list of places is a list; a line
through them is what a delivery actually is, and it gives the eye an order
to read ten names in rather than ten things landing at once.

Each stop lands with one ring outward, its chips follow in sequence, and
each chip's glyph draws itself in the same stroke the about-page marks
use. One ring, not a pulse: three things blinking on a page somebody is
trying to read an address off is not attractive, it is a reason to scroll
past.

The line is built per stop rather than as one rule behind all three, so
each segment is released by its own stop's reveal and the route draws in
step with the reading. No second observer, and nothing animating height:
it is transform and opacity throughout, which the compositor does without
touching layout.

One thing worth remembering. The chips' entrance had to move onto the list
item around each chip: an entrance animation carries fill-mode: both, so
it goes on owning every property it names for good -- and a transform
animated on the chip would have silently beaten the hover lift, leaving a
chip that never moves under the pointer and no error anywhere to say why.

Nobody who has asked for less motion gets any of this, and with scripting
off every chip is visible from the start, as the rest of the file already
arranges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The section was words on white beside a drawing. It is now the thing it
describes: a dark band with the photograph from the panel behind it, the
route of stops down the left, and a sketch of the cape on the right with
the towns pinned on it.

Dark because of the photograph rather than for its own sake. On white, a
coast at sunrise has to be faded to about a fifth before type will sit on
it, by which point it is a grey smear that cost sixty kilobytes. On navy
it runs at full strength on the side with no words over it, and a gradient
does the separating. It also has to read with no photograph at all, since
that is the state a panel slot is in until somebody fills it -- so the
dark is the brand's own navy and the picture is an improvement on it, not
a requirement of it.

The map is drawn rather than fetched: a provider's tile is a request, a
licence, an attribution line and a grey rectangle while it loads, and it
invites somebody to read a service boundary off it that we have not drawn.
What is not sketched is where the towns are. Every pin is placed from its
real latitude and longitude, because somebody deciding whether we come to
them reads distance off this.

Two things that took a second attempt. The coastline was first smoothed
through the midpoint of every edge, which rounds an outline evenly and
turns a cape into an egg -- the tip at Kanyakumari is the one corner that
has to survive, so the corners are cut back individually now and the runs
between them stay straight. And the pin for Kanyakumari sat in the sea:
the town is on the tip, the rounding pulls the drawn edge inside the
corner, and the test that puts every pin through isPointInFill is what
said so rather than anybody's eye.

Five pins, not ten: Parvathipuram is inside Nagercoil, and the stations
and the bus stand are inside their towns. At this size they would be one
smudge of overlapping labels, and the list beside the map names all ten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Thirty-nine of the site's forty-four pages now carry the phrase in the
title, the heading or both, beside the place they are about. Most already
said it somewhere; what they did not do was say it consistently or near
the front, and half the town pages buried it after the place name as
"— Self Drive Hire", which is the part of a title a search result cuts
off.

Five pages are deliberately left alone. Wedding cars and tourist vehicles
come with a driver -- the wedding page's own FAQ answers "Does the wedding
car come with a driver?" with "Wedding hire is with a driver" -- so the
phrase there would be a claim the business does not make and the page
itself contradicts two paragraphs later.

The pickup points on the home page say it in sentences rather than in the
chips. "Self drive car at Nagercoil Railway Station, at Nagercoil Town
Railway Station and at Vadasery Bus Stand" is the phrase somebody types,
and it now exists on the page in that order -- but ten chips each opening
with the same four words is those words forty times in one glance, which
is the pattern search engines have discounted for a decade and the one
that makes a page read as written for a machine.

Bikes say self drive bike rather than self drive car, for the same reason
the wedding pages say neither.

Nothing here is panel copy. Titles, the town data and the service-area
headings are the site's own files, so these actually take effect; the
heading on the home page's own band is edited in Website content and is
the business's to change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Two things.

The line of twelve town names under the pickup points is gone. They are
one step further away now rather than unreachable: "See every town we
deliver to" goes to /car-rental, which lists all twelve and links each.

And the site says rental where it said hire. Nought occurrences left in
the built pages, down from a hundred and twenty-nine. British English
calls it car hire; nobody in Tamil Nadu searching for one types that.

Not a find-and-replace, because "hire" is a noun here as often as a verb
and the two take different words: a rental, but to rent. A mechanical pass
produced "everyone who rentals a vehicle", "People rental here to reach
the temple", and "Rental a Maruti Swift without a driver" -- all of which
were caught by searching the result for "rental" sitting in a verb slot,
rather than by reading four hundred strings again. The ordered rules did
the bulk; eleven verb forms were then put right by hand, and a second
sweep with wider nets found nothing else.

Headings move with it: "What we hire in Nagercoil" is "Rental cars in
Nagercoil", "Hiring in Marthandam" is "Rental cars in Marthandam", and the
two band headings on the home page follow.

Those two are panel copy. The baked HTML carries the new wording, but if
the panel holds a saved version of either section it will win a moment
after the page loads -- so they want changing in Website content too, and
that is the business's to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Three cards in a three-wide grid meant the home page named three of the
district's places and stopped. The rest were behind a link, and a link is
a decision somebody has to make before they have seen anything worth
deciding about. All twelve go past now, four at a time, and a card leaving
the frame is what says there are more.

Four rather than three because these cards are a picture, a category and a
sentence: narrower than the service cards and perfectly readable at a
quarter of the column, where three left each one wider than it had
anything to fill with. The width is worked out from the column rather than
set in rem, so four cards and their three gaps come to exactly the width
the rest of the page uses -- a fixed width that nearly fits leaves a sliver
of a fifth card, which reads as a fault rather than as there being more.

The crawl moves out of Services.tsx into lib/useRailCrawl and both rails
use it. A hundred lines of scroll arithmetic copied into a second file is
a hundred lines that get fixed in one of them.

One thing found on the way. The rail reported itself as several thousand
pixels wide to document.scrollWidth -- which is the number every "is this
page wider than the screen" check reads, Lighthouse's included -- even
though the page could not be panned sideways and never could. Saying
contain: paint, which is only what was already true of a scroller, settles
it. Pixel for pixel the rails render identically with it and without, so
nothing that is drawn has changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Say "self drive car" where a place is named
The names did not start at the same height across a row, and the cards
were five hundred pixels tall -- four of them filled a laptop screen on
their own.

Both come from the same thing: every part of the card was sized by what
happened to be in it. The picture took its height from its own proportions
and the name took one line or two depending on the name, so each card set
its own heights and the row read as four separate things rather than as a
row.

The picture is a fixed height now, the name has two lines of room whether
it needs them or not, and the sentence has three. Every heading in a row
starts on the same line, every Directions button ends on one, and the card
is three hundred and seventy-seven pixels rather than five hundred and
seven.

Checked with photographs of three different shapes standing in for the
panel's, which is the condition the headings went out of step in, on the
home rail and on the places page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Both rails started their animation frame on mount and kept it for as long
as the tab was open. On the home page both are below the fold, so the
first thing they did was animate something nobody could see, on the one
thread that was also hydrating the page -- and then carry on doing it for
the rest of the visit.

They wait to be on screen now, with a little margin so a rail is already
moving when it comes into view rather than visibly starting under
somebody's eye, and they stop again when it leaves.

What it bought, measured on a throttled phone: the home page's LCP went
2176ms to 2040ms. Blocking time did not move -- 486ms to 477ms -- which
says plainly that the blocking is React hydrating sixteen hundred nodes
rather than anything the rails do. Worth saying rather than claiming the
win: the real gain is a phone that stops running two animation loops for
sections nobody is looking at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Each rail rendered its list twice so the crawl had somewhere to wrap. The
wrap only ever needs the cards that fit on screen at the moment it
happens -- four or five -- and every card beyond that is laid out and
styled for nobody. The places rail was carrying twelve such cards.

So the repeat is five cards now, and the crawl wraps where the repeat
begins rather than at the halfway mark. The home page is 1433 elements
rather than 1564.

What this does not do is show up in the blocking time. The honest figure:
on this machine the measurement varies by about a hundred milliseconds
between identical runs, and a hundred and thirty fewer elements is worth
perhaps thirty of them by the slope measured elsewhere in this work. It is
below the noise. What can be said is that it is strictly less work for
every phone that loads the page, it is verified not to change what is
drawn, and the loop still wraps seamlessly -- the repeat is wider than the
frame at both sizes, which is the condition that matters, and the tests
check that rather than the old "rendered twice".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
One page, rewritten: the town page for the town the business is in, which
is the page with the most to gain and the one whose words were furthest
from the words anybody types. It described Nagercoil well and never once
said "self drive car rental in Nagercoil".

It does now, and so do the things that make this town different from the
other eleven: the junction, the Town station, the Vadasery bus stand,
Parvathipuram. Those are where the handovers actually happen, they are
what somebody arriving by train or bus is searching for, and they were on
the home page's list and nowhere on the page about the town itself.

Written as sentences, not as a list of terms. "Self drive" lands eight
times in five hundred and seventy words, under three per cent, and every
one of them is in a sentence that would still be there if nobody searched
for it.

Nothing new is claimed on the business's behalf. Delivery to the station
and the bus stand, the same day, often within the hour, the licence and
the photo ID: all of it is already said elsewhere on this site. The two
distances added to the drives are the ones this same file already states
for those places, so the page cannot contradict the one next to it.

Title and description are inside what a search result shows, and no other
page on the site now shares either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Write the Nagercoil page in the words people search
Same treatment as Nagercoil, eleven times. Each page now leads with "self
drive car rental in" its own town, says it once more in the prose, and
carries a description that names the one thing somebody choosing that town
would want: the hotel delivery in Kanyakumari, the airport run from
Marthandam, the palace at Thuckalay and Padmanabhapuram, the border at
Kuzhithurai.

Surgical, not rewritten. The existing copy was written by somebody who
knows these roads -- the fishing traffic at six in Colachel, the wind
through the gap north of Boothapandi, the palace shutting for lunch -- and
none of that is worth losing for a keyword. So each page keeps every
sentence it had and gains one, appended to the paragraph it belongs with.

Density is two to two point two per cent across the eleven, against two
point eight on Nagercoil, which has more to say about pickup points. Every
page has the exact phrase at least once; none of them reads as a page
written for a machine.

Eleven titles, eleven distinct: a page that differs from its neighbour by
one word is a page Google has to choose between, and it does that by
ignoring eleven of them.

Half the "Known for ___" lines did not finish that sentence -- "Known for a
fishing harbour", "Known for the town on the Pazhayar". Fixed while the
file was open, since it is the same sentence the words went into.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The other eleven town pages, in the same words
Same treatment again, and the same restraint: these pages know their cars
-- the Swift's turning circle in the streets round the Nagaraja temple,
the Rumion's third row being a real seat and the smallest one, the
WagonR's doors. None of that moved.

Each page gains a sentence naming the car, the words "self drive" and the
place, because that is the search and five of the six never quite said it.
Two of them mentioned Nagercoil once in seven hundred words.

And each gains a question. The FAQ on these pages is rendered and also
emitted as FAQPage structured data, so a question somebody actually types
-- can I get a self drive Swift in Nagercoil the same day, where can I
rent an automatic, can I take an Innova to Thiruvananthapuram airport --
earns its place twice over. Every answer is something this site already
states: delivery across the district, the licence and the photo ID, the
KM limit agreed first, the day's notice further out, the Kerala paperwork.

Density goes from between nought point nine and one point five per cent
to between one point three and one point eight. Not two, and not pushed
to two: the Rumion and the WagonR answer in terms of Kanyakumari rather
than Nagercoil because that is where those two are wanted, and inflating
a number is not a reason to write a sentence.

Three of the six sentences were rewritten before committing -- one
repeated the line above it word for word, one said "automatic" three times
in a breath, one referred to "this page", which is not how anybody speaks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The six car pages, and the question each one is asked
The towns and the car models now each say their own buying phrase in
their own body copy. These eight did not. /tariff listed rates without
once calling them self drive car rental rates; /contact invited an
enquiry without naming what the enquiry would be about; the two bike
pages argued for a two-wheeler and left the words "bike rental in
Nagercoil" to the title tag alone.

A title is a claim about a page; the body is where a reader and a
crawler both check it. One sentence per page, woven into the intro
rather than appended to it, each claiming only what the rest of the
site already states:

  /cars      hatchbacks, sedans, SUVs and 7 seaters *for self drive car
             rental in Nagercoil and across Kanyakumari district*
  /tariff    "These are our self drive car rental rates, per day."
  /about     "NiteSha Cars & Bikes is a self drive car rental in
             Nagercoil, and we rent two-wheelers, wedding cars and
             tourist vehicles across Kanyakumari district as well."
  /contact   the form is for self drive car rental in Nagercoil, or a
             vehicle anywhere else in the district
  /services  "Self drive car rental is the bulk of it"
  /nri       "Arrange self drive car rental, or a bike, before you land."
  /bikes/*   bike rental in Nagercoil is the sensible choice rather than
             the cheap one; bike rental in Kanyakumari suits the town
             better than a car

Not touched, and deliberately: /wedding-cars and /tourist-vehicles are
driven services, and the site's own FAQ says so. Putting "self drive"
on either would contradict a page three clicks away. /monthly,
/monthly/nagercoil and /places already state theirs.

Two "hire"s had also crept back into the towns rewrite -- Marthandam's
"a car is most often hired for a month" and Colachel's "either end of a
hire". The site is back to zero of them in visible text.

Checked on the built output rather than the source: all eight state
their phrase in body text with <head> excluded, 43 titles and 43
descriptions still unique, none over 60 characters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The eight pages that never said what they rent
Two things, both about the questions band.

THE TARIFF PAGE ASKS ITS OWN

It rendered the panel's ten, six of which the home page already shows.
Six questions and six answers at two URLs, and in both pages' FAQPage
data -- of a duplicate pair Google shows one and the other earns
nothing.

So the tariff page now asks the six questions its own table raises, and
nothing else does:

  Is the weekly rate the price for the whole week, or per day?
  What does the daily rate cover, and what is added?
  Is the deposit part of the rent or on top of it?
  How is the extra-KM charge worked out?
  Why does a car say "On request" instead of a rate?
  Are these rates for the car on its own, without a driver?

Then the panel's, from the seventh -- the four the home page does not
show. Every question on this site is now answered at exactly one URL:
checked across all 43 pages, zero appear twice.

They live in src/data/tariff-faq.json rather than the panel because
they are about how the table is written, not about what is in it. The
figures change weekly; "the weekly column is a per-day rate" does not.
Every answer is something the site already states somewhere -- the
odometer written down at handover, fuel being the renter's, the deposit
coming back less what is owing.

The component grew two props to do it, `items` and `skip`, and
prerender-seo.mjs builds each page's structured data by the same rule
the component builds its list by. Verified on the built HTML: visible
questions and FAQPage entries match exactly, 6 and 6 on the home page,
10 and 10 on the tariff page.

THE BAND IS SHORTER

Padding, gaps and panel insets all came down a step; no type got
smaller and nothing was removed.

  phone   home 913 -> 815   tariff 1172 -> 1047
  desktop home 698 -> 594   tariff  811 ->  666

-11% on a phone, -15 to -18% on a desktop.

AND THE HOVER THAT WAS NEVER THERE

Measured on the way past: the panels' hover lift has never once
happened. The reveal animation fills forwards, so it goes on owning
`transform` after it ends, and an animation beats a transition. The
rule computed and nothing moved. Handing the property back after the
entrance costs nothing -- fade-up ends where the panel sits anyway --
and the lift works now: rest none, hover -2px, back to none. The
entrance still runs, 0 to 1 with its 18px rise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Six questions about the rates, and a shorter questions band
Three sweeps in a browser, not a reading of the stylesheet.

FIRST: does each :hover rule do anything?

Fifty hover rules are live in the built CSS. Each one was found an
element on one of sixteen pages, the element was scrolled still, a
point inside it that the pointer can actually reach was worked out,
and the properties the rule declares were read before, during and
after. A rule counts as dead only when nothing it declares moved and
the element went back to where it started -- a measurement that did
not return is a measurement that was never on.

  50 live rules, 33 measured working, 0 dead.

Three could not be reached by the sweep, all of them on the service
cards, which are inside the rail that crawls -- scroll a card into
view and the crawl has moved it before the pointer arrives. Those were
done by hand with the rail stopped: the card lifts 5px, the glow
behind it goes 0 to 1, and the illustration scales to 1.06. The same
rule on an uploaded photograph was checked by putting one in: also
1.06, so the owner's uploads will meet a hover that works.

SECOND: is an animation holding a property a hover rule wants?

This is the one that bit us last time. An animation beats a
transition, and one that fills forwards goes on owning its properties
after it ends, so the hover rule computes and nothing moves. Asked
statically now, of every element on every page: which properties do
its animations own, and does a hover rule that matches it declare one
of them.

  Seventeen pages, zero clashes.

The detector was checked against the bug it looks for rather than
trusted: put the old fill-mode back with an injected rule and it finds
all ten FAQ panels and the hover is dead again.

THIRD: is anything clickable with no hover at all?

The question from the other end -- every link and button on eighteen
pages hovered, and the ones where nothing changes anywhere inside
them reported. 700-odd elements. Three real ones, now fixed:

  The header logo. The one link on every page, and the only thing in
  the header that did not answer a pointer. It brightens now --
  brightness rather than a lift, because the mark is gold on navy and
  lighting it is what gold does.

  The three accordion rows on the home page. Full-width buttons with
  nothing under the pointer at all: the only sign they opened was a
  chevron, which looks like decoration until something moves. The row
  turns gold-deep now, as one thing.

  "About our wedding cars" on the NRI page. No transition, no hover,
  and an arrow that sat still while every other arrow on the site
  slides. It slides.

What is left silent is deliberate and was checked: the nav link for
the page you are already on, and the selected filter chip on /cars.
Both are already in their "on" state.

ALSO

.card-lift.on-navy is gone. Eighteen lines of CSS for a hover on a
class no component has ever put on an element -- written for a navy
band that no longer carries these cards.

The one thing not changed: three gold CTA buttons -- "Check
availability in X" on the town, model and service-area pages -- have a
colour hover but their arrow does not slide, where most arrows on the
site do. All three agree with each other, so that is a style, not a
fault. Easy to change if it should be the other way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every hover on the site, tried rather than read

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants