MAIN - #17280
MAIN#17280niteeshkanna-sh wants to merge 152 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! |
Scaffold a new standalone single-page app alongside the existing project: - Vite 8 with @vitejs/plugin-react, TypeScript, React 19 - Tailwind CSS v4 wired through @tailwindcss/vite - Replace the Vite demo page with a minimal starter component - Pin an inline (empty) PostCSS config so Vite does not walk up and load the parent directory's postcss.config.js, which broke the build - README covering setup, scripts, and layout Verified: npm run lint, npm run build, and npm run dev all pass, and the dev server renders the styled page with working component state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
`npm ci` failed at the install step of every CI job:
npm ERR! `npm ci` can only install packages when your package.json
npm ERR! and package-lock.json are in sync.
npm ERR! Missing: yaml@2.9.1 from lock file
The lockfile predates the current dependency tree: tailwindcss 3.4.19
pulls postcss-load-config, which needs yaml@^2, but the lockfile only
carried the top-level yaml@1.10.2.
Regenerated with `npm install --package-lock-only`. The change adds the
missing nested yaml@2.9.1 and drops some stale `peer` metadata flags; no
dependency version or integrity hash changes.
Verified `npm ci` validates under both npm 10 and npm 8.19.4, the version
the lint workflow pins via Node 16.
This unblocks the install step only. The workflows have further problems
that predate this branch, noted on the pull request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Adds a workflow that builds my-app and publishes it to GitHub Pages, plus the CNAME for the custom domain. The workflow installs with `npm ci`, lints, builds, and uploads my-app/dist as the Pages artifact. It triggers on pushes to main that touch my-app/, and on manual dispatch. Because it is scoped to main, merging is what deploys; nothing publishes from a feature branch. `public/CNAME` is copied verbatim into dist/ by Vite, landing at the site root where Pages looks for it. Vite's default base of '/' is correct here: a custom domain serves from the root, and the built asset paths are root-relative to match. Verified by running the workflow's exact steps locally -- npm ci, npm run lint, npm run build -- and confirming dist/ contains CNAME, index.html with root-relative asset URLs, and the hashed JS/CSS bundles. Deploying still needs two manual steps that cannot be done from here: enabling Pages with the GitHub Actions source, and pointing DNS at GitHub. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Scaffolds my-app/ as a standalone Vite + React + TypeScript + Tailwind SPA, syncs the root package-lock.json with package.json so npm ci works again, and adds a GitHub Pages deploy workflow with a CNAME for niteshacars.in. Merged at the repo owner's request. CI is red from pre-existing failures that reproduce on main and are unrelated to these changes; see the pull request description for the four causes.
Every workflow run in this repo's history has failed, on main as well as on every branch. The cause is commit dc079b1, which replaced the monorepo's root package.json with a create-react-app app's. That dropped the `workspaces` field and the alex, eslint, prettier and test:integration scripts the inherited CI depends on, leaving four distinct failures: lint npm run alex -> Missing script: "alex" Build / build react-scripts build -> no public/index.html Integration Tests npm run test:integration -> Missing script E2E tasks/publish.sh -> No workspaces found Remove build-and-test.yml, e2e-base.yml and lint.yml. These are upstream create-react-app CI: they exercise packages/ through lerna, verdaccio and puppeteer on Node 16. Restoring the old root package.json would not fix them either -- it runs `prettier --list-different` and `eslint .` across the whole repo, now including the uploaded public_html/ tree and my-app/, so it trades four failures for a larger set. Retarget node.js.yml at my-app, the app this repo actively maintains, running install, lint and build. Drop the 18.x matrix leg: Vite 8 requires ^20.19.0 || >=22.12.0, so that leg could never pass. This also closes a real gap. No workflow covered my-app on pull requests -- deploy-my-app.yml only runs on main -- so its code was never built or linted before reaching the default branch. packages/, docusaurus/, tasks/ and test/ are left untouched; only CI changes. Verified by running the workflow's exact steps -- npm ci, npm run lint, npm run build -- from my-app on Node 22. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Removes the inherited upstream create-react-app CI (build-and-test.yml, e2e-base.yml, lint.yml), which exercised packages/ through lerna, verdaccio and puppeteer on Node 16 and could not pass after commit dc079b1 replaced the monorepo root package.json with a create-react-app app's. Retargets node.js.yml at my-app, running install, lint and build on Node 20.x and 22.x. The 18.x leg is dropped because Vite 8 requires ^20.19.0 || >=22.12.0. This also gives my-app its first pull request coverage; deploy-my-app.yml only runs on main, so its code was previously never built or linted before reaching the default branch.
This repository forked create-react-app and carried its entire monorepo, none of which was ever developed here. Commit dc079b1 replaced the root package.json with an app's, dropping the workspaces field, so the monorepo had already stopped being buildable; PR #4 then removed the CI that exercised it. What remained was 116k lines of upstream source that nothing builds, tests, or ships. Removed: packages/ 334 files, 7.7M upstream CRA packages docusaurus/ 59 files, 508K upstream documentation site test/ 78 files, 456K upstream integration tests tasks/ 15 files, 88K upstream release and e2e scripts CHANGELOG*.md 6 files upstream release history CONTRIBUTING.md upstream contributor guide SECURITY.md Create React App disclosure policy screencast*.svg upstream README assets Also removed, because each only configured the directories above and now resolves to nothing: lerna.json monorepo config, no packages left netlify.toml builds docusaurus/website .eslintrc.json, .eslintignore scoped to packages/, test/, docusaurus/ .prettierrc, .prettierignore scoped to packages/, test/ .alexrc, .alexignore alex prose linter, upstream prose only .github/CODEOWNERS assigns packages/ and docusaurus/ to upstream maintainers who do not work here .github/FUNDING.yml upstream funding links .github/ISSUE_TEMPLATE/ templates for reporting CRA bugs .github/lock.yml, stale.yml upstream issue-bot config LICENSE is kept: the fork lineage is real and removing a license is not a cleanup decision. CODE_OF_CONDUCT.md and the pull request template are generic and still apply. README.md described a create-react-app app at the repo root via `npm start`. No such app exists -- the root has no src/ or public/ -- so it was boilerplate for something that could not run. Rewritten to describe what is actually here: my-app and public_html, how each is built and deployed, and this history. Nothing in my-app/ or public_html/ referenced any removed path. Verified with the CI steps -- npm ci, npm run lint, npm run build -- from my-app, and dist/ still contains CNAME, index.html and the hashed bundles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Removes ~116,000 lines of upstream create-react-app source that nothing in this repository builds, tests, or ships: packages/, docusaurus/, test/, tasks/, the changelogs, CONTRIBUTING.md, SECURITY.md and the screencast assets, along with the configs that only scoped to those directories -- lerna.json, netlify.toml, the eslint/prettier/alex configs, and the upstream .github meta files. LICENSE is kept; the fork lineage is real. CODE_OF_CONDUCT.md and the pull request template are generic and still apply. README.md described a create-react-app app at the repo root that does not exist, since the root has no src/ or public/. Rewritten to describe my-app and public_html, how each is built and deployed, and this history. Verified with the CI steps from my-app -- npm ci, npm run lint, npm run build -- and by grepping the tree for references to every removed path.
Replaces the Vite starter with an actual site for the business: hero, filterable fleet, how-it-works, and an enquiry form that posts to the live admin API. The old public site's theme is not in this repository. asset-manifest.json references /static/css/main.0700b630.css and /static/js/main.b3748527.js, and neither file was ever committed. What did survive is the brand token block in public_html/admin.niteshacars.in/admin/admin.css, whose comments name the colours outright -- navy #0A0E20, gold #F5A500, Poppins, 14px radius, warm #FAF8F4 ground. Those are carried into @theme in index.css, so the public site and the admin panel now share one palette. The fleet in src/data/cars.ts is placeholder data shaped to the real vehicles and vehicle_rates tables: body type, fuel, transmission, seats, model year, daily/weekly/monthly rates, KM limit, extra-KM rate and deposit. Editing that file is all it takes to put the real cars up. The enquiry form is wired to the real endpoint rather than mocked. api/enquiry-submit.php needs no sign-in and config.sample.php already allowlists https://niteshacars.in for CORS, so submissions land in the admin panel. It sends the honeypot field the endpoint expects, surfaces per-field errors from the API, and shows the returned enquiry number on success. vehicle_id is deliberately not sent. The endpoint validates it against the vehicles table and the placeholder ids do not exist there, so sending one would be rejected; the chosen car goes in `requirements` as free text instead. Once cars.ts carries real ids the vehicle can be linked properly. Live inventory is not fetched because api/vehicles.php calls api_guard('vehicle.view'), which a public page cannot satisfy. Images reuse the two web-sized files already in public_html: background car.webp (8.4K) and brezza1.avif (27K). The two PNGs there are 876K and 3.5M, too heavy to ship without processing. Verified with npm ci, npm run lint and npm run build, then driven in a real browser: six cards render, the SUV filter narrows to two, and clicking a card's enquire button preselects that car in the form. The only console error is the Google Fonts request, which this sandbox's proxy blocks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Replaces the Vite starter with a site for the business: hero, filterable fleet, how-it-works, and an enquiry form posting to the live admin API. The theme is rebuilt from the brand tokens in the admin panel's admin.css -- navy #0A0E20, gold #F5A500, Poppins, 14px radius -- because the old public site's compiled CSS and JS were never committed to this repository. The fleet in src/data/cars.ts is placeholder data shaped to the real vehicles and vehicle_rates tables; editing that file puts the real cars up. Live inventory cannot be fetched because api/vehicles.php requires authentication. Verified with lint and build on Node 20 and 22, and driven in a browser.
public_html/admin.niteshacars.in/admin/config.php was committed with real values: a database name, user and password that are in use. This repository is a fork of facebook/create-react-app and is public, so those credentials are readable by anyone who finds it. Untracks the file and adds it to .gitignore. config.sample.php is the template to copy on a new server, and its values were checked against the live ones -- they differ, so the sample leaks nothing. The working copy is left in place; `git rm --cached` only stops tracking. The copy on the server is untouched, and the panel keeps running. This does NOT remove the file from git history. Every commit that carried it still does, so the credentials remain readable to anyone who clones. Rotating the database password is what actually closes this; that has to happen in the hosting panel. Scanned the rest of public_html for other committed secrets. Only two other files matched a credential-shaped pattern, and neither is one: config.sample.php holds example values, and tools/test-auth.php uses a fixed throwaway password for a test user it creates and deletes in the same run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
config.php was committed with a real database name, user and password. This repository is public, so those values were readable by anyone who found it. Untracks the file and adds it to .gitignore. The working copy and the server's copy are both untouched, so the admin panel keeps running. This does not remove the file from git history; rotating the database password in the hosting panel is what actually closes the exposure.
Replaces the placeholders the site shipped with -- +91 90000 00000 and hello@niteshacars.in -- which were visible to customers on the live domain. Four places: the header's Call us button, and the footer's phone and email, each as both the visible text and the tel:/mailto: href. The tel: href carries no spaces (+916374942976) so phone dialers parse it, while the visible text keeps the readable +91 63749 42976 grouping. Verified in a browser: all four resolve to the new values, and a grep over my-app confirms no placeholder survives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Replaces the placeholders the site shipped with -- +91 90000 00000 and hello@niteshacars.in -- which were visible to customers on the live domain. Four places: the header's Call us button, and the footer's phone and email, each as both the visible text and the tel:/mailto: href. The tel: href carries no spaces so phone dialers parse it, while the visible text keeps the readable +91 63749 42976 grouping. Verified in a browser, and by grepping my-app for surviving placeholders.
The site shipped with six invented vehicles -- a Brezza, Swift, City, Innova,
Baleno and Nexon EV -- with invented rates, KM limits and deposits. On a live
domain that is worse than showing nothing: a customer could read a price for a
car that does not exist and call about it.
cars.ts is now an empty array with a commented template for one entry. Adding
the real fleet is filling it back in; nothing else has to change.
The three places that read the array each handle it being empty, so the page
stays coherent rather than rendering a blank grid:
Fleet shows a card inviting an enquiry or a call, and hides the
body-type filters, which would otherwise be a row of controls
that filter nothing
Enquiry hides the car picker, and relabels the free-text field to
"What kind of car do you need?" so the visitor can say what the
dropdown would have asked
Header unchanged; it never read the fleet
Verified in a browser: no placeholder name appears anywhere on the page, zero
cards and zero filters render, the empty-state card shows, the car picker is
gone, the enquiry form still works, and there are no page errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Replaces the single scrolling page with six routes matching the old site's menu: Home, About Us, Our Cars, Tariff, Blog, Contact, plus a 404. / Home hero, how it works, a closing call to action /about About Us what hiring involves; deliberately generic copy /cars Our Cars the fleet listing /tariff Tariff a rate table built from the same fleet data /blog Blog post list, currently empty /contact Contact phone and email cards, and the enquiry form * 404 a real page rather than a blank screen Uses react-router-dom. The nav marks the current page with a gold dot above it, matching the menu on the old site, and collapses to a button-toggled list below the lg breakpoint so the six items do not crowd a phone. Two components were written for one page and had to be rewired. Fleet took an onEnquire callback to scroll down to the form; it now links to /contact?car=<name>, and Enquiry reads that query parameter, so choosing a car survives the navigation between pages. Neither takes props any more. Tariff and Blog handle being empty the same way the fleet listing does: a card that invites a call or an enquiry, rather than an empty table or an empty list. About and Blog carry TODO comments -- their copy states only what the booking flow actually does, because inventing a founding year, a fleet size or posts would put untrue claims on a live site. scripts/spa-fallback.mjs copies dist/index.html to dist/404.html after every build. GitHub Pages serves static files and knows nothing about client-side routes, so without it a direct visit to /about, or a refresh on /cars, returns Pages' own 404 and the app never boots. Pages does serve 404.html for anything it cannot match, so an exact copy loads the app and the router resolves the URL. Copied rather than symlinked because the Pages artifact upload does not follow symlinks, and written in Node so the build still works on Windows. ScrollToTop resets scroll on navigation, since a client-side route change otherwise keeps the previous scroll position and opens a page halfway down. Hash links are left alone so #anchors still work. Verified in a browser: all seven routes render the right heading, clicking a nav item changes the URL, /contact?car= is handled with the fleet empty, the enquiry form is present, the mobile menu opens with all seven links, and no page errors are raised. 404.html is byte-identical to index.html. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
) Two changes shipped together. The six invented vehicles are gone. cars.ts is now an empty array with a commented template; everything reading it handles being empty, so no page renders a hole. Adding the real fleet is filling the array back in. The single scrolling page becomes six routes matching the old site's menu -- Home, About Us, Our Cars, Tariff, Blog, Contact -- plus a 404 page. The nav marks the current page with a gold dot and collapses to a toggle on phones. Fleet and Enquiry were written for one page; a car chosen on the fleet page now travels as /contact?car=<name> so the choice survives the navigation. scripts/spa-fallback.mjs copies dist/index.html to dist/404.html after every build. GitHub Pages knows nothing about client-side routes, so without it a direct visit to /about or a refresh on /cars returns Pages' own 404 and the app never boots. About and Blog carry TODO comments: their copy states only what the booking flow actually does, rather than inventing a founding year or posts.
Two pieces: a public read-only endpoint so the panel becomes the single place the fleet is managed, and the SEO work needed for a client-rendered site on a static host. ## The endpoint api/public-vehicles.php returns vehicles whose status is Available, with rates read from the dated rate card the same way the admin reads them, so a price scheduled for next month does not leak out early. Adding a car in the panel puts it on the site; setting one to Maintenance takes it off. It deliberately omits columns the admin's own endpoint returns: reg_number, a plate is not the public's business; current_km, which reveals how hard a car has been worked; created_by and created_at, internal; colour, a swatch for the panel's UI. A vehicle with no rate card is skipped rather than listed with a blank price, since that would invite an enquiry nobody can answer. CORS uses the existing public_site_origin allowlist, echoed rather than wildcarded, matching enquiry-submit.php. GET only. No session, no writes. The site falls back to src/data/cars.ts when the request fails -- offline, a CORS rejection, or the endpoint not yet uploaded. That file is empty, so the page degrades to its "ask us what's available" state instead of an error. ## SEO The app renders in the browser, so every route was served the same index.html carrying the home page's title. Google runs JavaScript and would eventually see the right tags, but WhatsApp, Facebook and X do not run it at all -- they read the HTML as served and stop. Sharing niteshacars.in/tariff previewed as the home page. scripts/prerender-seo.mjs now writes a directory per route with that route's title, description, canonical and og: tags baked in. Pages serves /tariff/ from /tariff/index.html. dist/404.html keeps the root page's tags, since it is what Pages serves for unmatched paths. src/data/seo.json is the single source for those strings and is also read by src/lib/useSeo.ts, which updates the same tags on client-side navigation -- the app never re-requests HTML after boot, so without it the title would stay on whichever page loaded first. Also added: sitemap.xml generated from the same file, robots.txt pointing at it, and AutoRental structured data in index.html carrying the business name, phone and email, which is what local search results are built from. This replaces scripts/spa-fallback.mjs; the 404 copy now happens here. Verified: php -l passes on the endpoint; all six routes build with their own title, canonical and og:url; 404.html carries the root tags; the sitemap lists every route. In a browser with the API unreachable, the listing falls back to the empty state rather than hanging, the title changes on navigation, and no page errors are raised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
api/public-vehicles.php returns Available vehicles with rates from the dated rate card, so adding a car in the admin panel puts it on the site. It omits registration numbers, current KM and internal columns, and skips vehicles with no rate card rather than listing a blank price. CORS reuses the existing public_site_origin allowlist. The site falls back to an empty cars.ts when the request fails, so it degrades rather than erroring. For SEO, every route was served the same index.html carrying the home page's title. Google runs JavaScript, but WhatsApp, Facebook and X do not -- sharing /tariff previewed as the home page. The build now writes a directory per route with its own title, description, canonical and og: tags, plus sitemap.xml, robots.txt and AutoRental structured data. The endpoint still has to be uploaded to the server; the repo is not deployed to Hostinger.
The metadata described the service generically, so it competed with every self-drive rental in India and matched none of the searches that actually convert. Someone looking for a car does not search "self-drive car hire" -- they search "car rental Nagercoil". Titles and descriptions now name the district and its towns, kept inside the lengths search results truncate at: every title is 42-53 characters against a ~60 limit, and every description 107-150 against ~155, so none is cut mid sentence. The structured data declares the service area rather than only the business: areaServed is Kanyakumari district within Tamil Nadu, and the address carries Nagercoil and Tamil Nadu. No street address or coordinates are invented -- those have to be real to be worth anything, and a wrong one is worse than none. Local ranking also reads the visible page, not just the head. AreasServed lists the twelve main towns as text on the home and contact pages, so a search for "car rental Marthandam" lands on a page that says Marthandam. The hero now names the district and four largest towns in its opening paragraph. Verified in a browser: all six towns checked appear on the home page, the title is the district one, and the JSON-LD parses with areaServed "Kanyakumari district", locality Nagercoil, region Tamil Nadu. No page errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Search Console shows the site at position 3.2 for its main query, with clicks down 47% over 28 days. The indexed listing explains part of why: it advertises "NiteSha Cars & Bikes ... premium cars, wedding rentals, and tourist vehicle services", and the rebuilt site mentioned none of that. Replacing a page that ranks with one covering less of what the business does is how a ranking is lost, and the click drop may already be that. Three services now have their own pages, because they are separate searches and a page can only rank for what it is about. Somebody hiring a scooter is not the person booking a wedding car. /bikes two-wheelers, hourly to weekly /wedding-cars decorated cars, reserved dates, vehicles for the family /tourist-vehicles cars and vans with a driver, sightseeing and temple tours The brand was wrong throughout: the site said "Nitesha Cars", the indexed listing and the business are "NiteSha Cars & Bikes". Inconsistent naming costs local ranking directly, so it now comes from seo.json wherever it appears. Titles now lead with Nagercoil rather than Kanyakumari. The district name is what people add after the town, not before it, and the competitor ranking above us leads with Nagercoil too. All nine titles are 40-51 characters and all nine descriptions 107-152, inside where results truncate. The home h1 was "Take the wheel. We'll handle the rest." -- the strongest on-page heading, carrying no keyword at all. It now reads "Self drive car & bike rental in Nagercoil and across Kanyakumari district", with the slogan kept as a tagline beneath it. Nine nav items do not fit a desktop row, so the four services sit behind one Services trigger, which keeps the six-item menu shape the old site had. Each service keeps its own route. The mobile menu lists all nine flat. Menus now close from the click that navigates rather than an effect watching the path, which oxlint flagged as cascading an extra render. Verified in a browser: all nine routes render their own h1, the brand appears in the header, the dropdown holds four items and closes after navigating, and the home page links to each service two or three times over -- nav, card grid and footer. No page errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Motion throughout: the hero staggers in, sections and cards fade up as they are scrolled to, each route change fades the new page in, menus drop open, and cards lift on hover. Built on CSS keyframes and one IntersectionObserver rather than a motion library. Framer Motion is around 34 kB gzipped; this is 0.43 kB, measured against the previous build. That matters here specifically: the site competes on local search, Core Web Vitals feed that, and most visitors arrive on a mid-range phone over mobile data. Only opacity and transform are animated. Both are composited, so no frame triggers layout or paint -- animating height, top or margin is what makes a site judder on the hardware most people actually have. translate3d and scale3d keep the work on the GPU. The reveal uses an IntersectionObserver, not a scroll listener: intersections are reported off the main thread, where a scroll handler would run on every frame of every scroll. Each element unobserves after firing, so content animates once rather than re-animating whenever it passes the viewport again. Route transitions need main to be keyed on the pathname. Without the key React reuses the DOM node, no mount happens, and the animation never replays. prefers-reduced-motion is handled in both directions. The whole motion block is inside a no-preference query, and a reduce query resets [data-reveal] to full opacity -- without that second rule the reveal would leave every section permanently invisible for anyone who has asked their system for less motion, which is worse than having no animation at all. AreasServed is rewritten rather than patched: wrapping its list items left the JSX unbalanced, and the structure reads better with the chip as a span inside the revealed li. Verified in a browser: a below-fold section measures opacity 0 before scroll and 1 after, the hero is already at full opacity on load, an animationstart event for fade-up fires on main at every route change, and under reduced motion zero elements are left invisible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Kanyakumari district sends a lot of people to the Gulf, Singapore and Malaysia, and they come back for weddings, the December holidays and family occasions. Their problems are not a local customer's: they book months ahead from another country, land at an airport in a different state, and usually need a vehicle for weeks rather than days. None of that was addressed anywhere on the site. /nri covers the things they actually ask about -- arranging a vehicle before flying, being met at Trivandrum, which licence works, and dropping the car back on the way out. Trivandrum is named specifically because it is the nearest international airport to Nagercoil and where most Gulf and Singapore flights land, and because "car rental Trivandrum airport Nagercoil" is a search somebody makes. WhatsApp is the primary call to action on this page, per the owner. It costs nothing from abroad and survives the time difference, where a phone call to India from the Gulf does neither. The link is wa.me with the country code and no punctuation, which is what the format requires -- a space or a leading plus fails silently. It carries a prefilled message with blanks for arrival and return dates, so the first message already has the information we need. The two offers the owner chose, a long-stay discount and airport pickup, are described without figures. No percentage, no rate, no "free". They have not set the terms, and a number on a live page is a promise a customer can hold them to; inventing one would commit their money. The copy says the daily rate comes down for longer hires and to ask for the rate, which is true and still converts. A test asserts no percentage or rupee figure appears on the page. The licence guidance is the part most worth getting right, since being turned away at the counter after a night flight is the failure people fear: an Indian licence works if unexpired, a foreign one needs an International Driving Permit alongside, passport and visa or OCI as photo ID, and send a photo ahead so it is confirmed before travelling rather than on arrival. The services grid now holds five cards and moves to a 3-then-5 column layout so none is orphaned on its own row. Verified in a browser: the page renders with its own title, the WhatsApp link resolves to wa.me/916374942976 with the prefilled text and opens in a new tab with noopener, Trivandrum, the IDP rule, long-stay and weddings are all mentioned, no invented figure appears anywhere, and the home page links to it twice. No page errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
"Monthly car rental Nagercoil" is a different search from "self drive car rental Nagercoil", and the competitor ranking above us has a page for it while we had nothing. A page can only rank for what it is about. Their version is a section: a paragraph, four bullets on what affects the rate, and a button. This goes further, because the way to outrank a thin page is to answer what it leaves out. Six questions somebody actually has before handing over a month of hire -- how the KM allowance works across a month rather than a day, who services the car while they have it, what happens on a breakdown, whether they can extend, what deposit is held, what to bring -- and four audiences, since a family back from the Gulf and someone on a work posting arrive with different worries. Their page does confirm one thing: it quotes no price either, only what affects the rate. So the no-figures approach is not a handicap against the site currently ranking first. This page lists five factors and offers a same-day quote. Both spellings of the vehicle class are present. The page said "seven-seater", but the search is typed "7 seater" -- the numeral now appears here and on the fleet page, where somebody looking for one would land. Monthly is linked from the NRI page's long-stay step, which is the natural path: a visitor reading about staying for weeks is the person who wants this. Verified in a browser: the page renders with its own h1 and title, mentions 7 seater, NRI, work postings, extra-KM, deposits and servicing, carries six Q&A entries, and contains no invented figure. All eleven routes still render an h1. No page errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Monthly hire, NRI visitors and weddings were reachable only through the nav or a small card in the services grid, where they read as four equal options. They are not equal: a month-long hire, a family flying in for a season and a wedding booking are each worth many times a weekend rental, and they are the bookings worth putting in front of someone before they leave the page. Each is now a band on navy rather than the page's sand, so it reads as an offer block instead of more body copy. The layout follows what works on the competitor's monthly section -- icon, heading, a gold line naming the question in the visitor's head, a paragraph, and a panel of four checked points ending in a call to action -- because it is a good pattern, not because it is theirs. Ours carries three of these where they have one. Still no figures anywhere in the band. The points say what decides the rate, not what the rate is; a test asserts no percentage or rupee figure appears. Verified in a browser: three blocks render, each call to action points at its own route and navigates, the band contains no invented price, and at 390px the blocks stack with no horizontal overflow. No page errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The layout pattern only: two overlapping photographs with a circular badge across them, and beside it a pill label, a heading, a paragraph and numbered accordion rows. The content is ours. Nobody's founder story is borrowed, and nothing is claimed about the fleet that has not been established. The three rows say what is actually true and worth knowing before hiring: the rate, KM allowance, extra-KM rate and deposit are all stated before the vehicle is handed over; we cover the whole district including airport delivery; and cars, bikes, wedding vehicles and drivers come from one place rather than four. The circular badge is an SVG textPath around a circle, not letters rotated individually with transforms -- that approach drifts at different font sizes and falls apart when the font falls back. Only the svg spins, so the arrow in the middle stays upright, and it stops under prefers-reduced-motion. The accordion is buttons with aria-expanded rather than details and summary, because only one row should be open at a time and native details has no notion of a group. Clicking an open row closes it, so the section can be collapsed entirely. Reuses the two web-sized images already in the project. No new assets: the remaining photographs in public_html are 876K and 3.5M, too heavy to ship. Verified in a browser: three rows, the first open at load, opening the second closes the first, its panel becomes visible, clicking again closes it, the badge links to /contact, both images render, and at 390px there is no horizontal overflow. No page errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
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
Footer map, a Footer section in the panel, and a check that says why an uploaded picture is missing
Three things, all on the top of the home page. The uploaded background was invisible. Two separate dimmers sat on it: the image itself at 25% opacity, and a navy gradient over that reaching 95% in the middle. Multiplied, about one part in fifty of the picture survived -- so uploading a background looked exactly like the upload not saving, which is what it was reported as. The image is at full strength now and a single scrim carries the legibility, the same weighted one the page banners use. It needed its own variant rather than .page-scrim. Four of the hero's five blocks are a left-hand column, and then the three points at the bottom run the full width, so a scrim that clears on the right the way the banners do would leave the last of those three on bare photograph. This one clears less far and deepens towards the bottom where that row sits. Measured rather than judged, against a pure white photograph -- the worst case, and not a rare one, since a bright sky behind a car is the commonest thing an owner uploads. Every piece of text: 4.98:1 at the tightest, across 1536, 1280, 768 and 390. Below 1024 it goes to even coverage, because there every line runs the full width and there is nowhere the photograph can show without something being read over it. The band of empty navy between the menu and the first word is gone. The hero had even padding top and bottom, which is right for a section in the middle of a page and wrong for the one thing at the top that should be read without scrolling. Tight above, unchanged below. And the page is wider: 86rem rather than 72rem, with the side gutters growing at sm and lg instead of staying at 20px. On a 1536 screen the content ran 1152px with 192px of nothing on each side. Seventeen containers, all the same change, so the header, the sections and the footer still line up. No horizontal overflow at 320, 390, 768, 1024, 1280, 1536 or 1920 on six routes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Let the home page photograph show, and give the page its width back
…rives
The three highlights were full-width rows, each split into a description and
a bulleted panel. Three of them ran to about two and a half screens, and every
row left a column of empty navy beside whichever half had less to say -- which
is what the gap in the screenshot is. Side by side they are one screen, and
three offers next to each other can be compared, which is the only reason to
put three offers next to each other.
The inner panel goes with it: a bordered box inside a bordered box was what
made the emptiness look deliberate. The points sit under a gold hairline in
the same card now, and the button is pushed down with mt-auto so all three
line up however long the copy above them runs. They stagger in on scroll and
lift on hover -- .card-lift as the light cards already use, with a gold glow
instead of a drop shadow, because a dark shadow under a dark card is nothing.
--- and then the speed.
The page was served as an empty <div id="root"> with 365 KB of JavaScript
beside it. Nothing at all was on screen until that had been fetched, parsed
and run: three seconds on a mid-range phone on a rural connection, which is
most of this site's visitors, and everything a page is scored on happens
inside that window. Three changes, largest first.
The site is rendered to HTML at build time. vite build --ssr produces a server
bundle, and prerender-seo.mjs -- which already wrote each route's title and
description -- now also writes its markup. The script still loads and takes
over; it is what makes the fleet list and the enquiry form work. But the words
and the layout are in the file the server sends, so the page is readable while
it arrives. prerenderToNodeStream rather than renderToString, because the
routes suspend now and renderToString would bake the fallback into every page
but the home page.
main.tsx keeps createRoot rather than switching to hydrateRoot, deliberately.
The footer prints the current year and the date picker opens on today's month,
so a page built yesterday and read today would not match and React would tear
it down anyway, loudly. createRoot replaces the markup in the same frame it
paints, which costs one extra render and cannot be wrong.
Every route but the home page is now fetched when it is asked for. One bundle
held all fourteen, so someone landing on the home page downloaded the tariff
table, the blog, the places list and the enquiry form before the first word
appeared. Home stays eager: it is where most people arrive, and splitting it
would add a round trip before the thing they came for.
Poppins is served from this site instead of Google. A third-party stylesheet
is the slowest thing on a cold load -- the browser cannot paint until it has
it, and getting it costs a DNS lookup, a handshake and a round trip, then all
three again to fonts.gstatic.com for the files it names. The rupee sign was
its own tax: Google puts U+20B9 in the devanagari subset, so every page with a
price pulled 38 KB of Hindi per weight. Subset to that one glyph it is 570
bytes, and the four weights together are 2.3 KB instead of 155 KB.
The hero photograph is marked as the priority fetch, since it is what Google
measures this page's loading time by.
Measured on a throttled phone -- 390px, 1.6 Mbps, 150 ms latency, 4x CPU:
first paint largest paint
/ 3124 -> 1436 ms 3452 -> 2032 ms
/cars 2784 -> 1108 ms 3048 -> 1440 ms
/tariff 2760 -> 1112 ms 3024 -> 1448 ms
All thirteen routes checked after: no console errors, one header and one
footer each, no sideways scrolling. An upload in the panel still replaces the
committed photograph once the panel answers, and the committed one is what
goes into the HTML -- which is the right way round, since a crawler should not
wait on a request to /admin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Three offers across, and a page that is readable before the script arrives
Every image on this site is stored above the document root and streamed back by a small PHP reader, so the address a visitor sees is one this code makes up. It made up photo.php?f=v12-9f3a1c4b7d2e5a60.jpg. On a business whose traffic comes from searching for "self drive car rental Nagercoil", the file name is one of the few things Google has to go on about a picture besides its alt text and the page around it -- and that one said nothing at all. They are named for their subject now: /admin/images/self-drive-car-rental-nagercoil-9f3a1c4b7d2e5a60.webp /admin/photos/maruti-swift-self-drive-car-rental-nagercoil-<hex>.jpg /admin/place-photos/vattakottai-fort-places-to-visit-kanyakumari-<hex>.jpg The words come from the site, never from the uploader. Each site-image slot has a phrase in SITE_IMAGE_KEYWORDS -- what is in the picture, not where it goes, because "Cars page banner" is of no use to anyone searching. A vehicle's photograph takes the car's own make and model; a place's takes the place's name. The random suffix stays: it is what makes a replaced picture a new address, which is what lets the reader cache for a year. A path rather than a query string, so the words are in the address and not only in a parameter. admin/.htaccess routes /admin/images/, /admin/photos/ and /admin/place-photos/ to the three readers, which still answer ?f= as well -- every address stored before today keeps working, and so does every file name, because the one pattern that replaces the three old ones matches v12-<hex>, p3-<hex> and bcars-hero-<hex> too. No migration, nothing to re-upload. The pattern is the security, not the rewrite. Lowercase words joined by single hyphens, sixteen hex, one extension: there is no way to write `..` or a separator in that. Checked both ways round -- ten kinds of hostile input reduced to harmless words (../../nitesha-config/config becomes nitesha-config-config), and twelve shapes that must never be answered, including name-<hex>.jpg.php and a traversal appended to a valid name. Two things found while testing it. admin/src/ had no deny rule, so its files were reachable over HTTP. Nothing leaks today -- every one of them only defines functions, so a request returns an empty 200 -- but that is a property of each file continuing to be written that way rather than a guarantee, and sql/ and tools/ were already denied. And the readers now answer a conditional request with 304 instead of the file. These addresses change whenever the picture does, so the year-long cache was already right; what was missing was the ETag that lets a returning visitor spend a few hundred bytes instead of a few hundred kilobytes. Alt text, which is the other half of this. The six service cards and the three steps passed none, so an uploaded photograph of a wedding car was announced to nobody and read by nothing. They pass the card's own title now. The drawings stay decorative, which is still correct -- SectionArt already makes that call. The site check tells the two new failure modes apart. If a picture will not load at its new address it tries the old one: if that works, the file is fine and the rewrite is not, and the page says so and names the remedy rather than reporting a missing picture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Name every uploaded picture after what is in it
No description provided.