CINXE.COM

Building a Progressive Web App To Practice Japanese Numbers • ~/osc

<!doctype html> <html lang="en" data-theme="light"> <head> <meta charset="UTF-8"><meta http-equiv="Content-Security-Policy" content="default-src 'self';font-src &#x27;self&#x27; data:;img-src &#x27;self&#x27; https:&#x2F;&#x2F;* data:;media-src &#x27;self&#x27; https:&#x2F;&#x2F;cdn.jsdelivr.net&#x2F;;style-src &#x27;self&#x27;;frame-src player.vimeo.com https:&#x2F;&#x2F;www.youtube-nocookie.com;connect-src 'self' https://stats.osc.garden https://osc.garden/comments/;script-src 'self' https://stats.osc.garden https://osc.garden/comments/ 'self'"> <meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="base" content="https://osc.garden"> <title>Building a Progressive Web App To Practice Japanese Numbers • ~/osc</title> <link rel="icon" type="image/png" href="https://osc.garden/img/seedling.png"> <link rel="icon" href='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="50%" x="50%" dominant-baseline="central" text-anchor="middle" font-size="88">🌱</text></svg>'> <link rel="alternate" type="application/atom+xml" title="~/osc - Atom Feed" href="https://osc.garden/atom.xml"> <link rel="stylesheet" href="https://osc.garden/custom_subset.css?h=8a40c2c7313f91484db9"> <link rel="stylesheet" href="https://osc.garden/main.css?h=5761abda13287149cf75"> <link rel="stylesheet" href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/css/styles.css?h=f1cd2d54228c1863fe39"> <meta name="color-scheme" content="light dark"> <meta name="theme-color" content="#087e96"> <meta name="description" content="I built a web app to practice reading and listening to Japanese numbers. In the process, I learnt about testing vanilla JavaScript, creating PWAs, accessibility, browser inconsistencies, and automatic cache busting without frameworks."> <meta property="og:description" content="I built a web app to practice reading and listening to Japanese numbers. In the process, I learnt about testing vanilla JavaScript, creating PWAs, accessibility, browser inconsistencies, and automatic cache busting without frameworks."> <meta property="og:title" content="Building a Progressive Web App To Practice Japanese Numbers"> <meta property="og:type" content="article"> <meta property="og:image" content="https://osc.garden/img/social_cards/blog_ramu_japanese_numbers_practice_webapp.jpg?h=00b3b2db44a885c08cea"> <meta property="og:image:width" content="1792"> <meta property="og:image:height" content="1024"> <meta name="twitter:image" content="https://osc.garden/img/social_cards/blog_ramu_japanese_numbers_practice_webapp.jpg?h=00b3b2db44a885c08cea"> <meta name="twitter:card" content="summary_large_image"> <meta property="og:locale:alternate" content="ca_ES"> <link rel="alternate" hreflang="ca" href="https://osc.garden/ca/blog/ramu-japanese-numbers-practice-web-app/"><meta property="og:locale:alternate" content="en_GB"> <link rel="alternate" hreflang="en" href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/"><meta property="og:locale:alternate" content="es_ES"> <link rel="alternate" hreflang="es" href="https://osc.garden/es/blog/ramu-japanese-numbers-practice-web-app/"> <meta property="og:url" content="https:&#x2F;&#x2F;osc.garden&#x2F;blog&#x2F;ramu-japanese-numbers-practice-web-app&#x2F;"><meta property="og:site_name" content="~&#x2F;osc"> <noscript><link rel="stylesheet" href="https://osc.garden/no_js.css"></noscript> <script type="text/javascript" src="https://osc.garden/js/initializeTheme.min.js"></script> <script defer="defer" src="https://osc.garden/js/themeSwitcher.min.js"></script> <script async data-goatcounter="https://stats.osc.garden/count" src="https://stats.osc.garden/count.js"></script> <script defer="defer" src="https://osc.garden/js/searchElasticlunr.min.js?h=3626c0ef99daa745b31e"></script> </head> <body> <header> <nav class="navbar"> <div class="nav-title"> <a class="home-title" href="https://osc.garden">~&#x2F;osc</a> </div> <div class="nav-navs"> <ul> <li> <a class="nav-links no-hover-padding" href="https://osc.garden/blog/">blog </a> </li> <li> <a class="nav-links no-hover-padding" href="https://osc.garden/archive/">archive </a> </li> <li> <a class="nav-links no-hover-padding" href="https://osc.garden/notes/">notes </a> </li> <li> <a class="nav-links no-hover-padding" href="https://osc.garden/projects/">projects </a> </li> <li class="menu-icons-container"> <ul class="menu-icons-group"> <li class="js menu-icon"> <div role="button" tabindex="0" id="search-button" class="search-icon interactive-icon" title="Click or press $SHORTCUT to open search" aria-label="Click or press $SHORTCUT to open search"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"> <path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/> </svg> </div> </li> <li class="language-switcher"> <details class="dropdown"> <summary role="button" aria-haspopup="true" title="Language selection" aria-label="Language selection"> <div class="language-switcher-icon"></div> </summary> <div class="dropdown-content" role="menu">English<a role="menuitem" lang="es" aria-label="Español" href="https:&#x2F;&#x2F;osc.garden/es/blog/ramu-japanese-numbers-practice-web-app/">Español</a><a role="menuitem" lang="ca" aria-label="Català" href="https:&#x2F;&#x2F;osc.garden/ca/blog/ramu-japanese-numbers-practice-web-app/">Català</a></div> </details> </li> <li class="theme-switcher-wrapper js"><div title="Toggle dark&#x2F;light mode" class="theme-switcher" tabindex="0" role="button" aria-label="Toggle dark mode" aria-pressed="false"> </div><div title="Reset mode to default" class="theme-resetter arrow" tabindex="0" role="button" aria-hidden="true" aria-label="Reset mode to default"> </div> </li> </ul> </li> </ul> </div> </nav> </header> <div class="content"> <main> <article> <h1 class="article-title"> Building a Progressive Web App To Practice Japanese Numbers </h1> <ul class="meta"> <li>5th Nov 2024</li><li title="2435 words"><span class="separator" aria-hidden="true">•</span>13 min read</li><li class="tag"><span class="separator" aria-hidden="true">•</span>Tags:&nbsp;</li><li class="tag"><a href="https://osc.garden/tags/code/">code</a>,&nbsp;</li><li class="tag"><a href="https://osc.garden/tags/japanese/">japanese</a>,&nbsp;</li><li class="tag"><a href="https://osc.garden/tags/linguistics/">linguistics</a>,&nbsp;</li><li class="tag"><a href="https://osc.garden/tags/javascript/">javascript</a>,&nbsp;</li><li class="tag"><a href="https://osc.garden/tags/web-app/">web app</a></li> </ul> <div class="admonition note"> <div class="admonition-icon admonition-icon-note"></div> <div class="admonition-content"> <strong class="admonition-title"> <span title="Too long; didn't read (summary)">TL;DR</span> </strong> <p></p><p>I built <a class="external" href="https://ramu.osc.garden"><ruby>ラ<rt>ra</rt>ム<rt>mu</rt></ruby>, a web-app</a> to practice reading and listening to Japanese numbers. I discover browser inconsistencies, how to test vanilla JavaScript, and how to make a Progressive Web App. <a class="external" href="https://github.com/welpo/ramu">Repository</a> and <a class="external" href="https://github.com/welpo/ramu?tab=readme-ov-file#demo">video demo</a>.</p> <p></p> </div> </div> <section class="body"><details> <summary>Table of contents</summary> <div class="toc-container"> <ul> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#the-idea">The idea</a> </li> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#testing-vanilla-javascript">Testing vanilla JavaScript</a> </li> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#accessibility">Accessibility</a> </li> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#browser-inconsistencies">Browser inconsistencies</a> <ul> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#debugging-on-mobile">Debugging on mobile</a> </li> </ul> </li> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#cache-busting">Cache busting</a> </li> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#progressive-web-app">Progressive Web App</a> <ul> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#respectful-user-prompts">Respectful user prompts</a> </li> </ul> </li> </ul> </div> </details> <p>Numbers were one of the first things I learnt in Japanese. Learning to count from one to ten is not hard. If you know 1-10, you know 1-99: you say how many tens there are, then the unit. For example, forty-two is “<ruby>four<rt>四</rt></ruby> <ruby>ten<rt>十</rt></ruby> <ruby>two<rt>二</rt></ruby>”: <ruby>四<rt>yon</rt></ruby><ruby>十<rt>juu</rt></ruby><ruby>二<rt>ni</rt></ruby>.</p> <p>Beyond 99, you need to learn the name for 100 (<ruby>百<rt>hyaku</rt></ruby>), 1,000 (<ruby>千<rt>sen</rt></ruby>), and 10,000 (<ruby>万<rt>man</rt></ruby>). After 10,000, numbers are created by grouping digits into myriads (every 10,000). This means 40,000 is not “forty thousands” (<span class="strike-through"><ruby>四<rt>yon</rt></ruby><ruby>十<rt>juu</rt></ruby><ruby>千<rt>sen</rt></ruby></span>), but “four ten-thousands” (<ruby>四<rt>yon</rt></ruby><ruby>万<rt>man</rt></ruby>). 100,000 is <ruby>十<rt>juu</rt></ruby><ruby>万<rt>man</rt></ruby>: “ten ten-thousands”. Every myriad gets a new unit:</p> <div class="container full-width" id="myriads"> <div class="number-chain"> <div class="number-unit"> <div class="reading">ichi</div> <div class="kanji">一</div> <div class="western" data-full="1">1</div> </div> <div class="number-unit"> <div class="reading">man</div> <div class="kanji">万</div> <div class="western" data-full="1,0000">10<sup>4</sup></div> </div> <div class="number-unit"> <div class="reading">oku</div> <div class="kanji">億</div> <div class="western" data-full="1,0000,0000">10<sup>8</sup></div> </div> <div class="number-unit"> <div class="reading">chō</div> <div class="kanji">兆</div> <div class="western" data-full="1,0000,0000,0000">10<sup>12</sup></div> </div> <div class="number-unit"> <div class="reading">kei</div> <div class="kanji">京</div> <div class="western" data-full="1,0000,0000,0000,0000">10<sup>16</sup></div> </div> <div class="number-unit"> <div class="reading">gai</div> <div class="kanji">垓</div> <div class="western" data-full="1,0000,0000,0000,0000,0000">10<sup>20</sup></div> </div> <div class="number-unit"> <div class="reading">shi</div> <div class="kanji">秭</div> <div class="western" data-full="1,0000,0000,0000,0000,0000,0000">10<sup>24</sup></div> </div> <div class="number-unit"> <div class="reading">jō</div> <div class="kanji">穣</div> <div class="western" data-full="1,0000,0000,0000,0000,0000,0000,0000">10<sup>28</sup></div> </div> <div class="number-unit"> <div class="reading">muryōtaisū</div> <div class="kanji">無量大数</div> <div class="western" data-full="1,0000,0000,0000,0000,0000,0000,0000,0000,0000,0000,0000,0000,0000,0000,0000,0000,0000">10<sup>68</sup></div> </div> </div> <div class="note">Note: There are more units between <ruby>穣<rt>jō</rt></ruby> and <ruby>無量大数<rt>muryōtaisū</rt></ruby> (a number with Buddhist origins that translates to "immeasurable large number"). See <a href="https://en.wikipedia.org/wiki/Japanese_numerals#Large_numbers">Japanese numerals (Wikipedia)</a> for the complete list.</div> </div> <p>But that’s not all. Japanese, like Korean and Chinese, has “counters”: nouns added as suffix to numbers. They indicate what that number refers to: books (<ruby>冊<rt>satsu</rt></ruby>), people (<ruby>人<rt>ri/nin</rt></ruby> or <ruby>名<rt>mei</rt></ruby>), machinery (<ruby>台<rt>dai</rt></ruby>), sentences (<ruby>文<rt>bun</rt></ruby>), years of age (<ruby>歳<rt>sai</rt></ruby>), countries (<ruby>箇国<rt>kakoku</rt></ruby>), locations (<ruby>箇所<rt>kasho</rt></ruby>), cans (<ruby>缶<rt>kan</rt></ruby>)…</p> <p>An example: <span class="number">three</span> is <span class="no-wrap"><ruby class="number">三<rt>san</rt></ruby></span>. <span class="noun">Frog</span> is <span class="no-wrap"><ruby class="noun">カ<rt>ka</rt></ruby><ruby class="noun">エ<rt>e</rt></ruby><ruby class="noun">ル<rt>ru</rt></ruby></span>. You’d think “<span class="number">three</span> <span class="noun">frogs</span>” is <span class="no-wrap"><ruby class="number">三<rt>san</rt></ruby><ruby class="noun">カ<rt>ka</rt></ruby><ruby class="noun">エ<rt>e</rt></ruby><ruby class="noun">ル<rt>ru</rt></ruby></span> or <span class="no-wrap"><ruby class="noun">カ<rt>ka</rt></ruby><ruby class="noun">エ<rt>e</rt></ruby><ruby class="noun">ル<rt>ru</rt></ruby><ruby class="number">三<rt>san</rt></ruby></span>. But that’s not right; you’re missing the counter (and the possessive <ruby>の<rt>no</rt></ruby>). In this case, we’d use the counter for small animals: <ruby class="counter">匹<rt>hiki</rt></ruby>. “<span class="number">Three</span> <span class="noun">frogs</span>” becomes “<span class="number">three</span> <span class="counter">small-animal</span>’s <span class="noun">frog</span>”: <span class="phrase"><ruby class="number">三<rt>san</rt></ruby><ruby class="counter">匹<rt>biki</rt></ruby><ruby>の<rt>no</rt></ruby><ruby class="noun">カ<rt>ka</rt></ruby><ruby class="noun">エ<rt>e</rt></ruby><ruby class="noun">ル<rt>ru</rt></ruby></span>. (“<ruby class="counter">hiki<rt>ひき</rt></ruby>” becomes “<ruby class="counter">biki<rt>びき</rt></ruby>” through <a href="https://en.wikipedia.org/wiki/Rendaku"><ruby>連<rt>ren</rt></ruby><ruby>濁<rt>daku</rt></ruby> or sequential voicing</a>.)</p> <p>There are <a class="external" href="https://www.tofugu.com/japanese/japanese-counters-list/">over 300 counters</a>, though you can get by with a couple dozen.</p> <p>All that to say: to master Japanese numbers, you need to know the name/writing of all units, be able think in groups of 10,000 instead of 1,000, and know the right counters. That takes practice.</p> <h2 id="the-idea"><a class="header-anchor no-hover-padding" href="#the-idea" aria-label="Anchor link for: the-idea"><span class="link-icon" aria-hidden="true"></span></a> The idea</h2> <p>I thought: what would the ideal practice system look like? For reading:</p> <ul> <li>I see a random number, either in Arabic (e.g., 40) or kanji (<ruby>四<rt>yon</rt></ruby><ruby>十<rt>juu</rt></ruby>) numerals, and try to read it. Optionally, with a counter</li> <li>Shortly after, I hear the right answer, while seeing the number, reinforcing the association</li> <li>Repeat</li> </ul> <p>For listening, I hear a number and try to understand it. The answer is revealed after a few seconds, visually, while I hear the number again.</p> <p>I did not want to spend more than a few hours on this; I tend to keep adding requirements to my projects, turning weekend projects into multi-month ones. I asked <a class="external" href="https://claude.ai/">Claude</a> 3.5 Sonnet (New) for help creating a web-app using HTML, CSS, and vanilla JavaScript —using frameworks I’m not familiar with would be a time-sink.</p> <p>The first prototype was decent and supported Arabic numerals, but I wanted more. It was me against my perfectionism. I decided to add support for kanji numerals. I hadn’t yet learnt about the 10,000 grouping and the names for the units, so I underestimated how long this would take. I didn’t want to add dependencies, which meant writing the functions myself —or guide Claude into writing them</p> <p>I wanted to test the <code>number → kanji</code> function. Was it getting the right kanji, in the right order, with the right prefix?</p> <h2 id="testing-vanilla-javascript"><a class="header-anchor no-hover-padding" href="#testing-vanilla-javascript" aria-label="Anchor link for: testing-vanilla-javascript"><span class="link-icon" aria-hidden="true"></span></a> Testing vanilla JavaScript</h2> <p>Manual testing was a hassle. I was tempted to give up on vanilla everything and use <a class="external" href="https://astro.build/">Astro</a>, but I asked Claude if there was a way to add tests to my vanilla JavaScript. It came up with this snippet:</p> <pre data-lang="js" class="language-js z-code"><code class="language-js" data-lang="js"><span class="z-source z-ts"><span class="z-keyword z-control z-conditional z-ts">if</span> <span class="z-meta z-brace z-round z-ts">(</span><span class="z-new z-expr z-ts"><span class="z-keyword z-operator z-new z-ts">new</span> <span class="z-entity z-name z-type z-ts">URLSearchParams</span><span class="z-meta z-brace z-round z-ts">(</span><span class="z-support z-variable z-dom z-ts">window</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-support z-variable z-property z-dom z-ts">location</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-variable z-other z-property z-ts">search</span><span class="z-meta z-brace z-round z-ts">)</span></span><span class="z-meta z-function-call z-ts"><span class="z-punctuation z-accessor z-ts">.</span><span class="z-support z-function z-dom z-ts">has</span></span><span class="z-meta z-brace z-round z-ts">(</span><span class="z-string z-quoted z-double z-ts"><span class="z-punctuation z-definition z-string z-begin z-ts">&quot;</span>test<span class="z-punctuation z-definition z-string z-end z-ts">&quot;</span></span><span class="z-meta z-brace z-round z-ts">)</span><span class="z-meta z-brace z-round z-ts">)</span> <span class="z-meta z-block z-ts"><span class="z-punctuation z-definition z-block z-ts">{</span> </span></span><span class="z-source z-ts"><span class="z-meta z-block z-ts"> <span class="z-meta z-var z-expr z-ts"><span class="z-storage z-type z-ts">const</span> <span class="z-meta z-var-single-variable z-expr z-ts"><span class="z-meta z-definition z-variable z-ts"><span class="z-variable z-other z-constant z-ts">script</span></span> </span><span class="z-keyword z-operator z-assignment z-ts">=</span> <span class="z-meta z-function-call z-ts"><span class="z-support z-variable z-dom z-ts">document</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-support z-function z-dom z-ts">createElement</span></span><span class="z-meta z-brace z-round z-ts">(</span><span class="z-string z-quoted z-double z-ts"><span class="z-punctuation z-definition z-string z-begin z-ts">&quot;</span>script<span class="z-punctuation z-definition z-string z-end z-ts">&quot;</span></span><span class="z-meta z-brace z-round z-ts">)</span></span><span class="z-punctuation z-terminator z-statement z-ts">;</span> </span></span><span class="z-source z-ts"><span class="z-meta z-block z-ts"> <span class="z-variable z-other z-object z-ts">script</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-variable z-other z-property z-ts">src</span> <span class="z-keyword z-operator z-assignment z-ts">=</span> <span class="z-string z-quoted z-double z-ts"><span class="z-punctuation z-definition z-string z-begin z-ts">&quot;</span>tests.js<span class="z-punctuation z-definition z-string z-end z-ts">&quot;</span></span><span class="z-punctuation z-terminator z-statement z-ts">;</span> </span></span><span class="z-source z-ts"><span class="z-meta z-block z-ts"> <span class="z-meta z-function-call z-ts"><span class="z-support z-variable z-dom z-ts">document</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-variable z-other z-object z-property z-ts">head</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-support z-function z-dom z-ts">appendChild</span></span><span class="z-meta z-brace z-round z-ts">(</span><span class="z-variable z-other z-readwrite z-ts">script</span><span class="z-meta z-brace z-round z-ts">)</span><span class="z-punctuation z-terminator z-statement z-ts">;</span> </span></span><span class="z-source z-ts"><span class="z-meta z-block z-ts"><span class="z-punctuation z-definition z-block z-ts">}</span></span> </span></code></pre> <p>With this, visiting the main page with <code>?test</code> at the end would run <a class="external" href="https://github.com/welpo/ramu/blob/main/app/tests.js"><code>tests.js</code></a>. The results of the (&gt;100) tests would be shown on the browser’s console:</p> <pre data-lang="txt" class="language-txt z-code"><code class="language-txt" data-lang="txt"><span class="z-text z-plain">running kanji conversion tests… </span><span class="z-text z-plain">✅ Passed: 0 → 零 </span><span class="z-text z-plain">✅ Passed: 1 → 一 </span><span class="z-text z-plain">✅ Passed: 5 → 五 </span><span class="z-text z-plain">✅ Passed: 1000 → 千 </span><span class="z-text z-plain">✅ Passed: 2036521801 → 二十億三千六百五十二万千八百一 </span><span class="z-text z-plain">✅ Passed: 100000000000000000000 → 一垓 </span><span class="z-text z-plain">✅ Passed: 1e+24 → 一秭 </span><span class="z-text z-plain">✅ Passed: 1e+68 → 一無量大数 </span><span class="z-text z-plain">✅ Passed: -1 → -1 </span><span class="z-text z-plain">✅ Passed: NaN → NaN </span></code></pre> <p>Pretty neat! Now I could update the main script, reload the browser with the <code>?test</code> flag, and see whether the changes fixed the failing tests.</p> <p>Determining what the expected output should be took a significant amount of time and learning. After some reading and code iterations, all tests were passing.</p> <h2 id="accessibility"><a class="header-anchor no-hover-padding" href="#accessibility" aria-label="Anchor link for: accessibility"><span class="link-icon" aria-hidden="true"></span></a> Accessibility</h2> <p>The original code was passably accessible: it used semantic HTML. I added:</p> <ul> <li>Keyboard navigation</li> <li>Aria attributes with dynamic updates where needed (e.g. pause button toggle)</li> <li>Screen reader compatibility</li> </ul> <p>This last one was hard. First, I needed to decide how to adapt the practice flow for people with vision impairment. I used a big font and strong contrast. For Arabic numerals practice, I added screen reader support through <a class="external" href="https://developer.mozilla.org/docs/Web/Accessibility/ARIA/Attributes/aria-live"><code>aria-live</code></a>: updates to a hidden element would be announced by screen readers:</p> <pre data-lang="html" class="language-html z-code"><code class="language-html" data-lang="html"><span class="z-text z-html z-basic"><span class="z-meta z-tag z-block z-any z-html"><span class="z-punctuation z-definition z-tag z-begin z-html">&lt;</span><span class="z-entity z-name z-tag z-block z-any z-html">div</span> </span></span><span class="z-text z-html z-basic"><span class="z-meta z-tag z-block z-any z-html"> <span class="z-meta z-attribute-with-value z-id z-html"><span class="z-entity z-other z-attribute-name z-id z-html">id</span><span class="z-punctuation z-separator z-key-value z-html">=</span><span class="z-string z-quoted z-double z-html"><span class="z-punctuation z-definition z-string z-begin z-html">&quot;</span></span><span class="z-string z-quoted z-double z-html"><span class="z-meta z-toc-list z-id z-html">screen-reader-announcement</span><span class="z-punctuation z-definition z-string z-end z-html">&quot;</span></span></span> </span></span><span class="z-text z-html z-basic"><span class="z-meta z-tag z-block z-any z-html"> <span class="z-meta z-attribute-with-value z-class z-html"><span class="z-entity z-other z-attribute-name z-class z-html">class</span><span class="z-punctuation z-separator z-key-value z-html">=</span><span class="z-string z-quoted z-double z-html"><span class="z-punctuation z-definition z-string z-begin z-html">&quot;</span></span><span class="z-string z-quoted z-double z-html"><span class="z-meta z-class-name z-html">visually-hidden</span><span class="z-punctuation z-definition z-string z-end z-html">&quot;</span></span></span> </span></span><span class="z-text z-html z-basic"><span class="z-meta z-tag z-block z-any z-html"> <span class="z-meta z-attribute-with-value z-html"><span class="z-entity z-other z-attribute-name z-html">aria-live</span><span class="z-punctuation z-separator z-key-value z-html">=</span><span class="z-string z-quoted z-double z-html"><span class="z-punctuation z-definition z-string z-begin z-html">&quot;</span>polite<span class="z-punctuation z-definition z-string z-end z-html">&quot;</span></span></span> </span></span><span class="z-text z-html z-basic"><span class="z-meta z-tag z-block z-any z-html"> <span class="z-meta z-attribute-with-value z-html"><span class="z-entity z-other z-attribute-name z-html">aria-atomic</span><span class="z-punctuation z-separator z-key-value z-html">=</span><span class="z-string z-quoted z-double z-html"><span class="z-punctuation z-definition z-string z-begin z-html">&quot;</span>true<span class="z-punctuation z-definition z-string z-end z-html">&quot;</span></span></span><span class="z-punctuation z-definition z-tag z-end z-html">&gt;</span></span> </span><span class="z-text z-html z-basic"><span class="z-meta z-tag z-block z-any z-html"><span class="z-punctuation z-definition z-tag z-begin z-html">&lt;/</span><span class="z-entity z-name z-tag z-block z-any z-html">div</span><span class="z-punctuation z-definition z-tag z-end z-html">&gt;</span></span> </span></code></pre> <p>The app has two modes: reading and listening practice. In reading practice, you’re shown a number for a few seconds. You need to say/think of the Japanese pronunciation before the answer is heard. For screen reader users, I ensured the “reading” part was read aloud, whilst letting the Japanese text-to-speech (TTS) handle the answer part. This is what it sounds like:</p> <div class="audio-container"> <audio controls src="/blog/ramu-japanese-numbers-practice-web-app/media/voiceover_demo.mp3" title="Screen reader demo"></audio> <span class="audio-note">(the speed of the voices can be adjusted)</span> </div> <p>For the “listening” practice, the flow is inverted. In this case, since JavaScript can’t detect screen reader usage, I can’t silence the TTS for the answer. This means the Japanese voice and the answer (in the user’s language) overlap. Since voice-over has priority, it’s louder. While not ideal, it’s usable enough.</p> <h2 id="browser-inconsistencies"><a class="header-anchor no-hover-padding" href="#browser-inconsistencies" aria-label="Anchor link for: browser-inconsistencies"><span class="link-icon" aria-hidden="true"></span></a> Browser inconsistencies</h2> <p>I expected there would be one (1) way to get the voices from the system, with consistent results across browsers.</p> <p>Nope. Chrome initialises voices differently than Firefox/Safari. On the same system with two Japanese voices installed, Firefox gets both voices, Chrome gets four voices, and Safari gets one —lower quality— voice (well, two, but they sound identical).</p> <p>Safari requires user interaction shortly before allowing a TTS utterance, but only on mobile. I worked around this by uttering “<ruby>あ<rt>a</rt></ruby>” at 0 volume right after pressing “start” in reading mode on iOS devices.</p> <h3 id="debugging-on-mobile"><a class="header-anchor no-hover-padding" href="#debugging-on-mobile" aria-label="Anchor link for: debugging-on-mobile"><span class="link-icon" aria-hidden="true"></span></a> Debugging on mobile</h3> <p>I couldn’t get the Android emulator to find the Japanese voices. Since I had no access to the browser’s console, I asked Claude:</p> <blockquote> <p>How can we debug without access to console? Maybe create a div and populate it through appending paragraphs?</p> </blockquote> <p>It returned code to create a styled div and a function to log to it:</p> <pre data-lang="javascript" class="language-javascript z-code"><code class="language-javascript" data-lang="javascript"><span class="z-source z-ts"><span class="z-meta z-var z-expr z-ts"><span class="z-storage z-type z-ts">const</span> <span class="z-meta z-var-single-variable z-expr z-ts"><span class="z-meta z-definition z-variable z-ts"><span class="z-variable z-other z-constant z-ts">debugPanel</span></span> </span><span class="z-keyword z-operator z-assignment z-ts">=</span> <span class="z-meta z-function-call z-ts"><span class="z-support z-variable z-dom z-ts">document</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-support z-function z-dom z-ts">createElement</span></span><span class="z-meta z-brace z-round z-ts">(</span><span class="z-string z-quoted z-single z-ts"><span class="z-punctuation z-definition z-string z-begin z-ts">&#39;</span>div<span class="z-punctuation z-definition z-string z-end z-ts">&#39;</span></span><span class="z-meta z-brace z-round z-ts">)</span></span><span class="z-punctuation z-terminator z-statement z-ts">;</span> </span><span class="z-source z-ts"><span class="z-variable z-other z-object z-ts">debugPanel</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-support z-variable z-property z-dom z-ts">id</span> <span class="z-keyword z-operator z-assignment z-ts">=</span> <span class="z-string z-quoted z-single z-ts"><span class="z-punctuation z-definition z-string z-begin z-ts">&#39;</span>debug-panel<span class="z-punctuation z-definition z-string z-end z-ts">&#39;</span></span><span class="z-punctuation z-terminator z-statement z-ts">;</span> </span><span class="z-source z-ts"><span class="z-variable z-other z-object z-ts">debugPanel</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-support z-variable z-property z-dom z-ts">style</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-variable z-other z-property z-ts">cssText</span> <span class="z-keyword z-operator z-assignment z-ts">=</span> <span class="z-string z-quoted z-single z-ts"><span class="z-punctuation z-definition z-string z-begin z-ts">&#39;</span>position: fixed; bottom: 10px; left: 10px; background: rgba(0,0,0,0.8); color: white; padding: 10px; max-height: 200px; overflow-y: auto; font-family: monospace; font-size: 12px; z-index: 9999;<span class="z-punctuation z-definition z-string z-end z-ts">&#39;</span></span><span class="z-punctuation z-terminator z-statement z-ts">;</span> </span><span class="z-source z-ts"><span class="z-meta z-function-call z-ts"><span class="z-support z-variable z-dom z-ts">document</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-support z-variable z-property z-dom z-ts">body</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-support z-function z-dom z-ts">appendChild</span></span><span class="z-meta z-brace z-round z-ts">(</span><span class="z-variable z-other z-readwrite z-ts">debugPanel</span><span class="z-meta z-brace z-round z-ts">)</span><span class="z-punctuation z-terminator z-statement z-ts">;</span> </span><span class="z-source z-ts"> </span><span class="z-source z-ts"><span class="z-meta z-function z-ts"><span class="z-storage z-type z-function z-ts">function</span> <span class="z-meta z-definition z-function z-ts"><span class="z-entity z-name z-function z-ts">debugLog</span></span><span class="z-meta z-parameters z-ts"><span class="z-punctuation z-definition z-parameters z-begin z-ts">(</span><span class="z-variable z-parameter z-ts">message</span><span class="z-punctuation z-definition z-parameters z-end z-ts">)</span></span> <span class="z-meta z-block z-ts"><span class="z-punctuation z-definition z-block z-ts">{</span> </span></span></span><span class="z-source z-ts"><span class="z-meta z-function z-ts"><span class="z-meta z-block z-ts"> <span class="z-meta z-var z-expr z-ts"><span class="z-storage z-type z-ts">const</span> <span class="z-meta z-var-single-variable z-expr z-ts"><span class="z-meta z-definition z-variable z-ts"><span class="z-variable z-other z-constant z-ts">time</span></span> </span><span class="z-keyword z-operator z-assignment z-ts">=</span> <span class="z-new z-expr z-ts"><span class="z-keyword z-operator z-new z-ts">new</span> <span class="z-entity z-name z-type z-ts">Date</span><span class="z-meta z-brace z-round z-ts">(</span><span class="z-meta z-brace z-round z-ts">)</span></span><span class="z-meta z-function-call z-ts"><span class="z-punctuation z-accessor z-ts">.</span><span class="z-entity z-name z-function z-ts">toLocaleTimeString</span></span><span class="z-meta z-brace z-round z-ts">(</span><span class="z-meta z-brace z-round z-ts">)</span></span><span class="z-punctuation z-terminator z-statement z-ts">;</span> </span></span></span><span class="z-source z-ts"><span class="z-meta z-function z-ts"><span class="z-meta z-block z-ts"> <span class="z-meta z-var z-expr z-ts"><span class="z-storage z-type z-ts">const</span> <span class="z-meta z-var-single-variable z-expr z-ts"><span class="z-meta z-definition z-variable z-ts"><span class="z-variable z-other z-constant z-ts">p</span></span> </span><span class="z-keyword z-operator z-assignment z-ts">=</span> <span class="z-meta z-function-call z-ts"><span class="z-support z-variable z-dom z-ts">document</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-support z-function z-dom z-ts">createElement</span></span><span class="z-meta z-brace z-round z-ts">(</span><span class="z-string z-quoted z-single z-ts"><span class="z-punctuation z-definition z-string z-begin z-ts">&#39;</span>p<span class="z-punctuation z-definition z-string z-end z-ts">&#39;</span></span><span class="z-meta z-brace z-round z-ts">)</span></span><span class="z-punctuation z-terminator z-statement z-ts">;</span> </span></span></span><span class="z-source z-ts"><span class="z-meta z-function z-ts"><span class="z-meta z-block z-ts"> <span class="z-variable z-other z-object z-ts">p</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-support z-variable z-property z-dom z-ts">style</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-variable z-other z-property z-ts">margin</span> <span class="z-keyword z-operator z-assignment z-ts">=</span> <span class="z-string z-quoted z-single z-ts"><span class="z-punctuation z-definition z-string z-begin z-ts">&#39;</span>2px 0<span class="z-punctuation z-definition z-string z-end z-ts">&#39;</span></span><span class="z-punctuation z-terminator z-statement z-ts">;</span> </span></span></span><span class="z-source z-ts"><span class="z-meta z-function z-ts"><span class="z-meta z-block z-ts"> <span class="z-variable z-other z-object z-ts">p</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-variable z-other z-property z-ts">textContent</span> <span class="z-keyword z-operator z-assignment z-ts">=</span> <span class="z-string z-template z-ts"><span class="z-punctuation z-definition z-string z-template z-begin z-ts">`</span><span class="z-meta z-template z-expression z-ts"><span class="z-punctuation z-definition z-template-expression z-begin z-ts">${</span></span><span class="z-meta z-template z-expression z-ts"><span class="z-meta z-embedded z-line z-ts"><span class="z-variable z-other z-readwrite z-ts">time</span></span><span class="z-punctuation z-definition z-template-expression z-end z-ts">}</span></span>: <span class="z-meta z-template z-expression z-ts"><span class="z-punctuation z-definition z-template-expression z-begin z-ts">${</span></span><span class="z-meta z-template z-expression z-ts"><span class="z-meta z-embedded z-line z-ts"><span class="z-variable z-other z-readwrite z-ts">message</span></span><span class="z-punctuation z-definition z-template-expression z-end z-ts">}</span></span><span class="z-punctuation z-definition z-string z-template z-end z-ts">`</span></span><span class="z-punctuation z-terminator z-statement z-ts">;</span> </span></span></span><span class="z-source z-ts"><span class="z-meta z-function z-ts"><span class="z-meta z-block z-ts"> <span class="z-meta z-function-call z-ts"><span class="z-variable z-other z-object z-ts">debugPanel</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-support z-function z-dom z-ts">insertBefore</span></span><span class="z-meta z-brace z-round z-ts">(</span><span class="z-variable z-other z-readwrite z-ts">p</span><span class="z-punctuation z-separator z-comma z-ts">,</span> <span class="z-variable z-other z-object z-ts">debugPanel</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-support z-variable z-property z-dom z-ts">firstChild</span><span class="z-meta z-brace z-round z-ts">)</span><span class="z-punctuation z-terminator z-statement z-ts">;</span> </span></span></span><span class="z-source z-ts"><span class="z-meta z-function z-ts"><span class="z-meta z-block z-ts"> <span class="z-meta z-function-call z-ts"><span class="z-support z-class z-console z-ts">console</span><span class="z-punctuation z-accessor z-ts">.</span><span class="z-support z-function z-console z-ts">log</span></span><span class="z-meta z-brace z-round z-ts">(</span><span class="z-string z-template z-ts"><span class="z-punctuation z-definition z-string z-template z-begin z-ts">`</span><span class="z-meta z-template z-expression z-ts"><span class="z-punctuation z-definition z-template-expression z-begin z-ts">${</span></span><span class="z-meta z-template z-expression z-ts"><span class="z-meta z-embedded z-line z-ts"><span class="z-variable z-other z-readwrite z-ts">time</span></span><span class="z-punctuation z-definition z-template-expression z-end z-ts">}</span></span>: <span class="z-meta z-template z-expression z-ts"><span class="z-punctuation z-definition z-template-expression z-begin z-ts">${</span></span><span class="z-meta z-template z-expression z-ts"><span class="z-meta z-embedded z-line z-ts"><span class="z-variable z-other z-readwrite z-ts">message</span></span><span class="z-punctuation z-definition z-template-expression z-end z-ts">}</span></span><span class="z-punctuation z-definition z-string z-template z-end z-ts">`</span></span><span class="z-meta z-brace z-round z-ts">)</span><span class="z-punctuation z-terminator z-statement z-ts">;</span> </span></span></span><span class="z-source z-ts"><span class="z-meta z-function z-ts"><span class="z-meta z-block z-ts"><span class="z-punctuation z-definition z-block z-ts">}</span></span></span> </span></code></pre> <p>And it updated the TTS initialisation function to log to the debug panel:</p> <img class="img-light" src="https:&#x2F;&#x2F;osc.garden&#x2F;blog&#x2F;ramu-japanese-numbers-practice-web-app&#x2F;media&#x2F;debug_light.webp" loading="lazy" alt="Debug panel with messages" width="966" height="673"> <img class="img-dark" src="https:&#x2F;&#x2F;osc.garden&#x2F;blog&#x2F;ramu-japanese-numbers-practice-web-app&#x2F;media&#x2F;debug_dark.webp" loading="lazy" alt="Debug panel with messages" width="961" height="661"> <p>Except I didn’t see this at first. What was going on? Nothing I tried made a difference. The reason: cache. Even though I was forcing a reload on the HTML, the JavaScript file was cached.</p> <h2 id="cache-busting"><a class="header-anchor no-hover-padding" href="#cache-busting" aria-label="Anchor link for: cache-busting"><span class="link-icon" aria-hidden="true"></span></a> Cache busting</h2> <p>One way to ignore cache is to append a question mark and text/numbers after a URL: <code>example.com/?hello</code>.</p> <p>I was doing this to ensure the HTML was updated, but if the HTML itself contains a plain reference to <code>app.js</code>, and that file is cached, you’re out of luck.</p> <p>The solution is to bust the cache by appending the <a class="external" href="https://simple.wikipedia.org/wiki/Cryptographic_hash_function">file hash</a> —or part of it— to the URL you’re loading:</p> <pre data-lang="diff" class="language-diff z-code"><code class="language-diff" data-lang="diff"><span class="z-source z-diff"><span class="z-markup z-deleted z-diff"><span class="z-punctuation z-definition z-deleted z-diff">-</span> &lt;script src=&quot;/app.js&quot; defer&gt;&lt;/script&gt; </span></span><span class="z-source z-diff"><span class="z-markup z-inserted z-diff"><span class="z-punctuation z-definition z-inserted z-diff">+</span> &lt;script src=&quot;/app.js?h=0158eccd&quot; defer&gt;&lt;/script&gt; </span></span></code></pre> <p>If I used Astro or a similar framework, this would not be an issue. Not wanting to complicate things, I updated the <a class="external" href="https://github.com/welpo/ramu/blob/main/.githooks/pre-commit">pre-commit hook</a> —a script that runs every time I commit changes. It checks if I’ve modified a file that needs to be cache busted. If so, it updates the hash in the HTML and includes the changes in the commit.</p> <p>Easy! And without any dependencies.</p> <h2 id="progressive-web-app"><a class="header-anchor no-hover-padding" href="#progressive-web-app" aria-label="Anchor link for: progressive-web-app"><span class="link-icon" aria-hidden="true"></span></a> Progressive Web App</h2> <p>I wanted the app to work offline. All processing is done locally, including the voice generation; the only problem would be accessing the URL in an area without connectivity.</p> <p>The solution: turning it into a Progressive Web App (PWA). This makes it possibel to install the app operate offline, using the whole screen. PWAs feel like a proper app.</p> <p>Being unfamiliar with their implementation, I used the <a class="external" href="https://learn.microsoft.com/microsoft-edge/progressive-web-apps-chromium/how-to/">Microsoft documentation on PWAs</a> and Claude’s help to turn my HTML+CSS+JS into a PWA.</p> <p>This didn’t take long. The main hurdle was testing whether the PWA worked correctly without deploying it. Python’s HTTP server (<code>python3 -m http.server</code>) was not enough, but <a class="external" href="https://www.npmjs.com/package/http-server"><code>http-server</code></a> with local OpenSSL keys worked.</p> <h3 id="respectful-user-prompts"><a class="header-anchor no-hover-padding" href="#respectful-user-prompts" aria-label="Anchor link for: respectful-user-prompts"><span class="link-icon" aria-hidden="true"></span></a> Respectful user prompts</h3> <p>I dislike when, shortly after I visit a website for the first time, I’m interrupted with a prompt: “Please subscribe to our newsletter” or “Can we show you notifications?”. No.</p> <p>I wanted users to know they could install the PWA, as I don’t think it’s a popular technology. My first idea was “wait two seconds after first visit on iOS” (Chrome suggests PWA installation by default, and I don’t think it makes much sense to suggest an install on non-mobile devices). This felt too aggressive: the user hasn’t even tested the app. It may not even work on their device! (It should, though.)</p> <p>I settled for “show the prompt to iOS users after they complete their session”. It’s a small prompt with simple instructions:</p> <div id="pwa-prompt"> <img class="img-light" src="https:&#x2F;&#x2F;osc.garden&#x2F;blog&#x2F;ramu-japanese-numbers-practice-web-app&#x2F;media&#x2F;pwa_prompt_light.webp" loading="lazy" alt="Prompt to install the PWA" width="1179" height="737"> <img class="img-dark" src="https:&#x2F;&#x2F;osc.garden&#x2F;blog&#x2F;ramu-japanese-numbers-practice-web-app&#x2F;media&#x2F;pwa_prompt_dark.webp" loading="lazy" alt="Prompt to install the PWA" width="1179" height="725"> </div> <p>To make it easier to dismiss, I added padding to the close button for a larger click target (visually offset by a negative margin). If the prompt is dismissed, it’s never shown again.</p> <hr> <p>A majority of the time was spent figuring out browser inconsistencies, accessibility, and the number-to-kanji function. Other than that, I worked on styling, responsiveness (made it look good at various resolutions), logo creation, documentation, and error catching. For example, if the script can’t find a Japanese voice, it shows a warning with a clear explanation of what went wrong, including OS-specific steps on how to install a voice.</p> <p>My goal was to finish this project in an afternoon, including this write-up. I did not succeed. In the end, I spent a few hours —not a few months!— spread over less than a week. Not bad! Though I was tempted to add multiple modes (specific month practice or reading the time), I exercised restraint.</p> <p>I’m calling this a victory. I learnt about Japanese numerals, PWAs, accessibility, testing vanilla JS, browser API details… and I got a cute, responsive, fun —as fun as these drills can be—, and useful little web app!</p> <p>Want to try it? <a class="external" href="https://ramu.osc.garden">Here’s the link</a>. The <a class="external" href="https://github.com/welpo/ramu">source code is on GitHub</a>. <ruby>がんばってください!</ruby></p> </section> <nav class="full-width article-navigation"> <div><a href="https://osc.garden/blog/zutsu-offline-task-planner-web-app/" aria-label="Next" aria-describedby="left_title"><span class="arrow">←</span>&nbsp;Next</a> <p aria-hidden="true" id="left_title">Building a Minimal Time Management Web App</p></div> <div><a href="https://osc.garden/blog/ichiko-aoba-lyrics-japanese-morphology/" aria-label="Prev" aria-describedby="right_title">Prev&nbsp;<span class="arrow">→</span></a> <p aria-hidden="true" id="right_title">Learning Japanese Through Music: An Analysis of Ichiko Aoba’s Lyrics</p></div> </nav> <div id="comments" class="comments" data-endpoint-url="https:&#x2F;&#x2F;osc.garden&#x2F;comments&#x2F;" data-isso-id="&#x2F;blog&#x2F;ramu-japanese-numbers-practice-web-app&#x2F;" data-title="&#x2F;blog&#x2F;ramu-japanese-numbers-practice-web-app&#x2F;" data-page-language="en" data-max-comments-top="inf" data-max-comments-nested="5" data-avatar="false" data-voting="true" data-page-author-hashes=" 91c8fbce5e09" data-lazy-loading="true"> <script src="https://osc.garden/js/isso.min.js" async></script> <noscript>You need JavaScript to view the comments.</noscript> </div> </article> </main> <div id="button-container"> <div id="toc-floating-container"> <input type="checkbox" id="toc-toggle" class="toggle"> <label for="toc-toggle" class="overlay"></label> <label for="toc-toggle" id="toc-button" class="button" title="Toggle Table of Contents"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M414.82-193.094q-18.044 0-30.497-12.32-12.453-12.319-12.453-30.036t12.453-30.086q12.453-12.37 30.497-12.37h392.767q17.237 0 29.927 12.487 12.69 12.486 12.69 30.203 0 17.716-12.69 29.919t-29.927 12.203H414.82Zm0-244.833q-18.044 0-30.497-12.487Q371.87-462.9 371.87-480.45t12.453-29.92q12.453-12.369 30.497-12.369h392.767q17.237 0 29.927 12.511 12.69 12.512 12.69 29.845 0 17.716-12.69 30.086-12.69 12.37-29.927 12.37H414.82Zm0-245.167q-18.044 0-30.497-12.32t-12.453-30.037q0-17.716 12.453-30.086 12.453-12.369 30.497-12.369h392.767q17.237 0 29.927 12.486 12.69 12.487 12.69 30.203 0 17.717-12.69 29.92-12.69 12.203-29.927 12.203H414.82ZM189.379-156.681q-32.652 0-55.878-22.829t-23.226-55.731q0-32.549 23.15-55.647 23.151-23.097 55.95-23.097 32.799 0 55.313 23.484 22.515 23.484 22.515 56.246 0 32.212-22.861 54.893-22.861 22.681-54.963 22.681Zm0-245.167q-32.652 0-55.878-23.134-23.226-23.135-23.226-55.623 0-32.487 23.467-55.517t56.12-23.03q32.102 0 54.721 23.288 22.62 23.288 22.62 55.775 0 32.488-22.861 55.364-22.861 22.877-54.963 22.877Zm-.82-244.833q-32.224 0-55.254-23.288-23.03-23.289-23.03-55.623 0-32.333 23.271-55.364 23.272-23.03 55.495-23.03 32.224 0 55.193 23.288 22.969 23.289 22.969 55.622 0 32.334-23.21 55.364-23.21 23.031-55.434 23.031Z"/></svg> </label> <div class="toc-content"> <div class="toc-container"> <ul> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#the-idea">The idea</a> </li> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#testing-vanilla-javascript">Testing vanilla JavaScript</a> </li> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#accessibility">Accessibility</a> </li> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#browser-inconsistencies">Browser inconsistencies</a> <ul> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#debugging-on-mobile">Debugging on mobile</a> </li> </ul> </li> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#cache-busting">Cache busting</a> </li> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#progressive-web-app">Progressive Web App</a> <ul> <li><a href="https://osc.garden/blog/ramu-japanese-numbers-practice-web-app/#respectful-user-prompts">Respectful user prompts</a> </li> </ul> </li> </ul> </div> </div> </div> <a href="#comments" id="comments-button" class="no-hover-padding" title="Go to the comments section"> <svg viewBox="0 0 20 20" fill="currentColor"><path d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z" clip-rule="evenodd" fill-rule="evenodd"/></svg> </a> <a href="#" id="top-button" class="no-hover-padding" title="Go to the top of the page"> <svg viewBox="0 0 20 20" fill="currentColor"><path d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z"/></svg> </a> </div> <span id="copy-success" class="hidden"> Copied! </span> <span id="copy-init" class="hidden"> Copy code to clipboard </span> <script defer="defer" src="https://osc.garden/js/copyCodeToClipboard.min.js"></script> </div> <footer> <section> <nav class="socials nav-navs"><ul><li> <a class="nav-links no-hover-padding social" rel="" href="https://osc.garden/atom.xml"> <img loading="lazy" alt="feed" title="feed" src="https://osc.garden/social_icons/rss.svg"> </a> </li><li class="js"><a class="nav-links no-hover-padding social" href="#" data-encoded-email="b3NjQG9zYy5nYXJkZW4="><img loading="lazy" alt="email" title="email" src="https://osc.garden/social_icons/email.svg"> </a> </li> <li> <a class="nav-links no-hover-padding social" rel=" me" href="https://signal.me/#eu/WVzg7DsJWw7Y5GApgH1xu913HGB7zuB4yTmpLnhnUsGeCOzF049MzIBzI79W1I0w"> <img loading="lazy" alt="signal" title="signal" src="https://osc.garden/social_icons/signal.svg"> </a> </li> <li> <a class="nav-links no-hover-padding social" rel=" me" href="https://github.com/welpo/"> <img loading="lazy" alt="github" title="github" src="https://osc.garden/social_icons/github.svg"> </a> </li> <li> <a class="nav-links no-hover-padding social" rel=" me" href="https://soundcloud.com/oskerwyld"> <img loading="lazy" alt="soundcloud" title="soundcloud" src="https://osc.garden/social_icons/soundcloud.svg"> </a> </li> <li> <a class="nav-links no-hover-padding social" rel=" me" href="https://instagram.com/oskerwyld"> <img loading="lazy" alt="instagram" title="instagram" src="https://osc.garden/social_icons/instagram.svg"> </a> </li> <li> <a class="nav-links no-hover-padding social" rel=" me" href="https://youtube.com/@oskerwyld"> <img loading="lazy" alt="youtube" title="youtube" src="https://osc.garden/social_icons/youtube.svg"> </a> </li> <li> <a class="nav-links no-hover-padding social" rel=" me" href="https://open.spotify.com/artist/5Hv2bYBhMp1lUHFri06xkE"> <img loading="lazy" alt="spotify" title="spotify" src="https://osc.garden/social_icons/spotify.svg"> </a> </li> </ul> </nav> <nav class="nav-navs"> <small> <ul> <li><a class="nav-links no-hover-padding" href="https:&#x2F;&#x2F;osc.garden&#x2F;privacy&#x2F;"> privacy policy </a> </li> <li><a class="nav-links no-hover-padding" href="https:&#x2F;&#x2F;stats.osc.garden&#x2F;"> site statistics </a> </li> <li><a class="nav-links no-hover-padding" href="https:&#x2F;&#x2F;osc.garden&#x2F;sitemap.xml"> sitemap </a> </li> </ul> </small> </nav> <div class="credits"> <small> Powered by <a rel="" href="https://www.getzola.org">Zola</a> &amp; <a rel="" href="https://github.com/welpo/tabi">tabi</a> • <a rel="" href="https:&#x2F;&#x2F;github.com&#x2F;welpo&#x2F;osc.garden"> Site source </a></small> </div> </section> <script src="https://osc.garden/js/decodeMail.min.js" async></script><div id="searchModal" class="search-modal js" role="dialog" aria-labelledby="modalTitle"> <h1 id="modalTitle" class="visually-hidden">Search</h1> <div id="modal-content"> <div id="searchBar"> <div class="search-icon" aria-hidden="true"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"> <path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/> </svg> </div> <input id="searchInput" role="combobox" autocomplete="off" spellcheck="false" aria-expanded="false" aria-controls="results-container" placeholder="Search…"> <div id="clear-search" class="close-icon interactive-icon" tabindex="0" role="button" title="Clear search"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"> <path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/> </svg> </div> </div> <div id="results-container"> <div id="results-info"><span id="zero_results"> No results</span> <span id="one_results"> $NUMBER result</span> <span id="many_results"> $NUMBER results</span><span id="two_results"> $NUMBER results</span> <span id="few_results"> $NUMBER results</span> </div> <div id="results" role="listbox"></div> </div> </div> </div> </footer> </body> </html>

Pages: 1 2 3 4 5 6 7 8 9 10