From c40407b0eb12be544c23579fe4114ef30220b68a Mon Sep 17 00:00:00 2001 From: Stefan Seeland Date: Tue, 6 Oct 2026 22:26:54 +0200 Subject: [PATCH] Make the site installable and add an Install app button A web app manifest with 192 and 512 px icons (drawn from the logo) makes browsers offer "Install app" / "Add to Home screen". The homepage shows an Install app button while a Chromium browser offers its install prompt; a client module catches that event early, because it fires before React hydrates. Safari and Firefox never fire it, so no button appears there. No service worker: the installed app needs a connection, like the site. --- docusaurus.config.ts | 12 ++++++ e2e/install.spec.ts | 37 +++++++++++++++++ src/clientModules/installPrompt.ts | 53 +++++++++++++++++++++++++ src/components/InstallButton/index.tsx | 21 ++++++++++ src/pages/index.tsx | 2 + static/img/app/icon-192.png | Bin 0 -> 863 bytes static/img/app/icon-512.png | Bin 0 -> 2129 bytes static/img/app/icon-maskable-512.png | Bin 0 -> 1896 bytes static/manifest.webmanifest | 15 +++++++ 9 files changed, 140 insertions(+) create mode 100644 e2e/install.spec.ts create mode 100644 src/clientModules/installPrompt.ts create mode 100644 src/components/InstallButton/index.tsx create mode 100644 static/img/app/icon-192.png create mode 100644 static/img/app/icon-512.png create mode 100644 static/img/app/icon-maskable-512.png create mode 100644 static/manifest.webmanifest diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 56f97a4..bdb77ab 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -46,6 +46,18 @@ const config: Config = { }, ], + // Makes the site installable ("Add to Home screen" / "Install app"): the manifest names + // the app and its icons. There is deliberately no service worker, so the installed app + // needs a connection, like the site itself. + headTags: [ + { tagName: 'link', attributes: { rel: 'manifest', href: '/manifest.webmanifest' } }, + { tagName: 'meta', attributes: { name: 'theme-color', content: '#1b1b1d' } }, + { tagName: 'link', attributes: { rel: 'apple-touch-icon', href: '/img/app/icon-192.png' } }, + ], + + // Catches the browser's install offer for the homepage's "Install app" button. + clientModules: ['./src/clientModules/installPrompt.ts'], + presets: [ [ 'classic', diff --git a/e2e/install.spec.ts b/e2e/install.spec.ts new file mode 100644 index 0000000..8ec4e3a --- /dev/null +++ b/e2e/install.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; + +test('links a web app manifest with installable icons', async ({ page, request }) => { + await page.goto('/'); + + await expect(page.locator('link[rel="manifest"]')).toHaveAttribute('href', '/manifest.webmanifest'); + + const manifest = await (await request.get('/manifest.webmanifest')).json(); + expect(manifest.display).toBe('standalone'); + for (const size of ['192x192', '512x512']) { + const icon = manifest.icons.find((candidate: { sizes: string }) => candidate.sizes === size); + expect((await request.get(icon.src)).status()).toBe(200); + } +}); + +test('shows the install button only while the browser offers to install', async ({ page }) => { + await page.goto('/'); + const button = page.getByRole('button', { name: 'Install app' }); + await expect(button).toHaveCount(0); + + // Browsers only fire the real event for a site they consider installable, so stand in for it. + await page.evaluate(() => { + const offer = new Event('beforeinstallprompt', { cancelable: true }); + Object.assign(offer, { + prompt: () => { + document.body.dataset.installPrompted = 'yes'; + return Promise.resolve(); + }, + }); + window.dispatchEvent(offer); + }); + await expect(button).toBeVisible(); + + await button.click(); + await expect(page.locator('body')).toHaveAttribute('data-install-prompted', 'yes'); + await expect(button).toHaveCount(0); +}); diff --git a/src/clientModules/installPrompt.ts b/src/clientModules/installPrompt.ts new file mode 100644 index 0000000..fa10d96 --- /dev/null +++ b/src/clientModules/installPrompt.ts @@ -0,0 +1,53 @@ +import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; + +/** + * Chromium browsers announce that the site can be installed through a single + * `beforeinstallprompt` event, usually right after the page loads and before React has + * hydrated. This client module runs as soon as the bundle does, keeps the event, and lets + * the InstallButton component ask for it later, on any page and after any navigation. + * Safari and Firefox never fire the event, so nothing is ever offered there. + */ +export interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; +} + +type Listener = (available: boolean) => void; + +let deferred: BeforeInstallPromptEvent | null = null; +const listeners = new Set(); + +function notify(): void { + listeners.forEach((listener) => listener(deferred !== null)); +} + +/** Calls `listener` now and whenever the install offer appears or goes away. */ +export function onInstallAvailable(listener: Listener): () => void { + listeners.add(listener); + listener(deferred !== null); + return () => { + listeners.delete(listener); + }; +} + +/** Raises the browser's install dialog. An offer can be used once. */ +export function promptInstall(): void { + const prompt = deferred; + deferred = null; + notify(); + prompt?.prompt().catch(() => { + // Already used or blocked: the browser's own menu still offers installing. + }); +} + +if (ExecutionEnvironment.canUseDOM) { + window.addEventListener('beforeinstallprompt', (event) => { + // The button replaces the browser's own mini-infobar. + event.preventDefault(); + deferred = event as BeforeInstallPromptEvent; + notify(); + }); + window.addEventListener('appinstalled', () => { + deferred = null; + notify(); + }); +} diff --git a/src/components/InstallButton/index.tsx b/src/components/InstallButton/index.tsx new file mode 100644 index 0000000..3d265e1 --- /dev/null +++ b/src/components/InstallButton/index.tsx @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; +import { onInstallAvailable, promptInstall } from "../../clientModules/installPrompt"; + +/** + * "Install app": rendered only while the browser offers to install the site (see + * clientModules/installPrompt.ts), so it never shows in Safari or Firefox, or once the + * site is installed. + */ +export default function InstallButton(): JSX.Element | null { + const [available, setAvailable] = useState(false); + useEffect(() => onInstallAvailable(setAvailable), []); + + if (!available) { + return null; + } + return ( + + ); +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index d6fef05..39eca08 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -3,6 +3,7 @@ import Link from "@docusaurus/Link"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import Layout from "@theme/Layout"; import HomepageFeatures from "../components/HomepageFeatures"; +import InstallButton from "../components/InstallButton"; import Heading from "@theme/Heading"; import styles from "./index.module.css"; @@ -17,6 +18,7 @@ function HomepageHeader() {

{siteConfig.tagline}

+ {/* diff --git a/static/img/app/icon-192.png b/static/img/app/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..07f4932a5de51da9c20318b3a6715b737b21c21c GIT binary patch literal 863 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE8Azrw%`pX1p96eCT&1OD^%Paq zrhfu%2PCu3@0AVY#J!n7)yWv}~xJQH`}jm9;~k zqkE;5L!FIdt+nGKcYjM&btes-md$fk0lm*z;1OBOz`*q4HOLkvVJ>S>x zcgtVf|C8Td_vlx$yz{o}Z;2F4Eu3C(m_gg~cW~1GlV710+t^4*P+xd;{#)~tj zOsNjeFG&l1nx@)wZc70>^QxkTQqcp8WD*=%4OG}qFkhGvWnyK%{K+%7-g9?NtrNWX zF7P`fm)&}CXTj9japqR#GoHTVmzW#<#F4>xBl9V(2Bn6$-u0aE?>gTe~ HDWM4f;LbTa literal 0 HcmV?d00001 diff --git a/static/img/app/icon-512.png b/static/img/app/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..b850929d35e5d9eb2f67e05150c23711854145db GIT binary patch literal 2129 zcmb_bdr(tX9zOSyND>N-JVb<$o5xK;2qXk5B3Oe2LPFjTLI5$eOrZsl(Uq!Hx$8mNHid*a%?Fx`NBR8#AT36%H9b6>b;e+i=ivqNg(-rl zy3~xrD#KUS{5URtT9@6fwdBbX8kL&k8dIG@C17)s@hBM=kKyn*5$sMwPBg|_A`nM} zv)&X$g)fL$$QKGDIZ_RM{?S-%px{d%Hhj-cw|H*JAy4_WBf?$oH1uH zE=s~h7tFa54o{5n`Yd^U<~&w7E0)7&M?}iGcsx&#gbQ&rqkfsHIa8l1jNYEA^5|_}=xir-Hn+}}9wkvmNsg!u zJC|i1&NQgRF=s`4ZoxaFS7bWY0N^u9{>YbA=28IWWUG~_)^&Hr{!(1kU<>~6I!326 z{qe0gy?LWs>P0r@XXfBvx_=$6dUk75kZSV6?IhJu@rfzF-RsArgJP#Y3Y!ld|KCQ* zx5;VM>()N;w4MH4XjeU(_%yX^=B+b$tN)!_i8`vR-0RvjS5bbAj&-cjQ7gV_UfzAz z3iiEr=hg!q)jia_d?QZW+f*(leCCWj{M%9VjMM8XSS2Pd!sV``=%NxM;vKjIC+~S( zBuWE@m3umXO7vn{pjzzr)%hCcIP#4@jII8;&ou_vTAu&(kCJ%Hlf zd1>C*=vlD(SxY;pbbRrN;SR&uot`{X06LcpXM-xqfn>rTQo20oqQdL-?{tLp>gw(X zI9t-GiXT9IUc&p}GRfIHF9g*33>IC#hy2n36UcVVCUE6&r_W#s1SO{-D3FLjps)LD z2M3nTjohR;RZZ6-nuLV-#S!}CzOQ5b znl+&Cko0*ju%WccbwdX9SfHZm@;8U(D)xfDl!EC~;5dbOf8tak)rb?a*Y~AJ077g2 z0E%Kaq>-XExtr2w#T5g0(f)q~jhGNC-Pr+F`!etUb@w$2DQo?i&A{FVJLoXlBg)R? zS%h3zOz3Y4s2&W*315+3Pthc-gyy{f)ipyn?WB@ z!$>2>1`rttll73~$-|V#TVQCJ9iU7CP%xOR;^pbau;5q0C{s}n#zS7U^)7nG@UjQA zWC>ARJl%HX(eI`3xUWobSM>i}gfORsy*tvV5#^=l9{8Q(7rrTN-a53By0bLp-!g!C zrBeyO1gHYg0D=L^%ZvXczoLj&m-fG+qt~{FQOj0F6BDR(pizWeVIBQ&{Q&yg$|GS? z`pAl$2cZWFhKA7$^4^}JqiZukr%zL`VjN84e$IAy*zP>g{Vm_m`A4wfUr*re+M3V^X-8mL=vbtF?+YeMMzmFB-PWJZ(C1+Mn7zHd0`9qj)slaYy%Wy-xjd> zP-Y0jxgTtf%nW8YKY<58Yz>vyp!0*EE}M>x)>>P@G!(wr5z27x0hKb$rhiDd>YtTWAfG>Hj!W$HY+@w3X4)BP|e4;#V z1YKm6mq;Htu^8D#hdtG{o9b4=n&&eW-tZ)nZ*hT28uqZ(x*vELu)S#Od3FJD5Z$_g zw58B;ECOPZY*!QV{E`l~^N7*~0vOoC?fLsbfFJZ4N?}fAxU|5Rw0f`>9o!pDTqPwq zqFk~|9Ml^_zJh#UTJ;mz##WjWvx8Rh9fZLE{LZq+o;2Q#1s@fp?LeP2J--nxe`Cz= j)CV)6MHAq^9xwKOkKCXKzE&ODr6EP?biLBG%(?lWr_`QB literal 0 HcmV?d00001 diff --git a/static/img/app/icon-maskable-512.png b/static/img/app/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..2881ca2249ed0606a5a6c2d20df7514c1bbb4682 GIT binary patch literal 1896 zcmcIjYfw{16yBQvQk4#0|nGl0Uy+>ol-2+ z*0EL`tymvuRWJyR(u$^HAGY>2B1D2VT12OXO2vsz5$&e^(;xomOs6|@_wJtao$s9e z_Utx`*$^?6G!+04Va(Fm03g660fZCbucsyMD*%wkmYh6&Y%HZ(seV(VV^U~RCfhEO ztxHi~%3E+2bH1-Lby=KwB59L8yF{L>;>2h21UjAoS7o&3*c;O4a%hZnt{{=cNs8kd z;zcL2ZK>?|6gD?Gj*G@|m2BQ(SyGWKNyCj_l8Cx;>d$Nii==34w*7*&K*(U(MbZR1OF(DY6QtGzX-Q&ob&6(Pisqud(3O*S z(N;K5EZd%8=)@eGQnedWwH;>1dZl*1!Q7eaaOFA@S!^1as*4wvB`H@XDPL2iA2wnK zjo2!MvQ(jbHzTVyHT_@~#-PyX6j}wEdIob|u;rh(=6_}{bYt^=7w{~w6<%1Am0t+} z@m}ylKHg|s1wiO*qb@zKW?=M&v)Z92e{t*3VRHH*?Tkpm)ELt&i#2Li)L|a4-n;RB zceUO%XJp}}r;4=^U1QACeboMe@AfR_*Ynwql+b3$>Bf`CXyJI}xSCxKyf*m&2y|7@sXd5!P zY-%C_60#en`0D~qfh;I#a-WRE!Pk|i ziU%Gn-GQiHURve=BgPRKIQ=>ru`WK)gm~m!sN(@fQo^afQY+BLeMMi=&-zENq zS`?&N4JU2Cq@CDQ5re$@^Y)n}W&2m)sVYF5ygFjsyWG6t$A^YV?~~^4wqn_hVZ+JN z8r`@-c=LMk#L(X1U-$Kn)i~Dv;x25#;9+T zJoTeH4F|(q{ejEF$B=zx6GJsfxE2tg`XO}i+$ByTCVw6vJRjt5>I*2wOUGHWQPu6= zh7FsRP7H!y{L|L-)@;s1XQ$l#qox1e=Dq;sow*DUf`do^iNXOw2O4bT>fq}i+F#hF%{+@4Y<;O!`dS~wF$F&szb3{Q&Dv4e( z?nQ)cOeoVFkdLVB`k-#Ho^eNrqyiqve1eJVp=%4#TLQO55IO4pKEnTyPl!3)9qd1q z?{3(d^C0Ah2}IZlndd-RyJ?=Vpt&jv^iQK#fxGjfJWZoZrno$TE@U4I zAP-*)c0jBrA3l=%Q(RvM+JoIO2yUKY;#SDv$^nFXlYkS%H1Y|LFG7HK2?PWjQQBsB zf_rncBcSLM6KH1aqIjbJrnw5)&pGtKIiN=IqoI+h_}$~-{^`M*Hb*1piebDc5DG7! zFl8(Q8kRu=^*9qp%V8R#BgFMNBq%${#IwuAK>0y?SEZq@MLvM;x^?ST^qm_o-(Iv@ nD!s<8dRp6X#61Q4w?Y4eW!RWEd4{>BIY?yGn{{m&i?{v>Djb8! literal 0 HcmV?d00001 diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest new file mode 100644 index 0000000..e46523e --- /dev/null +++ b/static/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "codeuctivity", + "short_name": "codeuctivity", + "description": "Turning Coffee into Code: A Journey of Joyful Creation", + "start_url": "/", + "scope": "/", + "display": "standalone", + "theme_color": "#1b1b1d", + "background_color": "#1b1b1d", + "icons": [ + { "src": "/img/app/icon-192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" }, + { "src": "/img/app/icon-512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" }, + { "src": "/img/app/icon-maskable-512.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" } + ] +}