Copy one of the two embed codes below and you are done. Everything after them is optional: parameters, sizing, the Content-Security-Policy contract, what the widget touches on your page, what leaves your visitor's browser, and what to check when something looks wrong.
This is the host-facing reference. AGENTS.md is the developer guide.
CHANGELOG.md records what changes under an embed that updates itself.
There are exactly two ways to embed the atlas, and both are one copy-paste. Pick by whether
your platform lets you add a <script> tag to a page's body.
<script type="module" src="https://sahajatlas.com/auto.js?key=YOUR_KEY"></script>That is the whole snippet. Put it in the body where the widget should appear — it renders full-screen over your page, which is what the atlas wants when it has a page to itself.
For an embed that stays inside your layout, add map=false and give it a height:
<style>
sahaj-atlas {
display: block;
height: 640px;
}
</style>
<script type="module" src="https://sahajatlas.com/auto.js?key=YOUR_KEY&map=false"></script><iframe
src="https://sahajatlas.com/?key=YOUR_KEY&map=false"
title="Find a meditation class near you"
width="100%"
height="640"
loading="lazy"
allow="geolocation; clipboard-write; web-share"
style="border: 0"
></iframe>Use this on platforms that strip <script> from saved content — most WordPress roles below
Administrator, and some page builders. It is a complete embed: everything below is optional.
| Script | Iframe | |
|---|---|---|
Works where <script> is stripped |
no | yes |
| The route appears in your page's URL, and is shareable and indexable | yes | no |
| Grows out of a small slot when it needs room | yes, in place | opens a new tab |
| Costs before a visitor scrolls to it | ~3 KiB | the whole page |
Prefer the script. The iframe cannot put the widget's route on your URL, which is the whole mechanism by which each city and event becomes a page search engines can index on your domain.
Everything either snippet needs is in it. Specifically:
keyis the only required setting and is the same for both. Ask the Sahaj Atlas maintainers for one. It is public and published — it ships in your page's HTML, it is scoped to read-only atlas data, and it is not a secret.- Every other setting rides on that URL's query string — see Parameters. There is no element to add and no attribute to misspell.
allow=on the iframe is not optional decoration. Three browser features are denied to a cross-origin frame by default and each fails silently — see Permissions Policy.titleon the iframe is required for accessibility. Without one it is announced as an unlabelled frame. Write it for your visitors, in your language.- Do not sandbox the iframe. If your platform insists, add
allow-popups— see Embedding in an iframe.
Three rules for the script tag specifically:
- Put it in the body, where the widget should appear. The element is inserted immediately
before the script tag. A snippet in
<head>has nowhere to render and says so in the console. - No
asyncordefer. The loader reads its own script tag to find both its settings and its position, and those attributes hide it. type="module"is required — the loader is an ES module. Keep script optimizers away from it: a tag rewritten without the type, orauto.jscombined into a classic bundle, fails before any of the widget runs — see Troubleshooting.
auto.js is a ~3 KiB loader, not the widget. It works out what your page supports, then fetches
the widget itself only when the embed is about to come into view — so an embed further down your
page costs your visitors almost nothing until they scroll to it.
Nothing below is needed to get a working embed. Reach for it when you want more:
| You want | Read |
|---|---|
| A different language, no map, or a specific opening view | Parameters |
| The embed to fit a column rather than the page | Sizing the element |
| Your page to send a Content-Security-Policy | Content-Security-Policy |
| The locate, copy-link or share buttons to work behind a policy header | Permissions Policy |
Clean paths instead of ?atlas= |
The URL |
A <title> and description that match the route the widget opened at |
Metadata for your page's <head> |
| To know what the widget touches on your page | What the widget does to your page |
| To answer a privacy question | Privacy |
- The two embed codes
- Parameters
- Sizing the element
- Embedding in an iframe
- The URL
- Metadata for your page's
<head> - Content-Security-Policy
- Permissions Policy
- Where to load it from
- Browser support
- What the widget does to your page
- Privacy, storage and third-party requests
- Updates and caching
- Migrating from the old element
- Troubleshooting
Six parameters exist on the script URL, all optional except key. Percent-encode reserved
characters as usual (atlas=%2Fgb%2Flondon and atlas=/gb/london are equivalent).
| Parameter | Default | What it does |
|---|---|---|
key |
— | Required. Your published SahajCloud client key. An invalid key shows the widget's configuration-error screen, and the loader logs an error naming the problem. |
locale |
your page's <html lang> |
Force a UI language, e.g. fr. A language we do not offer falls back to English — the set is managed in SahajCloud, not by this parameter. Precedence: ?locale= on the page URL → this parameter → your page's <html lang> → the browser's language → English. ⚠ A ?locale= the atlas ships outranks this parameter, because the widget writes that parameter itself (below). |
map |
true |
map=false renders the atlas as lists and event pages with no map canvas at all. No Mapbox, no map token, and none of the Mapbox origins or storage below. Changes how you size it (see Sizing). |
routing |
query |
Where the widget's route lives. path also needs your server to serve one page for everything under the atlas prefix — a prefix set on your client record, not here. |
atlas |
— | The route to open when the page's own URL does not already name one, e.g. /gb/london. Must be site-relative. |
gestures |
— | gestures=cooperative lets a visitor scroll your page past a contained map: one finger and the mouse wheel scroll the page, and the map moves on two fingers or Ctrl/⌘ + wheel, with a hint saying so. Only for a contained map; a window-filling map and the compact card's overlay ignore it. |
map follows one spelling rule: only the exact values false and 0 switch it off.
Anything else — absent, empty, true, no, FALSE — leaves it on, so a typo can never
silently disable it. It also means map=no does not do what it looks like.
atlas is a default, not an override. A route already on the page's own URL always wins:
your-page?atlas=/nl/amsterdam + auto.js?atlas=/gb/london → opens /nl/amsterdam
your-page + auto.js?atlas=/gb/london → opens /gb/london
A visitor with ?atlas= in the URL deep-linked, navigated, or followed a shared link, and
sending them to your default instead would discard where they asked to go. Use atlas for an
embed that should always open somewhere specific, such as a single city page or a registration
form.
Under routing=path, the default applies when the interface first opens. Your page's own
address is the root view there — there is no other URL for it — so the widget boots at the root
and then navigates to your default, the same way it opens a client record's home region. Your
page's URL then shows the route (/classes/gb/london) in place of your page's own entry, so Back
leaves your page as it would without the widget. Your default outranks the home region. A deep
link to any other route is left alone.
You usually do not need the locale parameter. The widget reads your page's <html lang>
and matches it, so a Dutch page gets a Dutch atlas whatever the visitor's browser prefers —
almost always right, since the widget is part of your content rather than a separate app. Reach
for locale only when your page's declared language is wrong, missing, or deliberately different
from the atlas you want shown.
This used to be a setting on your client record, and no longer is, on purpose. A record-level
language applies to every page you embed on and then must stay in step with a site that may not
be monolingual. <html lang> already says it per page, and your CMS already sets it.
Page translators leave the widget alone. Its subtree carries translate="no", so Google
Translate, GTranslate and the browser's own page translation skip it rather than rewriting text
the widget is still managing — which used to crash it onto its error screen. A visitor who
translates your page keeps the atlas in the language it chose, and can switch it from the atlas's
settings menu. A plugin that translates in the browser and ignores the attribute (check Weglot's
JavaScript integration) needs sahaj-atlas on its list of excluded elements.
When a visitor picks a language from the atlas's settings menu, the widget adds
?locale=<code> to your page's address with history.replaceState. It is the second and
last parameter the widget ever writes to your URL, beside atlas. (It also reads and removes
a third, feedback — see below.)
- Your own query parameters are preserved byte for byte, and no history entry is added, so
the Back button behaves as before. The widget edits only the one pair it writes, so a parameter
carrying
%20,/or,comes back exactly as your page had it, not re-encoded. - The choice survives a reload and travels in a copied link — why it outranks the script-URL
localeabove. Pinlocale=fr, and if a visitor switches to Dutch, the link they share opens in Dutch. - Nothing is stored on your origin. The widget writes no cookie and no
localStorageentry for language (see Privacy). The URL is the whole mechanism, so the choice lasts exactly as long as the address that carries it.
An unrecognised ?locale= is ignored rather than honoured — it falls through the precedence
chain instead of forcing English. Case does not matter: ?locale=PT-br and ?locale=pt-BR
both resolve, so a tag that has been through a CMS or a browser's own normalisation still counts.
People who register for a class get a follow-up email asking whether it took place. Both answers are recorded by SahajCloud and redirect the reader to your site, at the canonical URL for that class or its region, carrying one extra parameter:
https://your-site.org/classes/gb/london/1204?feedback=confirmed
https://your-site.org/classes/gb/london?feedback=denied
The widget reads it, shows a short dismissible acknowledgement, then removes it from the
address with history.replaceState — so it does not linger in a copied link or reappear on
every reload.
- You do not have to add the parameter to anything. It arrives on links the CMS already sends. Your page just has to be reachable at its canonical URL, which it already is.
- Your own query parameters are untouched, including their exact encoding. Only the one pair is removed, byte for byte.
- It is never treated as a page of its own. The canonical URL the widget emits is the CMS's
clean one, with no
feedbackon it, so the two answers cannot be indexed as duplicates.
A feedback value the widget does not recognise is ignored and left alone (it is presumably
yours), with no banner shown.
Some things are not parameters, on purpose:
| Setting | Where it lives |
|---|---|
| Display name, brand colours | your client record |
The path prefix for routing=path |
your client record — the same value your canonical URLs are built from |
| The domains you embed on | your client record — the API refuses reads from anywhere else, and the captcha renders nowhere else |
Identity is the same on every page you embed on, so making it per-embed only risks two of your pages disagreeing about what the product is called. The routing prefix is worse than merely redundant: it is the value we compose your canonical URLs from, so a second copy on a script tag could disagree with the one the canonical was built from — and a canonical pointing at a URL that does not restore the view is exactly the failure canonical URLs exist to prevent.
By default the widget's route is a parameter on your page's own URL:
https://your-site.org/classes?atlas=/gb/london. With routing=path it becomes part of the path
instead — https://your-site.org/classes/gb/london — the shape a search engine and a reader both
prefer.
It asks two things of you, and the widget refuses rather than half-works if either is missing.
- Your server must serve the same page for everything under the prefix. The widget navigates
with the History API, so an in-page click never hits your server — but a reload, bookmark, or
shared link does, and
/classes/gb/londonhas to return the page your embed is on. On most platforms that is a wildcard route or catch-all rewrite. On WordPress it needs a rewrite rule, which the plugin sets up for you. - A canonical embed on your client record, naming the page the widget is mounted on. The prefix comes from there, not this script tag — it is the same value your canonical URLs are built from, and two copies could disagree.
If either is missing, the widget uses query routing and says so in the console, naming which one was missing. It never silently switches modes — a host whose server config does nothing should learn that from us, not from their analytics six months later. If your client record cannot be read at all — SahajCloud down or erroring, rather than your key refused — it does not fall back: the visitor gets the widget's own error screen, with a "Try again" that re-reads the record. That is the same screen a query-mode embed shows for the same failure, so neither mode answers an outage worse than the other. Its copy is translated like the rest of the widget, which means an outage big enough to take the translations with it leaves this screen in English too. It carries no "Report an issue" control: the report form mounts below this point, so there is nothing for it to open yet.
It does not cost you a round trip. The widget reads your client record before it can build its router in either mode, before it renders anything — so path mode makes the same single request query mode already makes, with the same spinner.
Your other query parameters are still safe, and there is still only one of ours. In path mode
the route's path is the pathname and ?atlas= carries whatever is left — the searched place,
filters, sort order. The rule is the same in both modes: we set and read exactly one parameter,
and leave every other one on your URL alone.
⚠ Existing ?atlas= links do not survive the switch. A URL you have already shared or
indexed — your-site.org/classes?atlas=/gb/london — opens the root view once path routing is on,
because the route is now read from the path. Plan the change like any URL migration: old links
work right up until you flip it, and not after.
There are also no privacy opt-outs. The flows they used to switch off are under Privacy: analytics is cookieless and aggregate, crash reports carry no cookies or session replay and reduce your page to origin and path, and the location lookup is a keyless once-per-session city lookup. If a flow is still a problem for your privacy notice, talk to the maintainers — the fix is a client-record setting, not something an editor changes on a script tag.
One widget per page. The loader marks the element it takes charge of, so a second copy of the
snippet renders nothing and says so in the console, rather than silently adopting the first
widget's settings. This is a real constraint, not an oversight: two widgets would both write the
same ?atlas= parameter and fight over it on every navigation.
On each load the widget notes a few things about the page it is on — top-level or framed,
whether the URL can be written, whether a query parameter survives your router — and sends them
to POST /api/clients/report once it has actually rendered.
It sends the page's origin and path only, as two separate fields. Your query string and fragment are never included, the same reason crash reports strip them: they can carry a reset token or an email address. This is how we learn which of your pages can serve as the canonical atlas page for your region, instead of asking you and keeping the answer current by hand.
One query parameter is the exception: ?p=<number>. WordPress sites on default permalinks
address every page that way, so discarding it would report every post as the same page, leaving
you unable to nominate one — a post id is not something your visitor typed. Anything else in the
query is still dropped whole, including ?p=123&utm_source=…, rather than trimmed.
It reports on every load, not only when something changed — that is what makes "last seen" mean anything: an embed that reported once and went quiet is indistinguishable from a removed one. Repeat reports of an unchanged page collapse server-side into at most one write in any 6 h window, so this is one small request per page view, and nothing your visitor sees depends on it.
Two things can refuse it, both configuration rather than a fault in your page, and both reported to the console while the widget carries on working:
| Console message mentions | What it means |
|---|---|
| the origin is not allowed | your domain is not on the service's allowed-domains list in the CMS. An empty list allows every origin, here as on the read endpoints |
| the maximum number of mounts | the service already tracks 50 distinct pages. Pages already known keep reporting |
Once the widget has genuinely mounted, it sets one attribute on your page's <html> element:
<html data-sahaj-atlas-ready='{"v":2,"routing":"query","topLevel":true,"urlWritable":true}'></html>It is written after the widget renders, never on script load, and removed again if the widget unmounts. It exists so our CMS can confirm the embed works, from your own page, before treating it as your region's canonical page — a claim the widget makes about itself is not evidence. Nothing on your page needs to read it.
One rule covers both modes: give the element a height and the widget lives inside it. What differs is only what happens when you don't.
With map=false, a height is required. The widget fills its container (height: 100%), and
an unsized custom element is an inline box of zero height — it collapses and appears not to
render.
With the map (default), a height is optional and changes the shape you get.
| What you give the element | What you get |
|---|---|
A height (height: 640px, a grid track, an aspect-ratio box). |
The map lives inside that box, in your page's flow — your header above it, your content below. |
| No height at all. | The map fills the browser window. The canvas is position: fixed; inset: 0, and the drawers sit over it, whatever size the element is. |
The second is the older behaviour, and still right for a dedicated atlas page — a full-page slot with no surrounding content to cover, since a map halfway down an article covers the article rather than scaling into it. (An article-column map with no height gets the compact card instead — see below.)
A contained map is one element in your layout, and stacks like one. The widget puts the map,
its panels, and its controls inside a single stacking context, so its internal z-index values
never compete with your CSS — a sticky header with a z-index floats over the map like any other
content.
Either way, sizing looks the same. The loader inserts the element for you, so size it with a rule rather than an inline style:
<style>
sahaj-atlas {
display: block;
height: 640px;
}
</style>
<script type="module" src="https://sahajatlas.com/auto.js?key=…&map=false"></script>(Drop &map=false for a contained map instead.) Any way of giving it a height works — a CSS
class, a grid or flex track, an aspect-ratio box. display:block (or flex/grid) matters as
much as the height: a custom element defaults to display: inline, which cannot take one, so a
height rule with no display rule leaves it unsized and the map takes the window instead.
⚠ min-height on its own is not a height. The widget fills your element with height: 100%,
which needs a definite height to resolve against. min-height: 640px sizes the element on screen
but leaves the widget nothing to fill, so it refuses the box, says so in your console, and fills
the browser window instead. Use height, a grid or flex track, or aspect-ratio (same for width
and display: inline-block).
⚠ Size it to what a visitor can see at once. Nothing caps a contained map from above: give the
element height: 100vh below your own sticky header, and its bottom edge — the mobile bottom
sheet and its drag handle — sits below the fold, leaving a first-time visitor with no visible
controls until they scroll. Subtract your header, or use a fixed height.
⚠ A flex or grid item is sized even without a height rule. A stretched item takes its track's
cross size, so <sahaj-atlas> in a full-height flex column is a contained map — almost always
what you want, but the one way an embed ends up contained without one.
The widget adapts to your element, not the browser window (issue #107), in map=false and a
contained map alike. A 320px column on a large desktop screen gets the narrow layout — a bottom
sheet with a drag handle and swipe-to-dismiss — because the widget measures its own box, not the
viewport.
Touch affordances are the deliberate exception, and follow the device, not the box: a phone
number is a tel: link on a touchscreen and a copyable number everywhere else, however narrow
the column, since whether a dial link reaches a dialer is hardware.
An unsized map embed is the one thing that does not adapt, since there is no box to adapt to — it fills the window. A map embed with neither a height nor the page to itself renders the compact card instead, whose button opens the map full-screen (below).
Below a certain size no layout works, so the widget shows a compact card instead: a heading and one button that opens the whole interface somewhere it fits. Your visitor still gets everything — it just does not try to live in a 300-pixel box.
The card is the button. It takes only the room it needs, centred across whatever box you gave
and sitting at the top of it. Given no height at all, it sizes to its own content rather than
collapsing, so a bare <sahaj-atlas> in a narrow column renders as a card rather than nothing. It
renders no events and makes no location lookup — a control whose whole job is to lead
elsewhere has nothing to show, and does not warm the event feed or region tree, which wait for
the interface to open. It records no analytics pageview either. It is not silent, though: the
widget still reads your client record and sends the one-per-load
embed report while collapsed.
Where the button goes depends on whether the widget can grow where it is.
| Your embed | The button |
|---|---|
| A script embed on your page | Opens the interface in a full-screen overlay, in place |
| Inside an iframe | Opens a page that fits, in a new tab (see Embedding in an iframe) |
The floors are 360px wide and 420px tall, with a second condition that matters as much: the widget only degrades when there is somewhere bigger to go. A slot that is essentially the whole screen is never called too small, since a card there would take the interface away and hand back nothing — a full-width phone embed, or a small iframe on a small screen, keeps the normal interface.
Map mode is measured differently. A map embed with no height of its own fills the
viewport, so the only question is whether it has the page to itself: one that does not — a map in
an article column — renders the compact card, whose button opens the map full-screen. A map
embed you gave a height is not asked that question: it lives in your box, measured exactly
like map=false, against the floors above and nothing else.
- It is decided once, when the widget mounts. Resizing your page afterwards does not switch between the two — that would remount the widget and lose the visitor's navigation. There is deliberately no override parameter: a knob for a measurement we can get right is a setting you would maintain forever. A wrongly measured slot is a bug worth reporting, not working around.
- Entering the compact card logs a console warning naming what was measured and what to change, so an unintentionally small slot is findable rather than mysterious.
- The overlay locks your page's scroll while open, restored on close. As a modal dialog it
also marks the rest of your page
aria-hiddenwhile open, so a screen reader reads the widget rather than the page underneath, undone on close too. It keeps a margin, so your page stays visible behind it and reads as a layer over your site. Three ways out — clicking outside, Escape (steps outward, dismissing whatever is open before closing), and the × in the corner — all return focus to the button that opened it. - The overlay sits above your page's own chrome. Its scrim and frame use
z-index: 2147483000, so a fixed or sticky header of yours sits under the scrim, and clicking it closes the overlay like any click outside. To keep something of yours on top, give it a higherz-index— see the ⚠ below for the ancestors that cap it. - A deep link opens the route, and loads eagerly to do it.
?atlas=/gb/londonon your page's URL means a visitor followed a link, so the widget mounts immediately rather than waiting to be scrolled to, and the overlay opens straight onto that route. Theatlasparameter on the script URL does not do this — that is your default view, not a visitor's request, so it stays lazy. Closing the overlay does not reopen it.
⚠ Do not put the embed inside an element with transform, filter or contain. Those make
that element the containing block for fixed-position content, confining the overlay to it instead
of the page — and the same applies to an unsized map, which is fixed too. Not a real trap if it
happens (clicking outside and Escape both still close it, wherever confined), but it will look
wrong. (container-type is fine — it does not have this effect, however often it is said to.) A
contained map is unaffected, since it already establishes that containing block itself. If
you are reaching for one of those properties to stop a map painting over your page, give the
element a height instead — the supported way to say the same thing. Those properties, and any
ancestor that forms a stacking context — a positioned element with a z-index, opacity below 1,
or isolation: isolate — also cap the overlay's z-index at that ancestor's level, so a header
outside it can still cover the overlay.
The script above is the primary way to embed the atlas. An iframe is the supported
alternative for platforms that will not let you add a script tag. (sahajatlas.com itself is not
a destination — it exists to host these assets and to be framed.)
<iframe
src="https://sahajatlas.com/?key=YOUR_KEY&map=false"
title="Find a meditation class near you"
width="100%"
height="600"
loading="lazy"
allow="geolocation; clipboard-write; web-share"
style="border: 0"
></iframe>Five things about that snippet are load-bearing:
titleis required for accessibility. An iframe with no title is announced as an unlabelled frame. Write it for your visitors, in your language.allow="geolocation; clipboard-write; web-share"— see Permissions Policy. Without these the relevant features fail silently, the worst way to find out.- Do not sandbox the frame. If you must, include
allow-popups— without it the compact card's button cannot open anything, and the browser blocks it silently with nothing in your console to say why. Addallow-same-origintoo if you want the widget to keep its own settings between visits, since a sandbox gives the frame an opaque origin, which makes storage throw. (A sandbox does not stop the widget writing its own URL, contrary to what this guide used to say — measured in Chrome 151.) map=falseis usually what you want in a frame, unless the frame is large.- Give it a real height. As with the element, a frame with no height collapses.
A frame is its own viewport, so map=true works inside one — the map fills the frame rather
than your page. That is the opposite of the advice under Sizing, which is
about a script embed sharing a page with your content. A frame has no such neighbours.
If the frame is too small for the interface, the widget shows the compact card, and its
button opens the atlas in a new tab rather than an overlay — an overlay inside a frame would
only cover the frame, the problem it was trying to solve. ⚠ That new tab currently goes to
wemeditate.com/map, a different organisation's site — per-region canonical ownership will
replace it with the owner's own domain. ⚠ Today that link lands on the map's home page rather
than the exact route the visitor was on. It will carry the route once the canonical work lands.
The widget's route lives in a query parameter on your own page's URL:
https://your-site.example/classes?atlas=/gb/london
That is the URL your visitors see, copy, and share — and, the point of the whole thing, the URL a search engine can index. Every event and every city is a distinct URL on your domain.
- Your other query parameters survive. We set and read exactly one,
atlas, and leave the rest alone, in both routing modes. WordPress's/?p=123permalinks keep working. - Your page's
#anchoris none of our business. The widget never reads or writes the fragment: a page loading at#respondscrolls where you expect, anything readinglocation.hashstill works, and the widget routes normally alongside it. (Before this, it routed off the fragment and had to guess whether it or you owned it.) - In-widget links are real links. Middle-click, "copy link address," and open-in-new-tab all give somebody a working URL on your site, because every href the widget renders is absolute and on your origin.
Mounting does not add a history entry. The widget writes the parameter once a visitor
navigates inside it, or when it first opens your client record's home region — and that write
replaces your page's entry, so Back still leaves your page. To make an embed open somewhere
specific, use the atlas parameter — the default the
widget starts at when your page's URL names no route.
atlas is not a WordPress reserved query var and does not collide with anything we know of. If
it collides with something on your site, tell the maintainers — the name is a single constant,
not something each embed sets, so the fix is on our side.
In a file:// document, and in any browser that refuses history.replaceState outright, the URL
cannot be written at all. The widget detects that at load and routes in memory instead: it
works normally, but its route is not in the address bar, so it cannot be deep-linked from that
page, and browser Back leaves your page rather than stepping back through the widget. Where the
widget would offer a share link it offers the event's canonical page instead, and where there is
honestly no link, it offers none rather than handing somebody your page's URL.
Your page's metadata describes the atlas route the widget mounted with. Not the atlas in
general, and not your site in general: a page mounting /gb/london is a page about meditation
classes in London, so its <title>, <meta name="description">, canonical and structured data
should say London. The route is the atlas parameter on the
script URL, or the pathname under routing=path (see The URL).
You do not have to write any of it. SahajCloud answers metadata for every route the widget can mount with, the atlas root included:
GET https://cloud.sydevelopers.com/api/atlas/seo?route=/gb/london&locale=en
Authorization: clients API-Key <your published client key>
| Parameter | Required | What it is |
|---|---|---|
route |
yes | The atlas route, the same string you pass as atlas or read off your path prefix. / is the root. |
locale |
no | Defaults to en. One of the nineteen codes below, matched exactly. |
⚠ locale is matched exactly, and an unknown code is a 400 — never a fallback to en. The
accepted set is:
en, en-AU, es, de, it, fr, ru, ro, cs, uk, el, hy, pl, pt-BR, fa,
bg, tr, hu, nl
So do not pass your page's <html lang> through untouched: en-GB, en-US and pt are each
refused. Map a regional tag down to its base language — en-GB → en — and send en when
the base language is not in the set either. Only en-AU and pt-BR carry a region, and pt-BR
is the only Portuguese offered.
The key is the published client key your embed already uses — the same key from the
snippet, sent as an Authorization header instead of a query parameter.
There is no second credential to obtain.
Each of these is a real response, taken with a key that publishes no canonical page of its own —
so canonical here is the fallback surface described under The canonical, and what it depends
on. Yours will be your own mounted page. Everything
else is the shape you will get.
… stands for an elided origin, in canonical, alternates, og:url, jsonLd and
breadcrumbs alike. The real responses carry an absolute URL in every one of those fields, and so
will yours — but the host a canonical-less caller resolves to is not part of this contract and has
changed before, so printing it here would date the page.
A region route — ?route=/gb/london&locale=en. Abridged: jsonLd, breadcrumbs and
content.events carry every class in the city.
{
"type": "region",
"id": 21,
"route": "/gb/south-east/london",
"locale": "en",
"title": "London, United Kingdom",
"description": null,
"canonical": "…/map/gb/south-east/london",
"alternates": [
{ "hreflang": "en", "href": "…/map/gb/south-east/london?locale=en" },
{ "hreflang": "x-default", "href": "…/map/gb/south-east/london" }
],
"openGraph": {
"og:type": "website",
"og:title": "London, United Kingdom",
"og:locale": "en",
"og:url": "…/map/gb/south-east/london"
},
"jsonLd": "{\"@context\":\"https://schema.org\",\"@graph\":[{\"@type\":\"City\",…}]}",
"breadcrumbs": [
{ "name": "United Kingdom", "route": "/gb", "url": "…/map/gb" },
{ "name": "South East", "route": "/gb/south-east", "url": "…/map/gb/south-east" },
{ "name": "London", "route": "/gb/south-east/london", "url": "…/map/gb/south-east/london" }
],
"content": { "name": "London", "subtitle": null, "level": "city", "events": [ … ], "eventCount": 18 }
}⚠ route comes back normalized, and may differ from what you sent. We asked for /gb/london
and got /gb/south-east/london — the region's full ancestry. Compare your page against the
returned route, not the one you sent.
The root — ?route=/&locale=en, the ordinary "here is our class finder" page:
{
"type": "root",
"id": null,
"route": "/",
"locale": "en",
"title": "Free Meditation Classes",
"description": null,
"canonical": "…/map",
"alternates": [
{ "hreflang": "en", "href": "…/map?locale=en" },
{ "hreflang": "x-default", "href": "…/map" }
],
"openGraph": {
"og:type": "website",
"og:title": "Free Meditation Classes",
"og:locale": "en",
"og:url": "…/map"
},
"jsonLd": "{\"@context\":\"https://schema.org\",\"@graph\":[{\"@type\":\"WebSite\",\"name\":\"Free Meditation Classes\",\"url\":\"…/map\"}]}",
"breadcrumbs": [],
"content": { "paragraphs": [] }
}The root's structured data is a WebSite node and there is no BreadcrumbList — a trail of
one rung tells a crawler nothing. id is null, because no row is being described.
⚠ Free Meditation Classes is the built-in constant, not operator copy. It is the bottom of
the fallback chain, reached because no root title has been written
in the CMS yet — in any language, so locale=de returns that same English string today. Expect a
real title here once the copy lands, and do not hard-code this one.
A bare view route — ?route=/search&locale=en returns that same root document, byte for byte,
with route normalized back to /. So does /calendar, /filters, /online, /share, and
/events/areas.
A route answers as the root when nothing is left after the widget's own view and legacy segments are dropped. That is the rule; the list below is what it is made of, not a list of five special cases.
| Segments dropped wherever they appear | Why |
|---|---|
search, calendar, filters, register, share, online, preview |
the widget's own views — a view of something |
events, areas, regions, venues |
legacy Atlas URL prefixes, carrying no view |
Eleven in all, matched case-insensitively. Dropping them is what makes /gb/london/1204/register
describe event 1204 rather than 404, and what makes /events/areas — every segment reserved —
reduce to the root. A region slug can never collide with one of these; the same list is
reserved on our side.
titleis always a string.descriptioncan benull— see below.jsonLdarrives already serialized and escaped. Emit it verbatim inside<script type="application/ld+json">. Do not re-escape it, and do not parse and re-stringify it:<,>,&and the two Unicode line separators are already escaped in it, and re-stringifying loses exactly that.canonicalis locale-free, and so is thex-defaultalternate. Emitalternatesas<link rel="alternate" hreflang="…">rows.openGraphis a flat map of property name to content, ready for<meta property="…">.
Fetch it server-side, at render time. <head> metadata has to be in the HTML a crawler
receives, so a fetch from the browser is not an option — by the time it resolves, the crawler has
already read the page. A server-side fetch also sends no Origin or Referer, which is fine:
the API key is the gate there, and the allowed-domains list on your client record does not refuse
a server-to-server call.
Then cache it, for up to 300 seconds, keyed by route and locale. That is the endpoint's own freshness, and it says so:
Cache-Control: public, max-age=300, s-maxage=300
Vary: Authorization
⚠ Vary: Authorization costs you nothing, despite how it reads. The credential is your
site's own published client key — the same value on every request your server makes, never a
visitor's. So it splits the cache one variant per site, not one per visitor. Do not read it as
"this response is per-user, do not cache".
For the root, canonical is your own verified mount page — read off your client record, the
same canonical embed that routing=path takes its prefix from. It is therefore
per-API-key by design: two sites asking for / get two different canonicals, each naming its own
page.
A caller that can publish no canonical of its own gets the We Meditate surface instead, the page that already indexes the atlas. That is what the worked examples above show. If you want your own page named there, ask the maintainers to set the canonical embed on your client record.
For a region or an event, canonical is that document's own URL, resolved through region
ownership rather than per caller — and null where no site publishes one. Treat null the same
way you treat a null description: write your own self-referencing canonical.
A locale with no operator-written description returns description: null. It never falls back
to English: an untranslated English sentence in a Dutch page's <head> is worse than none. Region
and event routes follow the same rule.
When you get null, write your own one-line description, in your page's own language, and
emit a self-referencing <link rel="canonical"> on the URL the widget is mounted at. Do not leave
the tag out, and do not fall back to your site's global description — that is the failure this
whole section exists to prevent: an atlas page presenting to a crawler as a duplicate of your
homepage.
title behaves the opposite way, and always resolves: for the root it falls back within the
locale first, to the widget's own name for itself in that language, and only then to English and
a built-in constant. A locale nobody has written root copy for is still named in its own language.
| Response | What happened |
|---|---|
| 400 | route was empty or over 512 characters, or locale was not one of the nineteen codes above. Your request is malformed. |
404 That is not a valid atlas route. |
route carried a ?, # or whitespace, or more than twelve segments. Probably your page's full URL spliced in by mistake. |
404 That route does not name a region or an event. |
A well-formed route naming nothing we have. A city that closed, or a typo. |
The two 404s are deliberately different sentences. "You sent nonsense" and "no such city" want different fixes, and collapsing them would answer a malformed URL with a real page.
Nothing in this section adds a line to the CSP table below. Your
server makes this request, not your visitor's browser, so no connect-src directive is involved —
and cloud.sydevelopers.com is in connect-src already, for the widget's own reads.
If your page sends a CSP, this is what the widget needs added to it. It is a list of additions to your policy, not a policy — your own sources still have to be in there.
The captcha needs nothing of yours but a listed domain. Every form in the widget is behind a
Cloudflare Turnstile challenge that cloud.sydevelopers.com serves, renders and validates, so it
needs no key or account of yours. It renders only on a domain your client record lists (What you
configure in the CMS instead) — tell us every domain you
embed on — and the frame-src line below is your policy's whole part.
script-src https://sahajatlas.com https://api.mapbox.com https://cdn.usefathom.com
worker-src blob:
child-src blob:
style-src 'unsafe-inline'
font-src https://sahajatlas.com
img-src data: https://api.mapbox.com https://imagedelivery.net https://cloud.sydevelopers.com https://react-circle-flags.pages.dev
connect-src https://cloud.sydevelopers.com https://api.mapbox.com https://events.mapbox.com https://ipwho.is https://cdn.usefathom.com https://*.sentry.io
frame-src https://cloud.sydevelopers.com
One line per directive, deliberately: CSP ignores every repeat of a directive name after the
first, so splitting script-src across two lines silently drops the second, leaving you
believing you allowed something you hadn't.
| Directive | Source | Why | Blocked ⇒ |
|---|---|---|---|
script-src |
the widget's origin | The <script> tag fetches a small entry that pulls the rest of the bundle from the same origin. It also pulls the calendar, registration and share panels, but only once a viewer opens them. |
nothing renders — or worse, it renders and then dies on one of those three presses |
api.mapbox.com |
Mapbox GL's right-to-left text plugin, loaded on demand from inside the map's worker on first RTL text. Workers inherit the document's policy, so this lands on script-src rather than worker-src. |
Arabic, Hebrew and Persian labels on the map render in the wrong order | |
cdn.usefathom.com |
analytics, only on a build with an analytics ID | analytics only | |
worker-src / child-src |
blob: |
Mapbox GL compiles its worker bundle into a Blob and starts a module Worker from the resulting blob: URL. child-src is the fallback for engines predating worker-src. |
the map fails, the rest of the widget is unaffected |
style-src |
'unsafe-inline' |
Narrower than its name suggests. The widget's own stylesheet is a constructed CSSStyleSheet adopted by its shadow root, which style-src does not reach on the one engine that was measured. What the directive governs is the <style> elements the widget and its libraries still install at runtime — the @font-face rules in your <head>, and the place-search field's own stylesheet — plus the style attributes they set, which the note below covers separately. |
the interface renders, fully styled. You lose the typeface — as the font-src row below — and place search, whose library reads back the stylesheet it just installed and throws when the browser refuses it |
font-src |
the widget's origin | The typeface is self-hosted. No request goes to fonts.googleapis.com or fonts.gstatic.com, so neither belongs in your policy, and no visitor IP reaches a third party for a font. |
text falls back to your system sans, everything works. style-src above costs you the same typeface by a different route: the @font-face rules never register, so no font is requested at all |
img-src |
data: |
The map's pins and cluster bubbles are inline SVG rasterised from a data: URI. The widget ships them itself, rather than relying on the map style's sprites. |
the map paints with no pins |
api.mapbox.com |
map tiles, sprites and glyphs | the map fails | |
imagedelivery.net, cloud.sydevelopers.com |
event and venue photography. The URL comes from the CMS, so the origin is data rather than a bundle-pinned value. Production serves the Cloudflare Images CDN (imagedelivery.net) today. Any relative URL resolves against the API origin. |
images only | |
react-circle-flags.pages.dev |
country flag SVGs on the country list and the country-website offer, sent with referrer-policy: no-referrer, so your page's URL is never disclosed. |
the flag glyphs only, the lists still render | |
connect-src |
cloud.sydevelopers.com |
the API — every event, region and venue, and since 2026-09 every UI string and the list of languages offered, plus the one-per-load embed report. | the widget has no data and shows an error screen |
api.mapbox.com |
tile and style requests. Also the place-search geocoder, and the reverse lookup naming the visitor's own location for "find my location". | the map and search fail | |
events.mapbox.com |
Mapbox GL's own map-load telemetry | nothing visible | |
ipwho.is |
the once-per-session IP→city lookup behind "classes near you" | degrades: the nearby suggestion is skipped | |
cdn.usefathom.com |
analytics, conditional as above | analytics only | |
*.sentry.io |
crash reporting. Contacted only after the widget has already failed, and only on a build with a DSN. | degrades: the widget notices the refusal and stops trying for the rest of the page's life | |
frame-src |
cloud.sydevelopers.com |
The captcha — required. Every write the widget makes is captcha-gated. The challenge renders in a frame cloud.sydevelopers.com serves, so challenges.cloudflare.com belongs in no directive of yours. The frame mounts with the interface, so a policy that omits this fails within seconds rather than when somebody registers. |
the widget shows an error screen and does not run. Not a degradation — see the note below |
Notes on that table:
Do not add 'unsafe-eval' or challenges.cloudflare.com. The challenge page on
cloud.sydevelopers.com loads Turnstile's api.js under its own policy, so your frame-src
above is the only directive of yours that reaches any of it.
⚠ The challenge page has not yet been watched solving in a browser. The same protocol solved on this policy in Chromium 141 on 2026-10-11, when the challenge rendered in the host's document, but no measurement has gone past the token to an accepted submission, or covered map mode. Tell us if a challenge will not solve on this policy.
The style-src row was measured, not deduced — Chromium 141, 2026-10-10, against a
map=false embed served with the policy above and 'unsafe-inline' removed from style-src
alone. The widget's own stylesheet applied in full, and went on applying under style-src 'none'. What stopped was the @font-face block and the place-search field.
⚠ That was one engine, and the claim is scoped to it. Whether a constructed
CSSStyleSheet is outside style-src's reach has been argued both ways against the spec, and
the support floor below also names Firefox 128 and Safari 16.4 — 16.4 being
the release that first shipped adoptedStyleSheets at all. Neither was measured. If either
refuses an adopted sheet, style-src 'unsafe-inline' is load-bearing again on that engine and
this section understates what it costs you. Until then, treat the row as what Chromium does and
keep allowing the directive.
Do not reach for style-src-elem and style-src-attr to tighten this. They do split the
cost — granting only style-src-elem 'unsafe-inline' was indistinguishable from allowing
everything, and granting only style-src-attr 'unsafe-inline' was indistinguishable from
allowing nothing — but the attribute half is not free. The widget hoists the place-search
library's screen-reader live region into your <body>, and the library clips it to 1×1 with a
style attribute, so a policy that refuses the attribute leaves that region full-width,
unclipped and in your page's flow: its announcements ("3 results") become visible text on your
page the moment a visitor types.
One hazard does not apply here, so you do not have to weigh it: every browser in the
support floor implements the split — Chrome 75, Firefox 108 and Safari 15.4,
all below the floor, per MDN's compatibility data. A browser older than that ignores the two
directives entirely and falls back to your own style-src, blocking everything the widget
installs. That reaches you only if your audience runs browsers older than the widget supports.
connect-src cloud.sydevelopers.com is the entry most likely to be missing, and the most
expensive — absent from every earlier version of this documentation. Without it, a strict-CSP
host gets a widget that loads, styles itself, and then cannot fetch a single event.
The *.sentry.io wildcard is deliberately that wide. A CSP host wildcard matches a suffix
only, and Sentry organisations created since 2024 get a regional ingest host
(o123.ingest.us.sentry.io, …de.sentry.io) that *.ingest.sentry.io does not match — a
policy written that way looks correct and silently blocks everything. To name one host instead,
take the exact one from the DSN rather than deriving it. Leaving it out is a supported choice:
you get one blocked request and one violation report, not one per error, since the widget
latches off after the first refusal rather than retrying.
Five entries are load-bearing. The rest cost only the feature in their own row. If you allow nothing else, allow these — each breaks the widget as a whole:
script-src the widget's origin |
nothing renders |
frame-src cloud.sydevelopers.com |
the widget shows an error and stops |
connect-src cloud.sydevelopers.com |
no data at all |
worker-src blob: |
the map never renders |
img-src data: |
the map renders with no pins |
⚠ style-src 'unsafe-inline' was on that list and has left it. It was there on the strength
of a consequence measured before the widget moved into a shadow root, when the stylesheet really
was <style> elements the policy could refuse. It is a degradation now, and its own row says
what of — on Chromium, which is the only engine that degradation was measured on. Keep allowing
it: a visitor who gets your typeface and no place search is still worse off. But do not trade a
load-bearing entry away to afford it.
⚠ The captcha entry is fatal on purpose. Every write the widget makes is captcha-gated, registration included, and registration is the thing the atlas exists for. A degraded challenge would let a visitor browse classes and then silently fail to sign up for one, with no reason for the page owner to suspect anything was wrong. Failing loudly is worse for five minutes and better thereafter. The widget writes both possible fixes to the browser console — this directive, or the domain to have listed on your client record — so a developer looking at the page gets the answer without opening this document.
Everything else degrades to exactly what its "Blocked ⇒" column says — a missing typeface, a search field that offers no places, missing photography, missing flags, no analytics, no telemetry. Read the row rather than assuming — over-allowing because a summary sounded absolute is its own cost on a strict-policy page.
Two entries are conditional on the build's configuration rather than anything you control:
analytics is absent unless the build carries an analytics ID, and Sentry is absent unless it
carries a DSN. Including them costs nothing if unused. And one omission is deliberate:
*.tiles.mapbox.com is not in the list. Older Mapbox guidance names it, but this version
routes every tile, glyph and sprite request through api.mapbox.com, with no reference to the
legacy host anywhere in the shipped map library. Add it only if you see it in a violation report.
Alongside the origins in the CSP table above, the widget needs three browser capabilities.
Each is denied to a cross-origin iframe by default, and each can also be denied to a script embed
by a Permissions-Policy header on your own page.
| Feature | What uses it | What breaks without it |
|---|---|---|
geolocation |
The map's "find my location" control, which opens the classes nearest the visitor | The control is disabled, or says it could not get a location |
clipboard-write |
The copy-link button on the share sheet | Copying silently fails |
web-share |
The native share sheet on mobile | The share button falls back to the copy/link list |
None of the three errors, so nothing in your console will tell you. If
you send a Permissions-Policy header, allow these three for your own origin. If you embed in
an iframe, put them in its allow attribute as shown above.
The captcha's frame carries allow="cross-origin-isolated; fullscreen; autoplay; keyboard-map; gamepad; xr-spatial-tracking", the six features Turnstile's own widget asks for. A
Permissions-Policy of yours that denies them does reach the frame, but the challenge renders and
solves with all six denied (Chromium 153), so add nothing for the captcha. ⚠ Private State
Tokens are not among them: Turnstile asks for them as private-token, a name no current engine
publishes, so no policy either of us writes can delegate them.
One host serves the bundle:
| Host | What it is |
|---|---|
https://sahajatlas.com |
the production domain |
⚠ sahajatlas.pages.dev used to serve the same bundle and is no longer an origin of ours.
If your snippet or your CSP still names it, move both to https://sahajatlas.com. The name is
withdrawn rather than redirected, so no request to it reaches what serves the domain, and once
the project behind it is deleted the name is claimable by anybody — treat a script still loaded
from there as a third party, not as us.
The widget's data all comes from one other origin: cloud.sydevelopers.com. That is the only
host connect-src has to name for the atlas itself. Its UI strings come from there too, alongside
the events — the widget ships an English copy compiled in and fetches every other language from
the CMS, so a blocked connect-src leaves a non-English page reading English rather than showing
raw key names.
Two files sit at that origin's root, and only the first is one you reference:
| File | What it is |
|---|---|
auto.js |
the loader — the one you install |
embed.js |
the widget itself, fetched by the loader on demand |
The bundle targets Baseline Widely Available 2026-01-01, which is the Vite 8 default and is left there deliberately (the map already requires a modern browser):
| Browser | Minimum |
|---|---|
| Chrome / Edge | 111 |
| Firefox | 128 |
| Safari (macOS) | 16.4 |
| Safari (iOS) | 16.4 |
The Firefox floor moved from 114 to 128. It is the stylesheet, not the JavaScript, that sets it: these three numbers are Tailwind 4's own declared minimum, and the widget's styling is built on it. The JavaScript target is unchanged.
A Firefox between 114 and 127 is not unstyled. Tailwind emits an @supports-guarded block
that sets every one of its --tw-… properties to its initial value for exactly the browsers
below the floor, and this build keeps that block. Measured in the shipped CSS: layout, spacing,
colour and radius utilities all emit plain declarations, no declaration uses relative colour
syntax, and the color-mix() ones are themselves @supports-guarded. So the floor is the
version Tailwind supports, not the version below which the widget stops rendering — what you
lose under it is @property's typed interpolation, which nothing in this bundle animates.
Older browsers are not transpiled for, and fail on modern syntax rather than degrading. There is no polyfill build.
It will not restyle your page. The widget's stylesheet is adopted by its shadow root, so
none of it is in your document to begin with — a rule of the widget's cannot match one of your
elements. On top of that, every selector is still confined to the widget's own subtree
(.sy-atlas) and every animation name is still namespaced, enforced by a build-time check that
fails the build if a rule escapes. Your headings, links, lists, forms, .container, a .dark
theme class, and your own Swiper or Mapbox instances are all left alone.
Seven honest exceptions, none of them styling your content:
-
Opening a modal panel inside the widget sets
overflow: hiddenon your<body>while open — standard scroll-lock, reverted on close. -
It writes to your page's query string, to exactly two parameters:
atlas, carrying the widget's route (see The URL), andlocale, written once when a visitor picks a language from the settings menu. Both go throughhistory.replaceStateorpushState. Your own parameters, path, and fragment are preserved, and the widget never reads or writes your#anchor. -
It sets one attribute,
data-sahaj-atlas-ready, on your<html>element once mounted, and removes it again on unmount. See the readiness marker for what it is for. No styling, no classes, nothing else of yours is touched. -
@font-faceis the one rule that cannot be scoped, since it carries no selector. The widget registers three of them (one per character-set subset) for its self-hosted typeface, document-global by nature. They are declared under the family nameAtlas Rethink Sans, deliberately not a plain typeface name — so if your page self-hosts the same typeface, the widget's faces cannot override yours. That is the whole reason for the odd name. These three are the only@font-facerules the widget contributes. Mapbox and Swiper register none. -
One hidden
aria-liveregion sits in your<body>while the widget's place-search field is mounted, and is removed with it. The search field announces its suggestion counts through it, and the library that owns it looks the region up on the document, which cannot see into the shadow root. It is empty except while announcing, carries its own inline clipping styles, and is removed on unmount. Nothing else of yours is touched. ⚠ Those clipping styles are astyleattribute, which is why the CSP section tells you not to tightenstyle-src-attr: refuse it and the region sits full-width and unclipped in your page's flow, announcements and all. -
@propertycannot be scoped either, for the same reason — it registers a name, not a selector. Tailwind 4 composes its utilities through registered custom properties, so the widget registers 62 of them, every one named--tw-…. Registering a name in your document gives it a typed initial value andinherits: falsethere, so a--tw-…property of your own would pick up those semantics. They cannot be renamed, since Tailwind writes thevar()references itself, so the build gate allows that one prefix by name and fails on any other. They reach your document as one constructed stylesheet appended todocument.adoptedStyleSheets, holding those registrations and nothing else: a registration inside a shadow root is ignored, and without them the widget draws no borders and no shadows. Like the widget's own sheet, it is constructed, so it asks nothing new of your CSP. -
A 1-pixel hidden
<iframe>sits in your<body>for a moment after the widget mounts. It is the captcha's availability check, loading the challenge page fromcloud.sydevelopers.comso that a page where registration is impossible says so within seconds. It isposition: fixed, transparent, unfocusable, hidden from assistive technology, and adds no scroll height.
The reverse direction is a boundary, not a defence. The widget renders inside a shadow
root, so a selector in your stylesheet cannot match an element inside it — at any specificity,
with or without !important. A blanket button { … }, an a { color: … !important }, a
body h2 { font-family: … }, a global letter-spacing: none of them reach the widget's own
elements. There is nothing you need to do, and nothing to work around.
⚠ A third thing stops crossing: your own children of <sahaj-atlas>. The element attaches
its shadow root in its constructor, so light-DOM children are hidden from the moment the element
upgrades. There is no <slot>, so they are not replaced — they never paint at all, and that
includes a loading state, a no-JavaScript message, or a server-rendered fallback. A widget that
refuses to boot therefore leaves a blank box rather than revealing what you nested. Put a
fallback beside the element, not inside it.
Two things still cross, and both are meant to.
- Inherited properties arrive from the
<sahaj-atlas>element itself, exactly as they arrive at any element on your page. Sobody { font-family }is inherited INTO the widget — and then overridden, because the widget restatesfont-family,color,letter-spacing,text-align,text-transformandword-spacingon its own root. If you set an inherited property the widget does not restate, it will reach the text. That is a short list and a deliberate one. - A rule aimed at
sahaj-atlasitself still sets that element's own box —display,width,height,margin,border. That element is your slot, and sizing it is how you control where the widget sits. See sizing.
This replaced a reset that could not hold (see the changelog): the widget used to roll
its own subtree back to browser defaults before applying its styles, at zero specificity so it
could never outrank the widget's own rules. That left it outranked by yours. Measured across 18
WordPress themes at four viewports, the widget's typeface was lost on 24 of 72 page checks, to
rules carrying no !important at all. If you built around the old behaviour — or around the
!important exception it documented — nothing you did stops working; it is simply no longer
needed.
Your root font size does not resize it either. The widget sizes its layout in pixels, not
rem, so a theme that sets html { font-size: 62.5% } — Twenty Twenty and OceanWP both do — gets
the same side panel, and the same map framing around it, as any other page. The calendar view is
the one part still sized in rem: its grid comes from a third-party theme, so on such a page the
calendar's text renders smaller than the rest of the widget.
⚠ BREAKING: sahaj-atlas-style no longer exists. The widget's stylesheet moved
into its shadow root, which is what makes your CSS unable to reach in — and the same boundary
means a stylesheet in your document could not reach the widget either, so there is no longer a
<style> element in your <head> to carry the id. The sheet is adopted by the shadow root
instead, and document.querySelectorAll('style#sahaj-atlas-style') now returns nothing.
One id remains, and is unchanged:
| Id | Where | Holds |
|---|---|---|
sahaj-atlas-fonts |
your <head> |
the @font-face blocks for the self-hosted typeface |
sahaj-atlas-fonts stays in your document on purpose. A @font-face registered inside a
shadow root is never applied, so this one rule has to live where every other document-global rule
lives. It is also why the family is named Atlas Rethink Sans rather than its real typeface
name — see the @font-face note above.
If you were keying off sahaj-atlas-style, tell us what for: the shadow root is reachable as
document.querySelector('sahaj-atlas').shadowRoot (it is deliberately open, not closed), and
its adoptedStyleSheets hold the same CSS. We would rather publish a supported way to do what
you needed than have you reach through the boundary.
The widget renders as a labelled role="region" landmark, so a screen-reader user can jump to
and out of it rather than meeting an unbounded run of content mid-page. It carries its own lang
and dir, tracking the active locale — your page's <html lang> is almost never the widget's.
Everything the widget does happens in your visitor's browser, on your origin, so your privacy notice, not ours, is the one that has to describe it. This is the summary. The README's privacy section carries the full detail on each flow, including exactly what an error report does and does not contain.
It sets no cookies. It stores four keys under your origin — two of its own, written inside a
try/catch so a sandboxed iframe degrades the setting rather than breaking the widget, and two
written by Mapbox GL:
| Key | Store | Holds | Lifetime |
|---|---|---|---|
atlas.theme |
localStorage |
the viewer's light/dark/auto choice | until cleared |
atlas.geolocationPromptDismissed |
sessionStorage |
that they dismissed the "classes near you" suggestion | the browser session |
mapbox.eventData:<token> |
localStorage |
Mapbox GL's own telemetry bookkeeping | until cleared |
mapbox.eventData.uuid:<token> |
localStorage |
a persistent anonymous id Mapbox generates | until cleared |
The theme key used to be un-namespaced — the bare string theme — so a page storing its
own light/dark preference under that name had it read and overwritten. That is fixed: our key is
now atlas.theme, and the old one is read once, never written, so nobody loses a choice they
already made. The two mapbox.* keys appear only when the map renders, so map=false removes
them.
The language picker persists nothing — i18next's detector would cache i18nextLng on your
origin by default, and that write is switched off.
Three third-party flows leave the browser. None has a script-URL opt-out — each is designed so it does not need one. If a flow is still a problem for your privacy notice, talk to the maintainers rather than editing the snippet:
ipwho.is— a keyless IP→city lookup, once per session, so the widget can offer "classes near you" before the visitor types. No referrer, no key, no cookies, a five-second timeout, silent on failure, and skipped entirely when that prompt could not show — including for the rest of a session after the visitor dismissed it. An IP is personal data in the EU, and it is not stored — used to pick a city, then discarded.cdn.usefathom.com— cookieless, aggregate pageview counting, loaded only when the bundle was built with an analytics ID and your client record names a real primary domain. Auto-tracking is off, so it reports the widget's own route under that domain — your page's real URL and query string are never sent.*.sentry.io— crash reports, sent only when the widget has already broken and only on a build with a DSN. Your page reaches it as origin and path only, never its query string or fragment, which on your site can carry a reset token or an OAuth#access_token. It also carries the visitor'snavigator.userAgent. No global error handler is installed, so your own scripts' exceptions are never captured. No breadcrumbs, no session replay.
The captcha talks to Cloudflare from a frame, not from your page. The Turnstile challenge
gating every form is a small frame served from cloud.sydevelopers.com, and that frame's document
makes the requests to challenges.cloudflare.com — so Cloudflare sees the visitor's IP but not
your page, and no script of Cloudflare's runs in your document. The frame's URL carries your
page's origin — scheme, host and port, never a path — because that is the one window it hands
its token to. It mounts with the interface, whether or not the visitor opens a form.
map=false removes the Mapbox flows too — tiles, the geocoder (which sends the visitor's
typed query and the map centre to Mapbox), and the map-load telemetry — along with the two
mapbox.* storage keys. That is the only switch for them, since they are Mapbox's own behaviour,
not ours.
Two things a visitor can send us on purpose, both on submit and never in the background: a class registration (their name, email and any organiser questions) and a report about an issue (their message, and the page they were on with its query string and fragment stripped). Both go over HTTPS to SahajCloud. Neither is stored in the browser.
One thing the widget itself sends in the background, about your page rather than your visitor:
the embed report described under what the loader reports back
— your origin and path, and four booleans about how the widget is mounted. Its body carries
nothing about the visitor: no identifier, no location, and none of your query string bar a
WordPress ?p=<number>. Like any request, it still carries the visitor's IP and User-Agent as
headers, which no client-side code can suppress — unlike the crash reports above, which put the
user agent in the payload deliberately.
The embed is evergreen, and there is no version to pin. auto.js is served unhashed from
our origin and updated in place, roughly daily, so every host serves the current build.
CHANGELOG.md is written for embedding sites and records what you can
observe — parameters, origins, CSP, visible behaviour.
We serve both root files with Cache-Control: public, max-age=0, must-revalidate, so a
browser always asks before reusing one. That is deliberate, and it is the contract: the files
are unhashed and mutable, and they import content-hashed chunks by name.
- Don't cache
auto.jsorembed.jsaggressively at your edge. A stale copy held by a proxy or service worker will ask for chunk filenames the CDN no longer serves, and those 404s kill the widget with no fallback. Let it revalidate. The hashed chunks underneath it are immutable and safe to cache hard. - Because there is no pinning, a breaking change is a change you receive. The changelog is the channel — if you need advance notice for a large deployment, say so to the maintainers rather than pinning a URL.
The element and all nine attributes are gone. Most became query parameters, the rest moved to
your client record or were removed (see Parameters). The reason is about your
platform, not our taste: WordPress strips <script> (and unknown attributes) from saved content
for anyone below Administrator, and for every Site Administrator on multisite, while Wix
supplies a bare script URL and its own attribute panel. One mechanism on the URL works
identically on all of them, and the only thing a sanitizer can now destroy is the whole snippet —
a failure you can see.
<!-- before -->
<script type="module" src="https://sahajatlas.com/embed.js"></script>
<sahaj-atlas api-key="…" map="false" locale="fr"></sahaj-atlas>
<!-- now -->
<script type="module" src="https://sahajatlas.com/auto.js?key=…&map=false&locale=fr"></script>Three things to check when you migrate:
api-keybecamekey.base-pathbecameatlas, and now defers to a route already on the page's URL.analytics,geolocation,error-reporting,name,primary-color,secondary-colorandmountno longer exist. A leftover value in your URL is ignored rather than misread.
| Symptom | Likely cause |
|---|---|
| Nothing renders, a 404 for the script | The filename is auto.js. embed.js exists but is the widget the loader fetches, not the file you install. |
| "Not set up correctly" / configuration error | The parameter is key, not api-key (renamed when configuration moved onto the script URL). The loader logs an error naming it. Otherwise the key is wrong, revoked, or not yet issued. |
| Nothing renders, no console error, script loaded fine | map=false with no height on the element — it collapsed to zero. Give it display:block;height:…. |
| Console: "the embed script is on this page more than once" | Exactly that. Only one widget runs per page, so the second copy renders nothing and its settings are ignored. Remove the extra script tag. |
| Text is in your own font, and the search field offers no places | style-src 'unsafe-inline' is missing from your CSP. The console says Refused to apply inline style, and the violation reports name style-src-elem. The field keeps its box — it falls back to a plain text input — and the rest of the interface keeps its styling, since the widget's own sheet is not governed by style-src. |
| Labels are English on a non-English page | Either connect-src https://cloud.sydevelopers.com is missing, or that language is not switched on in the CMS. The widget carries English compiled in, so this is what a blocked or unavailable language looks like — ask us to enable the language you need. |
| Widget loads and styles, but shows an error instead of any events | connect-src https://cloud.sydevelopers.com is missing. |
| The map area is blank or grey | worker-src blob: (Mapbox starts its worker from a blob: URL), or api.mapbox.com missing from img-src/connect-src. |
| The map renders but has no pins | img-src data: — the pins are inline SVG rasterised from a data: URI. |
| Country flags are missing, everything else fine | img-src https://react-circle-flags.pages.dev. Cosmetic. |
| The widget shows "its security check was blocked" and nothing else | Read the browser console, which names both fixes. If the browser also logged a refused frame, your CSP is missing frame-src https://cloud.sydevelopers.com; otherwise your page's domain is not on your client record — ask us to add it. Since the atlas cannot take a registration without the captcha, this fails outright rather than degrading the form. |
| The widget's route never appears in the address bar | The document refuses history.replaceState — most often file://. The widget detects that and routes in memory. |
| The compact card's button does nothing, in an iframe | The frame is sandboxed without allow-popups, so its target="_blank" link cannot open. It fails silently, with nothing in the console. Add allow-popups, or drop sandbox. |
atlas= on the script URL seems to be ignored |
The page's own URL already carries an ?atlas= route, which always wins. That is intended — see atlas, and how the route is chosen. |
| Sharing offers no link | The same memory-routing mode, on a page with no canonical atlas URL to offer instead. |
| The widget covers the rest of the page | A map embed with no height of its own renders position: fixed; inset: 0. Give the element a display: block and a height to keep the map inside it, or use map=false for an embed with no map. |
| The widget looks wrong on your site only | Not a selector of yours — the shadow root stops those. Look instead at an inherited property the widget does not restate on its own root, your root font-size (the calendar only), or the sahaj-atlas-fonts <style> missing from your <head> — or sitting there inert, which is what a style-src without 'unsafe-inline' leaves behind. See what the widget does to your page. |
| It broke after working yesterday | The embed updates in place. Check CHANGELOG.md. Then look for a cached auto.js or embed.js at your edge requesting chunk names that no longer exist. |
| Console: "could not find a place to render" | The snippet is in <head>, or carries async/defer. Move it into the body without those attributes, or add an empty <sahaj-atlas></sahaj-atlas> where the widget should appear. |
| Nothing renders; console: "Cannot use import statement outside a module" | The script tag lost its type="module". An optimizer or an HTML5-cleanup filter rewrote the tag, or combined auto.js into a classic bundle. Exclude auto.js from minifying, combining and delaying, and from any filter that rewrites script tags. None of the widget runs in this state, so this browser error is the only report you get. |
| Console: "could not load the widget" | The loader ran, but embed.js or one of its assets/ chunks did not load — the error printed with it names the URL. Look for that request in the network panel: a blocker, a CSP rule, or a stale copy cached at your edge (see Updates and caching). |
| The widget shows an error after the page is translated | A translation plugin rewrote the widget's text in the browser and ignored its translate="no". Add sahaj-atlas to the plugin's excluded elements — see the widget follows your page's language. |
Console: "no key parameter on the embed script URL" |
The query string is missing or was stripped. Some page builders drop everything after ? from a script URL — if so, that platform cannot host the widget this way. Talk to the maintainers. |
| The widget only appears when you scroll to it | Intended. The loader defers fetching the widget until it nears the viewport. A below-the-fold widget therefore costs visitors nothing until they reach it. |
If none of these fit, the widget's own Report an issue form (behind the settings control, and offered on most error screens) reaches the maintainers with the failure already attached. The form is managed in the CMS, so where the maintainers have not set one up it is absent everywhere — write to them directly instead.