"""Clerina - everyday Romansh phrases for where you are in Graubünden. app.py UI layout and event wiring brand.py design tokens, theme, CSS and HTML building blocks i18n.py interface texts in EN, DE, FR, IT and RM romansh.py location -> idiom lookup and the translation model """ import json from pathlib import Path import gradio as gr import brand from about import about_html from map_view import COLORS, STANDARD_COLOR, map_html from brand import esc from i18n import LANGS, DEFAULT_LANG, REGIONS, TIPS, detect, meanings, t from romansh import IDIOMS, idiom_code, idioms_by_distance, phrase_rows, phrases_for, resolve_location, translate # Set to True to show the free-text "Translate" tab again. SHOW_TRANSLATE = False VIEW_MINE, VIEW_ALL = "mine", "all" # Phrases per idiom (greeting + table phrases), not counting the numbers row N_PHRASES = sum(1 for _, _, key in phrase_rows("rm-rumgr") if key != "count") # Pronunciation clips: audio//.mp3, described in audio/manifest.json as # {"rm-sursilv": {"morning": {"file": "rm-sursilv/morning.mp3", "kind": "native" | "tts", "credit": "..."}}} AUDIO_DIR = Path(__file__).parent / "audio" _manifest = AUDIO_DIR / "manifest.json" AUDIO = json.loads(_manifest.read_text(encoding="utf-8")) if _manifest.exists() else {} gr.set_static_paths(paths=[AUDIO_DIR]) def audio_clip(idiom, key): clip = AUDIO.get(idiom, {}).get(key) if clip and (AUDIO_DIR / clip["file"]).exists(): return clip return None # One delegated click handler on the phrases block; survives re-renders of its content. PLAY_JS = """ element.addEventListener('click', (e) => { const b = e.target.closest('.al-play'); if (!b) return; e.preventDefault(); const a = window.__alAudio || (window.__alAudio = new Audio()); const wasThis = a.dataset.src === b.dataset.src && !a.paused; document.querySelectorAll('.al-play.is-playing').forEach(x => x.classList.remove('is-playing')); a.pause(); if (wasThis) return; a.src = b.dataset.src; a.dataset.src = b.dataset.src; b.classList.add('is-playing'); a.onended = () => b.classList.remove('is-playing'); a.play().catch(() => b.classList.remove('is-playing')); }); """ # ---------------------------------------------------------------- HTML builders def hero_html(lang): return brand.hero( eyebrow=None, # shown in the top bar instead lines=[t(lang, "hero_line1"), t(lang, "hero_line2")], rotating=["Allegra!", *(_phrase(i, "morning") for i in ("rm-sursilv", "rm-sutsilv", "rm-surmiran", "rm-rumgr"))], lead_html=t(lang, "hero_lead_html", n=N_PHRASES), stats=[("5", t(lang, "stat_idioms")), ("1", t(lang, "stat_standard")), (str(N_PHRASES), t(lang, "stat_phrases")), ("~60k", t(lang, "stat_speakers"))], bubble=("Allegra!", t(lang, "everywhere")), # ticker: dictionary-verified phrases only ticker=[(p["text"], IDIOMS[i]) for key in ("morning", "thanks", "evening", "bye") for i in ("rm-sursilv", "rm-sutsilv", "rm-surmiran", "rm-rumgr") for p in phrases_for(i) if p["key"] == key and p["source"] == "pledarigrond"], ) def _phrase(idiom, key): return next(p["text"] for p in phrases_for(idiom) if p["key"] == key) def step1_html(lang): return brand.step(t(lang, "step1"), anchor="al-start") BRAND_TEXT = "Clerina · Graubünden · Grischun · Grigioni" def brand_html(): head, rest = BRAND_TEXT.split(" · ", 1) return f'{esc(head)} · {esc(rest)}' def about_link_html(lang): return f'{esc(t(lang, "about"))}' def or_html(lang): return f'{esc(t(lang, "or"))}' GUIDE_LANGS = [("en", "English"), ("de", "Deutsch"), ("fr", "Français"), ("it", "Italiano")] def footer_html(lang): """Attribution plus links to the readable phrase pages (/en/, /de/, …; see seo.py).""" guide = " · ".join(f'{name}' for code, name in GUIDE_LANGS) return brand.footer(f'{t(lang, "footer_html")}
{esc(t(lang, "guide"))}: {guide}') def where_html(lang, info): """Turn the language-neutral location facts from romansh.resolve_location into text.""" status = info.get("status") if status == "picked": return t(lang, "picked") bold = lambda v: f"{esc(v)}" # noqa: E731 parts = [] src = info.get("src") if src == "town": parts.append(t(lang, "src_town")) elif src == "geo": parts.append(t(lang, "src_geo", city=info["city"], label=info.get("label", ""))) elif src == "device": parts.append(t(lang, "src_device")) if info.get("place"): key = "near_place" if info.get("near") else "in_place" parts.append(t(lang, key, place="\0").replace("\0", bold(info["place"]))) standard = lambda: t(lang, "fallback", idiom="\0").replace("\0", bold("Rumantsch Grischun")) # noqa: E731 if status == "local": parts.append(t(lang, "local_idiom", idiom="\0").replace("\0", bold(IDIOMS[info["idiom"]]))) elif status == "non_rm": parts += [t(lang, "non_rm"), standard()] elif status == "outside": parts += [t(lang, "outside"), standard()] elif status == "notfound": parts += [t(lang, "notfound", city=info["city"]), standard()] elif status == "noloc": parts += [t(lang, "noloc"), standard()] return " ".join(parts) def _luminance(hex_color): rgb = [int(hex_color[i:i + 2], 16) / 255 for i in (1, 3, 5)] r, g, b = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in rgb] return 0.2126 * r + 0.7152 * g + 0.0722 * b def idiom_tag(code): """Idiom name on its map colour, with white or dark text, whichever has more contrast.""" bg = idiom_color(code) lum = _luminance(bg) fg = "#FFFFFF" if 1.05 / (lum + 0.05) >= (lum + 0.05) / (_luminance(brand.NAVY) + 0.05) else brand.NAVY return (f'' f'{esc(IDIOMS[code])}') def step2_html(lang, info): """Step 2 heading: "You are …" until a place is known, then e.g. "You are in Scuol – the local idiom is Vallader".""" info = info or {} status = info.get("status") if not status or status == "picked": return brand.step(t(lang, "step2")) def clause(key, **kw): # sentence without its final full stop, first letter lower case (joined after a dash) s = t(lang, key, **kw).split(". ")[0].rstrip(".") return s[:1].lower() + s[1:] place = info.get("place") or info.get("label") if place: first = t(lang, "near_place" if info.get("near") else "in_place", place=place).rstrip(".") elif status == "notfound": first = t(lang, "notfound", city=info.get("city", "")).rstrip(".") elif status == "outside": first = t(lang, "outside").rstrip(".") else: first = t(lang, "noloc").split(". ")[0].rstrip(".") code = info["idiom"] if status == "local" else "rm-rumgr" second = (clause("local_idiom", idiom="\0") if status == "local" else clause("fallback", idiom="\0")) title = f"2. {first} – {second}" return brand.step(title.replace("\0", IDIOMS[code]), title_html=esc(title).replace("\0", idiom_tag(code))) def result_html(lang, idiom, info): if not idiom: return "" colon = " :" if lang == "fr" else ":" # French typography: space before the colon return ( f'
{esc(t(lang, "chip"))}' f'
{esc(IDIOMS[idiom])}
' f'

{where_html(lang, info)}

' f'

{esc(t(lang, "region"))}{colon} {esc(REGIONS[lang][idiom])}

' f'

💡 {esc(TIPS[lang][idiom])}

' ) def _meaning_cells(primary, secondary): sec = f'{esc(secondary)}' if secondary else "" return f"{esc(primary)}{sec}" def _rm(lang, idiom, text, verified, key): """Romansh phrase, a dagger if it is not from the dictionary, and a play button if a clip exists.""" html = esc(text) + ("" if verified else '†') clip = audio_clip(idiom, key) if clip: src = f"/gradio_api/file={(AUDIO_DIR / clip['file']).as_posix()}" label = t(lang, "play_native" if clip["kind"] == "native" else "play_tts", phrase=text) html = (f'') + html return html def legend_html(lang, rows, idioms): """rows: (text, verified, key) tuples shown; idioms: the idioms in the view (for audio kinds).""" out = [] if not all(ok for _, ok, _ in rows): out.append(f'

† {esc(t(lang, "legend_model"))}

') kinds = {c["kind"] for i in idioms for c in AUDIO.get(i, {}).values()} if "native" in kinds: out.append(f'

▶ {esc(t(lang, "audio_native"))}

') if "tts" in kinds: out.append(f'

▶ {esc(t(lang, "audio_tts"))}

') return "".join(out) def idiom_color(code): return COLORS.get(code, STANDARD_COLOR) def swatch(code): return f'' def cards_html(lang, idiom): """Phrase list for one idiom: a table on desktop, stacked cards on phones (switched in CSS).""" data = phrase_rows(idiom) rows = list(enumerate(zip(data, meanings(lang)), 1)) table = "".join( f'{i}{_meaning_cells(m, de)}' f'{_rm(lang, idiom, rm, ok, key)}' for i, ((rm, ok, key), (m, de)) in rows ) cards = "".join( f'
{i}
' f'

{esc(m)}

' + (f'

{esc(de)}

' if de else "") + f'

{_rm(lang, idiom, rm, ok, key)}

' + "
" for i, ((rm, ok, key), (m, de)) in rows ) return ( f'
' f'' f'{table}
#{esc(t(lang, "col_meaning"))}{swatch(idiom)}{esc(IDIOMS[idiom])}
{cards}
' + legend_html(lang, data, [idiom]) ) def here_label(lang, info): """Label above the highlighted column: the searched place, or "standard form" when the column is Rumantsch Grischun only as a fallback (outside Graubünden, non-Romansh town, nothing found).""" info = info or {} if info.get("status") == "local" and info.get("place"): return t(lang, "here_near" if info.get("near") else "here_in", place=info["place"]) return t(lang, "here_std") def compare_html(lang, local_idiom, info=None): """Table with one row per phrase and one column per idiom; the local column is highlighted.""" # The local idiom first, then the others by distance; Rumantsch Grischun (written standard) last. info = info or {} order = idioms_by_distance(info.get("lat"), info.get("lon"), local_idiom) rows = {code: phrase_rows(code) for code in order} def cls(code): """Column class and colour; the local column is highlighted in its own idiom colour.""" local = " is-local" if code == local_idiom else "" return f' class="al-col{local}" style="--c:{idiom_color(code)}"' here = here_label(lang, info) # The label sits on its own transparent row above the header, aligned with the local column. here_row = '' + "".join( f'{f"{here}" if code == local_idiom else ""}' for code in order) + "" def name(code): sub = f'{esc(t(lang, "std_written"))}' if code == "rm-rumgr" else "" return f"{swatch(code)}{esc(IDIOMS[code])}{sub}" head = "".join(f"{name(code)}" for code in order) body = "".join( f'
{i + 1}' f"
{_meaning_cells(m, de)}
" + "".join(f"{_rm(lang, code, *rows[code][i])}" for code in order) + "" for i, (m, de) in enumerate(meanings(lang)) ) return ( f'
' f'{here_row}{head}{body}
{esc(t(lang, "col_phrase"))}
' + legend_html(lang, [r for v in rows.values() for r in v], order) ) def phrases_html(lang, idiom, view, info=None): if not idiom: return brand.empty(t(lang, "empty")) return compare_html(lang, idiom, info) if view == VIEW_ALL else cards_html(lang, idiom) def view_choices(lang): return [(t(lang, "view_mine"), VIEW_MINE), (t(lang, "view_all"), VIEW_ALL)] # ---------------------------------------------------------------- events def on_locate(lat, lon, city, view, lang): idiom, info = resolve_location(lat, lon, city) info = {**info, "idiom": idiom} return (result_html(lang, idiom, info), phrases_html(lang, idiom, view, info), idiom, info, map_html(lang, idiom, info), step2_html(lang, info)) def on_view(idiom, view, lang, info): return phrases_html(lang, idiom, view, info) def on_lang(lang, idiom, info, view): """Re-render every visible text in the chosen language.""" lang = lang if lang in LANGS else DEFAULT_LANG updates = [ lang, gr.update(value=lang), gr.update(value=t(lang, "theme")), about_link_html(lang), hero_html(lang), step1_html(lang), gr.update(placeholder=t(lang, "city_ph")), gr.update(value=t(lang, "search")), or_html(lang), gr.update(value=t(lang, "locate")), step2_html(lang, info), map_html(lang, idiom, info), gr.update(choices=view_choices(lang), label=t(lang, "view_label"), value=view), phrases_html(lang, idiom, view, info), result_html(lang, idiom, info or {}), footer_html(lang), ] return updates def on_translate(text, direction, idiom_name): if not text.strip(): return "" idiom = idiom_code(idiom_name) src, tgt = ("deu_Latn", idiom) if direction == "de2rm" else (idiom, "deu_Latn") return "\n".join(translate(text.strip().splitlines(), src, tgt)) GEO_JS = """ async (lat, lon, city, view, lang) => { city = ''; // this button always uses the device location; the Search button handles typed towns if (!navigator.geolocation) return [null, null, city, view, lang]; try { const p = await new Promise((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, {enableHighAccuracy: false, timeout: 10000, maximumAge: 600000})); return [p.coords.latitude, p.coords.longitude, city, view, lang]; } catch (e) { return [null, null, city, view, lang]; } } """ # Links between our pages load the page fully. Gradio's own in-app page switch does not re-render # with the custom top bar (the address changed but the old page stayed), so we bypass it. FULL_RELOAD_JS = """ element.addEventListener('click', (e) => { const a = e.target.closest('a.al-navlink'); if (!a) return; e.preventDefault(); e.stopImmediatePropagation(); window.location.assign(a.href); }, true); """ # The about page is static; label the back link in the visitor's saved language. BACK_LABEL_JS = """ const labels = %s; let lang = 'en'; try { lang = localStorage.getItem('al-lang') || (navigator.language || 'en').slice(0, 2); } catch (e) {} const a = element.querySelector('a.al-back'); if (a) a.textContent = '← ' + (labels[lang] || labels.en); """ % json.dumps({k: t(k, "back") for k in LANGS}) # On first load: saved choice, else the browser language. Also applies the saved light/dark mode. LANG_LOAD_JS = """ (lang, idiom, info, view) => { (%s)(); let saved = null; try { saved = localStorage.getItem('al-lang'); } catch (e) {} const supported = %s; const browser = (navigator.language || '').slice(0, 2).toLowerCase(); const pick = saved && supported.includes(saved) ? saved : (supported.includes(browser) ? browser : '%s'); document.documentElement.lang = pick; return [pick, idiom, info, view]; } """ % (brand.LOAD_JS.strip(), list(LANGS), DEFAULT_LANG) LANG_SAVE_JS = """ (lang, idiom, info, view) => { try { localStorage.setItem('al-lang', lang); } catch (e) {} document.documentElement.lang = lang; return [lang, idiom, info, view]; } """ # ---------------------------------------------------------------- layout L = DEFAULT_LANG # initial render; the page switches to the visitor's language on load with gr.Blocks(title="Clerina – Romansh phrases for the village you are in") as demo: lang_state = gr.State(L) idiom_state = gr.State(None) info_state = gr.State(None) lat = gr.Number(visible=False) lon = gr.Number(visible=False) with gr.Row(elem_id="al-toolbar"): gr.HTML(brand_html(), container=False, padding=False, elem_id="al-brand") about_link = gr.HTML(about_link_html(L), container=False, padding=False, elem_id="al-about-link", js_on_load=FULL_RELOAD_JS) lang_pick = gr.Radio([(v, k) for k, v in LANGS.items()], value=L, show_label=False, container=False, elem_id="al-lang", scale=0) theme_btn = gr.Button(t(L, "theme"), elem_id="al-theme-toggle", size="sm", scale=0) hero = gr.HTML(hero_html(L), padding=False, container=False, elem_id="al-hero-block") # Without the translate tab there is nothing to switch between, so skip the tab bar. # Content column: centred, 80% of the window on desktop (top bar and header span the full window). with (gr.Tabs(elem_classes="al-page") if SHOW_TRANSLATE else gr.Column(elem_classes="al-page")): with (gr.Tab(t(L, "tab_phrases")) if SHOW_TRANSLATE else gr.Column()): step1 = gr.HTML(step1_html(L)) with gr.Row(equal_height=True, elem_id="al-locate-row"): city = gr.Textbox(placeholder=t(L, "city_ph"), show_label=False, max_lines=1, scale=4, elem_id="al-city") search_btn = gr.Button(t(L, "search"), variant="secondary", size="lg", scale=0, elem_id="al-search") or_label = gr.HTML(or_html(L), container=False, padding=False, elem_id="al-or") locate_btn = gr.Button(t(L, "locate"), variant="primary", size="lg", scale=2, elem_id="al-locate") step2 = gr.HTML(brand.step(t(L, "step2"))) region_map = gr.HTML(map_html(L), container=False, padding=False, elem_id="al-map-block") view = gr.Radio(view_choices(L), value=VIEW_MINE, label=t(L, "view_label"), container=False, elem_id="al-view") phrases = gr.HTML(brand.empty(t(L, "empty")), js_on_load=PLAY_JS, container=False, padding=False) result = gr.HTML(container=False, padding=False) # regional info, shown below the phrases with gr.Tab(t(L, "tab_translate"), visible=SHOW_TRANSLATE): with gr.Row(): direction = gr.Radio([("Deutsch → Rumantsch", "de2rm"), ("Rumantsch → Deutsch", "rm2de")], value="de2rm", label="↔") idiom_t = gr.Dropdown(list(IDIOMS.values()), value="Rumantsch Grischun", label=t(L, "pick_label")) with gr.Row(equal_height=True): src_text = gr.Textbox(lines=5, placeholder="Wo ist der Bahnhof?", show_label=False) out_text = gr.Textbox(lines=5, buttons=["copy"], show_label=False) translate_btn = gr.Button(t(L, "tab_translate"), variant="primary", size="lg") footer = gr.HTML(footer_html(L), elem_classes="al-page") # events theme_btn.click(None, js=brand.TOGGLE_JS) lang_inputs = [lang_pick, idiom_state, info_state, view] lang_outputs = [lang_state, lang_pick, theme_btn, about_link, hero, step1, city, search_btn, or_label, locate_btn, step2, region_map, view, phrases, result, footer] demo.load(on_lang, lang_inputs, lang_outputs, js=LANG_LOAD_JS, show_progress="hidden") lang_pick.input(on_lang, lang_inputs, lang_outputs, js=LANG_SAVE_JS, show_progress="hidden") locate_inputs = [lat, lon, city, view, lang_state] locate_outputs = [result, phrases, idiom_state, info_state, region_map, step2] locate_btn.click(on_locate, locate_inputs, locate_outputs, js=GEO_JS) city.submit(on_locate, locate_inputs, locate_outputs) search_btn.click(on_locate, locate_inputs, locate_outputs) view.input(on_view, [idiom_state, view, lang_state, info_state], phrases) if SHOW_TRANSLATE: # when hidden, don't expose the translator through the API either translate_btn.click(on_translate, [src_text, direction, idiom_t], out_text) src_text.submit(on_translate, [src_text, direction, idiom_t], out_text) # ---------------------------------------------------------------- about page with demo.route("About", "/about") as about_page: with gr.Row(elem_id="al-toolbar"): gr.HTML('← Back to Clerina', container=False, padding=False, elem_id="al-back-link", js_on_load=FULL_RELOAD_JS + BACK_LABEL_JS) about_theme = gr.Button("◐", elem_id="al-theme-toggle", size="sm", scale=0) gr.HTML(about_html(), container=False, padding=False, elem_classes="al-page") about_theme.click(None, js=brand.TOGGLE_JS) about_page.load(None, js=brand.LOAD_JS) if __name__ == "__main__": # Served through a thin wrapper that sets Clerina's head tags (see preview_tags.py), next to the crawlable # content pages, sitemap, robots.txt and icons (see seo.py). import os import uvicorn from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, Response from fastapi.staticfiles import StaticFiles import preview_tags import seo here = os.path.dirname(os.path.abspath(__file__)) icons = seo.ICON_DIR server = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) def get(path): # GET and HEAD (some crawlers and link checkers only send HEAD) return server.api_route(path, methods=["GET", "HEAD"], include_in_schema=False) def static_file(path, media_type): return lambda: FileResponse(path, media_type=media_type, headers={"cache-control": "public, max-age=86400"}) for route, path, media in [("/preview.png", os.path.join(here, "static", "preview.png"), "image/png"), ("/favicon.ico", icons / "favicon.ico", "image/x-icon"), ("/favicon.svg", icons / "favicon.svg", "image/svg+xml"), ("/apple-touch-icon.png", icons / "apple-touch-icon.png", "image/png")]: get(route)(static_file(path, media)) server.mount("/icons", StaticFiles(directory=icons), name="icons") server.mount("/audio", StaticFiles(directory=AUDIO_DIR), name="audio") @get("/robots.txt") def robots(): return PlainTextResponse(seo.robots_txt()) @get("/sitemap.xml") def sitemap(): return Response(seo.sitemap_xml(), media_type="application/xml") @get("/llms.txt") def llms(): return PlainTextResponse(seo.llms_txt()) @get("/romansh-phrases.md") def phrases_md(): return PlainTextResponse(seo.phrases_md(), media_type="text/markdown; charset=utf-8") @get("/manifest.json") def manifest(): return JSONResponse(seo.manifest_json(), media_type="application/manifest+json") content_pages = {path for path, *_ in seo.all_pages()} def content_page(path): def handler(): body = seo.render(path) if body is None: raise HTTPException(404) return HTMLResponse(body) return handler def redirect_to(target): return lambda: Response(status_code=301, headers={"location": target}) for path in sorted(content_pages): get(path)(content_page(path)) if path.endswith("/"): # "/de" -> "/de/" get(path.rstrip("/"))(redirect_to(path)) preview_tags.head_for = seo.app_head preview_tags.noscript_for = lambda path: seo.app_noscript(path, about_html()) server = gr.mount_gradio_app(server, demo, path="/", theme=brand.theme, css=brand.CSS, head=brand.HEAD, favicon_path=str(icons / "favicon-32.png")) uvicorn.run(preview_tags.PreviewTags(server), host=os.environ.get("GRADIO_SERVER_NAME", "127.0.0.1"), port=int(os.environ.get("GRADIO_SERVER_PORT", 7860)))