Every page a visitor can reach, everything staff can edit, the AI that drafts for them,
and the architecture underneath — on one page.
As built on 30 September 2026 · migrations 0001–0018
16public pages, in two languages each6 in the header, the rest in the footer
23admin tabs, drawn from one registryevery word and photograph is editable
26database tables, each with its own access policyenforced by the database
8AI helpers in the admin, through one endpointthey draft; only an editor saves
0script bundles on any public pagea few hundred inline bytes where needed
44pxminimum size for anything a finger has to hitthe audience is on phones
Tier one
The site
What is on it, what it does, who can change it, and what the AI does for them. No
technical background assumed.
Site map साइटचा नकाशा
Sixteen public pages in four groups, plus the files machines read and the door staff use.
Figure 1 — every URL a visitor can reach. The two gold, dashed routes are not
files: one page is generated for each published programme and each blog post, so adding
a programme in the admin adds a page to the site. The header carries six links, with
the logo as Home and Donate as a button beside them; Blog, News & media, Team and
Resources live in the footer's three columns with the rest, so nothing left the site when
the header was trimmed. /sitemap.xml names every page twice, once per
language, and no admin path.
What each page does प्रत्येक पान
One line each, roughly in the order a visitor meets them.
/Home. The Marathi tagline as the headline (in one of two hero layouts the admin
switches between), the impact figures counting up, the mission beside a photograph,
the first six programmes as cards with their icons, the latest three news stories,
and a closing band asking readers to give or volunteer.
/aboutThe foundation's story in three parts with its photograph and caption, the mission,
a list of key facts, and the values.
/programsEvery published programme as a card with its cover photograph and its icon.
/programs/:slugOne programme in full: the write-up — headings, lists, quotes and photographs placed
between paragraphs — the region, the focus area, the year it started, and a donate
prompt.
/satpuda"Come to Satpuda": the three steps to arranging a visit, what to expect, and
testimonials from people who have been — with their photographs and links to the
related blog post, a Facebook post, a YouTube video or any other page.
/eventsUpcoming, ongoing and past events, sorted on every visit from the current Indian
date so nothing is ever stale. An event can carry an "Open in Google Maps ↗"
link.
/galleryOne tab per category and, inside each, photographs grouped into albums — three per
group, the rest behind "Show more photos". Search the captions, pick an album, sort by
newest or oldest; tap a photograph to enlarge it.
/blogPosts with a cover, a category, an excerpt, the author and the date.
/blog/:slugOne post in full, with photographs between paragraphs, a "Share on WhatsApp" button
and a prompt to donate at the end.
/newsCoverage of the foundation by others — newspaper cuttings, online articles, TV and
video. Tabs by type; search, year, outlet and sort. Each story opens in a detail view
with its own link and share buttons. A video is a link to YouTube, not an
embed.
/teamThree groups — advisory, core and fellows — each with its own empty state, and a
drawn silhouette where there is no photograph.
/resourcesDocuments to download. A resource with no file attached shows as pending rather than
as a broken link.
/donateSuggested amounts, how to give, the UPI QR code, bank details, and the registration
numbers donors need for tax. Each block appears only when its own details are filled
in.
/contactEmail, phone numbers and office addresses; "Follow us" icons for WhatsApp, Facebook,
Instagram and YouTube; the trust's registration number; and a message form.
/volunteerThe roles available, other ways to help, and a sign-up form asking for a name and at
least one way to reply.
/legal/…Privacy, terms and an accessibility statement, as three tabs. The contact address in
them is read from the site settings, so it cannot drift from the real one.
A page never shows a blank where a fact is missing. Every block on
/donate, /about and /satpuda is tied to its own
data and disappears when that data is absent. An empty "80G —" line would read as a
claim the foundation cannot back, and donors rely on those numbers, so the whole line is
suppressed instead. Links follow the same rule: a social icon appears only when its
address is set, a Maps link only when an event has one, and a testimonial's "Read the
story" only while that post is published.
Found and shared शेअर आणि शोध
Most visitors arrive from a forwarded WhatsApp message or a search. Both are designed for.
A picture in every preview
A page with a photograph of its own — a programme, a post, a news story — shares that
photograph as a large card, with its alt text. Every other page shares the
foundation's logo, which is also the icon in the browser tab and on a phone's home
screen.
Sharing without anyone else's code
"Share on WhatsApp" is a plain link with the message already written: a post uses its
own WhatsApp message field, a news story builds one from its headline, outlet, date
and summary. "Share on Facebook" is a plain link too. Nothing reaches either company
until the reader taps.
A shared story opens on that story
/news?item=N renders the news page with that story already open, and
the page's title, description and preview image become the story's — so the
preview in a chat shows the cutting itself.
Two addresses per page, both findable
Each page tells search engines its canonical address and its twin in the other
language. The sitemap is written from the database, so a new programme is listed the
moment it is published and a draft never is.
Links, not embeds
YouTube videos and Google Maps are links that open in their own apps. No page loads
Google's or YouTube's code, and the privacy notice's list of providers stays short.
Counted without cookies
Visits are counted by Cloudflare's cookieless analytics, so there is no consent banner
to dismiss. The admin and the editors' preview are not counted.
Two languages दोन भाषा
Marathi is the site's own language. English is the translation — not the other way round.
Switching is a link
The toggle in the header is an ordinary link to ?lang=en. It works with
JavaScript off, the choice is remembered for a year, and each language version has its
own shareable, searchable address.
Everything is translatable
Every piece of text on the site — page copy, menu labels, dates, button words — exists
in both languages. Dates are formatted by hand rather than by the browser, and digits
are Latin in both, so numbers read the same to everyone.
Half-translated is a supported state
A programme can be written in Marathi and published before anyone has translated it.
An English reader sees the Marathi with a quiet (मराठी) marker
rather than a gap.
Figure 2 — one field, four outcomes. The first three are the design working. The
fourth is the failure it is built to avoid, and it is the reason the admin deletes a
translation rather than blanking it: a field containing an empty string looks filled in
to every check, so the Marathi never gets a chance to show.
What staff can edit संपादन
Twenty-three tabs behind a login at /admin, every one drawn from a single
registry of fields. Between them they cover every word and every photograph on the public
site — there is nothing an editor has to ask a developer to change. The admin itself
switches between Marathi and English, because the people editing it are in Dhule and
Nandurbar.
Single-record forms 3
One long form each, for settings that exist exactly once.
Site & hero — titles, tagline, hero copy and its layout, logo, email, phones, and the WhatsApp, Facebook, Instagram and YouTube links
About page — the story paragraphs, mission and story photograph
Donation info — UPI ID, QR code, bank details, 80G / 12A / trust registration / PAN, the domestic-only notice
Page text 1
The 73 lines of the foundation's own words that sit around the content — every
page's kicker, title and introduction, the Satpuda steps, the ways to volunteer, the
donate how-to. All on one screen, grouped by page; one Save writes only the lines
that changed.
Ordered lists 12
Add, edit, reorder, publish or unpublish rows. Order on the page is the order here.
Gallery categories are the page's tabs, Gallery albums group photographs
inside a category, and Gallery places each photograph. Unpublishing a category
or an album hides everything inside it, not just its heading.
Photos & media 1
Every image, video and document in one library: search, filter, bulk publish, alt
text and captions in both languages, several sections per photograph, and a "Used in"
line naming every page a file appears on before anyone deletes it.
Inboxes 2
Contact messages and volunteer sign-ups, read-only. The public can add to these and
nobody outside the login can read them back.
AI 1
Which provider and model the AI helpers use, a fallback, the prices used to estimate
cost, a Test button, and a year of usage by task, model and person. Only admins can
change it.
What editing feels like
Both languages, side by side
Every translatable field shows a Marathi box and an English box together. There is no
separate "translate this page" mode to remember to visit.
A real editor for long text
Programme and blog bodies get headings, bold and italic, lists, quotes, dividers,
links, and photographs from the library placed between paragraphs with a caption.
Pasting from Word or a web page keeps the text and drops what the site cannot show.
"Appears on", and the real page beside the form
Every tab says where its rows show on the site. "See it on the site" opens that page
beside the editor, with the row outlined and unsaved typing already in it — in Marathi
or English.
Nothing is public until it is published
New rows start unpublished, and so do new photographs. Picking a draft photograph for
a page is flagged in amber at the moment it happens, because a draft renders as a
placeholder and the editor would otherwise never know.
Photographs shrink before they upload
A phone picture is resized and re-encoded in the browser, and the form shows the
saving before anything is sent — "3.8 MB → 184 KB". SVGs, animated GIFs and images
already small go up untouched, which keeps the donation QR code exact.
Words while it waits
Anything slow shows a turning ✻ and a changing verb — "Marinating…",
"Reticulating…". A screen reader hears the plain label once instead, and the mark
stops turning for anyone who has asked for less motion.
The AI helpers AI ची मदत
Eight helpers in the admin. Every one of them writes a draft into the form; none of them
saves anything. The editor reads the draft, changes it, and presses Save — or presses
Undo.
Draft English · Draft MarathiTranslation in both directions, on every bilingual field and on a photograph's alt
text and caption. "Draft Marathi" appears whenever the English half has text.
Write · Expand · ShortenWriting help under each bilingual field, with an optional one-line note ("mention the
Dhadgaon school"). The rest of the row is the context, and nothing else is
sent.
Fill empty fieldsOne press drafts every empty field of a programme, post, event or value in both
languages, from what the row already says. It refuses a row with no text at all —
"write a title first" — because a draft from nothing is invention.
Describe with AIFor one photograph in the library: alt text and a caption in both languages, and up
to three sections it belongs in.
Describe photos missing alt textThe same, for up to fifty photographs at a time, paced one every few seconds, with a
counter and a Stop button. The results arrive as a list to tick; a save fills only
what was empty.
Suggest from libraryOn the gallery tabs: a category and album for each photograph not yet in the gallery,
plus any new categories and albums worth creating. Everything it creates is a
draft.
Read a news clippingOn News & media: reads the chosen photograph of a cutting or article and fills in
the headline and outlet as printed and translated, a short summary, the type, and the
date — only if one is printed.
Pull news from the libraryReads library photographs one at a time and proposes a draft news item for each that
is coverage. It skips photographs already used, and a headline that matches an
existing story is flagged "looks already added" and left unticked.
The rules every helper keeps
A draft, never a decision
A result lands in the form with one level of Undo. It fills only fields that are
still empty when it arrives, so nothing an editor typed meanwhile is overwritten.
Bulk results go to a review list, and whatever is created from them starts as a draft.
Quotes and figures are translate-only
A testimonial's quote, a key fact's value, an impact figure's label and a news story's
headline and outlet can be translated but never rewritten. Nor can the page text. The
server checks this again rather than trusting the button.
No invented numbers
Every number in a draft is compared with what the model was given, and any that
were not in it are listed under the field — "check them before saving: 2015". Digits
written in Devanagari are turned into Latin ones, as the site uses everywhere.
Photographs, not people
A description covers only what is visible. It never identifies anyone, never guesses
age, caste, tribe or religion, and never guesses a place, date or event unless it is
written in the picture.
Nothing from nothing
A cutting with no printed date comes back with no date, and its row cannot be ticked
until someone types one. A photograph that is not news comes back empty rather than
as a plausible story.
Garbled script is refused
Marathi containing another Indic script — a model once mixed Bengali into an alt text
— is dropped, so the field shows "needs translation" instead of a convincing
mistake.
Built for phones on slow networks फोनसाठी
The audience is in the Satpuda hills — Dhadgaon and Shirpur, in Nandurbar and Dhule
districts. Most of them arrive on a phone, on a patchy connection.
Finished HTMLPublic pages arrive already built and download no script bundle, so they render on a
slow connection and an old phone, and they work if a script fails. The menu, the
photo viewer and the news story view are HTML and CSS.
Split fontsThe Marathi typeface is 121 KB. English pages do not download it — including the two
Devanagari characters in the logo and the language toggle, which get their own
2.5 KB file for exactly that reason.
Capped imagesEvery uploaded photograph is resized to 1600px and squeezed under 250 KB before it is
stored, because what a photo costs to serve is fixed at upload time, not at display
time.
Photos on demandThe gallery shows three photographs per group. The rest are hidden until "Show more"
is opened, and a hidden photograph is not downloaded until then.
Filters are addressesSearch and filters on News and Gallery are ordinary forms, so every view has its own
link and works with scripting off. Picking from a dropdown applies at once.
44px targetsNothing tappable is smaller than that at any screen width, the editor's toolbar
included. A layout that does not fit gets changed; the button does not get
smaller.
AccessibilityWCAG 2.1 AA is the target. One main heading per page, text tagged with the language
it is actually in, and an accessibility statement published at
/legal/accessibility.
Tier two
How it is built
The stack, the data model, the AI layer, and the decisions that shape everything else.
Written for whoever maintains this next.
The stack तंत्रज्ञान
Read out of package.json and astro.config.mjs.
Astro 7server-rendered, no static build
React 19admin only, never on a public page
Tiptap 3the body editor, loaded only when opened
SupabasePostgres, storage and sign-in
Cloudflare Workersthe deploy runtime
TypeScriptstrict, with null checks
Turnstilebot check on the two public forms
Resendemails the office about a new submission
Web AnalyticsCloudflare's, cookieless, public pages only
Gemini · Anthropic · AgentRouterthe admin's AI providers
Server-rendered rather than statically built, on purpose. Every word of
page furniture — the menu labels, the site title, the page text — comes out of the
database, so a static build would have to be regenerated every time an editor changed a
comma. The cost is that every request reads the database, a known and recorded trade.
The development server and the production runtime are different engines.npm run dev runs on Node; production runs on Cloudflare's workerd, which
has no DOM, no Image and no canvas. Code that works on one can fail on the
other, so any server-side change is verified with
npm run build && npm run preview, which runs the real runtime
locally. It is also why photographs are resized in the browser: it is the only machine
in the path that can.
What happens on a request विनंतीचा प्रवास
One page view, from the tap to the HTML. Everything below happens inside a single
Cloudflare Worker before any markup is sent.
Figure 3 — one request, start to finish. The page never filters for "published"
itself. It asks for programmes and the database returns only the published ones,
because the policy says so. A redundant filter in the query would hide a broken policy —
the app would look correct while the database leaked. Step 3 matters only to editors:
?preview=1 from a signed-in writer marks the page up for the admin's live
preview and is never cached; from anyone else it is ignored.
The data model माहितीची रचना
Twenty-six tables, grouped by what they are for, from eighteen migrations.
Settings, exactly one row each 4
site_settings — titles, hero, contacts, socials
about_page — story and mission copy
donation_info — UPI, bank, registrations
ai_settings — provider, model, fallback, prices
Constrained to a single row in the schema, so "there is only one" is a fact about the
database rather than a convention.
media — one row per image, video or document, with its alt text and
caption in both languages, its size, and the sections it belongs to. Everything else
points at it.
Inboxes 3
contact_messages
volunteer_submissions
event_registrations
The public can insert and cannot read — not even the row they just wrote. Consent is a
column the database refuses to accept as false.
People and the AI log 2
profiles — each staff account's role
ai_usage — one row per AI attempt
Plus one view, ai_usage_monthly, which totals the log by month in the
database, because the API returns at most 1,000 rows at a time.
Rules the schema enforces rather than documents. Every content table
carries its own sort_order and an is_published flag that
defaults to off, so nothing is ever accidentally live. No table has a path
column — there is no cover_path anywhere, because a path is something
you can type wrongly and a foreign key is not. And every web address an editor can type
is checked by the database: WhatsApp, Facebook, YouTube and Google Maps links must be
https on those companies' own hosts, and a testimonial's extra links must be https with
no spaces. A javascript: link is refused for every writer, not just by this
form.
Relationships the database keeps. An album and its photographs share a
category through a two-column foreign key, so a photograph cannot sit in an album from
another category, and moving an album moves its photographs. A category still in use
cannot be deleted. A testimonial links to a blog post by ID, so renaming the post's
address cannot break the link, and deleting the post only detaches it.
Derived facts are not stored. An event has a start date and an end date
and no status column; whether it is upcoming, ongoing or past is worked out on every
render from the Indian date. A stored status is a fact that goes wrong quietly at
midnight.
Who can read what प्रवेश नियंत्रण
Permission is enforced by the database on every statement, not by the code that asks.
SettingsReadable by anyone — there is nothing private in them — except the AI settings,
which only staff can read and only admins can change.
ContentReadable by anyone if published. A draft is invisible to the public site and
404s by direct URL. Page text has no drafts and is always readable.
ParentsA gallery photograph is public only if its media row, its album and its category are
all published too. Unpublishing any one of them hides it.
InboxesAnyone may add a row. Nobody outside the login may read one — there is no read policy
at all, which is stronger than a policy that returns nothing.
AI logA writer may add rows only under their own account; only admins read the log. The
public cannot see it or the monthly view.
StaffA signed-in writer may read and write content, checked against a role stored in the
database rather than a claim in the token. A viewer can look but cannot save
or use the AI.
The all-powerful database key is used nowhere. It is declared to the
build as server-only and secret, so importing it into anything the browser sees is a
build error rather than a review comment. On top of that, continuous integration searches
the built output on every push for six kinds of leaked key — both formats of the
database key, and the Anthropic, Google (old and new format) and AgentRouter key shapes
— and fails the build if it finds one.
Media is referenced by ID ओळख क्रमांकाने
The part of this codebase least like the obvious version, and the one the most features
now stand on.
Figure 4 — every picture on the site is a foreign key. Covers, icons, gallery
tiles, testimonial photographs and even a photograph placed inside a blog post are
stored as the ID of a media row, never as a path. That one decision is what the "Used
in" line, the alt text written once and shown everywhere, and the captions that travel
with a photograph all stand on. A figure whose photograph becomes a draft or is deleted
simply disappears from the page rather than rendering broken.
One reference deletes, the rest detach. Removing a photograph from the
library empties every cover, icon and slot that used it — except a gallery tile, which
is nothing without its photograph and is deleted with it. The library's delete warning
says which, per page, before anyone confirms.
Where an editor's save goes बदल कुठे जातात
There is no content API. The one admin endpoint drafts text; it never saves a page.
Figure 5 — there is no endpoint between an editor and the database. The admin does
not post to an API that then talks to Postgres; the editor's browser writes directly,
carrying their own session, and the same policy that hides a draft from a visitor is what
decides whether the write is allowed. One check instead of two, and no second place for
the two to disagree. The AI endpoint in Figure 6 sits beside this lane, not in it: its
drafts come back to the form, and they reach the database only through this same save.
The AI layer AI ची रचना
Eight helpers, six tasks, one endpoint: /api/admin/ai. Everything the
helpers do passes through it, and it is the only place a provider key is ever used.
Figure 6 — one AI call, start to finish. The endpoint runs under the editor's own
session, with no all-powerful key, so it can read only what that editor could read. It
returns a draft and writes nothing but its own log row. Saving the draft is the ordinary
write in Figure 5.
The six tasks
translateMarathi to English or back, for one field or one photograph's alt text and caption.
Rich text comes back as HTML and is sanitised before it touches the editor.
rewriteWrite, Expand or Shorten one bilingual field. The context is built from the tab's own
registry fields only — inbox, media and AI tabs are never sent — and the endpoint
re-checks that the field is not translate-only.
fillOne call for every empty writable field on a row, in both languages. Refused when
the row has no text to draft from.
describeAlt text, caption and sections for one photograph. The bulk "missing alt text" run
calls this once per photograph, paced, and waits and retries once on a rate
limit.
clippingHeadline, outlet, summary, type and printed date from a photograph of coverage.
"Pull news from the library" calls this once per photograph over a chosen range,
skipping any already used by a story.
galleryCategory and album suggestions for up to 150 photographs per call. It sends the
photographs' descriptions, not the images, so a suggestion costs a few
hundred tokens; undescribed photographs are counted and left out.
Photographs are fetched by the server, by ID
For describe and clipping the browser sends only a media ID. The server looks up the
row under the editor's session and fetches the file itself — JPEG, PNG or WebP, at
most 1.5 MB — so the browser can never point the AI at an arbitrary address.
Keys are secrets, not settings
The provider keys, the backup Gemini key and AgentRouter's base address are
Cloudflare secrets. None is a column an admin can edit: an editable address is one an
admin could point at their own server, and the key would follow it.
Chosen, tested and counted in the AI tab
Model names are suggested from each provider's live list. A Test button calls exactly
what is typed, with no fallback, so a test never reports the wrong model as working.
Usage shows twelve months of totals, a month by task, model and person, an estimated
cost, and the last twenty failures.
Checked in code, not just asked for in prompts
Each prompt states its rules, and each rule a model broke on a real run became a
check in code as well — the number list, the digit conversion, the script filter, the
caption and slug rules. /api/health runs fixtures through the number,
digit, script and gallery checks on every call.
The story behind this section, slide by slide. How each helper was
asked for, built and caught out is told in
Drafts, Not Decisions, the
23-slide deck on the AI in this admin.
The live preview थेट पूर्वावलोकन
"See it on the site" shows the real public page, not a copy of it — with what the editor
is typing already in place.
Figure 7 — the preview is the site itself. Rendering the real page means the
preview can never drift from what visitors see. Switching the frame's language renders
that language without writing the cookie, so the admin's own language is left alone. A
new, unsaved row says "save as draft first". Impact stats, Key facts and Values also
keep a small instant preview drawn in the admin, because those change with every
keystroke.
The JavaScript budget JavaScript चा हिशोब
Spent where it earns its place, and measured from the build rather than estimated.
The public site
BundlesNone, on any page. The mobile menu is a native disclosure widget, the photo viewer
and the news story view are CSS reacting to the address bar, and the language toggle
is a link — all keyboard- and screen-reader-correct for free.
Home · 468 BInline, for the impact figures counting up — and only when there are figures to
count. The framework version of the same effect costs 184 KB.
News · 334 BInline, only while a story is open: it reveals a "Share" button where the phone can
share or copy. Without it, the WhatsApp, Facebook and plain links remain.
FiltersOne onchange attribute on each filter form, so a dropdown applies when
picked. With scripting off, the Apply button does the same.
Cloudflare'sThe Turnstile check on the contact and volunteer forms, and the cookieless analytics
beacon — both loaded deferred, neither on the admin.
The admin, behind the login
First load504 KB (143 KB compressed): the React runtime 184 KB, Astro's integration 8 KB, the
tab shell 41 KB, and the shared forms-and-library chunk 270 KB.
Body editor405 KB (126 KB compressed) more, fetched only when a rich-text field opens, so the
twenty tabs without one never carry it.
Preview scriptInside the preview frame only, and only for a signed-in writer.
The admin pays what the public site does not. It sits behind a login,
is used by a handful of staff, and caches after the first visit, so its size is a
deliberate trade. Every change to it is measured off the build, with and without the
change, and the difference is written down.
How it stays honest तपासण्या
The checks that run without anyone remembering to run them, and the habits behind the
rest.
On every pushType-check, format check, build, then a search of the built output for six shapes of
leaked key. Any one failing stops the deploy.
/api/healthNine checks — database, sign-in, languages, content, media, schema, forms, AI and
body photographs — each written so that it can actually fail. A missing optional key
is a warning, not an outage, so the check never gets deleted for blocking a
deploy.
npm run probe:rlsSigns in as nobody and asserts what can and cannot be read, table by table, against
the real database — including that drafts, inboxes and the AI log stay
invisible.
Every migrationRun first inside a transaction that is thrown away, with checks under simulated
roles; then a dry run that must list only that file; then applied. The recorded
history matches production, 0001–0018.
On the real runtimeServer changes are exercised under workerd with npm run preview, not
only under Node — and anything visual is checked in a screenshot, because a passing
build once shipped an invisible home-page heading.