Claude Sonnet 4.6
=============
BUILD OVERVIEW
=============
This file describes how to build a SINGLE installable PWA for the
OpenLang Estonian 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 /Estonian/:
/Estonian/index.html — the landing page AND the PWA shell
/Estonian/sw.js — service worker
/Estonian/manifest.json — web app manifest
/Estonian/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 /Estonian/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: "Estonian" (static label, shown in the
top bar to the right of BRANDING).
- Page : "OpenLang.ai Estonian" (for SEO only).
- 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
--------
The desktop top bar contains an Install button slot to the LEFT of
SOURCE; mobile and tablet do NOT have this slot (they use the
prominent body-level Install button described in INSTALL BUTTON
below).
Smartphone:
BRANDING, LANG, SOURCE on a single line.
Tablet:
BRANDING, LANG (1.3rem), SOURCE.
Desktop (strictly in this order):
BRANDING, LANG (2.0rem), INSTALL-DESKTOP, SOURCE.
INSTALL-DESKTOP is the #install-btn-desktop button. It is hidden by
default (display:none) and shown by JS only when the install
conditions are met (Android beforeinstallprompt fired, or iOS in
Safari) AND the viewport is desktop-sized. When hidden it takes no
space — use display:none, not visibility:hidden.
------------------
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 page as an app for offline use.
At the top are everyday phrases and amusing quotes, followed by the
complete 3,000 word
Oxford vocabulary list, grouped by learning level."
=================
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 /Estonian/
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 Estonian"
- "short_name": "OpenLang Estonian"
- "description": brief description of the hub
- "display": "standalone"
- "background_color": "#fafaf9"
- "theme_color": "#991b1b"
- "start_url": "/Estonian/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:
The "Icon:" field is a special structural field that selects a
category icon from the Category Icons section above; it is NOT
rendered in the metadata list.
Category: Phrases
Icon: Phrases
Idiomatic phrases, neologisms, and quotations (A2-C2)
Phrases of varying length, plus modern coinages and political/religious quotations
Here is the URL List, along with the Name and Icon Text:
URL,Name,Icon Text
https://openlang.ai/Estonian/Idiomatic-Phrases/index.html,Idiomatic Phrases,P1
https://openlang.ai/Estonian/Short-Phrases/index.html,Short Phrases,P2
https://openlang.ai/Estonian/Longer-Phrases/index.html,Longer Phrases,P3
https://openlang.ai/Estonian/Longest-Phrases/index.html,Longest Phrases,P4
https://openlang.ai/Estonian/Neologisms/index.html,Neologisms,N1
https://openlang.ai/Estonian/Leftist/index.html,Leftist Quotes,Q1
https://openlang.ai/Estonian/Capitalist/index.html,Capitalist Quotes,Q2
https://openlang.ai/Estonian/Religious/index.html,Religious Quotes,Q4
Basic personal info, simple phrases, and everyday expressions.
Icon: vocabulary
Here is the URL List, along with the Name and Icon Text:
URL,Name,Icon Text
https://openlang.ai/Estonian/Beginner-1/index.html,A1,[A-B]
https://openlang.ai/Estonian/Beginner-2/index.html,A1,[B-E]
https://openlang.ai/Estonian/Beginner-3/index.html,A1,[E-G]
https://openlang.ai/Estonian/Beginner-4/index.html,A1,[G-M]
https://openlang.ai/Estonian/Beginner-5/index.html,A1,[M-P]
https://openlang.ai/Estonian/Beginner-6/index.html,A1,[P-S]
https://openlang.ai/Estonian/Beginner-7/index.html,A1,[S-T]
https://openlang.ai/Estonian/Beginner-8/index.html,A1,[T-Y]
---------------------------
Direct exchange of information, routine tasks, and basic description of your background.
Icon: vocabulary
Here is the URL List, along with the Name and Icon Text:
URL,Name,Icon Text
https://openlang.ai/Estonian/Elementary-1/index.html,A2,[A-B]
https://openlang.ai/Estonian/Elementary-2/index.html,A2,[B-D]
https://openlang.ai/Estonian/Elementary-3/index.html,A2,[D-F]
https://openlang.ai/Estonian/Elementary-4/index.html,A2,[F-L]
https://openlang.ai/Estonian/Elementary-5/index.html,A2,[L-P]
https://openlang.ai/Estonian/Elementary-6/index.html,A2,[P-R]
https://openlang.ai/Estonian/Elementary-7/index.html,A2,[R-S]
https://openlang.ai/Estonian/Elementary-8/index.html,A2,[S-Z]
---------------------------
Handling travel situations, understanding main points on familiar topics, and producing simple connected text.
Icon: vocabulary
Here is the URL List, along with the Name and Icon Text:
URL,Name,Icon Text
https://openlang.ai/Estonian/Intermediate-1/index.html,B1,[A-C]
https://openlang.ai/Estonian/Intermediate-2/index.html,B1,[C-D]
https://openlang.ai/Estonian/Intermediate-3/index.html,B1,[D-F]
https://openlang.ai/Estonian/Intermediate-4/index.html,B1,[F-L]
https://openlang.ai/Estonian/Intermediate-5/index.html,B1,[L-P]
https://openlang.ai/Estonian/Intermediate-6/index.html,B1,[P-R]
https://openlang.ai/Estonian/Intermediate-7/index.html,B1,[R-S]
https://openlang.ai/Estonian/Intermediate-8/index.html,B1,[S-Y]
---------------------------
Understanding complex ideas, spontaneous interaction with native speakers, and producing clear, detailed text on varied subjects.
Icon: vocabulary
Here is the URL List, along with the Name and Icon Text:
URL,Name,Icon Text
https://openlang.ai/Estonian/Upper-Intermediate-1/index.html,B2,[A-C]
https://openlang.ai/Estonian/Upper-Intermediate-2/index.html,B2,[C-D]
https://openlang.ai/Estonian/Upper-Intermediate-3/index.html,B2,[D-F]
https://openlang.ai/Estonian/Upper-Intermediate-4/index.html,B2,[F-I]
https://openlang.ai/Estonian/Upper-Intermediate-5/index.html,B2,[J-O]
https://openlang.ai/Estonian/Upper-Intermediate-6/index.html,B2,[O-R]
https://openlang.ai/Estonian/Upper-Intermediate-7/index.html,B2,[R-S]
https://openlang.ai/Estonian/Upper-Intermediate-8/index.html,B2,[S-Z]
---------------------------