MAIN - #17280
MAIN#17280niteeshkanna-sh wants to merge 273 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! |
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
…te (#11) Search Console showed position 3.2 with clicks down 47%, and the indexed listing advertised bikes, wedding rentals and tourist vehicles that the rebuilt site did not mention at all. Adds pages for bikes, wedding cars, tourist vehicles, monthly rental and NRI visitors, each a separate route because each is a separate search. Corrects the brand to NiteSha Cars & Bikes, which is what the business is called and what Google already has indexed. Titles lead with Nagercoil rather than Kanyakumari, and the home h1 now carries the primary keyword instead of a slogan. Structured data declares the service area as Kanyakumari district, and an areas-served section names the twelve main towns as visible text. No street address or coordinates are invented. Motion throughout, built on CSS keyframes and one IntersectionObserver rather than a motion library: 0.43 kB gzipped against Framer Motion's ~34 kB, which matters on a site competing on local search. Only opacity and transform are animated, and prefers-reduced-motion is honoured in both directions. The home page gains three highlight bands for the high-value services and a stacked-image accordion section. No prices appear anywhere. The owner has not set terms for the long-stay discount or airport pickup, and a figure on a live page is a promise a customer can hold them to.
Gold led the brand but appeared only as a highlight on navy. It now carries the hero and every page header, with warm cream behind the body in place of the near-grey #FAF8F4, so the site reads gold rather than gold-trimmed. The relationship had to invert rather than simply swap. #F5A500 as text on white measures 2.05:1, well under the 4.5:1 body-text minimum -- gold can only be a surface. Navy on gold is 9.36:1, so gold goes behind and navy on top. This also fixes a contrast bug already shipped. --color-gold-deep was #B36B00, used for links on light backgrounds, and measured 4.18:1 on white: failing. It is now #8A5A00, which is 5.61:1 on cream. Every pairing was measured rather than judged by eye: hero h1, navy on gold 9.36 hero paragraph, navy/75 on gold 5.84 page header h1 on gold 9.36 body text on cream 7.13 links on cream 19.15 The alpha ones needed computing by hand. Reading them out of the browser gave 7424264.93 for the hero paragraph, because getComputedStyle returns rgba and the script was not compositing it against what sat behind. The highlights band stays navy on purpose. An entirely gold page is tiring to read, and one dark section gives the gold somewhere to land, so it reads as premium rather than relentless. The hero photograph drops to 15% opacity with luminosity blending, since a full-colour image under a saturated gold turns muddy. Verified in a browser: no bg-sand remains anywhere, every measured pairing passes AA for body text, and no page errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The previous commit made gold the surface and navy the type. The logo does the reverse — a dark navy field with a metallic gold monogram and a gold rule beneath. The logo is the authority, so the site follows it. Header, hero and page headers go back to navy with gold type. Gold becomes metallic rather than flat: the #F0D060 -> #D4AF37 -> #C8873A range from the logo, used as a gradient in .gold-rule and on the monogram, with .eyebrow-gold for the "PREMIUM RENTALS" lockup. Adds Logo.tsx, which draws the NS monogram rather than loading an image because the artwork file hasn't been supplied yet. Keeps the --color-gold-deep fix from the previous commit (#B36B00 was 4.18:1 on white, failing for link text; #8A5A00 passes) — that bug is independent of which way round the surfaces go. Measured on navy: #F0D060 12.67, #D4AF37 9.11, #C9A227 7.92, #C8873A 6.37 — all pass. Hero H1 19.15, nav and eyebrow 13.06, hero paragraph at white/70 9.53, body on cream 7.13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Sits between "how it works" and the areas we cover: you have just read how to book, so this is the payoff -- the road itself -- before the page goes back to detail. It also breaks up four light sections in a row. The picture is a CSS background rather than an <img> so a missing file degrades to the navy beneath instead of leaving a broken-image icon on a live page. The artwork is not in the repo yet; the band reads as a plain navy panel with the gold rule until it is. The scrim is gated at 1024px. Below that the text column still spans most of the frame, so a sideways fade would put the type over its pale end -- at 640px that measured 4.79:1 against a deliberately blown-out stand-in. With the flat wash instead, the worst case across 390-1920px is 7.17:1. Contrast measured from the rendered pixels with the type hidden, so the figures fold in the photograph, both scrim layers and the compositing rather than trusting getComputedStyle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Rebuild the theme around the logo: navy surface, metallic gold on top
useSeo looked the route up with seo.routes.find(r => r.path === pathname). Static hosts serve directory URLs with a trailing slash -- GitHub Pages redirects /cars to /cars/ -- while seo.json and the sitemap store the bare form, so the match failed for anyone arriving from search or refreshing the page, and the lookup fell through to the not-found branch. React Router matches trailing slashes, so the correct page still rendered. That is what hid this: the screen was right while the head was wrong. The head got, on every route but /: title Page not found - NiteSha Cars & Bikes description empty canonical https://niteshacars.in/cars/ (sitemap says /cars) Google renders JavaScript, so that is what it saw -- on a site whose whole purpose is local search. The prerendered HTML was always correct, which is why link previews looked fine. Normalises the trailing slash before the lookup and uses the normalised path for canonical and og:url too, so they agree with the sitemap. Verified across all 11 routes; 404.html still reports not found. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Passwords are bcrypt digests, so a forgotten one cannot be recovered, only
replaced -- and nothing in the panel replaced one. install.php refuses to run
once an account exists (correctly: otherwise the file would hand the panel to
whoever found it), and tools/ only created users. Someone locked out with no
SSH had no way back in.
Runs two ways. Over SSH the token is not asked for, because reaching a shell
already proves you hold the server. In a browser, for hosting without SSH, it
refuses unless every one of these holds:
- RESET_TOKEN has been changed from the placeholder and is >= 24 characters,
so the file is inert as shipped
- the token arrives by POST, keeping it out of access logs and history
- the connection is HTTPS, or token and password cross the wire in clear
- the file was uploaded within the last hour, so forgetting to delete it
closes the hole by itself rather than leaving one open
- wrong tokens are counted and answered with 429 after five, so the token
cannot be found by trying
It compares the token with hash_equals, and deletes itself once a password is
set -- saying so loudly in red if that fails.
Resetting also clears failed_logins and locked_until. Without that, someone
who locked themselves out by guessing would set a new password and still be
refused, with nothing on screen saying why. is_active is deliberately left
alone -- a disabled account was disabled on purpose -- but the caller is told,
so a later refusal is not a mystery.
Verified against SQLite through the real CLI path: the hash changes, the new
password verifies, the old one stops working, the lockout clears and an audit
row is written. Mismatched, too-short and unknown-email cases change nothing.
Browser guards checked under a live server, including the throttle tripping
to 429 on the sixth wrong token.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
…et tool (#13) Fix every route reporting "Page not found" to crawlers, and add an admin password reset tool
The public site deploys to GitHub Pages on every merge. The PHP panel had no route to its server at all, so api/public-vehicles.php has been sitting in git while the live fleet pages have nothing to read -- the endpoint was written, but never reached Hostinger. Credentials are repository secrets. FTP_REMOTE_DIR is one too, rather than a path hard-coded here: pointing this at the wrong folder would write admin files over the main site, so the path is set by someone who can see the server. A guard step checks all four are present and that the path ends in a slash, because otherwise the FTP action fails deep inside itself with a connection error that reads like the server is down. Mirrors what git tracks -- files removed from git are removed from the server -- but dangerous-clean-slate stays off and config.php is excluded. Wiping the folder would take config.php with it, and the panel stops with "Not set up yet" when it is missing. That file is untracked precisely because it holds the live database password, so a deploy must not be what destroys it. nitesha-storage sits outside admin/, so scoping the deploy to admin/ leaves uploads alone. A manual run defaults to a dry run, so the first use lists what it would change before anything is written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The push filter listed the workflow file among its paths, so merging it would have run a real deploy immediately. The dry run is only reachable through workflow_dispatch, so that first run would have written to the server before anyone had seen what it intended to do -- the opposite of the point. Triggering on the panel's own files only. The workflow is now started by hand the first time, which is where the dry run lives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Deploy the admin panel to Hostinger over FTPS
Two dry runs failed with "getaddrinfo ENOTFOUND", which reads like the server is missing rather than like the value has a prefix on it. hPanel displays the host as ftp://1.2.3.4, and pasting that whole string makes the client look up a hostname of "ftp://1.2.3.4". The same error hides a trailing space, an appended :21, or the surrounding label text coming along with a copy. Rejects those up front with a message naming the actual problem, and reports the value's length so a stray character shows up without the value being printed. Nothing here prints the secret or anything that reconstructs it -- the repository is public, so the logs are public. The lookup applies to hostnames only. Checking an IP with getent is a reverse lookup, which fails whenever the address has no PTR record: testing caught this rejecting the correct answer, since 8.8.8.8 and 1.1.1.1 have PTR records and the Hostinger address does not. Verified against a bare IP, a hostname, an ftp:// prefix, a trailing space, an appended port, and the hPanel label pasted whole. Only the bare IP passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Check the shape of FTP_SERVER before handing it to the FTP client
The FTPS deploy puts everything tracked in git into the subdomain's web root, so sql/001_schema.sql became fetchable -- the whole shape of the database, every table and relationship, handed to anyone who asked for the URL. The same went for README.md, SECURITY.md and the tools/ scripts. install.php reads the schema from disk with glob(__DIR__ . '/sql/*.sql'), not over HTTP, so denying it to the web does not affect installation. tools/ is denied by default, with one exception. create-user.php and clear-enquiry-throttle.php already refuse to run outside the command line, but test-auth.php and test-money.php do not -- they expect $argv and would run for anyone who loaded them. reset-password.php is explicitly allowed: its browser mode is the way back in when the plan has no SSH, which is exactly the case where a locked-out owner cannot reach the CLI tools. Blocking it would remove the only route it exists to provide, and it already ships inert, demands HTTPS, expires an hour after upload and stops answering after five wrong tokens. config.php is denied too. PHP executes it rather than printing it, so this changes nothing while PHP is healthy; it matters on the day PHP is disabled mid-upgrade and .php files are served as text, which is precisely when that file should be least reachable. Each rule is given in both Apache 2.4 and 2.2 form, guarded by IfModule. Checked every file in the panel against the patterns: index.php, dashboard.php, logout.php, install.php and the css and js are all still served. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every word on the home page was a string literal in a component, so changing one meant a code change. The eight sections -- hero, services, highlights, why us, how it works, open road, areas served and the closing call to action -- now come from content, edited at admin/content.php. Only overrides are stored. Each section ships a default in my-app/src/content/defaults.json, and a section nobody has touched has no row at all, so the table starts empty and "reset to original" is a DELETE rather than a restore. Sections are replaced whole rather than merged field by field: a deep merge would quietly put back a list item someone had just removed. The build pulls the content in before Vite runs rather than the browser fetching it per visit. A visitor's page load stays one request to a CDN instead of a second one to shared hosting, the public site keeps working when the panel does not, and there is no empty flash while copy arrives. If the panel cannot be reached the committed defaults are used and the build says so -- a site that will not deploy because a shared host had a bad minute is worse than one that deploys last week's wording. A section whose shape does not match the default it replaces is refused, so one malformed row cannot empty a section of the live site. Migrations had no route to an installed site: the runner lived in install.php, which refuses to run once an account exists. Moved to src/migrate.php so the installer and a signed-in admin share one definition, and content.php offers to run pending migrations when the table is missing. The glob inside it is now relative to the parent directory, since the file sits a level deeper. The panel never receives this repository, so it carries its own copy of the defaults to prefill the form. The build refuses to run if the two differ, which is the only thing keeping them honest. Content editing is a page of its own rather than another dashboard tab: dashboard.php is driven by admin.js and would need real surgery to hold a form this shape. Plain form posts, no JavaScript -- a repeater renders its rows server-side with one blank row on the end, which is enough to add an item. Verified: 13 tests over the content layer, including that the schema and the defaults agree in both directions; the SQL parser still handles semicolons in strings, comments and backticks after being moved; an edit served by a stub endpoint reaches the built site, a malformed section is refused, drift between the two defaults files fails the build, and an unreachable panel falls back without failing it. With nothing edited the page renders exactly as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Make the home page's wording editable from the admin panel
vehicles.ts, enquiry.ts and fetch-content.mjs all called https://admin.niteshacars.in/admin/api/... The panel's document root is the subdomain root, so those files are served without an admin segment -- every one of those three requests was a 404. Confirmed against the live server: /api/public-vehicles.php answers, /admin/api/public-vehicles.php does not. Nothing reported it. useFleet treats a failed request as "no cars" and falls back to the empty cars.ts, which is the right behaviour for a rental site whose panel is briefly down, and exactly wrong for a URL that was never going to work: the fleet pages have been showing their empty state since the endpoint was written. The enquiry form was posting into the same void. The mistake came from the repository layout. public_html/admin.niteshacars.in/ admin/ maps onto the document root, so admin/api/x.php in git is /api/x.php on the server -- the folder name is not part of the URL. The README said so all along; the code did not. README corrected to match, with a note on why the two paths differ, since the next person to add an endpoint will make the same assumption otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Drop the /admin/ segment that kept the fleet empty
The deploy cannot tell an empty directory from the admin panel. Pointed at the wrong path it does not fail -- it writes a second copy of all 57 files into a folder nobody serves, reports success, and leaves the real panel untouched and working. That has now happened twice, and the only symptom was a 404 on a page that had supposedly just deployed. config.php is the fingerprint: it holds the database password, it is gitignored, and no deploy has ever written it, so it exists in exactly one place -- where the panel was really installed. Checking for it before uploading anything turns a silent wrong-folder deploy into a refusal that names the problem, and prints what is in the folder instead so the mistake is recognisable. Whether an FTP path is read from the login directory or the filesystem root depends on the account, so both are tried before concluding the folder is wrong. Credentials go to a mode-0600 temp file rather than the command line, where the password would appear in the process list. Verified against a real FTP server, running the script extracted from this workflow rather than a copy: a folder holding config.php passes; a folder with index.php and dashboard.php but no config.php fails and lists what it found; a path that does not exist fails saying so. Only --ssl-reqd was dropped for the local run, the test server being plain FTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Refuse to deploy the panel into a folder that is not the panel
The check discarded curl's exit code and stderr, so a refused login, a failed TLS handshake and a blocked data connection all produced an empty listing -- indistinguishable from an empty directory. It then reported that the folder was not the panel and "may not exist". Its first run against the real server said exactly that, about a path the deploy had uploaded 57 files to an hour earlier. Those cannot both be true, and the check was the thing that was wrong: it never got far enough to look. Now it keeps the exit code, and separates "this folder is not the panel" from "could not tell". The second prints what curl actually said, which is the difference between changing a secret and fixing a connection. Still refuses to deploy when it cannot verify. Deploying into the wrong folder leaves a copy of the panel where nobody serves it, which is the failure this exists to prevent, and an unverified folder is not evidence against that. Verified against a real FTP server: config.php present passes; reachable folder without it fails and lists what is there; a plain-FTP server against the FTPS requirement reports "Requested SSL level failed"; a dead port reports "Couldn't connect to server". Each of the last three previously produced the same misleading message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Report why the panel check failed instead of blaming the folder
The words were written into the component, so the one line on the page the owner most wanted to change was the one he could not. It comes from the panel now, like the heading under it, and it says Our Services. Bigger with it: 16px, 17 on a wide screen, where it was 14. Wide-tracked gold caps are read as a label rather than as a sentence, and at 14 that label was smaller than the body text beneath it. All four of them, not just this one. Services, the three cards, Booking start to finish and Where we deliver are the same thing on the same page, and one of them being bigger than the others reads as a mistake rather than as emphasis. They were also the same five utility classes written out four times; they are one class now, so the next time the size is wrong it is wrong in one place. 5.5:1 against the cream behind it, which is above what 17px bold needs. The page walk flags nothing and the banner, image-address and accessibility-name suites are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
"Our Services", and the line above a heading reads like one
The three steps were the same card as the fleet grid further up the page -- a picture with a bordered box under it -- so two sections read as one long list of cards, and nothing in the design said these three happen in an order. The order is the whole point of the section. So the boxes go and the order is drawn: a numbered node per step with a gold line running from it towards the next, the picture and the words below it. The line is not decoration -- it moves left to right, in the order the steps happen, and it draws itself as each step arrives. The node lands first and the line sets off after it, both hung off the reveal the step already had, so the stagger that was there for the fade carries the rail too. Under the pointer a step's picture grows a little inside its frame, which already clips, so nothing moves on the page. Everything is off for prefers-reduced-motion, and the line needs saying separately: it is drawn rather than faded, so an untouched scaleX(0) with the animation disabled is an invisible line rather than a still one. Measured rather than assumed: the line goes 0 to 349 pixels over about half a second when motion is allowed, and is 349 from the first frame and never moves when it is not. Still an <ol>, so the order is there for a screen reader as well as for the eye. At 390px the three stack with the line hidden, where a line running right would run nowhere. The page walk flags nothing and the banner, image-address, accessibility-name and per-page SEO suites are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Three steps on a rail that draws itself
The row under the banner headline was three sentences of small grey type -- "Unlimited-choice pickup across the city" and two more. Three sentences side by side under a headline are read as a paragraph, which is to say they are not read at all, and the first of them was not really English. So: three badges. A tick in gold, two or three words beside it, on a tinted glass pill that lifts a little under a pointer. Nothing in a badge to skip. The words stay the panel's -- "Delivered to your doorstep", "Zero hidden charges", "Day, week or month" are the new defaults, not new hardcoding. The footer's logo goes. It was the same artwork as the header's, larger, at the other end of the same screen, with nothing between the two that needed reminding whose site this is. The business name set in type signs the page off just as well, stays crisp at any size, and is what a search result quotes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The FAQ was a white list on cream at the foot of the page, which is where small print goes. It is not small print -- it is the ten things people ring up to ask, answered in the words they would type into Google. So: a band. The banner photograph almost entirely under navy with the same gold glow the rest of the site uses, the heading and its line centred in white, and the questions as cream cards over it, each with a round gold badge that turns from a + into a x when its answer is open. Only one answer at a time. <details name="faq"> is enough on its own in Chrome 120, Safari 17.2 and Firefox 130 and later -- the browser shuts the open one with no script at all -- and the toggle handler does the same by hand in anything older, finding nothing to close where the browser already did it. Two stacks rather than a two-column grid. In a grid the row grows to the taller of the pair, so opening an answer leaves the card beside it hanging over a hole; each stack closes up on its own instead. Split in half rather than alternating, because on a phone the two become one and its order is the order of the markup -- deposit, documents, kilometres, which is the order they are asked in. Every answer is still in the HTML whether it is open or shut, so the FAQPage structured data the build writes from the same content still matches the page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every route on this site is one Suspense boundary, and a prerender emits the shell first and the boundary after it -- the whole page inside <div hidden id="S:0">, put on screen by a script at the end of the body. So the words were in the file and nothing that does not run JavaScript could see them. With scripting off the site was a navy band under the header. The point of prerendering is the readers that do not run scripts; this was the one thing it was not doing. Two passes now. The first is thrown away: it is what resolves the route's React.lazy chunk, which is the only thing these pages ever wait for. renderToString then writes the same tree with the boundary and its contents where they belong -- it cannot wait for anything, which is exactly why the pass before it exists. If a route ever suspends on something else, renderToString throws, the build says which route and falls back to the streamed HTML it shipped before, and prerender-seo says what that costs. The second half of the same problem was the scroll reveals: two thirds of each page is [data-reveal], which starts at opacity 0 and is released by an observer. Hiding it was unconditional, so with no script the page was delivered and then hidden. One line in the <head> sets data-js before anything paints, and the hiding rules are keyed on it -- the page is visible by default and hidden only where something can unhide it. The rail on "How it works" is the same: undrawn only where there is an observer to draw it. Checked with scripting disabled: all 47 revealed sections visible, the pages are full height, and the FAQ opens one answer at a time on <details> alone. With scripting on: hydration is silent on four routes, client-side navigation still works, the reveals still hide and release on the way down, the rail still draws (0 - 54 - 190 - 282 - 338), and reduced motion still shows everything at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Hero badges, a footer in type, an FAQ band, and pages that don't wait for a script
Cropped at the line under "CARS & BIKES", six pixels below it -- the same margin the artwork leaves at the top -- so the mark, the name and the trade line are untouched and only the tagline is gone. 1209x705 where it was 1209x771. Under a new name. Images are served with a week's cache, so keeping the old one would have gone on showing the old lockup, tagline and all, to everyone who had already been to the site. A new name is a new URL, and nothing cached can answer for it. The old file is deleted rather than left beside it: the only thing that referenced it was LOGO_FALLBACK. The header, the footer and the brand panels all read that one constant, so all three follow. An upload in the panel still overrides it, which is what the owner is looking at if the old one is still there after this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The menu opened onto six pages, so "what do you actually hire?" was six clicks and six back buttons -- and /services, the page that should have answered it, was the home page's card grid again: six pictures with a sentence each and "See details". It is one page now. Each service gets a block of its own: the drawing on one side, the name with its numeral, what it is, four ticked points of what comes with it, and the two ways to ask. The picture changes sides as you go and the background alternates cream and white, so six blocks read as six places rather than one long scroll. A row of buttons at the top jumps to whichever one somebody came for, by real anchors, so the link can be shared. The ticks are the panel's, in a new "Tick points" box on each service, one per line. A service saved before this field existed renders without them rather than not rendering. The six pages underneath are untouched and linked from every block -- they are separate searches, and a page can only rank for what it is about -- but the menu is one link now instead of a list, and it stays lit while you are on any of them. Checked: six blocks, six jump buttons that each land on their block, twenty-four ticks, the picture alternating sides all six times, no dropdown left in the header, none of the six in the menu, all six still serving 200, and a silent console. The page walk, the accessibility names, the FAQ, the reveals and the no-JavaScript pass are all unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Everything we hire on one page, and a logo without its tagline
Who we are, where to go, the company, how to reach us, where we are. The ways to reach us get a column of their own with a mark against each -- gold for the number to ring, green for the one to message, the envelope and the pin under them -- because that is what somebody scrolling this far is looking for, and a list of plain links makes them read all five to find it. Services is one link now. The column named five of them and then offered "View all services" underneath, which is six ways of saying the same thing; /services carries every service in full since the last change, so one line is the whole answer. WhatsApp leaves the follow row. It is a way to message us, not a page to follow, and two WhatsApp links a hand's width apart read as a mistake -- the contact row carries it now, with the number beside it, and the panel's WhatsApp box is what it links to. The row of icons is round rather than square with it, and its heading belongs to the column that places it: with the panel's social boxes empty, that heading was sitting over nothing at all. The map keeps its place under "Find us", and the line at the bottom carries the copyright on one side and the two things somebody down here still might want on the other. Checked: four column headings, five blocks on one row at 1440 and stacked on a phone with no sideways scroll, one link to /services and none to the six under it, one number to ring and one to message, an address, an email, the map, both ends of the bottom row, every footer link resolving 200, and a silent console. The page walk, the accessibility names, the services page, the FAQ and the no-JavaScript pass are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
A footer in five columns, and services as one line in it
Three cards in a row, each with a photograph and a numbered node on a drawn rail. The numeral -- the only part of a step that carries the order -- was the smallest thing in the card, and three small photographs were three things to look at and nothing to read. Now the numeral is the largest thing in the step, the steps stand in two columns, and one picture stands between them: three corners round and one square, which is the one shape on this page that is allowed not to be a rectangle. Each step is a numeral, a title, a sentence and a hairline that fades out, which separates them without drawing a box around each. Four steps rather than three, because the layout reads two and two -- and because the fourth was missing from a section called "booking, start to finish": the car comes back, we check it, the deposit follows. It is a content default like the other three, editable and removable in the panel. The picture keeps the middle step's slot rather than taking a new name. The site only ever sees a slot the panel's list names, so a new key would have quietly hidden a photograph already uploaded there. Its crop is 4:5 now; the other two step slots are gone from the panel, since nothing on the site reads them any more. The files stay on the server. Still an <ol>, and still 01 02 03 04 in the markup: the two columns are a layout, not a change to what comes after what. The list is laid out through its parent's grid, so role="list" is stated -- Safari drops list semantics from a list that generates no box of its own. Checked at 1440: four steps, two per column, 01 and 03 level, the picture between them and taller than it is wide, one <ol> announced as a list, the markup in order. On a 390px phone: one column in order with the picture above it and no sideways scroll. The page walk, accessibility names, reveals, no-JavaScript, footer, services and FAQ suites all pass unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The steps stand either side of one picture
Folders rather than boxes, stepped down and up across the row, in the four colours the site already owns -- navy, cream, gold, bronze -- with the stamp turning slowly in the gap between the middle two. It carries the line the logo used to have under the name, which is where it came from. What they say is what the owner asked for: trusted 100%, cleaned before pickup, 1000+ customers, 20+ vehicles. Those last two are claims that change, so they are content rather than markup -- a fleet of twenty is twenty until it is thirty, and a number written into a component is a number nobody can correct without a deploy. Which meant the panel needed a second page. The schema was one page deep in shape but only ever held "home", and content.php already read ?page= and saved under it, so what was missing was a way to get there: a row of two chips above the sections, links rather than script, so a page is somewhere you can be sent and somewhere the back button returns to. The stagger is margin rather than a transform. These cards are revealed on scroll and the reveal animates transform to nothing at its last frame, so a translate here was undone the moment each card arrived -- which is why the first attempt was a flat row. Checked against the real panel: the switcher lists both pages, the about page prefills from the defaults, an edit saves and comes back, the home page is untouched by it, the public endpoint carries the edit under about, and reset puts the shipped wording back. On the site: the cards step, the colours are the four, and the page walk, accessibility names, how-it-works, footer, services, FAQ, reveals and no-JavaScript suites are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Twenty-eight pixels of hairline on a coloured card is a smudge, and four of them were four smudges. The mark is forty pixels now, on a disc of its own: gold on the navy card, bronze on the cream one, navy on the gold one and cream on the bronze, so the row carries four colours rather than one gold repeated four times. Each one draws itself as its card arrives -- the outline first, the detail after it -- and then keeps a slow float, offset a beat per card so the row breathes rather than pulsing in time. Under a pointer the disc grows and tilts a little. pathLength="1" on every shape, so one dash rule draws all of them: measured in user units, the shield outline would crawl while the tick was over before anybody saw it. The undrawn state is keyed on data-js, like the reveals: with no script there is nothing to add .is-visible, and an icon left undrawn is an icon nobody sees. Reduced motion gets them drawn from the first frame, with no float and no hover. Checked: the marks start at a dash offset of 0.999 and finish at 0, four badges at 72px with four different colours and four different mark colours, the mark itself 40px, and -- the two that matter -- drawn from the start under reduced motion and drawn with no script at all. The page walk, accessibility names, reveals and no-JavaScript suites are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every icon on both was a hairline in one colour, drawn a piece at a time in whatever component needed one -- a phone, a pin and an envelope in identical grey say nothing about which is which before you read the line beside them. One recipe now, in two places because the two share no build: a plate with a diagonal gradient, a gloss across its top half, a shadow underneath and a rim inside the edge -- the four things that make a flat shape read as an object -- with the mark in white on top. Six tones: gold, bronze, navy, green, plum, slate, so a row of icons carries colour rather than one gold repeated. On the site (my-app/src/components/Icon3d.tsx): the footer's four contact rows, the social row, the WhatsApp bubble and the back-to-top button. In the panel (admin/src/icons.php): all eight sidebar entries, each on its own colour, and the five counters on the dashboard, where the plate sits top right and out of the way of the number. Not everywhere, deliberately. The chevron beside a menu, the tick inside a chip, the bin on a table row: a plate at sixteen pixels is a coloured square with something indistinct on it, and those are clearer as hairlines. The four marks on the about page keep their own discs, which already draw themselves. Ids are suffixed per instance in both. Six plates on one page all defining "plate" would every one of them paint with whichever gradient the browser read last -- the failure is silent and looks like a colour choice. Checked: 14 checks over both -- plates in the footer and the buttons with two gradients and a shadow each, none under 38px, no two definitions sharing an id, eight sidebar plates at 22px in four or more colours, five counters at 34px with none sitting on its number, and nothing thrown either side. The page walk, accessibility names, footer, benefits, services, how-it-works, FAQ and no-JavaScript suites are unchanged, as are the panel's nav, UI and records suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The about page's four cards, and one colour icon recipe across site and panel
Two cards and then a form centred in its own band meant that on a desktop the page was a column down the middle with a screen of nothing either side, and the phone number was a scroll away from the form by the time anybody had read it. Side by side, both are on screen at once. On the left: phone, WhatsApp, email and the address, each on the plate its kind of contact uses everywhere else, then the opening hours and the map -- which belongs on the page somebody opens to find us rather than only at the foot of every other one. All of it the panel's: the address, the hours and the map pin come from the footer section, so there is still one place to change them. On the right: the form. Enquiry is a card the page places now rather than a section that centres itself, which is the whole of the change to it -- same fields, same handler. The id stays on the card, because /contact#enquire is linked from half the site and has to land on the form rather than on the column holding it. Under a thousand pixels they stack, details first: most people opening this page on a phone want the number, not the form. Checked: four ways on the left, the form to the right of them and level with them, 780px of room for it, the anchor landing on the form, every field still posted, one map, stacked in that order on a 390px phone with no sideways scroll, and a silent console. The page walk, accessibility names, the icon plates and the no-JavaScript pass are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Contact: the ways to reach us on the left, the form on the right
Stacked one under another, the five blocks were a screen and a half of scrolling to reach a phone number: five links, then four more, each with a 40px tap target under it, before "Contact us" even appeared. The grid is two columns from the narrowest screen up now. The two lists of links share a row, and the three blocks that need the width -- the name and its line, the ways to reach us, and the map -- span both. Nothing changes above 1024px, where it is still five across. Checked at 390 and 360 pixels: two columns, the link lists side by side, the brand and contact blocks spanning both, no sideways scroll, and the whole footer 1084px tall where the two lists alone used to take more than that. At 1440 the five blocks are still on one row. The footer, page walk, accessibility name and contact suites all pass unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
A brand's mark is not ours to recolour. Instagram was on the house plum plate, which is a purple circle with the Instagram outline on it -- close enough to be read as the wrong icon rather than as a choice. It has Instagram's own gradient now: five stops, yellow at the bottom left through orange and pink to blue at the top right, which is the direction the real one runs. That meant the plate recipe had to take more than two colours and, where a brand's gradient runs its own way, its own pair of corners -- both optional, so every other plate is unchanged. The shadow of a five-stop plate is cast in the middle colour, because neither end is what the eye reads the plate as. Facebook goes with it, to its own blue. One of the two in brand colours and the other in ours would read as a fault in the row rather than as restraint. Checked: the Instagram plate has five stops starting #feda75 and the Facebook one two starting #3b8bf5, with the social row filled in temporarily to see them and the defaults put back empty afterwards. The page walk, accessibility names, the footer, its two-column phone layout and the icon plates on both site and panel are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Six cards in a three-wide grid is two rows, and the second row is under the fold on a laptop and a long way down on a phone -- so half of what the business does was seen only by somebody who kept scrolling. They are all in one gesture now: four across on a desktop with the fifth running off the edge, one and a peek on a phone, and the card half off the right is what says there is more. Native scrolling rather than a slider. The browser already does momentum, touch, trackpads, shift-wheel, the keyboard and the scrollbar; a library would reimplement all six and get one of them wrong. Snap points so a flick lands on a card rather than between two, smooth behaviour so the arrows glide rather than jump, and scroll-padding matching the page's gutter -- without that last one the first card snapped against the window edge and the rail sat 48 pixels scrolled before anybody had touched it, with a "back" arrow live and nothing behind it. The arrows are hidden until the page says it has a script, like the reveals: they are scrollBy() and nothing else, and a button that does nothing is worse than no button. They are also gone on a phone, where the gesture is the affordance, and each is disabled at its own end of the rail. The rail itself takes focus and is announced as a region with the section's name, so a keyboard can reach it and arrow-scroll it. Checked at 1440: six cards on one row, wider than the window, snap points and smooth behaviour, the back arrow disabled at rest and live after a press, a press moving one card and landing on a card edge, the forward arrow dead at the end, arrow keys scrolling it, and a silent console. At 390: no arrows, no sideways scroll on the page itself. The page walk, accessibility names, the services page, the phone footer and the no-JavaScript pass are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
A scrolling rail for "What we hire", a two-column footer on phones, and the social marks in their own colours
Two things about the rail were wrong. It ran to the window edge, which made it the one band on the page that ignores the column everything else lines up to. And it only moved when somebody moved it, which for a row with a card half off the edge is an invitation nobody was taking. It is inside the page's own padding now -- starting where the heading starts and ending where it ends -- with the cards fading out at both ends rather than being chopped, so it reads as something passing through the column rather than as a grid that overflowed it. And it crawls: twenty-six pixels a second, the list rendered twice and the position wrapped at the halfway mark, which is what makes the loop seamless -- at the wrap, the copy under the frame is pixel for pixel what was there a moment before. The second set is scenery: hidden from screen readers and out of the tab order, because six more identical links say nothing new. It gets out of the way rather than fighting anyone. Hover and focus stop it; a touch, a wheel or an arrow press stands it down for two and a half seconds and it picks up from wherever it was left; a background tab stops it; and prefers-reduced-motion turns it off altogether. Two things had to go for the crawl to work at all, and both are worth knowing. Scroll snapping: mandatory snap and a crawl are two things deciding where the rail should be, and the snap wins every frame -- the rail inches forward and is yanked back. And scroll-behavior: smooth, which animates every assignment to scrollLeft, including the crawl's own fraction of a pixel, which is still easing when the next frame overwrites it. The arrows ask for smooth scrolling themselves, which is the one place it is wanted. The position is also kept in a variable rather than read back from the element each frame: a browser stores the scroll offset in whole device pixels, so scrollLeft += 0.4 reads back as the number it already was, and the rail sits still at any speed under about thirty pixels a second. Checked at 1440: twelve cards with six in the tab order and six hidden, the rail starting and ending exactly where the heading does and inside the page padding, moving 26 pixels a second on its own, stopping under a pointer and starting again when it leaves, wrapping at the halfway mark, an arrow moving it about a card, and nothing on the console. With reduced motion it does not move at all. At 390 there are no arrows and the page still does not scroll sideways. The page walk, accessibility names, the services page, the reveals and the no-JavaScript pass are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The rail sits in the page's column, and moves on its own
Google shows a business's posts above the fold on its own name, and a post without a picture is a grey box of text nobody reads. This is the picture: the logo, the headline, the three numbers the site already stands behind, and the phone number in a band that runs off both edges. It is an HTML page rendered to a JPEG rather than a file from a design tool. The logo, the photograph, Poppins and the brand colours are all in this repository already, so a poster built out of them cannot drift away from the website, and changing a claim is a text edit and one command. Two sizes off the one page: 1200x900, which is what Google asks for, and 1080x1080 for WhatsApp status and Instagram, so the wording is written once and cannot disagree with itself. JPEG at quality 92 rather than PNG, because most of the card is a photograph -- a fifth of the bytes with nothing visible to tell them apart, which is the whole wait when the post is uploaded from a phone. marketing/ is source material, not the site, so it gets what assets-original/ gets: a 403 from .htaccess and a Disallow in robots.txt. Hostinger deploys the whole repository into the web root and only the site itself belongs there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every date field showed two calendars. Ours sat open under the box with the taken days marked in gold; the browser's opened on top of it when the box was tapped, and that one -- the one anybody actually used -- knew nothing about which days are already spoken for. On a phone it is worse still: a date input opens the operating system's picker on touch whatever we do to it, so our grid could only ever be a second opinion nobody asked for. A visitor picked a day that was gone, and the first they heard of it was the call back. So there is one calendar now, and it is ours. The field is a button; it opens the grid, and the grid is the only way in -- which is what lets a booked day be drawn as booked and refused when it is pressed. Booked days are tinted and struck through rather than filled solid, because a solid gold day read as the day you had chosen rather than the day you cannot have; the chosen day is navy. The line through the number carries it for anyone who cannot see the colour, and the reading is in the day's label too. The cost is that a date can no longer be typed. That is a real loss for somebody who knows their dates, and it buys the thing that matters more: on the one calendar anybody sees, the days we cannot give them are struck out before they ask. The field reads the date back as "Tue 6 Oct 2026" -- named month, because 06/10 is the sixth of October here and the tenth of June to half the people who will read it. Smaller, too. The grid is only in the page while it is open, so the contact form's two dates are two single-line fields where they used to be two whole months, and the banner card can afford the same picker -- which is why the dates in the banner now mark the booked days as well. Where it opens is measured from the field before it is drawn: under it when there is room, over it when there is not, pinned to the top of the window when there is neither. It hangs at the end of the page rather than inside the field, because the banner's section clips what overflows it and was cutting the last week off. And the arrows walk the days, across month boundaries, with one cell in the tab order at a time -- the keyboard route the date input used to provide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
One calendar on the dates, and a post card for the Google Business Profile
No description provided.