Skip to content

MAIN - #17280

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

MAIN#17280
niteeshkanna-sh wants to merge 381 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 11:31
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
Footer map, a Footer section in the panel, and a check that says why an uploaded picture is missing
Three things, all on the top of the home page.

The uploaded background was invisible. Two separate dimmers sat on it: the
image itself at 25% opacity, and a navy gradient over that reaching 95% in the
middle. Multiplied, about one part in fifty of the picture survived -- so
uploading a background looked exactly like the upload not saving, which is
what it was reported as. The image is at full strength now and a single scrim
carries the legibility, the same weighted one the page banners use.

It needed its own variant rather than .page-scrim. Four of the hero's five
blocks are a left-hand column, and then the three points at the bottom run the
full width, so a scrim that clears on the right the way the banners do would
leave the last of those three on bare photograph. This one clears less far and
deepens towards the bottom where that row sits.

Measured rather than judged, against a pure white photograph -- the worst case,
and not a rare one, since a bright sky behind a car is the commonest thing an
owner uploads. Every piece of text: 4.98:1 at the tightest, across 1536, 1280,
768 and 390. Below 1024 it goes to even coverage, because there every line runs
the full width and there is nowhere the photograph can show without something
being read over it.

The band of empty navy between the menu and the first word is gone. The hero
had even padding top and bottom, which is right for a section in the middle of
a page and wrong for the one thing at the top that should be read without
scrolling. Tight above, unchanged below.

And the page is wider: 86rem rather than 72rem, with the side gutters growing
at sm and lg instead of staying at 20px. On a 1536 screen the content ran
1152px with 192px of nothing on each side. Seventeen containers, all the same
change, so the header, the sections and the footer still line up. No horizontal
overflow at 320, 390, 768, 1024, 1280, 1536 or 1920 on six routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Let the home page photograph show, and give the page its width back
…rives

The three highlights were full-width rows, each split into a description and
a bulleted panel. Three of them ran to about two and a half screens, and every
row left a column of empty navy beside whichever half had less to say -- which
is what the gap in the screenshot is. Side by side they are one screen, and
three offers next to each other can be compared, which is the only reason to
put three offers next to each other.

The inner panel goes with it: a bordered box inside a bordered box was what
made the emptiness look deliberate. The points sit under a gold hairline in
the same card now, and the button is pushed down with mt-auto so all three
line up however long the copy above them runs. They stagger in on scroll and
lift on hover -- .card-lift as the light cards already use, with a gold glow
instead of a drop shadow, because a dark shadow under a dark card is nothing.

--- and then the speed.

The page was served as an empty <div id="root"> with 365 KB of JavaScript
beside it. Nothing at all was on screen until that had been fetched, parsed
and run: three seconds on a mid-range phone on a rural connection, which is
most of this site's visitors, and everything a page is scored on happens
inside that window. Three changes, largest first.

The site is rendered to HTML at build time. vite build --ssr produces a server
bundle, and prerender-seo.mjs -- which already wrote each route's title and
description -- now also writes its markup. The script still loads and takes
over; it is what makes the fleet list and the enquiry form work. But the words
and the layout are in the file the server sends, so the page is readable while
it arrives. prerenderToNodeStream rather than renderToString, because the
routes suspend now and renderToString would bake the fallback into every page
but the home page.

main.tsx keeps createRoot rather than switching to hydrateRoot, deliberately.
The footer prints the current year and the date picker opens on today's month,
so a page built yesterday and read today would not match and React would tear
it down anyway, loudly. createRoot replaces the markup in the same frame it
paints, which costs one extra render and cannot be wrong.

Every route but the home page is now fetched when it is asked for. One bundle
held all fourteen, so someone landing on the home page downloaded the tariff
table, the blog, the places list and the enquiry form before the first word
appeared. Home stays eager: it is where most people arrive, and splitting it
would add a round trip before the thing they came for.

Poppins is served from this site instead of Google. A third-party stylesheet
is the slowest thing on a cold load -- the browser cannot paint until it has
it, and getting it costs a DNS lookup, a handshake and a round trip, then all
three again to fonts.gstatic.com for the files it names. The rupee sign was
its own tax: Google puts U+20B9 in the devanagari subset, so every page with a
price pulled 38 KB of Hindi per weight. Subset to that one glyph it is 570
bytes, and the four weights together are 2.3 KB instead of 155 KB.

The hero photograph is marked as the priority fetch, since it is what Google
measures this page's loading time by.

Measured on a throttled phone -- 390px, 1.6 Mbps, 150 ms latency, 4x CPU:

              first paint        largest paint
  /           3124 -> 1436 ms    3452 -> 2032 ms
  /cars       2784 -> 1108 ms    3048 -> 1440 ms
  /tariff     2760 -> 1112 ms    3024 -> 1448 ms

All thirteen routes checked after: no console errors, one header and one
footer each, no sideways scrolling. An upload in the panel still replaces the
committed photograph once the panel answers, and the committed one is what
goes into the HTML -- which is the right way round, since a crawler should not
wait on a request to /admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Three offers across, and a page that is readable before the script arrives
Every image on this site is stored above the document root and streamed back
by a small PHP reader, so the address a visitor sees is one this code makes
up. It made up photo.php?f=v12-9f3a1c4b7d2e5a60.jpg. On a business whose
traffic comes from searching for "self drive car rental Nagercoil", the file
name is one of the few things Google has to go on about a picture besides its
alt text and the page around it -- and that one said nothing at all.

They are named for their subject now:

  /admin/images/self-drive-car-rental-nagercoil-9f3a1c4b7d2e5a60.webp
  /admin/photos/maruti-swift-self-drive-car-rental-nagercoil-<hex>.jpg
  /admin/place-photos/vattakottai-fort-places-to-visit-kanyakumari-<hex>.jpg

The words come from the site, never from the uploader. Each site-image slot
has a phrase in SITE_IMAGE_KEYWORDS -- what is in the picture, not where it
goes, because "Cars page banner" is of no use to anyone searching. A vehicle's
photograph takes the car's own make and model; a place's takes the place's
name. The random suffix stays: it is what makes a replaced picture a new
address, which is what lets the reader cache for a year.

A path rather than a query string, so the words are in the address and not
only in a parameter. admin/.htaccess routes /admin/images/, /admin/photos/ and
/admin/place-photos/ to the three readers, which still answer ?f= as well --
every address stored before today keeps working, and so does every file name,
because the one pattern that replaces the three old ones matches v12-<hex>,
p3-<hex> and bcars-hero-<hex> too. No migration, nothing to re-upload.

The pattern is the security, not the rewrite. Lowercase words joined by single
hyphens, sixteen hex, one extension: there is no way to write `..` or a
separator in that. Checked both ways round -- ten kinds of hostile input
reduced to harmless words (../../nitesha-config/config becomes
nitesha-config-config), and twelve shapes that must never be answered,
including name-<hex>.jpg.php and a traversal appended to a valid name.

Two things found while testing it.

admin/src/ had no deny rule, so its files were reachable over HTTP. Nothing
leaks today -- every one of them only defines functions, so a request returns
an empty 200 -- but that is a property of each file continuing to be written
that way rather than a guarantee, and sql/ and tools/ were already denied.

And the readers now answer a conditional request with 304 instead of the file.
These addresses change whenever the picture does, so the year-long cache was
already right; what was missing was the ETag that lets a returning visitor
spend a few hundred bytes instead of a few hundred kilobytes.

Alt text, which is the other half of this. The six service cards and the three
steps passed none, so an uploaded photograph of a wedding car was announced to
nobody and read by nothing. They pass the card's own title now. The drawings
stay decorative, which is still correct -- SectionArt already makes that call.

The site check tells the two new failure modes apart. If a picture will not
load at its new address it tries the old one: if that works, the file is fine
and the rewrite is not, and the page says so and names the remedy rather than
reporting a missing picture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Name every uploaded picture after what is in it
Two things: the site got a lot faster, and the block of structured data every
page carries now describes an actual business rather than a name and a phone
number.

--- speed

Nothing was compressed. That did not matter much while every page was an empty
<div id="root">; it matters a great deal now that each one carries its own
markup, because the home page went from 3 KB to 74 KB in the process. Gzipped
it is 13 KB. Brotli first where the host has it, gzip behind it, and images
and fonts deliberately left alone -- WebP, AVIF, JPEG and WOFF2 are already
compressed and running them through again spends CPU to make the file very
slightly larger.

Measured on a throttled phone -- 390px, 1.6 Mbps, 150 ms latency, 4x CPU:

              first paint       largest paint
  /           1484 -> 692 ms    2124 -> 1344 ms
  /cars       1108 -> 652 ms    1356 ->  908 ms
  /tariff     1108 -> 640 ms    1380 ->  916 ms

Against where this started two changes ago, the home page's first paint has
gone from 3124 ms to 692 ms.

One thing removed rather than added. The first version of this preloaded each
page's banner photograph by hand, and that was a second download of a file the
page already had: React emits a preload for an image marked fetchPriority=high
as it renders, with the exact URL the <img> uses, and mine used the absolute
form of the same address. Found by reading the built HTML rather than by
trusting it.

--- what the structured data says

It said the name, a phone number, a locality and a district. It now says:

  the postal address, including the postcode, read out of the panel's own
    footer fields rather than typed a second time for the crawler -- the two
    disagreeing is exactly how a business loses Google's trust in both;
  opening hours, from a new field in the panel, in the form schema.org wants,
    shown in the footer as well when it is filled in;
  every town served as its own City, not just "Kanyakumari district". Someone
    searching for a car in Marthandam is searching for a town, and a district
    named on its own does not say the town is covered. Twelve of them;
  the social profiles, as sameAs, which is how a page claims an account rather
    than merely linking to one. Only the ones that are filled in;
  a stable @id, so thirteen pages describe one business rather than thirteen
    businesses that share a name;
  hasMap, pointing at the same place the footer's map does.

serviceArea is gone: areaServed now says the same thing town by town, and two
properties describing one thing with different precision is a pair to keep in
step for no gain.

Every inner page also carries a BreadcrumbList, so a result prints
"niteshacars.in > Wedding car rental" instead of a bare URL. The leaf is the
title cut at the business name or the place, because a breadcrumb is read at a
glance and the full title is not.

robots.txt stops sending crawlers into the panel -- a login page can only ever
rank for the business's own name and disappoint whoever clicks it -- while
still allowing the three image readers, because a photograph that cannot be
fetched cannot be in image search.

Checked: all thirteen routes render clean, the structured data parses on every
page, and exactly one preload for the banner rather than two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Compress what is served, and tell Google what this business is
Somebody in Marthandam looking for a car does not search for "Kanyakumari
district" -- they search for Marthandam. The site named twelve towns in a row
of chips on the home page and competed properly for none of them, because a
page that mentions a town in passing has nothing to say about it.

Twelve town pages now, and a hub above them at /car-rental. Each one says what
that town is, what the roads around it are like, what people actually hire
there, and how far the drives worth making from it are.

The risk in doing this is the opposite failure, and it is worse than not doing
it: twelve pages that are one page with the name swapped. Google calls those
doorway pages and treats them as a reason to trust the whole site less. So
every page is written from its own entry in towns.json rather than generated
from a template with a placeholder in it -- Colachel is about a fishing
harbour and a battle in 1741, Marthandam about the rubber belt and the airport
run, Padmanabhapuram about a palace that shuts for a long lunch and catches
people out. Measured rather than assumed: the two most similar pages share 63%
of their vocabulary, and most of that is the header, the nav and the footer
that every page of the site carries.

Distances are approximate road distances and are in the data file, so they are
one edit each rather than a search through markup. They want checking against
what the owner actually drives -- that is the one part of this I cannot verify
from here, and it is flagged rather than left to be discovered.

Structure, because a set of pages nothing links to is a set of orphans:

  the town names on the home page are links now rather than chips that do
    nothing -- that section was the obvious place and it led nowhere;
  every town page links to the other eleven, so a crawler that finds one finds
    all of them;
  "Where we deliver" is in the footer, which puts the hub on every page of the
    site rather than only on the home page;
  all thirteen are in the sitemap, derived from towns.json by the same module
    the app reads, so adding a town cannot produce a page the sitemap does not
    list.

Each town page also carries a Service node naming that one town, pointing at
the business's @id rather than repeating it, and a three-step breadcrumb --
Home > Where we deliver > Marthandam. The leaf was "Self Drive Car Rental" on
all twelve at first: the rule that shortens a title by cutting at " in " threw
away the only word that distinguished them.

An unknown slug renders the 404 page rather than an empty frame with a name in
it, which is what a route matching anything would otherwise turn every typo
into.

readablePhone moved out of the footer into lib/phone.ts. The town pages print
the number too, and the second copy of a formatter is the one that goes out of
step with the first.

Checked: all twenty-six routes render with one h1, one header and one footer,
no sideways scrolling at 390px, and no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Five boxes in the booking screens take a file: Payment Screenshot / Receipt,
Deposit Proof, Refund Proof, Pickup Photos, Return Photos. They have been
there since those screens were built and were wired to nothing at all. No code
read the input, there was no table to put a file in, and nothing anywhere
displayed one. Choosing a screenshot and pressing Save recorded the payment
and dropped the file on the floor, silently -- which from the outside is
indistinguishable from it having worked.

Three things were missing and all three are here now: a preview so you can see
what you picked, the upload itself, and somewhere on the booking for them to
appear afterwards.

  booking_files, one row per file. kind says which box it came from, and
    ref_id ties a payment screenshot to the payment it proves rather than to
    the booking in general -- four payments would otherwise leave four
    screenshots in a heap nobody can match up;
  api/booking-files.php takes several files in one request, because the pickup
    and return boxes accept several and five round trips over a phone
    connection is how some of them go missing. A file refused for being the
    wrong sort is reported without losing the others;
  the detail screen shows each kind under the section it belongs to, click to
    open full size, with a remove button on the corner of each.

The upload runs after the record it belongs to is saved, not with it. A
booking_files row points at a booking, and it also means a refused image never
costs someone the payment they just typed in: that is already saved, and the
file problem is reported on its own.

These are not like the other images on this site, and the reader is where that
shows. brand.php and photo.php are deliberately public -- a vehicle photograph
is an advertisement. A payment screenshot carries a customer's name, their
bank and an amount; a pickup photograph shows a registration plate. So
booking-file.php requires a signed-in user, sends Cache-Control: private,
no-store so nothing in the middle keeps a copy, and the names are random
rather than descriptive -- a file name should not read "advance-payment-10000".
A random name is not access control; the session is.

Verified rather than assumed. Signed out, the reader answers 302 and zero
bytes. Signed in, the image and no more. Signed in with a name that is not
ours -- a traversal, a .php, a .jpg.php, a short hex, an uppercase one -- 404
every time. In the panel: five inputs wired, two files chosen give two
previews with the right names, reopening the modal clears them, the detail
screen groups by kind and renders nothing for a kind with no files, and a
caption containing <img src=x onerror=...> comes out as text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Keep the screenshots the panel has always asked for
Thirty-nine figures changed. They were written from recollection and flagged
as such; these are checked against the towns' own Wikipedia articles where
those state a distance, and against the road figures the travel sites agree on
where they do not.

Two were badly wrong rather than slightly off:

  Kuzhithurai is 26 km from Nagercoil, not 35;
  Boothapandi to Tirunelveli is about 70 km, not 60 -- Nagercoil to Tirunelveli
    is 81 km by road, and Boothapandi is only 11 km up that road.

Azhagiapandipuram was out by nearly half: 15 km north of Nagercoil rather than
22. Colachel is 20 km, not 27. Marthandam is 26, not 30. Thuckalay 16, not 20.
Eraniel is further than stated rather than nearer -- 20 km, not 17.

The airport runs were both overstated, which on a page about hiring a car for
exactly that run is the number people would have checked: Marthandam to
Trivandrum airport is about 45 km rather than 55, and Kuzhithurai about 42
rather than 48.

Smaller corrections follow from the corrected bases: a trip between two towns
cannot be longer than going through Nagercoil when Nagercoil is on the way, and
several were. Marunthuvazh Malai is 11 km from Nagercoil and 5 from Suchindram,
both verified, and both were out.

One sentence had to go with them. Kuzhithurai's page opened "closer to
Thiruvananthapuram than to Nagercoil", which the corrected figures make false
-- 26 km against 42. A number that is wrong is checkable; a sentence that
sounds like local knowledge and is wrong is worse, because nobody checks it.
The point it was making, that the work up there points north, is made without
the claim.

Two notes were rewritten for the same reason: "the long way round the coast"
for a drive now routed through Nagercoil, and "under an hour and a half" for a
42 km run that takes about an hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Check every distance on the town pages, and fix the wrong ones
niteeshkanna-sh and others added 30 commits October 7, 2026 11:15
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
Body prose was set at five different line-heights depending on which
section it sat in. Most paragraphs carry leading-relaxed at 1.625;
the ones written without it fell back to the browser's own 1.5; and
four sections had been given hand-picked values besides -- 1.55 on
the benefit text, 1.6 on the route sentences, 1.65 on the delivery
intro, 1.5 on the place-card blurbs.

Nobody decided that. It is what happens when each section is written
on its own day, and the result is that two paragraphs of the same
size, a screen apart, set differently.

So the leading is set once, on p and dd in @layer base, from
--leading-relaxed -- the value fifty-three places already used, so
the exceptions join the rule rather than the other way round. The
four sections stop naming their own number. Three small notes on
/monthly and /nri that took text-sm's tighter 1.43 are given the
house value: they close a section, they are not captions.

The base layer is what makes it hold. leading-snug and leading-tight
still win where a line is deliberately tighter -- headings, and the
eleven-pixel note under the hero form -- and the next paragraph
anyone writes starts level without being told to, instead of landing
at 1.5 again.

Left alone: the date stamp on a blog post. It is one line that never
wraps, so its leading is not visible, and it is metadata rather than
a section's prose.

Measured in the browser rather than read off the stylesheet. Before:
six values across the pages. After: one, on 43 routes at both
desktop and phone width, with nothing scrolling sideways and no page
erroring. The four sections grew by 4 to 7 pixels, and the
three-line clamp on the place cards still clamps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every other "read more" link on the site grew for touch a week ago --
the fleet's "More about the Swift", the footer's "Get directions",
"Why hire from us". The blog index was missed, so its five "Read it"
links stayed 20px tall on a phone where the rest are 44.

One class, the same one the others use. 69x20 to 69x44.

Found by crawling all 43 routes at phone and desktop width. The three
links reported at desktop width are the deliberate scope of
.tap-target, which only grows under (hover: none) or 700px -- a
decision to treat this as a touch concern, not an oversight, and one
to revisit on its own rather than inside this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
On a phone the blog index asked for a twenty-pixel thumb
.tap-target and .tap-list only grew under (hover: none) or 700px, so
with a mouse the footer's "Get directions", the fleet's "More about
the Swift" and the blog's "Read it" stayed 20px tall -- a line of 14px
text with nothing around it. WCAG 2.5.8 asks 24px of a target under
any pointer, and any pointer includes a trackpad, a tremor, a laptop
on a train.

So 24px is the floor at every width now, and the touch sizes stay
where they were. That is the whole change: the three links go to 24
and the footer bar's to 28, which is four pixels and one pixel. The
alternative -- giving a mouse the 44px a thumb wants -- would have put
two centimetres of nothing under every footer link on every page to
satisfy a rule that asks for half of it.

Checked at both widths rather than reasoned about: 43 routes, desktop
and phone, no target under its floor, and the phone sizes unchanged at
40 and 44.

Three times the crawl was wrong before it was right, which is worth
recording because each one would have become a bad change:

  - It skipped every display:inline element as "in a sentence". A
    standalone nav link is inline too. Tightened to ask whether there
    is other prose around it.
  - Tightened, it then flagged the fleet sentence on the twelve town
    pages -- "Maruti Suzuki Swift, Toyota Glanza Automatic, ... and
    Toyota Innova" -- because each car name is wrapped in its own
    span, and a span holding one link has no other text in it. It now
    measures against the nearest block.
  - It still flagged "or call +91 63749 42976", on a threshold of
    eight characters of surrounding text. "or call" is seven. The
    threshold is gone: any prose in the block means the link is in a
    sentence, which is what 2.5.8 says and what padding that link
    would spoil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
useSeo() keeps the tab title, description and canonical in step after a
client-side navigation, because navigation never fetches HTML again. To
know what each route is called it imported towns.json, models.json,
service-areas.json and posts.json and composed the list in the browser.

All four landed in the entry chunk. So every page of the site carried
every blog post's body, every car's luggage capacity and the prose for
twelve town pages -- about seventy kilobytes of object literal, run at
module top level before anything on screen could answer a tap -- to end
up with a path, a title and a sentence.

The list is composed at build time now, by scripts/seo-routes.mjs, from
exactly the same four files and the same four helpers. prerender-seo.mjs
reads what it writes instead of composing its own copy, so the titles
written into each page's HTML and the titles useSeo puts in the tab are
one list and cannot drift -- which is what the old comment in useSeo was
reaching for when it said a second list is a second list to forget.

  entry chunk   374,166 -> 314,667 bytes   (-59.5 KB, -16%)

Every page pays that, not just the home page.

What it did for blocking time, phone at CPU 4x and 1.6 Mbps, median of
eleven runs per arm, two builds measured the same way:

                   TBT    longest task   long tasks
  before           278ms      152ms           6
  after            256ms      144ms           5

Honest about the size of that: the runs scatter 208-388 before and
153-351 after, so a 22ms median is a real direction and not a precise
figure. The 59.5 KB is the certain part.

Verified the output did not move: across all 44 prerendered pages the
only lines that changed are the hashed asset filenames. Not one title or
description differs. A browser check confirms the other half -- clicking
Tariff, Blog and Contact retitles the tab and moves the canonical, which
is the whole job of the file this touched.

Two wrong turns on the way, both measured and both reverted rather than
shipped. content-visibility on the offscreen sections, which cut the
longest layout pass from 73ms to 41ms and did nothing for blocking time,
because the cost was never layout. And the scene drawings: stripping all
176 SVG nodes changed TBT by less than the noise. The page is not slow
because of what it draws. It was slow because of what it carried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every page shipped the blog to put a title in the tab
There was no document to send anybody. The panel takes PDFs in -- a
licence arrives as one -- and Reports has a Print/PDF button for the
owner's own figures, but nothing produced a page for the person who
booked the car.

A Confirmation button now sits beside Edit and Cancel on a booking.
It prints one page: who it is for, which car and its registration,
from when until when, what was agreed -- daily rate, KM included,
the rate beyond that, deposit -- and what the money is: rental, other
charges, discount, extra KM, extras, total, received, still to pay,
deposit held. Then a line about bringing the licence, and who to call.

Printed rather than generated as a file. The browser's own dialogue
saves a PDF on every machine and phone the panel is used from, with no
library to ship and nothing to install on Hostinger. It costs one
extra step -- choose Save as PDF -- and it works the first time on a
phone at a handover counter, which a server-side generator would not
have done without a dependency and a deploy.

The filename is what makes it sendable, and browsers take it from
document.title, so the title becomes the booking number for the length
of the dialogue and is put back after. A customer receives
NSC-2026-0024.pdf and knows what it is before opening it.

The letterhead reads what already exists rather than adding a third
copy of the phone number: name, phone and email from the website's
seo.json, the address from the panel's own Website content, which is
where the owner already edits it. Anything missing is left off instead
of guessed -- a confirmation with no email line is still a
confirmation; one with last year's phone number is a customer who
cannot reach anybody.

One defect found by reading the output rather than the code. The
discount printed as a plain 500, so the column did not reach the
total: 7,500 + 600 + 500 + 2,600 + 1,200 is 12,400, and the total said
11,400. The total was right -- it already had the discount off -- so
the line now prints it taken off, and a customer adding it up arrives
where the document says. That is the kind of thing a handover argument
is made of.

Verified by rendering real A4 PDFs from the shipped functions and the
shipped stylesheet, not from a copy written for the test: four cases
-- typical, completed with extra KM and extras and a discount, one
with no address or licence or registration at all, and one with names
long enough to wrap -- each one page, no errors. A test reads the
printed figures back and adds them to check they reach the printed
total. The panel is hidden while the sheet prints and the page is put
back whichever way the dialogue ends.

No database in this container, so the live panel could not be driven;
the money and crash-handling suites pass (53 checks).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
A booking confirmation the customer can be given
The confirmation had nothing to point at if a customer disputes the
fuel level or comes back a day late. It does now -- but the terms are
written in the panel, not here.

That is the whole design. These are the business's terms of hire, and
a rental agreement is not something for this file to invent and print
on a document a customer is handed. So Website content gains a
"Booking terms (printed on confirmations)" section: a heading and a
list, one line each. The note beside it says what is worth covering --
fuel, late return, what the deposit covers and when it comes back,
who pays a fine, what to do after an accident -- and leaves the words
to the person who has to stand behind them.

Written none, and the confirmation simply has no terms section. That
is the honest result of nobody having written any, and better than a
document carrying rules the business never agreed to. It ships empty.

They print numbered, in 9pt under the money, because "point four" is
how somebody refers to one on the phone.

Eight terms is what a real set runs to, and on the fullest booking --
extra KM, extras and a discount, so three more money lines -- that
spilled onto a second page. A confirmation sent on WhatsApp wants to
be one file and one page.

Tightening the margins would have bought about seventy pixels of the
eighty-one needed and left the page cramped. Instead "What was
agreed" now sits beside "The money" rather than above it: four lines
next to nine instead of four lines then nine. That recovered 154px,
and it reads better -- the rate and the KM allowance are the terms
the bill beside them is worked out from.

Six cases rendered as real A4 PDFs, each one page: typical, completed
with everything on it, bare with no optional field filled in, long
names that wrap, and both of those again with eight terms. Three more
check the empty state the panel ships in -- no terms written, the
field present but empty, and rows containing only spaces -- none of
which produces a terms section, a stray heading or an error. The
money column still adds up to the printed total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The logo. The letterhead was the business name typed out, on a
document meant to look like it came from somewhere. It now carries
the logo already uploaded in Website content, beside the name, capped
by height so a wide mark and a square one both sit on the same line.
No logo uploaded, no image -- the name still says who sent it.

The colours. It was black on white, which was the right instinct for
ink and the wrong one for a document a customer recognises. Navy
headings now, and gold twice: under the title and above the total.
Twice reads as the brand; everywhere would read as a template. All of
it is text and borders, never a background, because a browser printing
a page leaves backgrounds out unless somebody ticks a box.

The status line. The top right read "Completed" on a page headed
Booking confirmation, which raises a question it does not answer. The
status is the panel's business. It is gone -- except for Cancelled,
which is the one state that has to be said out loud, because handing
somebody a confirmation for a booking that is off is the only way
this document could mislead. That prints as a red box beside the
number.

The deposit. It was a line under "What was agreed" and a line again
in the money column, which reads as being charged for it twice. It is
not part of the total, so it is no longer a row in the column that
adds up to one: it is a note under it, saying how much is held and
that it comes back. One place, and the arithmetic is unambiguous.

A fifth thing the testing turned up rather than the eye. The logo is
written into the page and print() is called in the same breath, so
whether the image had arrived was a race -- and it lost often enough
in the harness to show up as a missing logo in three of eight PDFs.
Browsers usually wait. "Usually" is a confirmation handed over with
no logo on it, which is the kind of fault nobody reports and everybody
notices. printConfirmation now waits for it, for at most a second and
a half, and prints without it rather than not at all.

Eight cases rendered as real A4 PDFs, each one page, each checked for
whether the logo actually decoded: typical, completed with everything,
bare, long names, terms on two of them, cancelled, and one with no
logo uploaded. The money column still adds to the printed total and
the three empty-terms states still produce no terms section.

The harness had two faults of its own, both fixed: it named two files
the same so one case overwrote the other, and it extracted functions
by matching "function name(" which stopped finding this one the
moment it became async -- silently, which is the worst way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The confirmation was a plain sheet of labelled rows. This is the layout
from the two screenshots: a navy band carrying the logo, the business
details and the hero photo, with the booking number set over a scrim on
the photo; a gold-ruled title; the booking as four cards in a 2x2 grid,
each headed by an icon on a gold disc; the terms below; and a closing
line. The footer bar marked in the second screenshot is left out.

Three things the design did not settle, decided here:

The deposit is one figure in one place. It sits under "What was agreed",
which is where the agreed amounts are, and the note beside the money
only says why the total does not include it. Printing the number twice
reads as being charged for it twice.

The closing line follows the booking's state. "Please bring the original
licence" contradicts the Cancelled badge on a cancelled booking, and
asks a finished hire for a licence already brought, so cancelled and
completed each get the line that is true of them.

Images are awaited before the print dialogue opens, up to two seconds.
Without it the logo raced the dialogue and was missing from three runs
in eight.

Printed with print-color-adjust: exact, or browsers drop every
background and the band comes out white. The confirmation prints
full-bleed through a named page, @page confirmation { margin: 0 },
which leaves the reports on their 14mm.

Checked by rendering eight bookings to real A4 PDFs -- typical,
completed with extra KM and a discount, bare, cancelled, no logo, long
names that wrap, and both with and without terms. All eight come to one
page with the logo decoded, the money column adds up to the total it
prints, and an empty terms field prints no terms section at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The closing strip sat wherever the text stopped, which on a booking with
no terms was two thirds of the way up with a third of the page blank
beneath it. The sheet is a flex column now and the strip takes
margin-top:auto, so it lands under the last thing on a full page and at
the bottom of the paper on a short one.

The mark moves into the strip rather than floating loose above it.
Anchored to the paper it had nowhere safe to sit once the strip claimed
the bottom: high enough to clear the strip put it through the terms.
In the strip it has a place of its own and cannot land on anything, and
the booking with no logo uploaded just gets a strip without it.

Also keeps the phone number whole. It has a space in it, and with the
mark taking room at the right the line broke through the middle of it,
leaving the customer half a number to dial.

Re-rendered the same eight bookings: all one page, logo decoded, money
column still adding up to the printed total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Booking confirmation: terms, the supplied design, and a footer at the foot of the page

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