Skip to content

Commit be03057

Browse files
committed
feat(tinytorch): make the website an installable PWA
Add web app manifest, service worker, and app icons so the TinyTorch site can be installed to the home screen and read offline. - manifest.webmanifest with relative start_url/scope so it works both at mlsysbook.ai/tinytorch/ and in local previews; any + maskable icons generated from the flame mark in logo-tinytorch-simple.png - sw.js: network-first for pages (never stale after a deploy, cached fallback offline, branded offline page as last resort), stale-while-revalidate for same-origin css/js/images/fonts, passthrough for JSON/data so release-manifest and search stay live - assets/scripts/pwa.html registers the worker at the site base path, derived at runtime since pages render at several directory depths - fix apple-touch-icon to a real 180x180 icon (the old href pointed at the 583x328 wordmark)
1 parent fc26c62 commit be03057

9 files changed

Lines changed: 235 additions & 1 deletion

File tree

tinytorch/quarto/_quarto.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,18 @@ project:
2020
# install.sh and community/ moved here from the retired tinytorch/site/
2121
# (Jupyter Book) tree so this quarto tree is the only source for
2222
# anything published under /tinytorch/.
23+
# - manifest.webmanifest / sw.js / assets/images/icons/**
24+
# PWA installability + offline support. The
25+
# manifest link and service worker registration
26+
# are injected by assets/scripts/pwa.html, so
27+
# Quarto never sees them referenced statically.
2328
resources:
2429
- assets/downloads/**
2530
- install.sh
2631
- community/**
32+
- manifest.webmanifest
33+
- sw.js
34+
- assets/images/icons/**
2735

2836
website:
2937
title: "TinyTorch"
@@ -269,14 +277,19 @@ format:
269277
include-in-header:
270278
- file: ../../shared/config/site-head.html
271279
- text: |
272-
<link rel="apple-touch-icon" href="/assets/images/logos/logo-tinytorch.png">
280+
<!-- Leading-/ hrefs are project-root-relative: Quarto rewrites
281+
them per page depth (../ on nested pages), so they resolve
282+
correctly under mlsysbook.ai/tinytorch/ and local previews. -->
283+
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/icons/apple-touch-icon.png">
284+
<link rel="manifest" href="/manifest.webmanifest">
273285
<meta name="theme-color" content="#D4740C">
274286
<!-- Release identity (rendered into the footer pill below).
275287
The publish workflow drops release-manifest.json at
276288
/tinytorch/release-manifest.json before deploy; preview
277289
and local renders silently no-op. -->
278290
<meta name="release-manifest" content="/tinytorch/release-manifest.json">
279291
include-after-body:
292+
- file: assets/scripts/pwa.html
280293
- file: assets/scripts/subscribe-modal.js
281294
- file: assets/scripts/ml-timeline.js
282295
- file: assets/scripts/sidebar-subtitle.html
14.9 KB
Loading
16.8 KB
Loading
93.1 KB
Loading
9.9 KB
Loading
57.3 KB
Loading
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<script>
2+
/*
3+
* PWA bootstrap — registers the service worker (sw.js at the site root, so
4+
* its scope covers the whole TinyTorch site). The manifest link lives in
5+
* include-in-header (_quarto.yml); only the worker needs JS because
6+
* registration takes a URL resolved against the page, and pages render at
7+
* several directory depths. The site deploys under /tinytorch/ on
8+
* mlsysbook.ai but local previews serve the build at the server root, so
9+
* the base path is derived at runtime to handle both.
10+
*/
11+
(function () {
12+
'use strict';
13+
var secureContext = window.location.protocol === 'https:' ||
14+
window.location.hostname === 'localhost' ||
15+
window.location.hostname === '127.0.0.1';
16+
if (!('serviceWorker' in navigator) || !secureContext) return;
17+
18+
var match = window.location.pathname.match(/^(.*\/tinytorch\/)/);
19+
var base = match ? match[1] : '/';
20+
window.addEventListener('load', function () {
21+
navigator.serviceWorker.register(base + 'sw.js').catch(function (err) {
22+
// Expected on previews that serve the build under a different root;
23+
// the site works identically without the worker.
24+
console.warn('TinyTorch PWA: service worker registration failed:', err);
25+
});
26+
});
27+
})();
28+
</script>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"name": "TinyTorch — Build Your Own ML Framework",
3+
"short_name": "TinyTorch",
4+
"description": "An interactive course for building machine learning systems from the ground up. Learn by implementing your own PyTorch-style framework.",
5+
"id": "./",
6+
"start_url": "./",
7+
"scope": "./",
8+
"display": "standalone",
9+
"orientation": "any",
10+
"background_color": "#ffffff",
11+
"theme_color": "#D4740C",
12+
"icons": [
13+
{
14+
"src": "assets/images/icons/icon-192.png",
15+
"sizes": "192x192",
16+
"type": "image/png",
17+
"purpose": "any"
18+
},
19+
{
20+
"src": "assets/images/icons/icon-512.png",
21+
"sizes": "512x512",
22+
"type": "image/png",
23+
"purpose": "any"
24+
},
25+
{
26+
"src": "assets/images/icons/icon-maskable-192.png",
27+
"sizes": "192x192",
28+
"type": "image/png",
29+
"purpose": "maskable"
30+
},
31+
{
32+
"src": "assets/images/icons/icon-maskable-512.png",
33+
"sizes": "512x512",
34+
"type": "image/png",
35+
"purpose": "maskable"
36+
}
37+
]
38+
}

tinytorch/quarto/sw.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
* TinyTorch service worker — offline support for the PWA.
3+
*
4+
* Strategy:
5+
* - Navigations (HTML): network-first. The site deploys frequently, so
6+
* pages must never go stale; the cache is only a fallback for offline
7+
* revisits, with a branded offline page as the last resort.
8+
* - Same-origin static assets (css/js/images/fonts): stale-while-revalidate.
9+
* One deploy of staleness is acceptable for assets and keeps repeat
10+
* visits fast.
11+
* - Cross-origin requests (Google Fonts, cdnjs) are left to the browser —
12+
* caching opaque responses inflates storage quota unpredictably.
13+
*
14+
* Bump CACHE_VERSION when changing caching behavior; old caches are dropped
15+
* on activate.
16+
*/
17+
18+
'use strict';
19+
20+
const CACHE_VERSION = 'v1';
21+
const PAGE_CACHE = `tinytorch-pages-${CACHE_VERSION}`;
22+
const ASSET_CACHE = `tinytorch-assets-${CACHE_VERSION}`;
23+
const ASSET_CACHE_LIMIT = 200;
24+
25+
// Everything the offline fallback needs is inlined below, so install only
26+
// warms the cache with the manifest and install-prompt icons.
27+
const PRECACHE_URLS = [
28+
'./manifest.webmanifest',
29+
'./assets/images/icons/icon-192.png',
30+
'./assets/images/icons/icon-512.png',
31+
];
32+
33+
const OFFLINE_HTML = `<!DOCTYPE html>
34+
<html lang="en">
35+
<head>
36+
<meta charset="utf-8">
37+
<meta name="viewport" content="width=device-width, initial-scale=1">
38+
<title>Offline — TinyTorch</title>
39+
<style>
40+
body {
41+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
42+
display: flex; align-items: center; justify-content: center;
43+
min-height: 100vh; margin: 0; background: #fff; color: #333;
44+
text-align: center; padding: 1rem;
45+
}
46+
@media (prefers-color-scheme: dark) {
47+
body { background: #1a1a1a; color: #ddd; }
48+
}
49+
.flame { font-size: 4rem; }
50+
h1 { margin: 0.5rem 0; font-size: 1.5rem; }
51+
p { color: #888; max-width: 28rem; }
52+
button {
53+
margin-top: 1rem; padding: 0.5rem 1.5rem; font-size: 1rem;
54+
background: #D4740C; color: #fff; border: none; border-radius: 6px;
55+
cursor: pointer;
56+
}
57+
</style>
58+
</head>
59+
<body>
60+
<main>
61+
<div class="flame">🔥</div>
62+
<h1>You're offline</h1>
63+
<p>This TinyTorch page isn't cached yet. Pages you've visited before are
64+
available offline — reconnect to load this one.</p>
65+
<button onclick="location.reload()">Try again</button>
66+
</main>
67+
</body>
68+
</html>`;
69+
70+
self.addEventListener('install', (event) => {
71+
event.waitUntil(
72+
caches
73+
.open(ASSET_CACHE)
74+
.then((cache) => cache.addAll(PRECACHE_URLS))
75+
.then(() => self.skipWaiting())
76+
);
77+
});
78+
79+
self.addEventListener('activate', (event) => {
80+
event.waitUntil(
81+
(async () => {
82+
const names = await caches.keys();
83+
await Promise.all(
84+
names
85+
.filter((n) => n.startsWith('tinytorch-') && n !== PAGE_CACHE && n !== ASSET_CACHE)
86+
.map((n) => caches.delete(n))
87+
);
88+
if (self.registration.navigationPreload) {
89+
await self.registration.navigationPreload.enable();
90+
}
91+
await self.clients.claim();
92+
})()
93+
);
94+
});
95+
96+
async function trimCache(cacheName, limit) {
97+
const cache = await caches.open(cacheName);
98+
const keys = await cache.keys();
99+
if (keys.length <= limit) return;
100+
// Cache keys are ordered oldest-first; drop from the front.
101+
await Promise.all(keys.slice(0, keys.length - limit).map((k) => cache.delete(k)));
102+
}
103+
104+
async function networkFirstPage(event) {
105+
const cache = await caches.open(PAGE_CACHE);
106+
try {
107+
const response = (await event.preloadResponse) || (await fetch(event.request));
108+
if (response && response.ok) {
109+
cache.put(event.request, response.clone());
110+
}
111+
return response;
112+
} catch (err) {
113+
const cached = await cache.match(event.request);
114+
if (cached) return cached;
115+
return new Response(OFFLINE_HTML, {
116+
status: 503,
117+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
118+
});
119+
}
120+
}
121+
122+
async function staleWhileRevalidate(request) {
123+
const cache = await caches.open(ASSET_CACHE);
124+
const cached = await cache.match(request);
125+
const network = fetch(request)
126+
.then((response) => {
127+
if (response && response.ok) {
128+
cache.put(request, response.clone());
129+
trimCache(ASSET_CACHE, ASSET_CACHE_LIMIT);
130+
}
131+
return response;
132+
})
133+
.catch(() => undefined);
134+
return cached || network.then((r) => r || Response.error());
135+
}
136+
137+
self.addEventListener('fetch', (event) => {
138+
const request = event.request;
139+
if (request.method !== 'GET') return;
140+
141+
const url = new URL(request.url);
142+
if (url.origin !== self.location.origin) return;
143+
144+
if (request.mode === 'navigate') {
145+
event.respondWith(networkFirstPage(event));
146+
return;
147+
}
148+
149+
const dest = request.destination;
150+
if (dest === 'style' || dest === 'script' || dest === 'image' || dest === 'font') {
151+
event.respondWith(staleWhileRevalidate(request));
152+
}
153+
// Everything else (search.json, release-manifest.json, XHR/fetch data)
154+
// goes straight to the network so dynamic data is never served stale.
155+
});

0 commit comments

Comments
 (0)