MAIN - #17280
MAIN#17280niteeshkanna-sh wants to merge 338 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! |
…rawn logo The snake is drawn rather than a stock image: it sits on the navy header at about 48px, where a photograph of a snake is a brown smudge, and the gold is the site's own so it reads as part of the mark rather than a sticker on top of it. A coil is the one snake shape rotation flatters -- a straight snake spinning would read as a stick on a spindle. The body tapers, which a stroked arc cannot do because stroke-width is one number for the whole path, so it is built as a filled outline: down the outer edge, round the tail, back along the inner edge, with the head rotated onto the body's tangent where it ends rather than set at a fixed angle. The first version was drawn at a size nobody sees it at. At 36px the fine body and tight coil turned into something that looked like a loading spinner beside the logo, so the proportions are now set for the size it actually renders: a thicker body, a more open coil, and 48px rather than 36. It also sat alone in the gap before the navigation with nothing to belong to; it now sits next to the logo. It turns once every 18 seconds, slow enough to read as drift rather than as something loading, and it is hidden below 640px, where the row is already the logo, a call button and the menu. Continuous rotation is the kind of motion that makes some people nauseous and it never stops on its own, so prefers-reduced-motion turns it off. Verified both ways in a real browser: the transform advances about 20 degrees a second normally, and the animation computes to none with the setting on. The logo badge now follows the same rule the car photographs do: drop a file named logo into public/photos and it takes over the drawn NS badge on the next build, with nothing to edit here. A file named snake does the same for the snake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The deploy generated admin/config.php from DB_* secrets and uploaded it beside the panel. That was right when the panel's config could only live there. It is now a trap. The panel reads nitesha-config/config.php from above the document root, specifically so that a deploy cannot reach it. But config_path() checks beside the panel first, so an uploaded copy silently wins over the real one. A secret that had gone stale -- and the database was renamed since these were set -- would take the admin offline with "Database unavailable" on a deploy whose only intent was to change the website, while the working config sat there untouched and ignored. So the step is gone and config.php is excluded from the upload again. The deploy now has no way to affect the panel's database settings, which is the property the move above the document root was for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The guard that verifies the deploy target reported every curl failure as "could not list the FTP login directory, so the target is unverified". That reads like a network problem or a wrong path, so two runs went looking at the wrong thing -- the actual cause both times was curl exit 67, which means the server rejected the username and password. Now exit 67 gets its own message naming the cause and the three places to fix it. Everything else keeps the old wording, which is right for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
install.php wrote config.php to __DIR__ . '/config.php', beside the
panel. That is inside the document root, and the document root is rebuilt
from scratch on every deploy -- so the installer's own output was erased
by the next publish and the panel came back saying it had never been set
up. That is not hypothetical: it is what happened, and it took the admin
offline.
config() already looks for nitesha-config/config.php in each directory
above the panel, which is why the working config was moved there by hand.
The installer now writes to that same place, one level above the document
root, so an install survives a deploy without anyone having to know this.
Three cases, all exercised against a real directory layout:
nothing exists yet -> <account>/nitesha-config/config.php
a config already exists -> that one is reused, wherever it is, so a
working install never ends up with two
files and config() picking a coin flip
parent cannot be written -> falls back beside the panel, because a
panel that works until the next deploy
beats a panel that never starts
The environment check was reporting on the wrong directory -- it tested
the panel folder while the file was going somewhere else -- so it now
checks the directory that will actually be written, and says which one
that is and whether a deploy can reach it. The success screen names the
path, and warns when it landed inside the website folder.
storage_path moves above the document root for the same reason. Nothing
reads it today, which is exactly why it would have been found the hard
way later.
The "not set up yet" response is now a page rather than one line of plain
text on a 500. It is the first thing an owner sees after a deploy wipes
the config, and "Not set up yet" reads, at that moment, like the data is
gone. It now says which file is missing, shows the two paths that were
actually searched -- computed, so they cannot drift from the code -- says
plainly that the database is untouched, and links to the installer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
install() created the Super Admin account and only then wrote config.php. That breaks the one case this file is now most often opened for: a database that still holds every booking and account, and a config.php that a deploy erased. In that state create_user() fails on the duplicate email, install() returns early, and write_config() never runs -- so the panel is still unconfigured, still shows "not set up yet", and the installer sends you round the same loop with no way out. The data was never at risk, but there was no route back in. Now the config is written as soon as the database details are known to work, and an account count decides what happens next: rows already there means this is a reconnection rather than an installation, so no second Super Admin is made and the last screen says to sign in with the existing one. An empty database still gets the account it asked for, and if creating it fails the message says the settings were saved and a reload should let you in -- because by then it is true. Verified by extracting the call order from the parsed function with comments stripped: db_connect, migrate, write_config, the account count, create_user, with the only early return between the write and the account being the write failing itself. Checking it by eye first gave the wrong answer -- the regex matched the words create_user() and write_config() in the comment explaining the bug. The full path could not be run here: this sandbox has no MySQL server, so the installer's last four UI tests cannot pass and the reconnect branch is unexercised against a live database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Make the panel survive a deploy, and put a turning snake in the header
…lishes The site has been down with a 403 and the reason was never a wipe. Hostinger's Git deployment clones the repository and publishes the folder named in its "Root directory" setting. That setting is public_html, this repository has a folder called public_html, and it held five leftover images from the old site and no index.html. Apache had nothing to serve at the domain root and directory listings are off, so it answered 403. The deployment reported success every time, because it had done exactly what it was asked. Everything that did not add up follows from this. README.md 404s because it sits at the repository root, which is never published. The folder looked "empty" because five images and no index page is, to anyone looking for a website, empty. And Hostinger's deploy runs no build -- the deployment of the last merge took seven seconds -- so my-app/dist never existed on that server and nothing was ever going to appear there. So public_html is now the build output, committed. Carrying build output in a repository is not something to do lightly; it is the right answer when the host cannot build, because then the repository has to carry the built thing or the site does not exist. `npm run publish:site` rebuilds the folder from scratch -- from empty, so a deleted page actually leaves the server -- and refuses to write a tree missing index.html, .htaccess or admin/index.php, which are the three files whose absence takes the whole site or the whole panel down. The five old images move to assets-original/. They are the only copies, nothing references them, and anything left in public_html would now be served at the root of the domain and erased by the next publish. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Make public_html the built site, which is what Hostinger actually publishes
Hostinger publishes public_html/ verbatim and runs no build, so that folder is the website. A change to my-app/ or admin/ that does not also update it never reaches the server -- and nothing anywhere reports a problem. The deploy succeeds, the site keeps serving the previous build, and the only symptom is that the thing you changed is not there. That is the worst shape a failure can take, and it is one forgotten command away at all times. So this builds on every push and pull request and fails with the list of differing paths when the two do not match. It is deliberately read-only. Regenerating and committing the folder would mean a workflow that can push to main on its own, which is a far larger permission than catching a stale folder is worth; the fix stays a person running `npm run publish:site`. Checked both ways against the current tree: it passes as things stand, and appending one line to public_html/index.html makes it report "Files my-app/dist/index.html and public_html/index.html differ". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Vehicles added in the panel already reach the site the moment they are
saved -- the browser asks for them. The words did not. "Website content"
was read once, by the build, and baked into the HTML, so an edit sat
invisible until somebody happened to rebuild and publish. With the site
now published by committing a built folder, "somebody happens to
rebuild" is not a thing that occurs on its own.
That is the same silent failure as a car that never appears on the site:
the person did what the interface offered and nothing happened, with no
error to explain it.
Baking it was right for a reason that still holds. WhatsApp, Facebook and
most link unfurlers do not run JavaScript, and a site competing on local
search wants its words in the markup rather than behind a fetch. So the
baked copy stays and remains what is served; the browser then asks the
panel for anything newer and swaps it in. Crawlers and unfurlers get the
built copy, visitors get the current one. An edit therefore shows to
people immediately and to Google at the next publish, which is the right
way round.
The merge rules move to src/content/merge.mjs and are now imported by
both the build script and the browser. They decide which of the panel's
edits are safe to apply, and two copies would eventually disagree -- a
disagreement that shows up as the site displaying something the panel
never sanctioned.
Verified against a stub panel in a real browser:
a correctly shaped edit HTML as served keeps the built wording, and
the heading becomes the panel's after load
a malformed section ignored, shipped copy kept, and the console
names the nine missing fields
The first attempt at that test invented its own field names, so the merge
rejected it and the edit correctly did not appear -- which looked exactly
like the feature being broken. The shape has to come from defaults.json.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Show the panel's content edits live, and catch a stale public_html
Hostinger's Git deployment clones the whole repository into the document
root. "Root directory: public_html" is the destination on the server, not
a folder inside the repository. I read it the other way and spent several
rounds putting the site into a public_html/ folder here, which the deploy
then cloned to public_html/public_html/ -- one level too deep, no
index.html at the top, and a 403 every time. Both readings produce a 403,
which is why nothing distinguished them until a listing of the document
root showed .git, my-app, package.json and a nested public_html sitting
in it.
So the built site now sits at the top level of the repository, beside the
source, and public_html/ is gone. It is not a pretty tree. It is what
this host does, and the alternative was asking someone to change a
control panel setting every time the arrangement moved.
admin/ is both the panel's source and part of the build output -- the
build copies it verbatim, and the two are byte-identical -- so it stays
one directory rather than becoming two.
Because the source now shares the web root with the site, .htaccess has
to keep it unreachable. It already denied dotfiles, package.json and the
config files; it now also denies .github (which the .git rule misses,
needing a slash straight after "git" where this has "hub"), the my-app,
scripts and assets-original trees, every .md, and LICENSE. The .git rule
stops being a precaution here: the deploy puts .git in the document root
on every run by design, and it cannot be removed without giving up
automatic deploys, so those rules are the only thing between it and the
internet. The database password in that history still needs rotating.
publish-site.mjs replaces publish-public-html.mjs. It cannot empty the
target first -- the source lives there -- so it records what it wrote in
.site-manifest.json and removes those entries on the next run, which is
what stops a deleted page living at the top level for good.
Verified by serving the repository root the way Apache will:
/ 200, correct title
/cars/ 200
/fleet-check.html 200
/assets/index-*.js 200, 352540 bytes
/admin/install.php 200
/admin/ 500, and the body is "The panel cannot find its
configuration" -- correct, config.php is not in the
repository
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Put the site at the top level, where the deploy actually lands
That message means the CSRF check failed, and the CSRF check fails when
the token stored in the session on one request is not there on the next.
Which is almost never about tokens: it is the session not surviving
between the page loading and the form being submitted. Four quite
different faults produce it and they look identical from the outside,
which is exactly the shape of problem that has cost the most time here.
session-check.php does what signing in does -- stores a token, renders it
in a form, checks it when the form comes back -- and reports what
happened alongside the settings that decide it: whether the browser
returned a cookie, whether session storage is writable, whether the
cookie is marked Secure, and whether the visitor is actually on HTTPS.
It singles out the one combination that silently eats every session: a
cookie marked Secure served over plain HTTP, which the browser accepts
and then refuses to send back. That check outranks the round-trip result,
because browsers exempt localhost from the Secure rule -- a local copy
round-trips happily on settings that lose every session on a real domain.
Without that ordering the page reported success and named a fault in the
same breath, which is worse than reporting neither.
Two real mismatches found while reading the path, both in what install.php
writes:
https_only came from $_SERVER['HTTPS'] alone. This host terminates TLS
upstream, so PHP is handed a plain HTTP request for a visitor who
arrived over HTTPS -- the site's own .htaccess says exactly that about
%{HTTPS} and redirects on X-Forwarded-Proto instead. It now reads both,
and REQUEST_SCHEME as a third opinion. This is the value that decides
whether the session cookie carries Secure.
session_minutes was written; the panel reads session_idle_minutes. A
hardcoded fallback was doing the work, so changing the value in
config.php had no effect at all.
Neither is proven to be the cause of what is happening on the server --
that is what the new page is for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Say why the sign-in page reports "Session expired"
The Add Vehicle form had no way to attach a picture. Photographs only
worked by dropping a file into the repository under a filename matching
the car, which is no use to the person who actually knows which car is
which.
Pick a file, press Save, and it appears on the website's fleet card.
Where the file goes matters more than it looks. Uploads are written under
storage_path, above the document root, because this host deploys by
rebuilding the web root from the repository -- a photograph written
inside it would survive until the next push and no longer. config.php was
lost exactly that way, and a customer-facing photo vanishing during an
unrelated deploy would be the same bug in different clothes. Being
outside the web root also means Apache cannot serve it, so photo.php
reads and streams it: a PHP process per image, in exchange for nothing in
that directory ever being executed.
What decides whether an upload is an image is the file's own contents,
via getimagesize -- never its name or the type the browser claims, both
chosen by whoever is uploading. Verified over a real multipart POST:
a real PNG accepted as .png
a PHP script named car.jpg,
declared image/jpeg refused
The stored name is ours, never the uploader's, because a filename from a
browser can contain path separators. photo.php then only answers for names
matching the exact shape this generates, which is a better rule than
trying to enumerate the ways a path can escape a directory. Every attempt
returns 404:
../secret.txt, ../../nitesha-config/config.php, ..%2Fsecret.txt,
v7-../../../etc/passwd, a well-formed name for a file that does not
exist, and an empty name
The URL is built against the panel's root rather than used as returned.
The endpoint sends "photo.php?f=...", relative to the panel; in an <img>
that would resolve against the page instead, so the fleet page would ask
for /cars/photo.php and every photograph would 404 on some pages and work
on others. Confirmed in a browser: the card requests /admin/photo.php and
gets 200 image/png, and a vehicle with no photograph still falls back to
the drawing.
The upload is a second request after the save, because a photograph is
stored against a vehicle id and a vehicle being created has none until
the save returns one. The form hides that. Remove marks the photograph
for deletion but changes nothing until Save, so Remove then Cancel leaves
the vehicle as it was.
Not verified: anything touching the database. This sandbox has no MySQL,
so the migration, the UPDATE, and the audit entries are unexercised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Add a photograph to a vehicle from the panel
The document root caches every .js and .css for a year as immutable. That is right for the site's build output, where a changed file is a changed filename because the name carries a content hash. It is wrong for the panel: admin.js, admin.css, api.js, car-data.js and booking-data.js have fixed names, so a browser that loaded the panel once would not ask for them again until 2027 -- and every change to the panel would reach nobody who had ever used it, with no error and nothing to notice. The vehicle photograph upload merged minutes ago would have been the first casualty. Two parts, because one alone does not finish the job. admin/.htaccess overrides the header for this folder, which fixes it for browsers that ask -- and a browser already holding the file will not ask. So the tags now carry the file's modification time: a changed file is a different URL, and the cached copy cannot match it. asset() lives in its own src/assets.php. It went into http.php first, which was wrong in a way worth recording: http.php is the JSON endpoints' plumbing, and no page that renders HTML loads it. dashboard.php, index.php and content.php all require csrf.php and nothing else, so asset() was defined precisely where it was never called from and every panel page would have died on an undefined function. Caught by loading the pages rather than by reading them -- php -l passes on a file that calls a function that does not exist. Verified: asset() returns admin.css?v=<mtime> and falls back to ?v=0 for a file that is not there; the require precedes the first call in all three pages; index.php reaches its database call, which is as far as anything gets here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Stop the panel's own scripts being cached for a year
Saving a vehicle with a photograph failed, and the reason was worse than the symptom: the column the photograph feature needs had never been created, on any installation. migrate() ran from exactly two places -- install.php, which refuses once an account exists, and a button on the content page that nobody has a reason to press. So a deploy could add a migration and nothing would ever apply it. The code shipped and the database did not, and the first sign was a feature failing with a SQL error. It now runs on dashboard load. migrate() already records what it has applied and skips those, so the cost is one small SELECT per load. A failure is reported in a banner rather than thrown: a migration that cannot run is worth knowing about, and is not a reason to refuse to show a panel that otherwise works. The public fleet endpoint had the same problem from the other side, and it mattered more. It selects a narrow list of columns by name, and one of those was the new one -- so between a deploy and an admin next signing in, niteshacars.in/cars would have been asking for a column that did not exist. A change made entirely inside the panel could empty the fleet on the live site, for visitors, with nobody signed in to notice. It now names the column only when it is there and selects NULL otherwise, so the site does not depend on anyone having opened the admin. Both forms of the query were checked for validity. The upload endpoint says what to do rather than returning a SQL error, in the window where the column is genuinely missing. Still unverified: the migration actually applying. There is no MySQL here. What was checked is that the file parses to exactly the one ALTER statement intended, and that every new function is defined by the require chain the pages use -- the last change defined a helper in a file no page loads, which php -l cannot catch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Run pending migrations when the panel loads
The photograph upload told the user to "open the Dashboard tab once and try again". They did, and it could not have worked: the panel switches tabs in JavaScript without loading the page, so the migration that runs on page load never ran. An instruction that only works if you know that is not an instruction, it is a trap. That message was mine and it was wrong. So the schema is brought up to date in api_guard instead. Every endpoint in the panel goes through it, which means a deploy that adds a column has it created by the first action taken after that deploy, whatever the action is and whichever page the person is on. Nothing to be told, and nothing to remember. migrate() is cheap but not free, and the panel makes several API calls per page, so migrate_if_needed() decides whether to bother. What it compares is the newest migration filename: a deploy that adds one changes that string, so the next request applies it and every request after skips. There is no version number anyone has to remember to bump. A failure is returned rather than swallowed, and deliberately not remembered -- recording it would mean the session gave up retrying for as long as it lasted. The dashboard shows it in a banner; the upload now says the database could not be updated and that the database user may not be allowed to change tables, which is the actual remaining cause once the migration has genuinely been attempted. Exercised with migrate() stubbed, since there is still no MySQL here: first call runs once, records 002_b.sql two further calls skipped, nothing re-run a new file appears runs again, records 003_new.sql the call after that skipped migrate() throws error returned, marker left unset the call after that retries, and succeeds The first version of that test reported ran=0 for every case and looked like the feature was dead. The harness was wrong -- __DIR__ inside the eval'd copy pointed at the temp directory, so the glob found no migrations at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Apply pending migrations on any panel action, not on a page load
…t does not The photograph upload returned 503 from its own "column is missing" branch, which means table_has_column() said no on a database where the column had almost certainly just been created. It asked with "SHOW COLUMNS FROM `t` LIKE ?". This connection uses real prepared statements -- ATTR_EMULATE_PREPARES is false -- and a placeholder in a SHOW statement is not something MySQL accepts there. The catch below it then turned that failure into a confident "no", for every column, every time. An exception that becomes a plausible answer is worse than one that escapes, so the failure is logged now as well as caught. The same function gates the public fleet endpoint, which is the part that would have gone unnoticed: it was selecting NULL for every photograph, so no picture would ever have reached the website either, with nothing failing anywhere to say so. It now asks information_schema.columns -- an ordinary SELECT, so it prepares, and both the table and the column bind as parameters rather than being interpolated into SQL at all. That diagnosis cannot be run here, and there is a second possible cause for the same 503: the migration simply failing. Rather than pick one again, the endpoint now reports what the database actually said. api_guard runs migrate_if_needed() and discards its result, so an endpoint finding a column missing could not tell "never attempted" from "failed, and here is why"; last_migration_error() carries that across. The two previous messages here each named one plausible cause, were wrong about which, and neither carried the one fact that would have settled it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Ask information_schema whether a column exists, and report why when it does not
Four things, three of which were the same mistake in different places: something existed in the code and not in the form. Body type offered Hatchback, Sedan and SUV. The database ENUM and the API's validator both accept MUV and Other as well, so a seven-seater could not be recorded as one -- an Innova is filed as a hatchback on the live site right now. Nothing warned, because a choice you are not offered is not a bug anyone reports. The lists lived in three places; the form's copy had fallen behind. It and the API now read one list in src/vocab.php, and the form renders its options rather than spelling them out. The schema's ENUM is still its own declaration, since changing it means a migration, but two of the three copies are now one. Photographs are framed before upload and saved at 1200x750, which is the shape the website's cards use. That is what stops the fleet looking ragged: the page is no longer cropping pictures of different proportions and hoping, it is laying out identical rectangles. A 4 MB phone photograph also arrives at around 150 KB, which matters on a page that fetches one per car. The framing window is that same shape, so what is inside it is what a customer sees. Drag to choose what shows, a slider to zoom, and the image is held so it always covers the window -- an empty corner cannot be framed. Zoom works about the middle rather than the corner, which is where the subject is. Written by hand rather than with a cropping library: the panel has no build step, and every script in it is a plain file the browser loads. The admin card thumbnail had no fixed shape at all, so a tall photograph made its card taller than its neighbours. It is 16:10 now, with the drawn illustration fitted and a photograph covering. And a new photograph did not appear on the site because the fleet endpoint was cached for five minutes. Changing a picture and not seeing it reads as the upload having failed, and the natural response is to upload it again. One minute still absorbs any burst worth absorbing. Checked in a browser: the body type list offers all five, the framing window measures 358x223 (1.605 against 16/10's 1.600, from integer widths), and the export is exactly 1200x750 image/webp. The geometry was exercised with the same arithmetic the panel uses rather than by running admin.js itself, which needs the whole dashboard around it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Frame photographs to one size, and offer every body type
It turned continuously beside the logo. A mark that never stops moving
competes with the navigation next to it for attention it does not need,
and the rotation was the only reason the stylesheet needed a
reduced-motion exception for it at all. Nothing moves now, so there is
nothing to exempt.
The drawing stays as a placeholder. SnakeMark already prefers a real
image when one exists -- photoFor('snake') -- so dropping a file named
snake into my-app/public/photos replaces it with nothing else to change.
Verified in a browser: animation-name computes to none, and the
element's transform is identical 1.5 seconds apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Both were drawn in code, replaceable only by committing a file to the repository under the right name. That is not something the person who owns the brand should have to do, and it was the reason a cobra pasted into a chat could not become the site's mark. Website content now has a Logo and mark section: choose a file, upload, and it is on the site within a minute. "Use the drawn one" puts the drawing back. Nothing is destroyed until the replacement is safely stored -- the old file is deleted only after the row points at the new one. The files live under storage_path, above the document root, exactly as vehicle photographs do: this host rebuilds the web root from the repository on every deploy, so a logo written inside it would last until the next push. brand.php reads them back, since Apache cannot reach above the root. Validation is vehicle_photo_check(), the same function the car photographs use, so there is one answer to "is this an image" rather than two that can drift apart. It reads the file's own contents rather than its name or the type the browser claims. Confirmed over a real multipart POST: a PNG is accepted, a PHP script named snake.png and declared image/png is refused. brand.php answers only for names matching the shape it generates. Every escape attempt returns 404 -- ../brand-secret.txt, ../../nitesha-config/config.php, the URL-encoded form, blogo-../../../etc/passwd, a vehicle photograph's name, a well-formed name for a file that does not exist, and an empty name. The site reads them from the request it already makes for the wording, rather than a second round trip for two short strings, and resolves them against the panel rather than the page -- used as returned, the fleet page would ask for /cars/brand.php and the logo would break on some pages only. Checked in a browser: with nothing uploaded both stay drawn and no request is made; with both uploaded they resolve to /admin/brand.php and two requests go out. Not verified: the database half. There is no MySQL here, so the new table, the INSERT ... ON DUPLICATE KEY UPDATE and the audit entries are unexercised. The migration parses to the one CREATE TABLE intended and applies itself on the first panel action after this deploys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The questions band gets its own picture
A hire that already happened could not be written down. The grid stopped at today, so the days the car was actually out were greyed out, and the only way in was to type the date into the box beside it. Nothing else was stopping it, which is the part worth knowing: the date inputs carry no min, and the server has no rule about past dates at all. A past date typed by hand has always saved. This only ever blocked the clicking. Seven days back now, as asked. Far enough to catch up a week's paperwork, near enough that a mistyped year does not quietly land a booking in 2019. Days before it stay disabled and the month arrows still page back as far as anyone wants to look, so nothing is hidden -- it is just not clickable. A back-dated day is ringed rather than filled, and the key under the grid gained a second swatch for it. Selectable, so it must not read as taken; behind us, so it must not read as an ordinary open day either. Nobody should record last Tuesday's hire thinking they booked next Tuesday's. The window is BACKDATE_DAYS at the top of the file, if a week turns out to be the wrong number. Checked by running the real module against a stubbed availability endpoint, today being 5 October: 27 September and earlier disabled, 28 September -- the floor -- through 4 October clickable and marked, today onwards unchanged, the booked range still refused, clicking the floor sets the field, no page errors. The boundary was tested across the month edge, which is where it actually falls. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The booking calendar lets you back-date a week
EMI was asked for and is the smaller half of this. The form offered fifteen categories. The database allowed nine and the API validated against the same nine, so Tyre, Parking, Toll, GPS, Driver and Marketing were in the dropdown and refused on save. The only thing anyone had to do to hit it was pick the category that described what they had actually spent money on. vocab.php exists because this happened before, with body types: three copies of a list, the form's left behind, and a choice not offered is not a bug anyone reports. This is the same shape with the opposite symptom -- offered and not accepted -- so it gets the same fix. The categories and the payment methods live in vocab.php now; the form renders from there and the API validates against there. The schema's ENUM is still its own declaration, which is what the migration is. EMI sits beside Insurance because they are the same kind of cost: what the car costs to own rather than what it costs to run. A car bought on finance costs a fixed sum every month for years, and it was going in as Other -- which leaves the largest fixed outgoing of the business unreadable in its own books. Expenses already carry a vehicle, so an EMI is recorded against the car it belongs to and shows in that car's running costs. The list is grouped rather than alphabetical -- running the car, owning it, the road, the business -- because that is how somebody with a receipt in his hand looks for the right one. The existing names and their order within those groups are unchanged. Checked by parsing all three declarations and comparing them: the <option> tags the form renders, the array the API validates against, and the ENUM in the migration are now the same sixteen values in the same order, EMI among them. 015 sorts after 014, so migrate_if_needed() picks it up on the next dashboard load with nothing to run by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Expenses: the car loan, and six categories that never saved
A car on finance costs the same sum on the same day for years, and until now that was typed by hand every month. The month it is forgotten is the month the books stop matching the bank. The loan is described on the vehicle -- instalment, day it falls, first one paid, how many there are, lender -- and the expenses are written from it, one per instalment, as each falls due. Three rules, and they are the whole of the posting: Never ahead of today. An instalment due next month has not been paid, and an expense dated in the future makes this month's outgoings wrong. Never past the last one. 36 instalments writes 36 and stops. A loan that runs out and keeps writing is worse than one that never started. Never twice. A unique key on (loan_id, loan_seq) makes that the database's promise rather than this code's, so two dashboards opened in the same second cannot produce two rows for one instalment. The month-end is the part that quietly goes wrong. A loan due on the 31st has no 31st in February; banks take it on the last day of the month, and so does this. Rolling into 1 March would move the cost into the wrong month, which is the one thing the expense date is for. No cron on this host belongs to the business, so the dashboard being opened is what drives it -- once a day per session. Nothing is lost by not opening it: the dates come from the loan, not from today, so a fortnight away posts the fortnight's instalments on the days they actually fell. Closing is honest rather than tidy. Clearing the amount deletes the loan only when nothing has been posted from it; once instalments exist the loan is closed instead, because an expense that points at a loan needs the loan to still be there to explain it. A closed loan still owes whatever fell before it closed, and nothing after -- which is what happens when a car is sold mid-term. Checked in two passes, both against the real functions. The dates: 22 cases including the 31st through a short February, a leap February, the year rolling over, and a loan closed early. The posting: a 36-month loan run from a stubbed database -- three instalments on the first run, zero on the second, third and fourth, one more a month later, nothing the day before one falls, three at once after three months away each on its own date, and exactly 36 rows when run years past the end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Recurring EMI: describe the loan once
The roles table has described five of these since the schema was
written, and the Super Admin row says in so many words "Full access,
including user management" -- of a panel that had no screen for it. The
only way to add a second person was tools/create-user.php over SSH,
which on this hosting means the business cannot add its own staff
without asking a developer.
Guarded by 'user.manage', and the name is the guard. The obvious
'user.view' would have been granted to auditor, which holds '*.view' --
handing a read-only account the list of everyone who can sign in. One
ability, named so that only the '*' of super_admin reaches it. The four
other roles do not see the link and get 403 at the address.
What the screen refuses is the part worth reading. It will not act on
the signed-in account at all: not the role, not the switch. This is the
only page that can remove the last way into the panel, and changing your
own role to Staff locks you out of the page you would use to undo it --
on a one-super-admin business, that is the whole panel, recoverable only
over SSH. Somebody else's account is changed freely; your own is changed
by another Super Admin or at the command line.
Accounts are switched off, never deleted. Every booking, payment and
expense records who entered it, and a deleted user takes the answer to
"who did this" with it.
Creating goes through create_user(), so the ten-character minimum and
the hashing live in one place and this screen and the CLI tool cannot
drift. Resetting clears failed_logins and locked_until with it: somebody
who has forgotten a password has usually also locked themselves out
trying to remember it, and a new password that still refuses to sign in
is a second support call. No password is written to the audit log --
what is recorded is that it changed and who changed it.
Checked against a real MariaDB with all sixteen migrations applied and
the panel served by PHP, driven in a browser as all four roles:
the gate super_admin sees the link and gets 200; admin,
accounts and staff see no link and get 403, and the
refusal is in the audit log as permission_denied
creating the new account appears, signs in, and is itself
refused the Users page
refusing duplicate email, mismatched pair, and a short password
after stripping minlength from the browser -- the
server refuses it, not only the form
role staff -> accounts, and back
switch off the row reads Off and that person can no longer sign in
password the old one stops working, the new one works
self a forged POST to switch off my own account is refused
and I can still sign in afterwards
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
A screen for who can sign in
Five roles covered the business while everybody fitted one of them, and stopped the moment somebody nearly did: the person on the counter who is also trusted with the cash, the relative who should see the expenses and touch nothing. Both were a choice between a role that does too little and one that does far too much. So the role still answers first, and can now be adjusted. The Add somebody form shows every permission the panel enforces, ticked as the chosen role ticks them, and each one can be changed before the account is created; the same grid adjusts an existing account, person by person, under the list. What is stored is the difference, never a snapshot. "Staff, and may also take payments" survives Staff changing meaning later; twenty-seven ticks frozen on the day the account was made would not, and would read as a sentence nobody can check. Changing a role clears the differences with it, because a difference only means something against the role it was set against, and the screen says so. Three things it will not do. It will not hand out user.manage a tick at a time -- an account with that can reset the Super Admin's password and take the panel, so it is a role change, visible in one word on the list. It will not narrow a Super Admin, which is full access by definition. And it will not touch the account you are signed in as, the same rule as the role and the password beside it. The role map moves from auth.php into src/abilities.php, beside the catalogue of permissions and the per-person exceptions -- the other two thirds of the same answer. require_can() and api_guard() now ask user_allows(), so the exceptions are enforced on the server for every request, including one already in progress: a permission withdrawn applies on that person's next click, not their next sign-in. The catalogue only lists permissions something actually guards, and a test holds it to that by scanning the endpoints for them -- a tick that changes nothing is worse than no tick, because somebody will rely on having removed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The fingerprint was taken before the last round of wording and layout edits in admin/, which the fingerprint covers. Hostinger serves what is committed, so the stamp has to describe it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Permissions, not just a role, when adding somebody
"Who can sign in" described what the page is for, which is what a tooltip is for. In a sidebar beside Dashboard, Bookings, Vehicles and Reports it was the only entry that was a sentence, and the one word everybody was going to call it anyway was Users. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Call the page Users
Six boxes and three paragraphs on every vehicle, for something most cars do not have. The posting goes with it rather than being left behind. It was driven by the dashboard being opened, and with no form there is no way to describe a loan, change one, or close one -- so a car already carrying a loan row would have gone on writing an expense every month with nothing anywhere to stop it. A thing that writes to the books and cannot be seen is worse than a form nobody wanted. What stays: the vehicle_loans table and the loan_id on expenses. Any EMI already written is real money that was really paid, and the row it points at is the only explanation of it. EMI also stays an expense category, so it can still be entered by hand like any other cost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Take the Loan / EMI block out of the vehicle form
The home page listed twelve town names as twelve equal chips. What people actually ask for is narrower and more specific than a town: the railway station, the town station, the bus stand. "Self drive car at Nagercoil railway station" is a different search from "car rental Nagercoil", and the page said only the second. So the ten places we hand vehicles over at are named, with the kind of place each one is drawn beside it, and grouped under the town they sit in. Flat, ten chips would read as ten towns and overstate how far apart they are; under a heading each, the three Nagercoil spots read as what they are -- three doors into the same town. Every chip still leads somewhere: the town page it belongs to, or the list of towns for the two we do not have a page for yet. The twelve towns stay. They are the service area, they feed their own pages and the sitemap, and dropping them off the home page would quietly cut the one internal link each of those pages has from it -- so they are underneath, smaller, in a line rather than a row of chips. Parvathipuram and Muttom are named on the page, so they go into areaServed as well. A page that claims a place to the reader and not to Google is the same page disagreeing with itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The stops are a route now rather than three headings with chips under them: a gold line down the left, a point on it per town, and it draws itself downward as the section arrives. A list of places is a list; a line through them is what a delivery actually is, and it gives the eye an order to read ten names in rather than ten things landing at once. Each stop lands with one ring outward, its chips follow in sequence, and each chip's glyph draws itself in the same stroke the about-page marks use. One ring, not a pulse: three things blinking on a page somebody is trying to read an address off is not attractive, it is a reason to scroll past. The line is built per stop rather than as one rule behind all three, so each segment is released by its own stop's reveal and the route draws in step with the reading. No second observer, and nothing animating height: it is transform and opacity throughout, which the compositor does without touching layout. One thing worth remembering. The chips' entrance had to move onto the list item around each chip: an entrance animation carries fill-mode: both, so it goes on owning every property it names for good -- and a transform animated on the chip would have silently beaten the hover lift, leaving a chip that never moves under the pointer and no error anywhere to say why. Nobody who has asked for less motion gets any of this, and with scripting off every chip is visible from the start, as the rest of the file already arranges. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The section was words on white beside a drawing. It is now the thing it describes: a dark band with the photograph from the panel behind it, the route of stops down the left, and a sketch of the cape on the right with the towns pinned on it. Dark because of the photograph rather than for its own sake. On white, a coast at sunrise has to be faded to about a fifth before type will sit on it, by which point it is a grey smear that cost sixty kilobytes. On navy it runs at full strength on the side with no words over it, and a gradient does the separating. It also has to read with no photograph at all, since that is the state a panel slot is in until somebody fills it -- so the dark is the brand's own navy and the picture is an improvement on it, not a requirement of it. The map is drawn rather than fetched: a provider's tile is a request, a licence, an attribution line and a grey rectangle while it loads, and it invites somebody to read a service boundary off it that we have not drawn. What is not sketched is where the towns are. Every pin is placed from its real latitude and longitude, because somebody deciding whether we come to them reads distance off this. Two things that took a second attempt. The coastline was first smoothed through the midpoint of every edge, which rounds an outline evenly and turns a cape into an egg -- the tip at Kanyakumari is the one corner that has to survive, so the corners are cut back individually now and the runs between them stay straight. And the pin for Kanyakumari sat in the sea: the town is on the tip, the rounding pulls the drawn edge inside the corner, and the test that puts every pin through isPointInFill is what said so rather than anybody's eye. Five pins, not ten: Parvathipuram is inside Nagercoil, and the stations and the bus stand are inside their towns. At this size they would be one smudge of overlapping labels, and the list beside the map names all ten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The pickup points, drawn as a route
Thirty-nine of the site's forty-four pages now carry the phrase in the title, the heading or both, beside the place they are about. Most already said it somewhere; what they did not do was say it consistently or near the front, and half the town pages buried it after the place name as "— Self Drive Hire", which is the part of a title a search result cuts off. Five pages are deliberately left alone. Wedding cars and tourist vehicles come with a driver -- the wedding page's own FAQ answers "Does the wedding car come with a driver?" with "Wedding hire is with a driver" -- so the phrase there would be a claim the business does not make and the page itself contradicts two paragraphs later. The pickup points on the home page say it in sentences rather than in the chips. "Self drive car at Nagercoil Railway Station, at Nagercoil Town Railway Station and at Vadasery Bus Stand" is the phrase somebody types, and it now exists on the page in that order -- but ten chips each opening with the same four words is those words forty times in one glance, which is the pattern search engines have discounted for a decade and the one that makes a page read as written for a machine. Bikes say self drive bike rather than self drive car, for the same reason the wedding pages say neither. Nothing here is panel copy. Titles, the town data and the service-area headings are the site's own files, so these actually take effect; the heading on the home page's own band is edited in Website content and is the business's to change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Two things. The line of twelve town names under the pickup points is gone. They are one step further away now rather than unreachable: "See every town we deliver to" goes to /car-rental, which lists all twelve and links each. And the site says rental where it said hire. Nought occurrences left in the built pages, down from a hundred and twenty-nine. British English calls it car hire; nobody in Tamil Nadu searching for one types that. Not a find-and-replace, because "hire" is a noun here as often as a verb and the two take different words: a rental, but to rent. A mechanical pass produced "everyone who rentals a vehicle", "People rental here to reach the temple", and "Rental a Maruti Swift without a driver" -- all of which were caught by searching the result for "rental" sitting in a verb slot, rather than by reading four hundred strings again. The ordered rules did the bulk; eleven verb forms were then put right by hand, and a second sweep with wider nets found nothing else. Headings move with it: "What we hire in Nagercoil" is "Rental cars in Nagercoil", "Hiring in Marthandam" is "Rental cars in Marthandam", and the two band headings on the home page follow. Those two are panel copy. The baked HTML carries the new wording, but if the panel holds a saved version of either section it will win a moment after the page loads -- so they want changing in Website content too, and that is the business's to do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Three cards in a three-wide grid meant the home page named three of the district's places and stopped. The rest were behind a link, and a link is a decision somebody has to make before they have seen anything worth deciding about. All twelve go past now, four at a time, and a card leaving the frame is what says there are more. Four rather than three because these cards are a picture, a category and a sentence: narrower than the service cards and perfectly readable at a quarter of the column, where three left each one wider than it had anything to fill with. The width is worked out from the column rather than set in rem, so four cards and their three gaps come to exactly the width the rest of the page uses -- a fixed width that nearly fits leaves a sliver of a fifth card, which reads as a fault rather than as there being more. The crawl moves out of Services.tsx into lib/useRailCrawl and both rails use it. A hundred lines of scroll arithmetic copied into a second file is a hundred lines that get fixed in one of them. One thing found on the way. The rail reported itself as several thousand pixels wide to document.scrollWidth -- which is the number every "is this page wider than the screen" check reads, Lighthouse's included -- even though the page could not be panned sideways and never could. Saying contain: paint, which is only what was already true of a scroller, settles it. Pixel for pixel the rails render identically with it and without, so nothing that is drawn has changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Say "self drive car" where a place is named
The names did not start at the same height across a row, and the cards were five hundred pixels tall -- four of them filled a laptop screen on their own. Both come from the same thing: every part of the card was sized by what happened to be in it. The picture took its height from its own proportions and the name took one line or two depending on the name, so each card set its own heights and the row read as four separate things rather than as a row. The picture is a fixed height now, the name has two lines of room whether it needs them or not, and the sentence has three. Every heading in a row starts on the same line, every Directions button ends on one, and the card is three hundred and seventy-seven pixels rather than five hundred and seven. Checked with photographs of three different shapes standing in for the panel's, which is the condition the headings went out of step in, on the home rail and on the places page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Hold the place cards to one line each
Both rails started their animation frame on mount and kept it for as long as the tab was open. On the home page both are below the fold, so the first thing they did was animate something nobody could see, on the one thread that was also hydrating the page -- and then carry on doing it for the rest of the visit. They wait to be on screen now, with a little margin so a rail is already moving when it comes into view rather than visibly starting under somebody's eye, and they stop again when it leaves. What it bought, measured on a throttled phone: the home page's LCP went 2176ms to 2040ms. Blocking time did not move -- 486ms to 477ms -- which says plainly that the blocking is React hydrating sixteen hundred nodes rather than anything the rails do. Worth saying rather than claiming the win: the real gain is a phone that stops running two animation loops for sections nobody is looking at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Do not run a rail nobody is looking at
Each rail rendered its list twice so the crawl had somewhere to wrap. The wrap only ever needs the cards that fit on screen at the moment it happens -- four or five -- and every card beyond that is laid out and styled for nobody. The places rail was carrying twelve such cards. So the repeat is five cards now, and the crawl wraps where the repeat begins rather than at the halfway mark. The home page is 1433 elements rather than 1564. What this does not do is show up in the blocking time. The honest figure: on this machine the measurement varies by about a hundred milliseconds between identical runs, and a hundred and thirty fewer elements is worth perhaps thirty of them by the slope measured elsewhere in this work. It is below the noise. What can be said is that it is strictly less work for every phone that loads the page, it is verified not to change what is drawn, and the loop still wraps seamlessly -- the repeat is wider than the frame at both sizes, which is the condition that matters, and the tests check that rather than the old "rendered twice". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Repeat a screenful, not the whole list
No description provided.