verb-master

web application to practice verb conjugations for the korean language
git clone git://brookjeynes.dev/bjeynes/verb-master.git
Log | Files | Refs | README | LICENSE

commit d7d73afe95fe3ec8969f1c66bc1c68ae0186053f
parent 81f1b2d64fd72014626161192c51d92fe3db5401
Author: brookjeynes <me@brookjeynes.dev>
Date:   Sat, 30 May 2026 18:08:29 +1000

refactor: add better accessability

Signed-off-by: brookjeynes <me@brookjeynes.dev>

Diffstat:
MMakefile | 3++-
Mindex.html | 294+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Aminimal.css | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/config.js | 2+-
Msrc/store.js | 2+-
Mstyle.css | 224++++++++++++++++++++++++++-----------------------------------------------------
6 files changed, 428 insertions(+), 295 deletions(-)

diff --git a/Makefile b/Makefile @@ -1,8 +1,9 @@ # frequency list from https://kimchi-reader.app/explore/freq/words +FREQUENCY_LIST = <file-path>.json GENERATED = data/kimchi-verb-frequencies.js -$(GENERATED): data/word-frequency-2026-05-25.json +$(GENERATED): data/$(FREQUENCY_LIST) printf "/** @type {DB} */\n" > $@ && \ printf "const kimchiFrequency = " >> $@ && \ cat $< >> $@ && \ diff --git a/index.html b/index.html @@ -1,183 +1,197 @@ <!DOCTYPE html> -<html> +<html lang="en"> <head> <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> <title>동사마스터</title> + <link rel="stylesheet" href="minimal.css"> <link rel="stylesheet" href="style.css"> </head> <body> - <h1>동사마스터</h1> - <div class="home-screen" x-cloak x-data x-show="$store.app.screen === 'home'"> - <div class="question-count"> - <label for="question-count">questions</label> - <input class="text-input" id="question-count" type="number" min="1" :value="$store.app.questionCount" - @input="$store.app.questionCount = +$event.target.value" /> - </div> + <header> + <h1 lang="ko">동사마스터</h1> + </header> + <main> + <section class="home-screen" x-cloak x-data x-show="$store.app.screen === 'home'"> + <div class="question-count"> + <label for="question-count">questions</label> + <input id="question-count" type="number" min="1" :value="$store.app.questionCount" + @input="$store.app.questionCount = +$event.target.value" /> + </div> - <div class="home-actions"> - <button class="start-btn" @click="$store.app.start()">start</button> - <button @click="$store.app.reset()">reset</button> - </div> - <p class="error-msg" x-show="$store.app.error" x-text="$store.app.error"></p> - <hr class="config-divider"> - <div class="config-panels"> - <div> - <template x-for="group in $store.app.tenseGroups" :key="group.mood"> - <div class="tense-group"> - <h2 x-text="group.mood"></h2> - <table class="tense-grid"> - <thead> - <tr> - <th></th> - <template x-for="col in group.columns" :key="col"> - <th x-text="col"></th> - </template> - </tr> - </thead> - <tbody> - <template x-for="row in group.rows" :key="row.time"> + <div class="home-actions"> + <button class="-primary" @click="$store.app.start()">start</button> + <button @click="$store.app.reset()">reset</button> + </div> + <p class="alert-box -error" x-show="$store.app.error" x-text="$store.app.error"></p> + <hr class="config-divider"> + <div class="config-panels"> + <div> + <template x-for="group in $store.app.tenseGroups" :key="group.mood"> + <fieldset class="tense-group"> + <legend x-text="group.mood"></legend> + <table class="tense-grid"> + <thead> <tr> - <td x-text="row.time"></td> - <template x-for="cell in row.cells" :key="cell.key"> - <td> - <label class="tense-cell"> - <input type="checkbox" - :checked="$store.app.config.tenses[cell.key].enabled" - @change="$store.app.config.tenses[cell.key].enabled = $event.target.checked" /> - <span class="tense-example" - x-text="$store.app.config.tenses[cell.key].example"></span> - </label> - </td> + <td></td> + <template x-for="col in group.columns" :key="col"> + <th scope="col" x-text="col"></th> </template> </tr> - </template> - </tbody> - </table> - </div> - </template> + </thead> + <tbody> + <template x-for="row in group.rows" :key="row.time"> + <tr> + <th scope="row" x-text="row.time"></th> + <template x-for="cell in row.cells" :key="cell.key"> + <td> + <label class="tense-cell"> + <input type="checkbox" + :checked="$store.app.config.tenses[cell.key].enabled" + @change="$store.app.config.tenses[cell.key].enabled = $event.target.checked" /> + <span class="tense-example" + x-text="$store.app.config.tenses[cell.key].example"></span> + </label> + </td> + </template> + </tr> + </template> + </tbody> + </table> + </fieldset> + </template> - <div class="tense-group"> - <h2>other</h2> - <div class="tense-specials"> - <template x-for="key in $store.app.tenseSpecials" :key="key"> + <fieldset class="tense-group"> + <legend>other</legend> + <div class="tense-specials"> + <template x-for="key in $store.app.tenseSpecials" :key="key"> + <label class="tense-cell"> + <input type="checkbox" :checked="$store.app.config.tenses[key].enabled" + @change="$store.app.config.tenses[key].enabled = $event.target.checked" /> + <span x-text="key"></span> + <span class="tense-example" x-text="$store.app.config.tenses[key].example"></span> + </label> + </template> + </div> + </fieldset> + </div> + <fieldset> + <legend>decks</legend> + <template x-for="(dataset, key) in $store.app.config.datasets" :key="key"> + <div class="deck-item"> <label class="tense-cell"> - <input type="checkbox" :checked="$store.app.config.tenses[key].enabled" - @change="$store.app.config.tenses[key].enabled = $event.target.checked" /> - <span x-text="key"></span> - <span class="tense-example" x-text="$store.app.config.tenses[key].example"></span> + <input type="checkbox" :checked="dataset.enabled" + @change="$store.app.config.datasets[key].enabled = $event.target.checked" /> + <span x-text="dataset.name"></span> </label> - </template> + <button class="deck-remove" x-show="dataset.isCustom" + @click="$store.app.removeDataset(key)">&#10007;</button> + </div> + </template> + <div class="deck-upload"> + <label class="deck-upload-label" for="csv-upload">upload KimchiReader CSV</label> + <input id="csv-upload" type="file" accept=".csv" @change="$store.app.uploadCSV($event)" /> </div> - </div> + </fieldset> </div> - <div> - <h2>decks</h2> - <template x-for="(dataset, key) in $store.app.config.datasets" :key="key"> - <div class="deck-item"> - <label class="tense-cell"> - <input type="checkbox" :checked="dataset.enabled" - @change="$store.app.config.datasets[key].enabled = $event.target.checked" /> - <span x-text="dataset.name"></span> - </label> - <button class="deck-remove" x-show="dataset.isCustom" - @click="$store.app.removeDataset(key)">&#10007;</button> - </div> - </template> - <div class="deck-upload"> - <label class="deck-upload-label" for="csv-upload">upload KimchiReader CSV</label> - <input id="csv-upload" type="file" accept=".csv" @change="$store.app.uploadCSV($event)" /> - </div> + <div class="save-action"> + <button @click="$store.app.saveConfig()">save config</button> + <span class="alert-box -success" x-show="$store.app.configSaved">config saved</span> </div> - </div> - <div class="save-action"> - <button @click="$store.app.saveConfig()">save config</button> - <span class="save-msg" x-show="$store.app.configSaved">config saved</span> - </div> - </div> + </section> - <div class="question-screen" x-cloak x-data x-show="$store.app.screen === 'question'"> - <h2>question - <span x-text="$store.app.currentWordIndex + 1"></span> of - <span x-text="$store.app.questionCount"></span> - </h2> + <section class="question-screen" x-cloak x-data x-show="$store.app.screen === 'question'"> + <h2>question + <span x-text="$store.app.currentWordIndex + 1"></span> of + <span x-text="$store.app.questionCount"></span> + </h2> - <form class="question-form" @submit="$store.app.submit($event)"> - <label class="word-prompt"> - <span> - Make - <span x-text="$store.app.words[$store.app.currentWordIndex]?.word.text" :class="{ + <form class="question-form" @submit="$store.app.submit($event)"> + <label class="word-prompt"> + <span> + Make + <span lang="ko" x-text="$store.app.words[$store.app.currentWordIndex]?.word.text" :class="{ 'word-level -unknown': $store.app.words[$store.app.currentWordIndex]?.word.kimchiLevel === 0, 'word-level -seen': $store.app.words[$store.app.currentWordIndex]?.word.kimchiLevel === 1, 'word-level -known': $store.app.words[$store.app.currentWordIndex]?.word.kimchiLevel === 2 }"></span> - <span x-text="$store.app.words[$store.app.currentWordIndex]?.conjugation.tense"></span> - </span> - <br> - <input class="answer-input text-input" id="input" type="text" :class="{ + <span x-text="$store.app.words[$store.app.currentWordIndex]?.conjugation.tense"></span> + </span> + <br> + <input class="answer-input" id="input" type="text" :class="{ '-correct': $store.app.questionState === 'correct', '-incorrect': $store.app.questionState === 'incorrect' }" /> - </label> + </label> - <div class="form-actions"> - <button class="submit-btn" type="submit" - x-text="$store.app.questionState === 'undecided' ? 'check' : 'continue'"></button> - <button :disabled="$store.app.questionState !== 'undecided'" class="skip-btn" type="button" - @click="$store.app.skip()">skip</button> - <button class="quit-btn" type="button" @click="$store.app.quit()">quit</button> - </div> + <div class="form-actions"> + <button class="-primary" type="submit" + x-text="$store.app.questionState === 'undecided' ? 'check' : 'continue'"></button> + <button :disabled="$store.app.questionState !== 'undecided'" class="-secondary" type="button" + @click="$store.app.skip()">skip</button> + <button class="-secondary" type="button" @click="$store.app.quit()">quit</button> + </div> - <div class="answer-reveal" x-show="$store.app.questionState !== 'undecided'" :class="{ + <div class="answer-reveal" x-show="$store.app.questionState !== 'undecided'" :class="{ '-correct': $store.app.questionState === 'correct', '-incorrect': $store.app.questionState === 'incorrect' }"> - <p><span class="answer-label">Answer:</span> <span class="answer-value" - x-text="$store.app.words[$store.app.currentWordIndex]?.conjugation.conjugation"></span></p> - <ul> - <template - x-for="(reason, idx) in $store.app.words[$store.app.currentWordIndex]?.conjugation.reasons"> - <li> - <span x-text="idx+1"></span>. <span x-text="reason"></span> - </li> - </template> - </ul> - </div> - </form> - </div> + <p><span class="answer-label">Answer:</span> <span lang="ko" class="answer-value" + x-text="$store.app.words[$store.app.currentWordIndex]?.conjugation.conjugation"></span></p> + <ol> + <template x-for="reason in $store.app.words[$store.app.currentWordIndex]?.conjugation.reasons"> + <li x-text="reason"></li> + </template> + </ol> + </div> + </form> + </section> - <div class="result-screen" x-cloak x-data x-show="$store.app.screen === 'result'"> - <h2>result</h2> - <p class="result-score"> - <span x-text="$store.app.correctCount"></span> / <span x-text="$store.app.questionCount"></span> - </p> - <table class="result-table"> - <template x-for="result in $store.app.results"> - <tr> - <td class="outcome" :class="result.correct ? '-correct' : '-incorrect'" - x-text="result.correct ? '&#9675;' : '&#10007;'"></td> - <td> - <span x-text="result.word.text" :class="{ + <section class="result-screen" x-cloak x-data x-show="$store.app.screen === 'result'"> + <h2>result</h2> + <output class="result-score"> + <span x-text="$store.app.correctCount"></span> / <span x-text="$store.app.questionCount"></span> + </output> + <table class="result-table"> + <caption class="visually-hidden">Quiz results</caption> + <thead> + <tr> + <th scope="col" class="visually-hidden">Outcome</th> + <th scope="col" class="visually-hidden">Word</th> + <th scope="col" class="visually-hidden">Tense</th> + <th scope="col" class="visually-hidden">Conjugation</th> + </tr> + </thead> + <tbody> + <template x-for="result in $store.app.results"> + <tr> + <td class="outcome" :class="result.correct ? '-correct' : '-incorrect'" + x-text="result.correct ? '&#9675;' : '&#10007;'"></td> + <td> + <span lang="ko" x-text="result.word.text" :class="{ 'word-level -unknown': result.word.kimchiLevel === 0, 'word-level -seen': result.word.kimchiLevel === 1, 'word-level -known': result.word.kimchiLevel === 2 }"></span> - </td> - <td class="tense-label" x-text="result.conjugation.tense"></td> - <td x-text="result.conjugation.conjugation"></td> - </tr> - </template> - </table> - <div class="result-actions"> - <button class="again-btn" @click="$store.app.start()">again</button> - <button class="home-btn" @click="$store.app.quit()">home</button> - </div> - </div> + </td> + <td class="tense-label" x-text="result.conjugation.tense"></td> + <td lang="ko" x-text="result.conjugation.conjugation"></td> + </tr> + </template> + </tbody> + </table> + <div class="result-actions"> + <button class="-primary" @click="$store.app.start()">again</button> + <button class="-secondary" @click="$store.app.quit()">home</button> + </div> + </section> + </main> <footer> - <a href="index.html">Verb Master</a> - &centerdot; + <a href="index.html" lang="ko">동사마스터</a> + · <a href="https://tangled.org/brookjeynes.dev/verb-master">source</a> </footer> diff --git a/minimal.css b/minimal.css @@ -0,0 +1,198 @@ +/* +BSD Zero Clause License + +Copyright (c) 2026-present Brook Jeynes <me@brookjeynes.dev> + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +*/ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + --primary: #4070a0; + --text: #1a1a1a; + --secondary: #808080; + --light-gray: #e8e8e8; + --dark-gray: #c0c0c0; + --red: #a05050; + --green: #5a9060; +} + +body { + font-family: 'Georgia', serif; + font-size: 16px; + color: var(--text); + margin: 0; + padding: 0.5rem 1.5rem; + max-width: 900px; + margin-left: auto; + margin-right: auto; +} + +h1 { + font-size: 32px; + font-weight: normal; + line-height: 40px; + margin-top: 40px; +} + +h2 { + font-size: 16px; + font-weight: bold; + margin-top: 24px; +} + +a, +a:visited { + color: inherit; + text-decoration-thickness: 1px; + text-underline-offset: 3px; + + &:hover { + color: var(--primary); + } +} + +footer { + margin: 2rem 0; + font-size: 75%; + color: var(--secondary); +} + +button { + font: inherit; + cursor: pointer; + background: none; + border: none; + padding: 0; + color: inherit; + + &:disabled { + opacity: 0.35; + cursor: default; + } + + &:hover:not(:disabled) { + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; + } + + &:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + } + + &.-primary { + font-weight: bold; + } + + &.-secondary { + color: var(--secondary); + } +} + +.alert-box { + font-size: 14px; + + &.-error { + color: var(--red); + } + + &.-success { + color: var(--green); + } + + &.-info { + color: var(--primary); + } +} + +select { + font: inherit; + border: none; + border-bottom: 1px solid var(--dark-gray); + background: none; + padding: 2px 0; + cursor: pointer; + + &:focus { + outline: none; + border-bottom-color: var(--primary); + } + + &:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + border-bottom-color: transparent; + } +} + +input { + + &[type="radio"], + &[type="checkbox"] { + cursor: pointer; + } + + &:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + } + + &[type="text"], + &[type="email"], + &[type="password"], + &[type="search"], + &[type="tel"], + &[type="url"], + &[type="number"] { + font: inherit; + border: none; + border-bottom: 1px solid var(--dark-gray); + background: none; + padding: 2px 0; + + &:focus { + outline: none; + border-bottom-color: var(--primary); + } + } +} + +fieldset { + border: none; + padding: 0; + margin: 12px 0; +} + +legend { + font-weight: bold; + padding: 0; + margin-bottom: 8px; +} + +hr { + margin: 20px 0; + border: none; + border-top: 1px solid var(--light-gray); +} + +label { + & input { + margin: 8px 0; + } +} diff --git a/src/config.js b/src/config.js @@ -5,7 +5,7 @@ import "./types.js"; * @returns {Config} config */ export function loadConfig() { - const persistentConfig = localStorage.getItem("config"); + const persistentConfig = localStorage.getItem("verbmasterconfig"); if (persistentConfig) { return JSON.parse(persistentConfig); } diff --git a/src/store.js b/src/store.js @@ -129,7 +129,7 @@ window.Alpine.store("app", { }, saveConfig() { - localStorage.setItem("config", JSON.stringify(this.config)); + localStorage.setItem("verbmasterconfig", JSON.stringify(this.config)); this.configSaved = true; setTimeout(() => { diff --git a/style.css b/style.css @@ -1,23 +1,5 @@ body { - font-family: 'Georgia', serif; - font-size: 16px; padding: 0.5rem; - width: 900px; - margin-left: auto; - margin-right: auto; -} - -h1 { - font-size: 32px; - font-weight: normal; - line-height: 40px; - margin-top: 40px; -} - -h2 { - font-size: 16px; - font-weight: bold; - margin-top: 24px; } ul { @@ -26,48 +8,31 @@ ul { padding: 0; } -p { - width: 420px; +ol { + margin: 0; } -footer { - margin: 2rem 0; - font-size: 75%; - color: #808080; - - a, - a:visited { - color: inherit; - } +p { + width: 420px; } -button { - font: inherit; - cursor: pointer; - background: none; - border: none; +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; padding: 0; - color: inherit; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } -button:disabled { - opacity: 0.35; - cursor: default; -} - -.start-btn, -.submit-btn { - font-weight: bold; -} - -.skip-btn, -.quit-btn { - color: #808080; +.home-actions + .alert-box { + margin-top: 12px; } .config-divider { - border: none; - border-top: 1px solid #c0c0c0; margin: 32px 0; } @@ -79,13 +44,6 @@ button:disabled { } } -.error-msg { - width: auto; - margin-top: 12px; - font-size: 14px; - color: #a05050; -} - .question-form { margin-top: 8px; } @@ -94,33 +52,19 @@ button:disabled { display: block; } -.text-input { - font: inherit; - border: none; - border-bottom: 1px solid #c0c0c0; - outline: none; - background: none; - width: 200px; - padding: 2px 0; - - &:focus { - border-bottom-color: #4070a0; - } -} - .answer-input { margin-top: 8px; &.-correct { - border-bottom-color: #5a9060; + border-bottom-color: var(--green); } &.-incorrect { - border-bottom-color: #a05050; + border-bottom-color: var(--red); } &.-wrong-flash { - border-bottom-color: #a05050; + border-bottom-color: var(--red); animation: input-shake 0.35s ease-out; } } @@ -165,29 +109,35 @@ button:disabled { .tense-group { margin-top: 24px; + + legend { + font-size: 16px; + } } .tense-grid { border-collapse: collapse; margin-top: 8px; -} -.tense-grid th { - font-weight: normal; - color: #808080; - font-size: 14px; - text-align: left; - padding: 0 24px 8px 0; -} + th[scope="col"], + th[scope="row"] { + font-weight: normal; + color: var(--secondary); + font-size: 14px; + text-align: left; + } -.tense-grid td { - padding: 3px 24px 3px 0; -} + th[scope="col"] { + padding: 0 24px 8px 0; + } -.tense-grid td:first-child { - color: #808080; - font-size: 14px; - padding-right: 32px; + td { + padding: 3px 24px 3px 0; + } + + th[scope="row"] { + padding: 3px 32px 3px 0; + } } .tense-specials { @@ -215,11 +165,6 @@ button:disabled { gap: 16px; } -.save-msg { - font-size: 14px; - color: #5a9060; -} - .deck-item { display: flex; align-items: center; @@ -229,7 +174,7 @@ button:disabled { .deck-remove { font-size: 12px; - color: #a05050; + color: var(--red); } .deck-upload { @@ -241,83 +186,66 @@ button:disabled { .deck-upload-label { font-size: 14px; - color: #808080; + color: var(--secondary); } .word-level { text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 2px; -} - -.word-level.-unknown { - text-decoration-color: #a05050; -} - -.word-level.-seen { - text-decoration-color: #a08030; -} -.word-level.-known { - text-decoration-color: #5a9060; + &.-unknown { text-decoration-color: var(--red); } + &.-seen { text-decoration-color: #a08030; } + &.-known { text-decoration-color: var(--green); } } .answer-reveal { margin-top: 24px; -} -.answer-reveal>p { - width: auto; -} - -.answer-reveal .answer-value { - font-style: italic; -} + >p { + width: auto; + } -.answer-reveal>ul { - margin-top: 8px; -} + >ol { + margin-top: 8px; -.answer-reveal>ul>li+li { - margin-top: 4px; -} + >li+li { + margin-top: 4px; + } + } -.answer-reveal.-correct .answer-value { - color: #5a9060; -} + .answer-value { + font-style: italic; + } -.answer-reveal.-incorrect .answer-value { - color: #a05050; + &.-correct .answer-value { color: var(--green); } + &.-incorrect .answer-value { color: var(--red); } } -.result-score { - width: auto; +output.result-score { + display: block; font-size: 21px; - font-weight: normal; margin-top: 8px; } .result-table { border-collapse: collapse; margin-top: 24px; -} -.result-table td { - padding: 4px 24px 4px 0; - vertical-align: top; -} - -.result-table .outcome.-correct { - color: #5a9060; -} + td { + padding: 4px 24px 4px 0; + vertical-align: top; + } -.result-table .outcome.-incorrect { - color: #a05050; -} + .outcome { + &.-correct { color: var(--green); } + &.-incorrect { color: var(--red); } + } -.result-table .tense-label { - color: #808080; - font-size: 14px; + .tense-label { + color: var(--secondary); + font-size: 14px; + } } .result-actions { @@ -328,14 +256,6 @@ button:disabled { } } -.again-btn { - font-weight: bold; -} - -.home-btn { - color: #808080; -} - [x-cloak] { display: none !important; }