Skip to content

MAIN - #17280

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

MAIN#17280
niteeshkanna-sh wants to merge 366 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!

claude and others added 29 commits September 17, 2026 05:41
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
…o floating buttons

**The footer column headed "Pages" was nine entries of mixed kinds** -- six
services, a place to visit, an in-page anchor and an enquiry link. It is headed
Services now and names five, with a "View all services" button to a new
/services page carrying all of them. Places to visit and Enquire move to Get in
touch, where they belong.

The five come from the same list the home page reads, so adding a service in
the panel puts it on the home page, on /services and in the footer at once
rather than in three places that drift. The button only appears when there are
more than five to see.

/services is a route, prerendered, in the sitemap, and first in the header's
Services dropdown -- otherwise the page would exist only as a footer button. It
reuses the home page's grid with its heading suppressed, because a heading
repeating the banner directly above it reads as a rendering fault.

**Social icons in the footer, with the links set in the panel.** Website
content gains a Social media section with a named box per network. Named boxes
rather than a list of rows: the icon has to match the link, and a free-text
"network" field would let someone type "insta" and get no icon with nothing
explaining why. An empty box means that icon is not shown at all -- an icon
linking nowhere is worse than no icon -- so only WhatsApp appears until the
rest are filled in. Each icon carries the network as its accessible name; six
identical links announced as "link" is a row nobody can use.

**Two buttons that follow you down the page**: WhatsApp, and back to the top.
Back to the top only fades in past 600px, because a button that scrolls to
where you already are teaches people to ignore it, and it honours
prefers-reduced-motion rather than throwing the page at anyone who asked their
system for less of that.

Also: the footer's phone number is derived from seo.json rather than typed out
a second time, so the displayed number and the one tel: dials cannot end up
different. And a services-hero image slot, so the new page's banner can be
uploaded like every other.

Verified in Chromium at 320, 390 and 1280px: the footer shows Get in touch,
Services and Follow us; the Services column lists exactly five with the
view-all button; all six icons render and carry their network names when the
links are filled; /services lists all six services; both floating buttons are
present and sized 48 and 56px; and nothing overflows 320px on any page,
/services included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Footer: five services and a way to the rest, plus social icons and two floating buttons
It sat beside "+ Add Vehicle" and opened /fleet-check.html. Taken out at the
owner's request, along with .panel-header-link, which nothing else used.

The page itself stays. It is a connection test rather than a feature of the
panel, and the browser console still points at it when the site cannot reach
the panel -- which is the moment it is actually wanted, and a moment when the
panel may not be loading either.

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

**object-cover in a 44px circle.** A real logo is whatever shape it is, usually
square or wide, and usually carries the business name inside it. Cropped to a
circle at 44px, the name was cut off and what survived was past reading. The
drawn badge is a circle because it was designed as one; an uploaded logo now
gets its own frame and object-contain, so the whole mark shows.

**Its own background covered the white plate.** A logo with a dark background
sat edge to edge in the badge, so the white behind it never showed and the
result was a dark mark on a dark header with nothing separating them. The plate
gains padding, and that margin is what makes the logo visible.

The height is capped with max-h rather than h-full. Inside a grid, a percentage
height resolves against a row the image itself sizes, so h-full came back as
the image's own 400px and overflowed the plate it was supposed to fit -- which
is the first thing this change got wrong.

The footer showed a letter N while an uploaded logo sat in the header; it uses
the logo too now, and falls back to the N when there is none.

Verified with a stand-in logo the shape of the real one -- a dark square with
the name inside: header plate 48px with the mark 36px inside it, footer 40 and
28, nothing overflowing at 320px, and the drawn badge still rendering when no
logo is uploaded. The stand-in is not committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
YouTube, X and LinkedIn are gone from the panel's form, from the shipped copy
and from the footer's icon list.

The icon list is what decides what renders, so a stored override still holding
the three old keys cannot put them back on the site -- mergeContent copies a
section across whole, extra keys included, and the component ignores anything
it does not have an icon for.

Verified: the panel's Social media section now offers Heading, WhatsApp,
Instagram and Facebook; the footer renders those three and no others; both
copies of content-defaults.json still match byte for byte.

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

**The drawn NS badge is gone**, from the header and from the footer's letter N.
Both were placeholders so the site never looked unfinished before real artwork
existed. Once there is a logo to upload, a placeholder that looks like a logo
is a second mark competing with the first. Upload one under Website content and
it appears; until then the name carries the header on its own.

**The banner scrim was hiding the photograph.** It was a flat 82-88% navy over
the whole band, which kept white type legible against anything and turned every
uploaded photo into a dark rectangle. It is weighted now rather than flat:
heavy on the left where the heading and intro sit, clearing across the right
where there is nothing to read. Below 1024px the text runs the full width, so
that breakpoint keeps even coverage, just lighter.

Measured against a deliberately extreme banner -- a near-white sky, the worst
case for white type -- by hiding the words and sampling the brightest pixel
under each text box:

                  heading        intro        right third
    before        12.79:1        14.93:1      0.036 luminance
    after          3.55:1         8.51:1      0.204 luminance

The heading is large text, which WCAG puts at 3:1; the intro is body text at
4.5:1. Both clear their thresholds with the picture roughly six times more
visible. On a phone: 5.62:1 and 6.00:1, band luminance 0.036 to 0.109.

**"Areas we serve panel" was an upload that went nowhere.** The panel offers
the slot, but AreasServed rendered SectionArt, which only ever looks in
public/photos -- so an uploaded picture was ignored and the drawing stayed.
It reads the upload now, and the upload wins over both the file and the
drawing. It also gets a real alt, since it is a photograph of the district
rather than an ornament.

Verified with stand-in images seeded into the brand map: the header shows no
badge with nothing uploaded, the footer shows no N, and the areas panel renders
the uploaded picture at 485x243 with its description. The stand-ins are not
committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Logo, banner scrim, the areas image, and a trimmed social section
…t has one

**Dates that are already taken now say so before they are picked.** A date box
takes min, max and step and nothing else -- there is no way to say "these
particular days are gone" and no way to colour one -- so the days are drawn
under it, gold for taken and struck through, disabled so a keyboard cannot
reach them either. Colour alone is not a message to someone who cannot see it.

The date boxes stay beside the grid. Whoever is on the phone to a customer
types a date faster than they page a calendar, and typing a taken day is caught
on the way out with the reason said rather than the box silently clearing.

A new public endpoint answers what is taken. It returns days and nothing else:
a date being spoken for is ordinary availability, the kind any hire company
publishes; who took it is not. It reads BLOCKING_STATUSES -- Confirmed, Ready
and Active -- so it, the site and the check that refuses a double booking on
save cannot disagree about what "taken" means. The save-time check still runs;
this is the same truth shown earlier, not a replacement.

The public form has no vehicle chosen at first, so it marks only the days on
which every vehicle is out -- one car being busy says nothing about whether we
can help. Pick a car and it narrows to that car's diary. In the panel it is the
selected vehicle throughout, minus the booking being edited, which would
otherwise block its own dates and refuse to let you keep them.

**A bin, everywhere something can be removed.** One drawing rather than the word
"Delete" in some places and nothing at all in others: a row of buttons is
scanned by shape long before it is read. Each one carries a title and an
aria-label, because an icon button with neither is announced as nothing.

  Vehicles     the existing Retire action, now an icon
  Places       the existing Delete, now an icon
  Enquiries    new -- there was no way to remove one at all
  Content      new -- a repeated item had to be emptied box by box

Deleting an enquiry is refused when it became a booking: bookings.enquiry_id
points at that row, so removing it would either be refused by the database or
leave a booking pointing at nothing. The error names the booking and says to
cancel that instead. Bookings and expenses keep Cancel and Void rather than
gaining a delete -- they are the record of money owed, and the audit trail is
the point of them.

The content bin empties the item's boxes, because a section is saved whole and
an item with nothing in it is dropped. That is exactly what the hint used to
ask people to do by hand. It is not gone until Save, so the row says so instead
of letting someone leave believing otherwise.

Also removes .transaction-delete, a rule no markup had used since before this
branch.

Verified against a stubbed availability answer: both calendars paint the right
days gold and every one of them is disabled; a vehicle's second range in the
following month correctly does not appear in this month's grid; the content bin
empties three filled fields, marks the row and says when it takes effect; the
places and vehicle bins carry their labels. No page overflows 320px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Booked days shown in gold and refused, and a bin on every section that has one
Fourteen places showed an image and only some of them could be changed without
a deploy. Two separate causes.

**SectionArt never asked the panel.** It looked in public/photos and nowhere
else, so the six cards under "What we hire", the three "How it works" steps and
the areas-we-serve panel all ignored anything uploaded. That is worse than
having no slot: someone uploads a photograph of their own fleet, the panel says
it saved, and the drawing stays. It asks useSiteImage now -- uploaded first,
then a file in the repository, then the drawing -- which also makes the
special case AreasServed carried for `coast` unnecessary.

**Three more were hardcoded paths**: the home page background, the two
photographs beside "Why hire from us", and the open road band. The first
picture a visitor sees was the one picture the owner could not change.

Website content gains thirteen slots: Home page background, the two Why-hire
photos, Open road band, the six cards named after the page each links to, and
the three steps. Nineteen images on the site, every one of them uploadable.

Slot names may now contain digits. They could not: the sanitiser stripped them
and the reader's pattern rejected them, so step-1, step-2 and step-3 would all
have been written as "step-" and then served as a 404.

The open road band stays a CSS background rather than becoming an <img>. If the
file is missing the band falls back to the navy beneath it and still looks
deliberate, where an <img> would leave a broken-image icon on a live page.

Verified by seeding all thirteen new slots: every one renders, no drawing is
left on the home page, the six cards carry their uploads on /services too, and
nothing overflows at 390px. The seeds are not committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every picture on the site is uploadable from the panel
**The grid was ragged because of a control nobody chose the width of.** Each
slot carried a bare <input type="file">, which prints "Choose File — No file
chosen" at whatever width the browser feels like. With nineteen of them on one
page no two rows agreed where anything sat.

The input is still there and still the thing that opens the file chooser; it is
just no longer the thing anyone looks at. Every card is now the same width,
its buttons pinned to the bottom so they line up across a row whatever the
label above them did.

**Every image can be framed before it is uploaded, and re-framed afterwards.**
Drag to move, a slider and a pair of buttons to zoom. Whatever is inside the
frame is what the site shows -- rather than the browser cropping a photograph
of some other proportion and nobody knowing which part survived until the page
loads.

The frame is the shape the site actually lays that slot out in, and each is
saved at a size to match:

  logo, snake          1:1     512x512
  the page banners     16:5    1600x500
  home hero, open road 16:9    1600x900
  the why-us photos    4:3     1200x900
  cards and steps      16:10   1200x750

One crop for all of them would be wrong in both directions: a logo squeezed
into a letterbox loses its top and bottom, a banner squared off loses its
sides.

A slot with an image gets Replace, Edit and Remove; an empty one gets Choose
image. Edit re-frames what is already stored, which works because the panel
serves it from this same origin -- a cross-origin source would taint the canvas
and toBlob would throw.

The framed result goes into the hidden input through a DataTransfer, so this
stays an ordinary form post rather than a fetch with its own error handling.
Remove sets the action on that same form: a form cannot contain another, and a
second one beside it would be a second cell in the grid.

Also fixes a rule of mine from the mobile pass. `input[type="file"] { width:
100% }` outranked the visually-hidden class and stretched every hidden input to
the full width, pushing a 390px page out to 447. It now excludes the one input
that is not a visible control.

Verified: 28 cards, all one width, no native file control drawn, three to a row
on a desktop and one on a phone, nothing over 320px. Choosing a file opens a
frame measuring 1.00 for the logo and 3.20 for a banner; the zoom buttons move
100 to 140; saving puts a webp in the form and posts brand-upload; Edit reopens
the stored image and posts a new one; Remove confirms by name and posts
brand-clear with no file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Dropped into public/photos as home-hero.webp, which is all it takes: the build
manifest picks up whatever is in that folder, and the hero already reads the
home-hero slot. An upload in the panel still wins over it, so this is the
default rather than a fixture.

Converted from PNG to WebP, 423 KB down to 54 KB. It is the largest thing above
the fold and is fetched at high priority, so the size is the difference between
the page painting once and painting twice.

Kept at its own 383x801 rather than upscaled in the file: the browser stretches
it either way, and the extra bytes would buy no detail.

Worth knowing what that means on a desktop. The picture is a portrait and the
hero band is wide, so at 1280px it is stretched 3.3x across and only a slice of
it is in frame -- it reads as a dark texture at 25% opacity rather than as a
photograph. On a phone it fits almost exactly and reads as intended. A wider
original, or the same shot framed landscape, would carry the desktop view too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Site images: a grid that lines up, a frame you can crop and zoom, and a hero photograph
**The right-hand end of the footer is now Find us**: the address, a map of it,
and a directions link. The social icons move up under the business name, which
is where a reader looks for both anyway and leaves the last column free for
the map.

**The map is built here from a place name, not from a URL typed into the
panel.** An <iframe src> is the one field where a pasted address is genuinely
dangerous -- whatever it points at renders inside our page. Encoding a search
term into a URL this code constructs means the panel can only ever move the
pin, never change what is embedded. Leave the box empty and there is no map
rather than a frame pointing nowhere.

It is lazy, and proven to be: nothing is fetched until the footer is scrolled
to. A map at the bottom of the page should cost nothing to anyone who never
reaches it.

**Website content gains a Footer section** -- the line under the business name,
the heading above the map, the address one line at a time, where the map should
point, and the directions link text. The blurb was written into the component,
so the one paragraph describing the business could only be changed by a deploy.

Verified: four columns on a desktop at 84, 370, 656 and 942px and stacked on a
phone, the map 252x160 and 348x160 with loading="lazy", the address three lines,
one directions link, the social icons in the first column, and nothing
overflowing 390px. With the network stood in for, the frame is not fetched
before the footer is scrolled to and paints once it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
"The images I added are not on the site" has two causes that look identical
from a browser and need opposite fixes. Either the panel has not stored the
picture -- nothing uploaded, or site_images never created on this database --
or it has, and the copy of the website published on the server was built
before that slot existed and has nowhere to put it. Nothing anywhere said
which, so the only way through was to guess and check.

Everything in this repository is on the far side of that question already: the
panel returns every slot it holds, and the built site asks for every slot it
is given. Verified by serving the published build against a stub panel -- all
sixteen pictures on the home page, and every one on the seven other pages,
come from the panel when the panel answers. So the remaining failure lives
between the two, and it is a publishing step rather than a fault.

The site check now names it:

- public-content.php reports imagesReady separately from the picture list. An
  empty list means "nobody has uploaded anything" or "the table does not
  exist", and those send someone to two different places.

- publish-site.mjs writes build.json -- when the build was published, the
  commit it came from, and the hashed bundle names. A server holding an older
  publish says so instead of looking current.

- fleet-check.html asks both, loads each stored picture to see that the file
  is really there, and searches the site's own bundle for each slot name. A
  picture stored for a slot the running code has never heard of is exactly a
  stale deploy, and it is invisible from every other angle: the panel is
  right, the file is there, and the page keeps showing the drawing.

Checked against six servers -- healthy, no table, nothing uploaded, a stale
deploy, a missing file, and no build.json -- and each one reports its own
cause.

The Pictures section in the panel also stops describing itself as the logo and
the mark. It has covered every picture on the site since the slots were added,
and a heading that says otherwise is why someone would not think to look there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
claude and others added 30 commits October 5, 2026 12:22
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
"Check availability in X" on the town, model and service-area pages
was the one arrow on the site that did not move. It was left that way
in the hover audit because all three agreed with each other, which
made it a style rather than a fault -- but every other arrow slides,
and three buttons agreeing is not a reason for the site to disagree
with itself.

The same two words the rest of them use: group on the link, and
transition group-hover:translate-x-1 on the arrow.

Measured on all three pages: the arrow goes from none to 4px and
back, and the gold still lightens from #D4AF37 to #F0D060 underneath
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Vite appends it, so on every page the one request that blocks the first
paint arrived after six favicon links, a theme colour, the viewport, a
title, a description, a canonical, nine og: and twitter: tags, a block
of JSON-LD, three font preloads, an inline script, the module script
and a modulepreload. The browser had no reason to ask for it until it
had read all of that.

It is now the third thing in the head, after charset and viewport --
the first has to be inside the opening bytes, and a stylesheet applied
before the viewport is known is a layout done twice.

Measured on a throttled phone (4x CPU, 150ms, 1.6 Mbps), median of
three, ten pages:

                                    FCP          LCP
  /                           1544 -> 840   2732 -> 2568
  /cars                       2272 -> 736   2272 -> 1808
  /tariff                      920 -> 712   2204 -> 1616
  /contact                     944 -> 704   2048 -> 1708
  /places                      932 -> 748   2372 -> 1744
  /services                   2172 -> 816   2384 -> 1716
  /car-rental/nagercoil       2164 -> 816   2164 -> 1820
  /cars/swift                  916 -> 764   1856 -> 1584
  /blog/km-limits...           872 -> 768   1916 -> 1760
  /nri                         924 -> 768   2036 -> 1692

  mean first paint 1366ms -> 767ms

Every page improved, and the slowest improved most. Nine of ten pages
now have an LCP under two seconds; before, none did.

HOW WE KNOW IT IS THE QUEUE AND NOT THE BYTES

Inlining the whole 73 KB stylesheet into the page scored the same
first paint as moving the link -- 980ms against 1000ms, over HTTP/2,
seven runs a side. If the bytes had been the problem, removing a
request would have beaten reordering one. It did not, so the fix that
costs nothing and keeps the file cacheable is the right one.

Measured over HTTP/2 and TLS as well as HTTP/1.1, because on 1.1 an
asset may open its own connection and pay a round trip for it, which
would have made any change to request count look better than it is.
The win holds on both: /cars 1908ms -> 1000ms on HTTP/2.

The home page's first paint does not move for this (1496ms either
way). Its paint waits on laying out 1433 nodes, not on the stylesheet
-- a profile puts 1778ms in the browser's own parse, style and layout
against about 600ms of JavaScript. That one is still the six service
cards' drawings; real photographs in those slots take it to 1028
nodes.

THE REST OF THE CHECK

43 pages read as served and opened in a browser: unique titles,
descriptions and canonicals throughout, none over 60 characters, one
h1 each, every image with alt text, no heading-level jumps, valid
JSON-LD, 0 broken internal links, sitemap and pages in exact
agreement. 0 console errors, 0 broken images, 0 links or buttons
without an accessible name, no horizontal overflow at 360px or
1366px, CLS 0 on every page measured.

The only request that fails is the Google Maps iframe in the footer,
which this container's proxy blocks; it is lazy-loaded and fine for a
real visitor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The stylesheet was the last thing the browser was told about
1433 elements on arrival, now 1010. Two things, neither of which
changes a pixel.

TWELVE COPIES OF THE SAME FOUR GRADIENTS

Every illustrated card defined its own sky, glow, ground and metal
gradient, with a useId suffix on each id so twelve copies of #metal on
one page would not collide. The suffix was the right answer to the
wrong question: the four are byte for byte identical in every
instance, so the collision worth avoiding was the one being prevented.
Sixteen nodes of <defs> times twelve instances is 192 nodes on the
home page -- an eighth of the document -- describing four gradients.

They are defined once now, in App, with fixed ids. Saves 175 nodes on
the home page, 95 on /cars and /services, and leaves every
single-drawing page where it was: the shared block costs 16 and each
instance saves 16, so a page with one drawing breaks even exactly.

The coast scene's sea and beam gradients stay inside the coast scene.
Only it draws them, and in the shared block all 43 pages would carry
seven nodes for a sea they never paint.

SCENERY RENDERED BEFORE THERE WAS ANYTHING TO SEE

Each rail renders the first five cards again after the list so the
crawl has somewhere to wrap. That repeat was in the served HTML of
every page with a rail, and in its first layout: 265 nodes of service
cards and 60 of place cards, a quarter of the home page, for a wrap
that cannot happen until the rail has been on screen and crawling for
the better part of a minute.

It arrives with the crawl now -- on the same IntersectionObserver that
starts it, 200px before the rail comes into view. Somebody who has
asked for less motion never gets it at all: their rail never moves, so
it never wraps. That is 248 nodes they were being charged for nothing.

WHAT IT BOUGHT

Home page, 390x780 at 4x CPU, nine runs, medians from the engine's own
counters rather than a long-task total, which swings too much to read:

  nodes (incl. text)   2104 -> 1601   -24%
  layout objects       1703 -> 1306   -23%
  style recalculation   233ms -> 182ms
  layout                422ms -> 388ms
  script                634ms -> 567ms
                       1289ms -> 1137ms total, -12%

Elements on arrival 1433 -> 1010, and 1258 once a reader has scrolled
past both rails. Seven-run page timings moved the same way -- LCP 2648
-> 2476, TBT 815 -> 679 -- though the spread on those is wide enough
that the counters above are the honest measure.

CHECKED

The drawings are unchanged on screen: the six service scenes and the
coast scene photographed after the change. The crawl still runs, still
wraps (loop 2040 against a 1270 frame, so the repeat still covers it),
CLS still 0, and the repeat is correctly absent under reduced motion.
43 pages crawled: 0 console errors, 0 broken images, no overflow, and
the site audit unchanged -- unique titles and descriptions, 0 broken
links, sitemap exact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
A quarter of the home page was four gradients and some scenery
"admin.niteshacars.in says" is how a browser introduces an alert, and it
is how a malicious website introduces itself too. The panel was still
using alert(), confirm() and prompt() for 48 of the things it has to
say: pinned to the top of the window, drawn in the browser's furniture,
and announced by the domain name. For a panel that handles a business's
money that is the wrong voice, and it was never centred.

There was already one proper dialog here -- the one that asks before
something is deleted, with the list of what goes with it. That was the
right shape. dialog.js is that dialog generalised, and the old one is
now the same code:

  nsDialog.tell(...)     a message, one button
  nsDialog.confirm(...)  a question, two
  nsDialog.ask(...)      a question with a box to type in
  nsDialog.choose(...)   a question with a list to pick from

All four are centred, in the panel's card, with the heading, the
explanation and the red button where the rest of the panel puts them.
Escape closes, Enter submits, Tab stays inside, focus goes back where
it came from when it closes, and two questions in a row queue rather
than overwrite each other.

WHAT THE CALLERS GAINED, BEYOND LOOKS

A prompt() can only ask for a line of text, so several flows were
shaped around that limit and are better without it:

  Retiring a vehicle was a confirm and then a prompt -- the same
  decision answered twice. It is one dialog with a reason box.

  "Which document?" printed the list of kinds and asked you to type
  one back. It is a dropdown.

  "Was this the customer's decision? OK -- the customer cancelled.
  Cancel -- we cancelled it." is two named buttons now, because a
  confirm only has OK and Cancel and the message had to explain which
  was which.

  A reason that is required is checked in the dialog, under the box,
  with what you typed still there. It used to be an alert that threw
  the answer away and sent you back to the start.

The two inline onsubmit="return confirm(...)" handlers in PHP could not
wait for a promise, so they are data-confirm attributes that dialog.js
catches: the first submit is stopped, the question asked, and the form
sent again from the answer -- once, and it asks again next time.

ON A PHONE

The panel's forms rise from the bottom of the screen, which is where
the thumb is, and that stays. A question does not: it is a few lines
and two buttons, and the middle of the screen is where it reads as the
thing being asked. Measured at 390x780: 312px of space above and below.

CHECKED, IN A REAL PANEL

Against a local copy of the panel with its database, not by reading:

  test-ui.js          11 passed    test-nav-ui.js      17 passed
  test-booking-ui.js  45 passed    test-brand-ui.js    13 passed
  test-enquiry-ui.js  27 passed    test-finance-ui.js  30 passed

The suites drove the browser's dialogs through page.on('dialog'); they
drive the panel's through its buttons now, which is a truer test --
they press what a person presses. test-records-ui.js has 4 failures,
all of them about how much seed data is in this scratch database; it
fails the same way on the code before this change, where it then
crashed outright.

Every path was also walked by hand: each kind of dialog centred to the
pixel on a desktop and a phone, the delete dialog unchanged from what
it was, the places form and the content reset button asking before they
submit and not submitting when answered no, and a row cleared only when
the question is answered yes. No browser dialog appears anywhere in the
panel any more, and the suites now report one as a failure if it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The panel asks in its own words now, not the browser's
Every form in the panel closed on a click anywhere outside its card. On
the booking form that is twenty fields and several minutes of typing,
and it went with nothing asked and nothing kept. The owner reported
doing it two or three times in a row on one booking.

Three separate ways in, all closed:

  A press has to start AND end on the grey. Selecting text in a field
  and releasing outside the card was being counted as a click on the
  backdrop, which is the version of this that happens while you are
  still writing.

  A form with something typed in it asks first -- "Leave this form?
  What you have filled in is lost", Keep writing against Discard it,
  with Keep writing holding the focus so the reflex press keeps the
  work. The form is still behind the question, so answering it leaves
  every field exactly as it was.

  Escape did the same thing as the click and now goes through the same
  question.

A form nobody has touched still closes straight away. Opening the wrong
one and clicking away is not worth a question, and asking there would
teach people to dismiss the question without reading it -- which is
what makes it useless on the day it matters.

Whether anything was typed is the form's values against the ones it
opened with, snapshotted when the modal is shown rather than at the
nine places that show one -- the tenth is the one somebody adds later
without knowing this exists. A form that pre-fills itself from an
enquiry is therefore not "edited" before it has been touched.

Nine modals: the booking, vehicle, payment, deposit, refund, service,
pickup and return forms, and the reminder form in the panel shell. The
expense form never had the click-away behaviour and is unchanged.

CHECKED IN A REAL PANEL

  untouched form closes on a click away      yes, nothing asked
  typed-in form asks instead of closing      yes
  "Keep writing" leaves the form open        name and phone both intact
  Escape asks the same way                   yes
  a drag out of a field does not close it    yes, nothing asked
  "Discard it" closes it                     yes
  reopening starts clean                     yes
  the reminder form behaves the same         yes, text kept

And the six UI suites still pass in full: 143 assertions across
bookings, enquiries, finance, brand, navigation and the main suite --
all of which open, fill and cancel these same modals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
A click an inch wide of the form no longer empties it
EDIT, EVERYWHERE, WITHOUT OVERWRITING ANYTHING

A payment entered as 8,000 when 3,000 was taken could be voided and
typed in again, and that was the whole of it: no other section of a
booking could be changed at all. Every section has its own Edit now --
payments, the charges added later, the deposit, each refund, the pickup
and return records, damage, the customer's documents, and the three
cards on Overview.

None of them overwrites a figure. What each one does depends on what
the row is:

  a payment or an expense gets an adjustment row beside it, which is
  what those two endpoints have always done

  a deposit, a refund, a charge or an odometer reading is superseded --
  the wrong row is marked with the reason it was wrong and the right
  one cites it, the way a corrected reading already worked

  a damage estimate and a document's details are changed in place.
  Neither is money and no total is summed from either, so a second row
  describing the same dent would be a worse record than one line that
  says what the dent is. The before and after are in the audit log.

They all ask the same way, in a dialog form that is new here: what it
is now, what it should be, and why. Asking an amount, a date, a method
and a reason as four questions one after another is four chances to
lose your place, and it hides the old figure at the moment you are
typing the new one.

A correction is not an addition, so these stay available on a booking
that has been completed -- a figure found to be wrong a week later has
to be fixable. Adding something new to a closed booking is still shut,
as it was.

A CORRECTION COULD ONLY EVER REVISE A FIGURE UPWARDS

api/payments.php and api/expenses.php have both said since they were
written that an adjustment is "positive if too little was recorded,
negative if too much". Both validate the amount with the rule every
other amount in the panel uses, which refuses a negative outright. So
an amount entered too high could not be corrected at all -- in a year
of the panel being live, that path has never worked.

Validator::money() takes a $signed flag now, used by those two
endpoints and nothing else, and formatINR writes a negative as "- 500"
with the sign in front of the rupee mark rather than hidden beside it.

WEBSITE CONTENT AND PLACES WERE OPEN TO EVERYONE SIGNED IN

Both screens guarded nothing but being signed in, so an Auditor -- the
read-only role -- could rewrite the home page, and so could the counter
staff. Four permissions now cover them, with seeing and changing kept
apart: an Auditor reads the screens with every control disabled, and
Staff and Accounts do not see them at all. A Super Admin can still hand
either permission to one person without changing their role.

Three smaller permission faults, found in the same pass:

  Staff was shown Finance and Reports and, on opening them, told it
  could not load. The navigation is built from what the account may
  actually do now, and the dashboard asks only for the figures it may
  show -- it was firing two refused requests on every load, each one
  writing a permission_denied row into the audit log.

  "Set a reminder" is enforced by the server and was missing from the
  Users screen, so it could not be withheld from anybody. The bell
  offered "+ Add" to an Auditor, who cannot use it.

  The top bar read "Admin" whatever you were signed in as, three inches
  from a sidebar badge saying Staff. It says the role now, which on a
  phone is the only place it appears at all.

ONE THING IN THE TOOLING

tools/clear-enquiry-throttle.php printed the panel's "not configured"
HTML page and exited 0 when it could not find a database -- so the two
enquiry suites called it, saw nothing wrong, and then failed three
assertions later because the rate limit was never cleared. A command
line gets a one-line error and a non-zero exit, and both suites now say
so instead of swallowing it.

The UI suites also leaned on fixed pauses and fixed test data: a phone
number reused every run so the list went on showing the first run's
name, a vehicle booked on the same twelve dates until eleven of twelve
came back 409, and sleeps that were long enough only on a quiet
machine. They wait for the thing they are waiting for now.

CHECKED IN A REAL PANEL

  every Edit driven in a browser           payment, deposit, refund,
                                           charge, damage, document,
                                           pickup, return, the cards
  a correction of -500 on a payment        received 4,500 -> 4,000
  a charge corrected 500 -> 900            total 5,300 -> 5,900
  one row left, not two                    yes, the old one marked
  fuel level and condition corrected        1/2 -> 1/4, note kept
  the same figure is refused                "that is the figure
                                           already recorded"
  Auditor on Website content                245 of 253 controls
                                           disabled, a forced save 403
  Auditor on Places                         no form, no row actions
  Staff navigation                          no Finance, no Reports,
                                           no website, no 403s at all
  the dialog on a phone                     fits 390px, one column

  test-money.php         46   test-api.sh         20
  test-auth.php          60   test-bookings.sh    31
  test-api-failure.php    7   test-ledger.sh      80
  test-ui.js             11   test-expenses.sh    40
  test-nav-ui.js         17   test-enquiries.sh   36
  test-brand-ui.js       13   test-finance-ui.js  30
  test-records-ui.js     46   test-booking-ui.js  54
  test-enquiry-ui.js     27

  All green, and run in sequence against one database rather than one
  at a time, which is how the interference above was found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Found while checking the whole site: the footer's "Tariff" and "Enquire",
the "More about the Swift" under every fleet card, the car names in the
rate table, and "All articles" at the foot of a blog post were all 20 to
23 pixels tall. WCAG 2.2 puts the floor at 24, and a thumb is nearer 45.

All four are standalone links rather than words in a sentence, so they
take the tap-target and tap-list helpers the footer's page list already
uses -- which only grow on a touch screen, and leave a link inside a
paragraph exactly the size of its words.

CHECKED, ALL 43 ROUTES

  every standalone tap target is at least 24px
  every page fits 390px with no sideways scroll
  every page has its heading and full text with JavaScript off
  unique title and description on each, canonical and og:title on each
  one h1 each, no heading jumps, no duplicate ids
  every image has alt text and dimensions
  no console errors, no failed requests, no dead internal links
  structured data parses on all 43

  Phone (4x CPU, 150ms RTT, 1.6 Mbps), median of three. Measured on this
  build before the four links grew; a min-height on four links cannot move
  a paint or a long task, and the crawl and the tap check above were both
  re-run afterwards:

                     FCP    LCP    CLS    TBT     KB
    home             916   1684  0.000    295    296
    cars             736    988  0.000    149    193
    tariff           768   1004  0.000    126    251
    contact          776    980  0.000    154    199
    a town page      748    988  0.000    118    186
    a car page       724    972  0.000    154    193
    a blog post      744    976  0.000    119    186

  Every LCP under 1.7s and every CLS zero. The home page's 295ms of
  blocking time is one 260ms task at 584ms -- the bundle compiling and
  React hydrating -- and it does not reach the visitor: the worst
  interaction measured on the same throttled phone was 88ms to open the
  menu, 80ms to open an FAQ, 24ms to focus the enquiry form, all well
  inside the 200ms that counts as good.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every section of a booking can be put right, and no door the panel will not open
The enquiry form answered a refusal with "Please correct the
highlighted fields." and highlighted nothing. The reason appeared in
small red type under the box, the box itself looked exactly as it had
before, and on a phone the whole thing was usually below the fold --
so the message named a cue that was not there and pointed at a field
the customer could not see.

A refused box now carries a red border and a faint tint, aria-invalid
so it is announced and not only seen, and aria-describedby tying it to
its reason. The three go on together through one helper, so a field
cannot end up with the colour and not the announcement. The cursor
moves to the first box at fault and the page scrolls it to the middle,
which puts the reason on screen and has a screen reader read it out.

Pickup location and the message box could be refused for length and
said so nowhere at all; they say so now.

The server was answering one fault at a time: a wrong phone and a
wrong email meant being told about the phone, fixing it, sending
again, and only then hearing about the email. The checks it makes
after the Validator are gathered now and go back together, so
"fields" is plural because more than one can be marked.

The date picker already drew a taken day in red. A date the server
refuses is the same thing to the person reading the form, so it is
drawn the same way rather than given a second look of its own.

Verified against a local copy of the document root served the way
Hostinger serves it -- site at /, panel at /admin: 27 checks on the
highlight itself, and the 34-check form run and 36-check endpoint
suite still green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
is_temporary has marked a car brought in from another owner since the
commission work. It showed as Temporary in the fleet so it could be
found and retired afterwards, and that is all it did -- the website
never knew about it.

So the fleet page, the tariff table and the car list on both enquiry
forms were offering somebody else's car as part of what we hire out:
a photograph and a price that are not ours, for a car that is gone
next week, winning enquiries nobody can answer by the time they are
answered.

api/public-vehicles.php now excludes it, and api/public-availability
.php with it. A car the site does not list cannot be the one a visitor
picked, so its diary answers a question nobody asked -- and worse, it
was counted in the fleet, so a weekend with every advertised car out
still looked open because a car nobody can see was free on it.

Nothing changes inside the panel. It is still in the fleet list, it
still takes a booking, payments, handover and return like any other
car. It is the shop window it stays out of, not the business.

Both filters are guarded on the column existing, the way the rest of
public-vehicles.php is: this endpoint serves the live website before
anyone opens the panel to run a migration, and naming a column that is
not there yet empties the fleet rather than showing one car too many.

The panel now says so where the decision is made -- the tickbox
explains it keeps the car off the website, the fleet card carries a
quiet "Not on the website" beside the Temporary badge, and the line
under Vehicle Management no longer claims every available car shows on
the site.

tools/test-public.sh is new: the public endpoints had no coverage at
all, and they are the only door the fleet goes out through. 21 checks
on what they show and what they hold back -- the registration, the
odometer, the owner's name. Both halves of this fix were confirmed by
turning each off and watching the suite fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
A refused field now looks refused, and a borrowed car stays off the website

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