Repository navigation
MAIN - #17280
MAIN#17280niteeshkanna-sh wants to merge 366 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! |
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
…o floating buttons **The footer column headed "Pages" was nine entries of mixed kinds** -- six services, a place to visit, an in-page anchor and an enquiry link. It is headed Services now and names five, with a "View all services" button to a new /services page carrying all of them. Places to visit and Enquire move to Get in touch, where they belong. The five come from the same list the home page reads, so adding a service in the panel puts it on the home page, on /services and in the footer at once rather than in three places that drift. The button only appears when there are more than five to see. /services is a route, prerendered, in the sitemap, and first in the header's Services dropdown -- otherwise the page would exist only as a footer button. It reuses the home page's grid with its heading suppressed, because a heading repeating the banner directly above it reads as a rendering fault. **Social icons in the footer, with the links set in the panel.** Website content gains a Social media section with a named box per network. Named boxes rather than a list of rows: the icon has to match the link, and a free-text "network" field would let someone type "insta" and get no icon with nothing explaining why. An empty box means that icon is not shown at all -- an icon linking nowhere is worse than no icon -- so only WhatsApp appears until the rest are filled in. Each icon carries the network as its accessible name; six identical links announced as "link" is a row nobody can use. **Two buttons that follow you down the page**: WhatsApp, and back to the top. Back to the top only fades in past 600px, because a button that scrolls to where you already are teaches people to ignore it, and it honours prefers-reduced-motion rather than throwing the page at anyone who asked their system for less of that. Also: the footer's phone number is derived from seo.json rather than typed out a second time, so the displayed number and the one tel: dials cannot end up different. And a services-hero image slot, so the new page's banner can be uploaded like every other. Verified in Chromium at 320, 390 and 1280px: the footer shows Get in touch, Services and Follow us; the Services column lists exactly five with the view-all button; all six icons render and carry their network names when the links are filled; /services lists all six services; both floating buttons are present and sized 48 and 56px; and nothing overflows 320px on any page, /services included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Footer: five services and a way to the rest, plus social icons and two floating buttons
It sat beside "+ Add Vehicle" and opened /fleet-check.html. Taken out at the owner's request, along with .panel-header-link, which nothing else used. The page itself stays. It is a connection test rather than a feature of the panel, and the browser console still points at it when the site cannot reach the panel -- which is the moment it is actually wanted, and a moment when the panel may not be loading either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Two things were hiding it. **object-cover in a 44px circle.** A real logo is whatever shape it is, usually square or wide, and usually carries the business name inside it. Cropped to a circle at 44px, the name was cut off and what survived was past reading. The drawn badge is a circle because it was designed as one; an uploaded logo now gets its own frame and object-contain, so the whole mark shows. **Its own background covered the white plate.** A logo with a dark background sat edge to edge in the badge, so the white behind it never showed and the result was a dark mark on a dark header with nothing separating them. The plate gains padding, and that margin is what makes the logo visible. The height is capped with max-h rather than h-full. Inside a grid, a percentage height resolves against a row the image itself sizes, so h-full came back as the image's own 400px and overflowed the plate it was supposed to fit -- which is the first thing this change got wrong. The footer showed a letter N while an uploaded logo sat in the header; it uses the logo too now, and falls back to the N when there is none. Verified with a stand-in logo the shape of the real one -- a dark square with the name inside: header plate 48px with the mark 36px inside it, footer 40 and 28, nothing overflowing at 320px, and the drawn badge still rendering when no logo is uploaded. The stand-in is not committed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
YouTube, X and LinkedIn are gone from the panel's form, from the shipped copy and from the footer's icon list. The icon list is what decides what renders, so a stored override still holding the three old keys cannot put them back on the site -- mergeContent copies a section across whole, extra keys included, and the component ignores anything it does not have an icon for. Verified: the panel's Social media section now offers Heading, WhatsApp, Instagram and Facebook; the footer renders those three and no others; both copies of content-defaults.json still match byte for byte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
…reas image
**The drawn NS badge is gone**, from the header and from the footer's letter N.
Both were placeholders so the site never looked unfinished before real artwork
existed. Once there is a logo to upload, a placeholder that looks like a logo
is a second mark competing with the first. Upload one under Website content and
it appears; until then the name carries the header on its own.
**The banner scrim was hiding the photograph.** It was a flat 82-88% navy over
the whole band, which kept white type legible against anything and turned every
uploaded photo into a dark rectangle. It is weighted now rather than flat:
heavy on the left where the heading and intro sit, clearing across the right
where there is nothing to read. Below 1024px the text runs the full width, so
that breakpoint keeps even coverage, just lighter.
Measured against a deliberately extreme banner -- a near-white sky, the worst
case for white type -- by hiding the words and sampling the brightest pixel
under each text box:
heading intro right third
before 12.79:1 14.93:1 0.036 luminance
after 3.55:1 8.51:1 0.204 luminance
The heading is large text, which WCAG puts at 3:1; the intro is body text at
4.5:1. Both clear their thresholds with the picture roughly six times more
visible. On a phone: 5.62:1 and 6.00:1, band luminance 0.036 to 0.109.
**"Areas we serve panel" was an upload that went nowhere.** The panel offers
the slot, but AreasServed rendered SectionArt, which only ever looks in
public/photos -- so an uploaded picture was ignored and the drawing stayed.
It reads the upload now, and the upload wins over both the file and the
drawing. It also gets a real alt, since it is a photograph of the district
rather than an ornament.
Verified with stand-in images seeded into the brand map: the header shows no
badge with nothing uploaded, the footer shows no N, and the areas panel renders
the uploaded picture at 485x243 with its description. The stand-ins are not
committed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Logo, banner scrim, the areas image, and a trimmed social section
…t has one **Dates that are already taken now say so before they are picked.** A date box takes min, max and step and nothing else -- there is no way to say "these particular days are gone" and no way to colour one -- so the days are drawn under it, gold for taken and struck through, disabled so a keyboard cannot reach them either. Colour alone is not a message to someone who cannot see it. The date boxes stay beside the grid. Whoever is on the phone to a customer types a date faster than they page a calendar, and typing a taken day is caught on the way out with the reason said rather than the box silently clearing. A new public endpoint answers what is taken. It returns days and nothing else: a date being spoken for is ordinary availability, the kind any hire company publishes; who took it is not. It reads BLOCKING_STATUSES -- Confirmed, Ready and Active -- so it, the site and the check that refuses a double booking on save cannot disagree about what "taken" means. The save-time check still runs; this is the same truth shown earlier, not a replacement. The public form has no vehicle chosen at first, so it marks only the days on which every vehicle is out -- one car being busy says nothing about whether we can help. Pick a car and it narrows to that car's diary. In the panel it is the selected vehicle throughout, minus the booking being edited, which would otherwise block its own dates and refuse to let you keep them. **A bin, everywhere something can be removed.** One drawing rather than the word "Delete" in some places and nothing at all in others: a row of buttons is scanned by shape long before it is read. Each one carries a title and an aria-label, because an icon button with neither is announced as nothing. Vehicles the existing Retire action, now an icon Places the existing Delete, now an icon Enquiries new -- there was no way to remove one at all Content new -- a repeated item had to be emptied box by box Deleting an enquiry is refused when it became a booking: bookings.enquiry_id points at that row, so removing it would either be refused by the database or leave a booking pointing at nothing. The error names the booking and says to cancel that instead. Bookings and expenses keep Cancel and Void rather than gaining a delete -- they are the record of money owed, and the audit trail is the point of them. The content bin empties the item's boxes, because a section is saved whole and an item with nothing in it is dropped. That is exactly what the hint used to ask people to do by hand. It is not gone until Save, so the row says so instead of letting someone leave believing otherwise. Also removes .transaction-delete, a rule no markup had used since before this branch. Verified against a stubbed availability answer: both calendars paint the right days gold and every one of them is disabled; a vehicle's second range in the following month correctly does not appear in this month's grid; the content bin empties three filled fields, marks the row and says when it takes effect; the places and vehicle bins carry their labels. No page overflows 320px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Booked days shown in gold and refused, and a bin on every section that has one
Fourteen places showed an image and only some of them could be changed without a deploy. Two separate causes. **SectionArt never asked the panel.** It looked in public/photos and nowhere else, so the six cards under "What we hire", the three "How it works" steps and the areas-we-serve panel all ignored anything uploaded. That is worse than having no slot: someone uploads a photograph of their own fleet, the panel says it saved, and the drawing stays. It asks useSiteImage now -- uploaded first, then a file in the repository, then the drawing -- which also makes the special case AreasServed carried for `coast` unnecessary. **Three more were hardcoded paths**: the home page background, the two photographs beside "Why hire from us", and the open road band. The first picture a visitor sees was the one picture the owner could not change. Website content gains thirteen slots: Home page background, the two Why-hire photos, Open road band, the six cards named after the page each links to, and the three steps. Nineteen images on the site, every one of them uploadable. Slot names may now contain digits. They could not: the sanitiser stripped them and the reader's pattern rejected them, so step-1, step-2 and step-3 would all have been written as "step-" and then served as a 404. The open road band stays a CSS background rather than becoming an <img>. If the file is missing the band falls back to the navy beneath it and still looks deliberate, where an <img> would leave a broken-image icon on a live page. Verified by seeding all thirteen new slots: every one renders, no drawing is left on the home page, the six cards carry their uploads on /services too, and nothing overflows at 390px. The seeds are not committed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every picture on the site is uploadable from the panel
**The grid was ragged because of a control nobody chose the width of.** Each
slot carried a bare <input type="file">, which prints "Choose File — No file
chosen" at whatever width the browser feels like. With nineteen of them on one
page no two rows agreed where anything sat.
The input is still there and still the thing that opens the file chooser; it is
just no longer the thing anyone looks at. Every card is now the same width,
its buttons pinned to the bottom so they line up across a row whatever the
label above them did.
**Every image can be framed before it is uploaded, and re-framed afterwards.**
Drag to move, a slider and a pair of buttons to zoom. Whatever is inside the
frame is what the site shows -- rather than the browser cropping a photograph
of some other proportion and nobody knowing which part survived until the page
loads.
The frame is the shape the site actually lays that slot out in, and each is
saved at a size to match:
logo, snake 1:1 512x512
the page banners 16:5 1600x500
home hero, open road 16:9 1600x900
the why-us photos 4:3 1200x900
cards and steps 16:10 1200x750
One crop for all of them would be wrong in both directions: a logo squeezed
into a letterbox loses its top and bottom, a banner squared off loses its
sides.
A slot with an image gets Replace, Edit and Remove; an empty one gets Choose
image. Edit re-frames what is already stored, which works because the panel
serves it from this same origin -- a cross-origin source would taint the canvas
and toBlob would throw.
The framed result goes into the hidden input through a DataTransfer, so this
stays an ordinary form post rather than a fetch with its own error handling.
Remove sets the action on that same form: a form cannot contain another, and a
second one beside it would be a second cell in the grid.
Also fixes a rule of mine from the mobile pass. `input[type="file"] { width:
100% }` outranked the visually-hidden class and stretched every hidden input to
the full width, pushing a 390px page out to 447. It now excludes the one input
that is not a visible control.
Verified: 28 cards, all one width, no native file control drawn, three to a row
on a desktop and one on a phone, nothing over 320px. Choosing a file opens a
frame measuring 1.00 for the logo and 3.20 for a banner; the zoom buttons move
100 to 140; saving puts a webp in the form and posts brand-upload; Edit reopens
the stored image and posts a new one; Remove confirms by name and posts
brand-clear with no file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Dropped into public/photos as home-hero.webp, which is all it takes: the build manifest picks up whatever is in that folder, and the hero already reads the home-hero slot. An upload in the panel still wins over it, so this is the default rather than a fixture. Converted from PNG to WebP, 423 KB down to 54 KB. It is the largest thing above the fold and is fetched at high priority, so the size is the difference between the page painting once and painting twice. Kept at its own 383x801 rather than upscaled in the file: the browser stretches it either way, and the extra bytes would buy no detail. Worth knowing what that means on a desktop. The picture is a portrait and the hero band is wide, so at 1280px it is stretched 3.3x across and only a slice of it is in frame -- it reads as a dark texture at 25% opacity rather than as a photograph. On a phone it fits almost exactly and reads as intended. A wider original, or the same shot framed landscape, would carry the desktop view too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Site images: a grid that lines up, a frame you can crop and zoom, and a hero photograph
**The right-hand end of the footer is now Find us**: the address, a map of it, and a directions link. The social icons move up under the business name, which is where a reader looks for both anyway and leaves the last column free for the map. **The map is built here from a place name, not from a URL typed into the panel.** An <iframe src> is the one field where a pasted address is genuinely dangerous -- whatever it points at renders inside our page. Encoding a search term into a URL this code constructs means the panel can only ever move the pin, never change what is embedded. Leave the box empty and there is no map rather than a frame pointing nowhere. It is lazy, and proven to be: nothing is fetched until the footer is scrolled to. A map at the bottom of the page should cost nothing to anyone who never reaches it. **Website content gains a Footer section** -- the line under the business name, the heading above the map, the address one line at a time, where the map should point, and the directions link text. The blurb was written into the component, so the one paragraph describing the business could only be changed by a deploy. Verified: four columns on a desktop at 84, 370, 656 and 942px and stacked on a phone, the map 252x160 and 348x160 with loading="lazy", the address three lines, one directions link, the social icons in the first column, and nothing overflowing 390px. With the network stood in for, the frame is not fetched before the footer is scrolled to and paints once it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
"The images I added are not on the site" has two causes that look identical from a browser and need opposite fixes. Either the panel has not stored the picture -- nothing uploaded, or site_images never created on this database -- or it has, and the copy of the website published on the server was built before that slot existed and has nowhere to put it. Nothing anywhere said which, so the only way through was to guess and check. Everything in this repository is on the far side of that question already: the panel returns every slot it holds, and the built site asks for every slot it is given. Verified by serving the published build against a stub panel -- all sixteen pictures on the home page, and every one on the seven other pages, come from the panel when the panel answers. So the remaining failure lives between the two, and it is a publishing step rather than a fault. The site check now names it: - public-content.php reports imagesReady separately from the picture list. An empty list means "nobody has uploaded anything" or "the table does not exist", and those send someone to two different places. - publish-site.mjs writes build.json -- when the build was published, the commit it came from, and the hashed bundle names. A server holding an older publish says so instead of looking current. - fleet-check.html asks both, loads each stored picture to see that the file is really there, and searches the site's own bundle for each slot name. A picture stored for a slot the running code has never heard of is exactly a stale deploy, and it is invisible from every other angle: the panel is right, the file is there, and the page keeps showing the drawing. Checked against six servers -- healthy, no table, nothing uploaded, a stale deploy, a missing file, and no build.json -- and each one reports its own cause. The Pictures section in the panel also stops describing itself as the logo and the mark. It has covered every picture on the site since the slots were added, and a heading that says otherwise is why someone would not think to look there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
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
"Check availability in X" on the town, model and service-area pages was the one arrow on the site that did not move. It was left that way in the hover audit because all three agreed with each other, which made it a style rather than a fault -- but every other arrow slides, and three buttons agreeing is not a reason for the site to disagree with itself. The same two words the rest of them use: group on the link, and transition group-hover:translate-x-1 on the arrow. Measured on all three pages: the arrow goes from none to 4px and back, and the gold still lightens from #D4AF37 to #F0D060 underneath it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The last three arrows that stood still
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
No description provided.