Repository navigation
MAIN - #17280
MAIN#17280niteeshkanna-sh wants to merge 360 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! |
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
…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
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
"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
No description provided.