MAIN - #17280
MAIN#17280niteeshkanna-sh wants to merge 350 commits into
Conversation
|
Hi @niteeshkanna-sh! Thank you for your pull request and welcome to our community. Action RequiredIn order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you. ProcessIn order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA. Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks! |
Put the site at the top level, where the deploy actually lands
That message means the CSRF check failed, and the CSRF check fails when
the token stored in the session on one request is not there on the next.
Which is almost never about tokens: it is the session not surviving
between the page loading and the form being submitted. Four quite
different faults produce it and they look identical from the outside,
which is exactly the shape of problem that has cost the most time here.
session-check.php does what signing in does -- stores a token, renders it
in a form, checks it when the form comes back -- and reports what
happened alongside the settings that decide it: whether the browser
returned a cookie, whether session storage is writable, whether the
cookie is marked Secure, and whether the visitor is actually on HTTPS.
It singles out the one combination that silently eats every session: a
cookie marked Secure served over plain HTTP, which the browser accepts
and then refuses to send back. That check outranks the round-trip result,
because browsers exempt localhost from the Secure rule -- a local copy
round-trips happily on settings that lose every session on a real domain.
Without that ordering the page reported success and named a fault in the
same breath, which is worse than reporting neither.
Two real mismatches found while reading the path, both in what install.php
writes:
https_only came from $_SERVER['HTTPS'] alone. This host terminates TLS
upstream, so PHP is handed a plain HTTP request for a visitor who
arrived over HTTPS -- the site's own .htaccess says exactly that about
%{HTTPS} and redirects on X-Forwarded-Proto instead. It now reads both,
and REQUEST_SCHEME as a third opinion. This is the value that decides
whether the session cookie carries Secure.
session_minutes was written; the panel reads session_idle_minutes. A
hardcoded fallback was doing the work, so changing the value in
config.php had no effect at all.
Neither is proven to be the cause of what is happening on the server --
that is what the new page is for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Say why the sign-in page reports "Session expired"
The Add Vehicle form had no way to attach a picture. Photographs only
worked by dropping a file into the repository under a filename matching
the car, which is no use to the person who actually knows which car is
which.
Pick a file, press Save, and it appears on the website's fleet card.
Where the file goes matters more than it looks. Uploads are written under
storage_path, above the document root, because this host deploys by
rebuilding the web root from the repository -- a photograph written
inside it would survive until the next push and no longer. config.php was
lost exactly that way, and a customer-facing photo vanishing during an
unrelated deploy would be the same bug in different clothes. Being
outside the web root also means Apache cannot serve it, so photo.php
reads and streams it: a PHP process per image, in exchange for nothing in
that directory ever being executed.
What decides whether an upload is an image is the file's own contents,
via getimagesize -- never its name or the type the browser claims, both
chosen by whoever is uploading. Verified over a real multipart POST:
a real PNG accepted as .png
a PHP script named car.jpg,
declared image/jpeg refused
The stored name is ours, never the uploader's, because a filename from a
browser can contain path separators. photo.php then only answers for names
matching the exact shape this generates, which is a better rule than
trying to enumerate the ways a path can escape a directory. Every attempt
returns 404:
../secret.txt, ../../nitesha-config/config.php, ..%2Fsecret.txt,
v7-../../../etc/passwd, a well-formed name for a file that does not
exist, and an empty name
The URL is built against the panel's root rather than used as returned.
The endpoint sends "photo.php?f=...", relative to the panel; in an <img>
that would resolve against the page instead, so the fleet page would ask
for /cars/photo.php and every photograph would 404 on some pages and work
on others. Confirmed in a browser: the card requests /admin/photo.php and
gets 200 image/png, and a vehicle with no photograph still falls back to
the drawing.
The upload is a second request after the save, because a photograph is
stored against a vehicle id and a vehicle being created has none until
the save returns one. The form hides that. Remove marks the photograph
for deletion but changes nothing until Save, so Remove then Cancel leaves
the vehicle as it was.
Not verified: anything touching the database. This sandbox has no MySQL,
so the migration, the UPDATE, and the audit entries are unexercised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Add a photograph to a vehicle from the panel
The document root caches every .js and .css for a year as immutable. That is right for the site's build output, where a changed file is a changed filename because the name carries a content hash. It is wrong for the panel: admin.js, admin.css, api.js, car-data.js and booking-data.js have fixed names, so a browser that loaded the panel once would not ask for them again until 2027 -- and every change to the panel would reach nobody who had ever used it, with no error and nothing to notice. The vehicle photograph upload merged minutes ago would have been the first casualty. Two parts, because one alone does not finish the job. admin/.htaccess overrides the header for this folder, which fixes it for browsers that ask -- and a browser already holding the file will not ask. So the tags now carry the file's modification time: a changed file is a different URL, and the cached copy cannot match it. asset() lives in its own src/assets.php. It went into http.php first, which was wrong in a way worth recording: http.php is the JSON endpoints' plumbing, and no page that renders HTML loads it. dashboard.php, index.php and content.php all require csrf.php and nothing else, so asset() was defined precisely where it was never called from and every panel page would have died on an undefined function. Caught by loading the pages rather than by reading them -- php -l passes on a file that calls a function that does not exist. Verified: asset() returns admin.css?v=<mtime> and falls back to ?v=0 for a file that is not there; the require precedes the first call in all three pages; index.php reaches its database call, which is as far as anything gets here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Stop the panel's own scripts being cached for a year
Saving a vehicle with a photograph failed, and the reason was worse than the symptom: the column the photograph feature needs had never been created, on any installation. migrate() ran from exactly two places -- install.php, which refuses once an account exists, and a button on the content page that nobody has a reason to press. So a deploy could add a migration and nothing would ever apply it. The code shipped and the database did not, and the first sign was a feature failing with a SQL error. It now runs on dashboard load. migrate() already records what it has applied and skips those, so the cost is one small SELECT per load. A failure is reported in a banner rather than thrown: a migration that cannot run is worth knowing about, and is not a reason to refuse to show a panel that otherwise works. The public fleet endpoint had the same problem from the other side, and it mattered more. It selects a narrow list of columns by name, and one of those was the new one -- so between a deploy and an admin next signing in, niteshacars.in/cars would have been asking for a column that did not exist. A change made entirely inside the panel could empty the fleet on the live site, for visitors, with nobody signed in to notice. It now names the column only when it is there and selects NULL otherwise, so the site does not depend on anyone having opened the admin. Both forms of the query were checked for validity. The upload endpoint says what to do rather than returning a SQL error, in the window where the column is genuinely missing. Still unverified: the migration actually applying. There is no MySQL here. What was checked is that the file parses to exactly the one ALTER statement intended, and that every new function is defined by the require chain the pages use -- the last change defined a helper in a file no page loads, which php -l cannot catch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Run pending migrations when the panel loads
The photograph upload told the user to "open the Dashboard tab once and try again". They did, and it could not have worked: the panel switches tabs in JavaScript without loading the page, so the migration that runs on page load never ran. An instruction that only works if you know that is not an instruction, it is a trap. That message was mine and it was wrong. So the schema is brought up to date in api_guard instead. Every endpoint in the panel goes through it, which means a deploy that adds a column has it created by the first action taken after that deploy, whatever the action is and whichever page the person is on. Nothing to be told, and nothing to remember. migrate() is cheap but not free, and the panel makes several API calls per page, so migrate_if_needed() decides whether to bother. What it compares is the newest migration filename: a deploy that adds one changes that string, so the next request applies it and every request after skips. There is no version number anyone has to remember to bump. A failure is returned rather than swallowed, and deliberately not remembered -- recording it would mean the session gave up retrying for as long as it lasted. The dashboard shows it in a banner; the upload now says the database could not be updated and that the database user may not be allowed to change tables, which is the actual remaining cause once the migration has genuinely been attempted. Exercised with migrate() stubbed, since there is still no MySQL here: first call runs once, records 002_b.sql two further calls skipped, nothing re-run a new file appears runs again, records 003_new.sql the call after that skipped migrate() throws error returned, marker left unset the call after that retries, and succeeds The first version of that test reported ran=0 for every case and looked like the feature was dead. The harness was wrong -- __DIR__ inside the eval'd copy pointed at the temp directory, so the glob found no migrations at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Apply pending migrations on any panel action, not on a page load
…t does not The photograph upload returned 503 from its own "column is missing" branch, which means table_has_column() said no on a database where the column had almost certainly just been created. It asked with "SHOW COLUMNS FROM `t` LIKE ?". This connection uses real prepared statements -- ATTR_EMULATE_PREPARES is false -- and a placeholder in a SHOW statement is not something MySQL accepts there. The catch below it then turned that failure into a confident "no", for every column, every time. An exception that becomes a plausible answer is worse than one that escapes, so the failure is logged now as well as caught. The same function gates the public fleet endpoint, which is the part that would have gone unnoticed: it was selecting NULL for every photograph, so no picture would ever have reached the website either, with nothing failing anywhere to say so. It now asks information_schema.columns -- an ordinary SELECT, so it prepares, and both the table and the column bind as parameters rather than being interpolated into SQL at all. That diagnosis cannot be run here, and there is a second possible cause for the same 503: the migration simply failing. Rather than pick one again, the endpoint now reports what the database actually said. api_guard runs migrate_if_needed() and discards its result, so an endpoint finding a column missing could not tell "never attempted" from "failed, and here is why"; last_migration_error() carries that across. The two previous messages here each named one plausible cause, were wrong about which, and neither carried the one fact that would have settled it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Ask information_schema whether a column exists, and report why when it does not
Four things, three of which were the same mistake in different places: something existed in the code and not in the form. Body type offered Hatchback, Sedan and SUV. The database ENUM and the API's validator both accept MUV and Other as well, so a seven-seater could not be recorded as one -- an Innova is filed as a hatchback on the live site right now. Nothing warned, because a choice you are not offered is not a bug anyone reports. The lists lived in three places; the form's copy had fallen behind. It and the API now read one list in src/vocab.php, and the form renders its options rather than spelling them out. The schema's ENUM is still its own declaration, since changing it means a migration, but two of the three copies are now one. Photographs are framed before upload and saved at 1200x750, which is the shape the website's cards use. That is what stops the fleet looking ragged: the page is no longer cropping pictures of different proportions and hoping, it is laying out identical rectangles. A 4 MB phone photograph also arrives at around 150 KB, which matters on a page that fetches one per car. The framing window is that same shape, so what is inside it is what a customer sees. Drag to choose what shows, a slider to zoom, and the image is held so it always covers the window -- an empty corner cannot be framed. Zoom works about the middle rather than the corner, which is where the subject is. Written by hand rather than with a cropping library: the panel has no build step, and every script in it is a plain file the browser loads. The admin card thumbnail had no fixed shape at all, so a tall photograph made its card taller than its neighbours. It is 16:10 now, with the drawn illustration fitted and a photograph covering. And a new photograph did not appear on the site because the fleet endpoint was cached for five minutes. Changing a picture and not seeing it reads as the upload having failed, and the natural response is to upload it again. One minute still absorbs any burst worth absorbing. Checked in a browser: the body type list offers all five, the framing window measures 358x223 (1.605 against 16/10's 1.600, from integer widths), and the export is exactly 1200x750 image/webp. The geometry was exercised with the same arithmetic the panel uses rather than by running admin.js itself, which needs the whole dashboard around it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Frame photographs to one size, and offer every body type
It turned continuously beside the logo. A mark that never stops moving
competes with the navigation next to it for attention it does not need,
and the rotation was the only reason the stylesheet needed a
reduced-motion exception for it at all. Nothing moves now, so there is
nothing to exempt.
The drawing stays as a placeholder. SnakeMark already prefers a real
image when one exists -- photoFor('snake') -- so dropping a file named
snake into my-app/public/photos replaces it with nothing else to change.
Verified in a browser: animation-name computes to none, and the
element's transform is identical 1.5 seconds apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Both were drawn in code, replaceable only by committing a file to the repository under the right name. That is not something the person who owns the brand should have to do, and it was the reason a cobra pasted into a chat could not become the site's mark. Website content now has a Logo and mark section: choose a file, upload, and it is on the site within a minute. "Use the drawn one" puts the drawing back. Nothing is destroyed until the replacement is safely stored -- the old file is deleted only after the row points at the new one. The files live under storage_path, above the document root, exactly as vehicle photographs do: this host rebuilds the web root from the repository on every deploy, so a logo written inside it would last until the next push. brand.php reads them back, since Apache cannot reach above the root. Validation is vehicle_photo_check(), the same function the car photographs use, so there is one answer to "is this an image" rather than two that can drift apart. It reads the file's own contents rather than its name or the type the browser claims. Confirmed over a real multipart POST: a PNG is accepted, a PHP script named snake.png and declared image/png is refused. brand.php answers only for names matching the shape it generates. Every escape attempt returns 404 -- ../brand-secret.txt, ../../nitesha-config/config.php, the URL-encoded form, blogo-../../../etc/passwd, a vehicle photograph's name, a well-formed name for a file that does not exist, and an empty name. The site reads them from the request it already makes for the wording, rather than a second round trip for two short strings, and resolves them against the panel rather than the page -- used as returned, the fleet page would ask for /cars/brand.php and the logo would break on some pages only. Checked in a browser: with nothing uploaded both stay drawn and no request is made; with both uploaded they resolve to /admin/brand.php and two requests go out. Not verified: the database half. There is no MySQL here, so the new table, the INSERT ... ON DUPLICATE KEY UPDATE and the audit entries are unexercised. The migration parses to the one CREATE TABLE intended and applies itself on the first panel action after this deploys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Upload the logo and the header mark from the panel
…ogle Every page banner is now an upload slot alongside the logo and the mark: eleven of them, named for the places the site already looks. Choose a file in Website content and it is on that page within a minute. The site resolves one lookup -- uploaded first, then a file committed under public/photos -- so the panel and the repository are the same question with one answer. On the SEO side, three things, one of which was a mistake worth recording. Banner photographs were alt="" and hidden from assistive technology. That is right for an ornament and wrong for these: it is a photograph of the thing being hired, on a site that wants to be found for "self drive car Nagercoil". An empty alt is an image Google cannot read and a screen reader is told nothing about. Each page now says what its banner shows, in words someone would actually search. They also carry width and height so the browser reserves the space -- without them the heading jumps as the banner arrives, which is unpleasant and a ranking signal Google measures directly. sitemap.xml gains image entries with captions, for the routes whose banner exists. Google finds an <img> by crawling, but a sitemap is how a picture reaches image search promptly, and for a rental business people search for what a car looks like as often as for its price. Slots with no file are left out: a sitemap full of 404s is worse than a short one. And the mistake. I said the site had no structured data and added an AutoRental block -- the grep that went looking covered src/ and scripts/ and not index.html, which has carried one for a while. The build then put two AutoRental entities with different names on every page, which is worse than having none: it asks Google to decide which of two businesses this is. The prerender step now edits the block that exists, adding the banner photographs and the logo as absolute URLs, and leaves it untouched if it ever stops being valid JSON. Uploads reach the sitemap too. They already reach visitors the moment they are uploaded, because the browser asks the panel on every page load, but a sitemap is written once at build time -- so fetch-content.mjs, which already talks to the panel before every build, now writes down which images were uploaded and prerender-seo merges them over the committed ones, in the same order the site resolves them. Checked by building against a panel that reports two uploads: the slots land in brand.json, the /cars entry names its banner with its caption, and the structured data lists the banner and the logo -- in exactly one block per page. Two things this does not fix. The names disagree: index.html says "Nitesha Cars" and seo.json says "NiteSha Cars & Bikes", and one business with two names is a thing local search notices. Changing which one is correct is not mine to decide. And an image only enters the sitemap at the next build, so an upload is visible to people immediately and to image search at the next deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Upload page banners from the panel, and put the images in front of Google
…o edit it People planning a trip to Kanyakumari search for what is there before they search for a car. A page answering that question is worth having on its own, and it is the only page on this site that gives someone a reason to arrive who was not already looking for a rental. Three parts. The home page gets a short section -- three places and a way through to the rest, rendering nothing at all when the list is empty, because a heading that says "where people go" above white space is worse than no heading. /places is the full list, grouped by the categories the panel sets rather than a list fixed in code. And Places to visit in the panel adds, edits, reorders, hides and deletes them, each with a photograph and a map link. Content rather than code, deliberately. The glass bridge is the case that makes the point: it opened recently, and a hardcoded list would have missed it until somebody noticed and asked. Seeded with nineteen places so the page is useful the moment it exists -- the coast, the temples, the forts, the waterfalls and the quieter beaches -- every one editable and deletable like any other row. Their map links are plain Google Maps search URLs built from the name, which keep working when a place is renamed; the long share links with session parameters in them do not. The map link is validated as http or https with a host, and again on the way out in the browser. A link field that accepts anything is stored XSS the first time somebody pastes a javascript: URL into it, and the person pasting need not be hostile, only careless with something they copied. Checked: javascript:, data:, //evil.com and ftp:// are all refused, and an empty field stays allowed. Photographs follow the pattern the rest of the panel uses -- stored above the document root where a deploy cannot erase them, validated by the same function the car photographs use, and read back through a script that only answers for names of the shape it generates. Driven in a browser against a stub panel: three cards and a "View all 5 places" button on the home page, five cards in four groups on /places, every photograph loading, Directions opening Google Maps in a new tab with rel="noopener noreferrer", and no page errors. That test first showed three broken images and looked like a bug in the photo URLs. The URLs were fine -- the 2x2 PNG I had been using as a fixture all day is corrupt, and PIL refuses it too. It passes PHP's getimagesize because that reads the header and does not decode, which is worth knowing about the upload check: a file with a valid PNG header and corrupt data will be accepted and then not display. The preview in the form shows that immediately, so it is visible rather than silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Places to visit in Kanyakumari: a page, a home section, and a panel to edit it
The site called itself two things. index.html said "Nitesha Cars" in its title, its og:site_name and its structured data; seo.json said "NiteSha Cars & Bikes"; two page titles used a third, shorter form; and the alt text wrote it out as "NiteSha Cars and Bikes". For local search that is weaker than any one of them would have been on its own -- the name is one of the things Google matches a business against, and a business with four spellings matches less well than a business with one. Fixed at the source rather than by hand, so it cannot drift again. The prerender step now writes og:site_name and the structured data's name from seo.json, which is the file that already holds the canonical details. Whatever the template says about the name stops mattering. The literals went too: index.html, the About and Contact pages, the two odd titles, and the panel's own pages, which had both spellings between them. Checked in a browser rather than in the markup, because & 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 & 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
"Who can sign in" described what the page is for, which is what a tooltip is for. In a sidebar beside Dashboard, Bookings, Vehicles and Reports it was the only entry that was a sentence, and the one word everybody was going to call it anyway was Users. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Call the page Users
Six boxes and three paragraphs on every vehicle, for something most cars do not have. The posting goes with it rather than being left behind. It was driven by the dashboard being opened, and with no form there is no way to describe a loan, change one, or close one -- so a car already carrying a loan row would have gone on writing an expense every month with nothing anywhere to stop it. A thing that writes to the books and cannot be seen is worse than a form nobody wanted. What stays: the vehicle_loans table and the loan_id on expenses. Any EMI already written is real money that was really paid, and the row it points at is the only explanation of it. EMI also stays an expense category, so it can still be entered by hand like any other cost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Take the Loan / EMI block out of the vehicle form
The home page listed twelve town names as twelve equal chips. What people actually ask for is narrower and more specific than a town: the railway station, the town station, the bus stand. "Self drive car at Nagercoil railway station" is a different search from "car rental Nagercoil", and the page said only the second. So the ten places we hand vehicles over at are named, with the kind of place each one is drawn beside it, and grouped under the town they sit in. Flat, ten chips would read as ten towns and overstate how far apart they are; under a heading each, the three Nagercoil spots read as what they are -- three doors into the same town. Every chip still leads somewhere: the town page it belongs to, or the list of towns for the two we do not have a page for yet. The twelve towns stay. They are the service area, they feed their own pages and the sitemap, and dropping them off the home page would quietly cut the one internal link each of those pages has from it -- so they are underneath, smaller, in a line rather than a row of chips. Parvathipuram and Muttom are named on the page, so they go into areaServed as well. A page that claims a place to the reader and not to Google is the same page disagreeing with itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The stops are a route now rather than three headings with chips under them: a gold line down the left, a point on it per town, and it draws itself downward as the section arrives. A list of places is a list; a line through them is what a delivery actually is, and it gives the eye an order to read ten names in rather than ten things landing at once. Each stop lands with one ring outward, its chips follow in sequence, and each chip's glyph draws itself in the same stroke the about-page marks use. One ring, not a pulse: three things blinking on a page somebody is trying to read an address off is not attractive, it is a reason to scroll past. The line is built per stop rather than as one rule behind all three, so each segment is released by its own stop's reveal and the route draws in step with the reading. No second observer, and nothing animating height: it is transform and opacity throughout, which the compositor does without touching layout. One thing worth remembering. The chips' entrance had to move onto the list item around each chip: an entrance animation carries fill-mode: both, so it goes on owning every property it names for good -- and a transform animated on the chip would have silently beaten the hover lift, leaving a chip that never moves under the pointer and no error anywhere to say why. Nobody who has asked for less motion gets any of this, and with scripting off every chip is visible from the start, as the rest of the file already arranges. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The section was words on white beside a drawing. It is now the thing it describes: a dark band with the photograph from the panel behind it, the route of stops down the left, and a sketch of the cape on the right with the towns pinned on it. Dark because of the photograph rather than for its own sake. On white, a coast at sunrise has to be faded to about a fifth before type will sit on it, by which point it is a grey smear that cost sixty kilobytes. On navy it runs at full strength on the side with no words over it, and a gradient does the separating. It also has to read with no photograph at all, since that is the state a panel slot is in until somebody fills it -- so the dark is the brand's own navy and the picture is an improvement on it, not a requirement of it. The map is drawn rather than fetched: a provider's tile is a request, a licence, an attribution line and a grey rectangle while it loads, and it invites somebody to read a service boundary off it that we have not drawn. What is not sketched is where the towns are. Every pin is placed from its real latitude and longitude, because somebody deciding whether we come to them reads distance off this. Two things that took a second attempt. The coastline was first smoothed through the midpoint of every edge, which rounds an outline evenly and turns a cape into an egg -- the tip at Kanyakumari is the one corner that has to survive, so the corners are cut back individually now and the runs between them stay straight. And the pin for Kanyakumari sat in the sea: the town is on the tip, the rounding pulls the drawn edge inside the corner, and the test that puts every pin through isPointInFill is what said so rather than anybody's eye. Five pins, not ten: Parvathipuram is inside Nagercoil, and the stations and the bus stand are inside their towns. At this size they would be one smudge of overlapping labels, and the list beside the map names all ten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The pickup points, drawn as a route
Thirty-nine of the site's forty-four pages now carry the phrase in the title, the heading or both, beside the place they are about. Most already said it somewhere; what they did not do was say it consistently or near the front, and half the town pages buried it after the place name as "— Self Drive Hire", which is the part of a title a search result cuts off. Five pages are deliberately left alone. Wedding cars and tourist vehicles come with a driver -- the wedding page's own FAQ answers "Does the wedding car come with a driver?" with "Wedding hire is with a driver" -- so the phrase there would be a claim the business does not make and the page itself contradicts two paragraphs later. The pickup points on the home page say it in sentences rather than in the chips. "Self drive car at Nagercoil Railway Station, at Nagercoil Town Railway Station and at Vadasery Bus Stand" is the phrase somebody types, and it now exists on the page in that order -- but ten chips each opening with the same four words is those words forty times in one glance, which is the pattern search engines have discounted for a decade and the one that makes a page read as written for a machine. Bikes say self drive bike rather than self drive car, for the same reason the wedding pages say neither. Nothing here is panel copy. Titles, the town data and the service-area headings are the site's own files, so these actually take effect; the heading on the home page's own band is edited in Website content and is the business's to change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Two things. The line of twelve town names under the pickup points is gone. They are one step further away now rather than unreachable: "See every town we deliver to" goes to /car-rental, which lists all twelve and links each. And the site says rental where it said hire. Nought occurrences left in the built pages, down from a hundred and twenty-nine. British English calls it car hire; nobody in Tamil Nadu searching for one types that. Not a find-and-replace, because "hire" is a noun here as often as a verb and the two take different words: a rental, but to rent. A mechanical pass produced "everyone who rentals a vehicle", "People rental here to reach the temple", and "Rental a Maruti Swift without a driver" -- all of which were caught by searching the result for "rental" sitting in a verb slot, rather than by reading four hundred strings again. The ordered rules did the bulk; eleven verb forms were then put right by hand, and a second sweep with wider nets found nothing else. Headings move with it: "What we hire in Nagercoil" is "Rental cars in Nagercoil", "Hiring in Marthandam" is "Rental cars in Marthandam", and the two band headings on the home page follow. Those two are panel copy. The baked HTML carries the new wording, but if the panel holds a saved version of either section it will win a moment after the page loads -- so they want changing in Website content too, and that is the business's to do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Three cards in a three-wide grid meant the home page named three of the district's places and stopped. The rest were behind a link, and a link is a decision somebody has to make before they have seen anything worth deciding about. All twelve go past now, four at a time, and a card leaving the frame is what says there are more. Four rather than three because these cards are a picture, a category and a sentence: narrower than the service cards and perfectly readable at a quarter of the column, where three left each one wider than it had anything to fill with. The width is worked out from the column rather than set in rem, so four cards and their three gaps come to exactly the width the rest of the page uses -- a fixed width that nearly fits leaves a sliver of a fifth card, which reads as a fault rather than as there being more. The crawl moves out of Services.tsx into lib/useRailCrawl and both rails use it. A hundred lines of scroll arithmetic copied into a second file is a hundred lines that get fixed in one of them. One thing found on the way. The rail reported itself as several thousand pixels wide to document.scrollWidth -- which is the number every "is this page wider than the screen" check reads, Lighthouse's included -- even though the page could not be panned sideways and never could. Saying contain: paint, which is only what was already true of a scroller, settles it. Pixel for pixel the rails render identically with it and without, so nothing that is drawn has changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Say "self drive car" where a place is named
The names did not start at the same height across a row, and the cards were five hundred pixels tall -- four of them filled a laptop screen on their own. Both come from the same thing: every part of the card was sized by what happened to be in it. The picture took its height from its own proportions and the name took one line or two depending on the name, so each card set its own heights and the row read as four separate things rather than as a row. The picture is a fixed height now, the name has two lines of room whether it needs them or not, and the sentence has three. Every heading in a row starts on the same line, every Directions button ends on one, and the card is three hundred and seventy-seven pixels rather than five hundred and seven. Checked with photographs of three different shapes standing in for the panel's, which is the condition the headings went out of step in, on the home rail and on the places page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Hold the place cards to one line each
Both rails started their animation frame on mount and kept it for as long as the tab was open. On the home page both are below the fold, so the first thing they did was animate something nobody could see, on the one thread that was also hydrating the page -- and then carry on doing it for the rest of the visit. They wait to be on screen now, with a little margin so a rail is already moving when it comes into view rather than visibly starting under somebody's eye, and they stop again when it leaves. What it bought, measured on a throttled phone: the home page's LCP went 2176ms to 2040ms. Blocking time did not move -- 486ms to 477ms -- which says plainly that the blocking is React hydrating sixteen hundred nodes rather than anything the rails do. Worth saying rather than claiming the win: the real gain is a phone that stops running two animation loops for sections nobody is looking at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Do not run a rail nobody is looking at
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
Repeat a screenful, not the whole list
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
No description provided.