Claude Sonnet 4.5
=============
BUILD OVERVIEW
=============
This file describes how to build a SINGLE installable PWA for the
OpenLang Hebrew language-learning hub. The app is installed once from
its landing page and, when installed, caches every game from every
category for offline use.
Deliverables: four files, all at /Hebrew/:
/Hebrew/index.html — the landing page AND the PWA shell
/Hebrew/sw.js — service worker
/Hebrew/manifest.json — web app manifest
/Hebrew/icon.svg — home-screen icon
The landing page serves two roles at once. In a browser, it is a
normal page that lets the user click any game to play it online. On
first visit, it also offers a single prominent "Install" button that
installs the whole hub as a PWA. Once installed, the PWA's home-screen
icon launches the same /Hebrew/index.html, which detects standalone
mode and rewrites game links to point to the cached offline builds.
The page is organized into one collapsible block per CATEGORY
(Vocabulary, Phrases, ACA, Sign Language, etc.). Categories are
defined at the bottom of this file under "CATEGORY DEFINITIONS". The
service worker caches the union of all category URL lists as a single
bundle — there is no per-category install.
-------------
Theme & Style
-------------
Use the Tailwind 'Stone' palette. Headings should be deep red and
serif; body text sans-serif. Do not display any comments or notes.
Links: Bold font, navy blue color. Links open in the current window.
Screen width limited to 1024 pixels.
Do not include any comments or notes in the index.html file.
--------
Branding
--------
- BRANDING: SVG logo of "文" (2.5em, deep red) followed by "OpenLang.ai"
in large bold text. Links to "/".
- LANG HEADER TEXT: "OpenLang.ai Hebrew" (static label, shown in the
top bar to the right of BRANDING).
- Page : "OpenLang.ai Hebrew".
- Favicon: A data URI containing the character "文" (deep red on
white).
- SOURCE: Right-justified button with the text "SOURCE" that downloads
the build-instructions.txt file.
--------
Top Bar
--------
Smartphone:
BRANDING, LANG, SOURCE on a single line.
Tablet:
BRANDING, LANG (1.3rem), SOURCE.
Desktop (strictly in this order):
BRANDING, LANG (2.0rem), SOURCE.
------------------
Instruction Block
------------------
A short paragraph below the top bar, above the Install button:
"Browse the categories below to play games in your browser, or
install the whole hub as an app for offline use."
=================
PWA & OFFLINE
=================
- SELF-CONTAINED REQUIREMENT:
- index.html must have zero external dependencies (no CDN scripts,
no external fonts). All CSS and JS must be inline. Use system
font stacks: Georgia/'Times New Roman' for serif headings,
'Helvetica Neue'/Arial for body text.
- sw.js contains only plain JavaScript — no HTML, no module
imports.
- EXCEPTION: the Media.net ad loader script (see AD SLOT section)
is allowed.
- WHY REAL FILES FOR MANIFEST AND ICON:
Chrome 93+ requires the manifest to be served from the same origin
as the page with Content-Type: application/manifest+json. A data:
URI manifest silently fails the installability check,
beforeinstallprompt never fires, and the browser falls back to
"Create shortcut" instead of a proper PWA install. Icon src values
must also be fetchable URLs — data: URI icons fail the same check.
- DO NOT ATTEMPT SINGLE-FILE WORKAROUNDS:
All single-file approaches have been tried and fail. Do not
attempt to reintroduce any of the following:
1. Self-registration via query string (index.html?sw):
Nginx resolves Content-Type from the file extension via its
types{} map before any if{} block is evaluated. .html is
already mapped to text/html, so default_type inside an if{}
is never reached. There is no Nginx directive that can
conditionally override a known extension's Content-Type based
on a query string without restructuring the location blocks
in ways that break normal HTML serving. This approach is a
dead end.
2. Blob URL (URL.createObjectURL):
A Blob SW's script URL is blob:https://origin/. The SW
spec derives the maximum allowed scope from the script path,
which for a Blob is //. Registering with scope /Hebrew/
fails with a DOMException. Requires a Service-Worker-Allowed
response header to fix — same server-side problem, more
complex.
3. data: URI as SW script URL:
Browsers block SW registration from data: URIs; the origin is
opaque and registration throws immediately.
Since all assets are on the same HTTPS domain, sw.js is served
with the correct application/javascript MIME type automatically
by virtue of its .js extension. No server configuration is needed.
- MANIFEST (manifest.json — static file, not generated at runtime):
- "name": "OpenLang Hebrew"
- "short_name": "OpenLang Hebrew"
- "description": brief description of the hub
- "display": "standalone"
- "background_color": "#fafaf9"
- "theme_color": "#991b1b"
- "start_url": "/Hebrew/index.html" (absolute, hardcoded). Do not
use window.location.pathname.
- "icons": Two entries, both pointing to "icon.svg" (relative
path). Sizes "192x192" with "purpose": "any maskable", and
"512x512".
- ICON (icon.svg — static file):
- A plain SVG: white/stone-50 background, the "文" character
centred in deep red (#991b1b), Georgia serif font.
- Designed to work at both 192x192 and 512x512.
- index.html MANIFEST LINK:
Static tag in — no JavaScript needed:
- SERVICE WORKER (sw.js):
- Plain top-level JS. No IIFE, no typeof-window guard, no HTML.
- All strings must use only straight ASCII apostrophes and quotes.
- PAGE_URL: Derived at the top of sw.js by replacing the sw.js
filename with index.html:
var PAGE_URL = self.location.href.replace(/sw\.js.*$/, 'index.html');
Resolves to the exact absolute URL used as the cache key for
the landing page shell. Do not use self.registration.scope.
- CACHE_NAME: 'openlang-hebrew-v1'. Increment the version suffix
whenever sw.js or the URL list changes — incrementing is what
triggers the old cache deletion in the activate handler.
- TWO URL ARRAYS — phase 1 (shell) and phase 2 (games):
Derive manifest and icon absolute URLs the same way as PAGE_URL:
var BASE = self.location.href.replace(/sw\.js.*$/, '');
var SHELL_URLS = [ PAGE_URL, BASE + 'manifest.json', BASE + 'icon.svg' ];
var GAME_URLS = [ ];
Order: the GAME_URLS array should list every category's games
in the order the categories appear in CATEGORY DEFINITIONS.
Within a category, preserve the order of the URL rows.
- broadcast helper (module-level):
function broadcast(msg) {
self.clients.matchAll({includeUncontrolled:true, type:'window'})
.then(function(clients) {
clients.forEach(function(c) { c.postMessage(msg); });
});
}
- precacheOne(cache, url): fetches and stores one URL, ignoring
errors. Wraps the fetch in a 15-second timeout using
Promise.race() so a hung request never blocks the download
chain:
function precacheOne(cache, url) {
var timeout = new Promise(function(resolve) {
setTimeout(resolve, 15000);
});
return Promise.race([fetch(url), timeout])
.then(function(response) {
if (response && response.ok) {
return cache.put(url, response);
}
})
.catch(function() {});
}
- INSTALL — cache shell only:
- Open CACHE_NAME, run precacheOne for each SHELL_URLS entry.
- No progress broadcast during install — the shell is small
and fast; no overlay is shown at this stage.
- After Promise.all, call skipWaiting().
- ACTIVATE:
- Delete every cache whose name !== CACHE_NAME.
- Call self.clients.claim().
- MESSAGE HANDLER — listen for {type:'CACHE_GAMES'} from the page:
When the SW receives this message, cache all GAME_URLS
sequentially (one at a time) with progress broadcasting.
Sequential fetching prevents timeouts and SW termination on
low-powered devices:
var done = 0;
caches.open(CACHE_NAME).then(function(cache) {
return GAME_URLS.reduce(function(chain, url) {
return chain.then(function() {
return precacheOne(cache, url).then(function() {
done++;
broadcast({type:'PRECACHE_PROGRESS',
done:done, total:GAME_URLS.length});
});
});
}, Promise.resolve());
}).then(function() {
broadcast({type:'PRECACHE_DONE', total:GAME_URLS.length});
});
Wrap the handler in event.waitUntil() so the SW stays alive
for the duration of the downloads.
- FETCH HANDLER — three-path strategy:
1. Non-GET requests: pass straight to fetch(event.request);
no caching.
2. Cross-origin requests: pass straight to fetch(event.request);
no caching. Never store opaque responses.
3. Same-origin GET requests: cache-first.
url = event.request.url (use this string as the cache key)
cache.match(url)
hit -> return cached response
miss -> fetch from network
ok: cache.put(url, response.clone())
return response
failed: return cache.match(PAGE_URL)
hit -> return shell (offline fallback)
miss -> return new Response('Offline',
{status: 503})
=====================
LANDING PAGE HTML/JS
=====================
- JAVASCRIPT STRUCTURE (in index.html) — TWO separate script blocks:
BLOCK A: A
HTML for the ad slot:
Reserved for future ads
CSS for the ad slot:
.ad-slot: width: 100%, display: flex, flex-direction: column,
align-items: center, justify-content: center, gap: 0.25rem,
background: stone-100, border: 1px dashed stone-300,
border-radius: 8px, padding: 0.4rem 0, margin: 0.25rem 0.
.ad-label: font-size: 0.65rem, color: stone-400,
font-family: sans-serif, letter-spacing: 0.08em,
text-transform: uppercase, pointer-events: none.
#media-net-ad: min-height: 50px, width: 320px, max-width: 100%.
===============================================================================
CATEGORY DEFINITIONS
===============================================================================
Each category block starts with a "Category:" line and ends at the
next "Category:" line or at end-of-file. Between the Category line
and the "Here is the URL List:" line, any number of optional metadata
fields may appear, each on its own line in the form:
Label: value
The label is free-form text (e.g. "Tagline", "Level", "Word count",
"Author"). The value may contain inline HTML tags such as ,
, , , and — these pass through to the rendered
page verbatim. Metadata fields are rendered inside the collapsible
block's as a