feat(meta): language-metadata chip under each language tag (#83) - #124
Conversation
Closes #83. Adds a small grey chip beneath each sentence's language tag showing family · typology · morphology — e.g. "Japonic · SOV · agglutinative" under JA, "Romance · SVO · fusional" under FR. A tooltip on hover extends the line with the full family chain and the script name. This is the standout feature LangMap has and no peer authoring tool does: turns the diagram into an educational artefact, not just an alignment graphic. Data: - src/lib/lang-meta.ts — 70+ hand-curated BCP-47 entries covering every locale we ship UI translations for plus the major likely-user picks (Indo-European branches, Sino-Tibetan, Japonic, Koreanic, Ainu, Turkic, Mongolic, Uralic, Afro-Asiatic, Dravidian, Austroasiatic, Austronesian, Tai-Kadai, Niger-Congo, Kartvelian, isolates, constructed langs). Cross-referenced against WALS, Glottolog, Ethnologue. - getLangMeta() falls back from full BCP-47 (en-US → en, de-CH → de) while preserving curated variant entries (ja-Hira, ko-Kore stay distinct from ja, ko). UI: - showLangMeta param wired through Output / Parameters / +page. - Off by default — opt-in via a checkbox in the Text panel ("Show family / typology"). Persisted to localStorage with the other params. - 7 unit tests cover exact-match, region-subtag fallback, variant preservation, and uiLocales coverage. i18n: - Two new keys: params_show_lang_meta, params_show_lang_meta_on. - Translated into all 31 locales (24 major + ain/grc/la/ko-Kore/ ja-Hira/tok/ia) inline. No English fallbacks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
✅ Files skipped from review due to trivial changes (1)
📝 WalkthroughWalkthroughThis PR implements the language metadata display feature from issue ChangesLanguage Metadata Display Feature
Sequence Diagram(s)sequenceDiagram
participant Page
participant Output
participant Parameters
participant getLangMeta
Page->>Output: showLangMeta, sentences
Output->>getLangMeta: getLangMeta(lang)
getLangMeta-->>Output: LangMeta or null
Output->>Output: Render tag-meta chip
Output-->>Page: on:renameMeta({sentence, displayMeta})
Page->>Parameters: bind:showLangMeta
Parameters->>Parameters: Render checkbox with label
Parameters-->>Page: Toggle showLangMeta
Page->>Page: Save to persisted params
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The metadata chip ("Germanic · SVO · fusional") under each language tag
is auto-generated from lang-meta.ts. Make it inline-editable so the user
can override the line per sentence — same pattern as the language label
will use once #126 lands.
- New optional Sentence.displayMeta field; empty string is preserved as
override, undefined means use the auto-generated default.
- Chip is now contenteditable="plaintext-only". Enter blurs / commits,
Esc cancels, blur dispatches renameMeta with undefined when the field
is cleared. Customised state gets italic + accent-coloured text and a
faint focus background.
- normalizeSentence now round-trips displayName and displayMeta through
saveDoc/loadDoc — previously both were silently stripped on reload.
- params_show_lang_meta: 'Itak orowapaye' → 'Itak uepeker' - params_show_lang_meta_on: 'Aynu utari kor itak ari' → 'Ikiri sinrit' Confirmed wording from mkpoli.
Same UX rule as #126 / [[feedback-ux-inline-over-dialog]]: the final- render diagram must look identical whether a user has overridden the metadata chip or not. The italic + accent that I'd added to mirror the tag-text override pattern leaked the customised state into the exported PNG / SVG / PDF, which is exactly what the user asked us to avoid. Drop the .tag-meta.customised CSS and the class binding on the span. The contenteditable affordance + focus background remain, so the user can still edit the chip inline; we just no longer style the resting state any differently when an override is set.
UX request: "We should have language info edited and styled when edited
in Input fieldset". The chip in the Output diagram is now display-only;
the editing affordance + italic/accent customised state lives in the
SentenceInput row instead, so the exported artifact never carries any
visual indication that the metadata was customised.
- Output.svelte: drop contenteditable / onblur / onkeydown / focus
styling on .tag-meta. Also drop the unused renameMeta dispatcher
signature — the event is now emitted from SentenceInput.
- SentenceInput.svelte: when in modify mode and the Parameters chip is
enabled (showLangMeta prop), render a single-row editor labelled with
$LL.params.showLangMeta. Placeholder shows the auto-generated default
(family · typology · morphology) so the user sees what they're
overriding; empty value clears the override. Italic + accent on both
label and input mark the customised state; the diagram stays plain.
Enter blurs/commits, Esc reverts to the stored override.
- +page.svelte: pass {showLangMeta} into SentenceInput and move the
renameMeta event handler from <Output> to <SentenceInput>.
Grid layout grew a new 'm m m' row in both the desktop and mobile
input-form templates so the editor sits between the lang/buttons row
and the guidance row.
Per UX feedback: "instead of putting option to Text, group them together in Input fieldset". The toggle and the per-sentence editor now live together in the same row of SentenceInput so the user gets all language-related controls in one place instead of jumping between the Input fieldset and the Parameters → Text panel. - Parameters.svelte: drop the `showLangMeta` prop + the "Show family / typology" checkbox. - SentenceInput.svelte: `showLangMeta` is now `$bindable` and the toggle UI sits at the top of the new meta-row, with the per-sentence override input appearing below it on its own grid row when (a) the toggle is on, (b) modify mode is active, and (c) the current language has metadata coverage. - +page.svelte: bind:showLangMeta on SentenceInput, dropped from Parameters. Existing localStorage persistence + the prop into <Output> are unchanged — the master state still lives on +page.
…y plain User: "i want inline editing but not edited style it turns blue and italic". So click-to-edit on the chip is back, but the visual indicator of "this is customised" stays only in SentenceInput's meta-row. - Re-add contenteditable="plaintext-only" + onblur/onkeydown to the .tag-meta span in Output, plus the renameMeta dispatcher signature. Enter commits, Esc reverts to the stored value. - Restore the chip's edit affordance CSS (cursor: text, outline: none, focus background) — but no italic/accent for the customised state; that's intentionally absent so the rendered/exported diagram looks identical for default vs override. - +page.svelte now listens to renameMeta on BOTH <Output> (inline) and <SentenceInput> (the meta-row editor) — two entry points, same reducer that updates sentence.displayMeta.
User: "The line rendering is broken due to added height due to language info". The chip was a flex column child of .tag, which made the .tag grid item taller by the chip's height. The output's grid rows size to the tallest cell, so every row grew and the lines drawLines emits had to span the extra chip-height in addition to the user-configured verticalGap — visibly stretching the connector geometry. Pull the chip out of flow: position: absolute, anchored top: 100%, right: 0 against a now-relative .tag. The chip sits in the column gap below the tag-row on the tag's x-axis (connectors live in the sentence-body column, so they don't visually collide), and the grid row height is back to just the tag-row's natural height.
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/lib/types.ts (1)
141-149:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
displayMeta/displayNameare dropped for{ lang, words, ... }sentence objects.The non-token normalization path does not carry these optional overrides, so custom metadata/label can be silently lost.
Suggested patch
const sentenceObject = sentence as { lang: string; words?: string[]; glosses?: string[]; showGloss?: boolean; + displayName?: string; + displayMeta?: string; }; - return createSentence(sentenceObject.lang, sentenceObject.words ?? [], sentenceObject.glosses ?? [], sentenceObject.showGloss ?? false); + const normalized = createSentence( + sentenceObject.lang, + sentenceObject.words ?? [], + sentenceObject.glosses ?? [], + sentenceObject.showGloss ?? false + ); + return { + ...normalized, + ...(sentenceObject.displayName !== undefined ? { displayName: sentenceObject.displayName } : {}), + ...(sentenceObject.displayMeta !== undefined ? { displayMeta: sentenceObject.displayMeta } : {}) + }; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lib/types.ts` around lines 141 - 149, The normalization path is dropping optional displayMeta/displayName when coercing to the { lang, words, ... } shape; update the cast and call site so you preserve these fields (e.g., read displayMeta and displayName from sentence into sentenceObject and pass them into createSentence or apply them to the returned Sentence), updating the createSentence invocation and/or its signature to accept/display those optional overrides (identify the change around the sentenceObject variable and the createSentence(...) call).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/lib/lang-meta.ts`:
- Around line 92-94: The LANG_META lookup is missing canonical BCP-47 script
keys 'zh-Hans' and 'zh-Hant', so lookups like
getLangMeta('zh-Hans')/getLangMeta('zh-Hant') fall back to generic 'zh'; add
entries 'zh-Hans' (copying the object for 'zh-HanS') and 'zh-Hant' (copying the
object for 'zh-HanT') to the LANG_META map so exact lookups succeed, keeping the
same fields (family, typology, morphology, script, speakers) as the existing
HanS/HanT entries referenced in LANG_META and used by getLangMeta.
In `@src/lib/Output.svelte`:
- Around line 727-729: The tooltip (title) is being removed when metaCustomised
is true; keep the title always set so hover shows full info even for edited
chips: in the component using metaCustomised, remove the conditional that sets
title to an empty string and always set title to `${meta.family.join(' · ')} ·
${meta.typology} · ${meta.morphology} · ${meta.script}` (leave
lang={metaCustomised ? undefined : 'en'} and contenteditable as-is); update the
JSX/markup where title is assigned (referencing metaCustomised and title) so the
title value is constant regardless of metaCustomised.
In `@src/lib/SentenceInput.svelte`:
- Around line 323-324: The master global toggle input (id="show-lang-meta",
bind:checked={showLangMeta}) is incorrectly disabled when defaultMetaText is
falsy; remove the disabled={!defaultMetaText} from that input so the global
setting can always be toggled, and keep the availability logic for per-sentence
override in the block that uses {`#if` modifying !== -1 && showLangMeta &&
defaultMetaText} so the per-row control still respects defaultMetaText and
showLangMeta.
---
Outside diff comments:
In `@src/lib/types.ts`:
- Around line 141-149: The normalization path is dropping optional
displayMeta/displayName when coercing to the { lang, words, ... } shape; update
the cast and call site so you preserve these fields (e.g., read displayMeta and
displayName from sentence into sentenceObject and pass them into createSentence
or apply them to the returned Sentence), updating the createSentence invocation
and/or its signature to accept/display those optional overrides (identify the
change around the sentenceObject variable and the createSentence(...) call).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a15ea0ee-86dc-42f2-af8e-41bfd46ab9c0
📒 Files selected for processing (40)
project.inlang/messages/ain.jsonproject.inlang/messages/ar.jsonproject.inlang/messages/bg.jsonproject.inlang/messages/bn.jsonproject.inlang/messages/de.jsonproject.inlang/messages/en.jsonproject.inlang/messages/eo.jsonproject.inlang/messages/es.jsonproject.inlang/messages/fa.jsonproject.inlang/messages/fi.jsonproject.inlang/messages/fr.jsonproject.inlang/messages/grc.jsonproject.inlang/messages/hi.jsonproject.inlang/messages/ia.jsonproject.inlang/messages/id.jsonproject.inlang/messages/it.jsonproject.inlang/messages/ja-Hira.jsonproject.inlang/messages/ja.jsonproject.inlang/messages/ko-Kore.jsonproject.inlang/messages/ko.jsonproject.inlang/messages/la.jsonproject.inlang/messages/nl.jsonproject.inlang/messages/pl.jsonproject.inlang/messages/pt.jsonproject.inlang/messages/ru.jsonproject.inlang/messages/th.jsonproject.inlang/messages/tok.jsonproject.inlang/messages/tr.jsonproject.inlang/messages/uk.jsonproject.inlang/messages/vi.jsonproject.inlang/messages/zh-HanS.jsonproject.inlang/messages/zh-HanT.jsonsrc/i18n/i18n-svelte.tssrc/lib/Output.sveltesrc/lib/SentenceInput.sveltesrc/lib/lang-meta.test.tssrc/lib/lang-meta.tssrc/lib/persisted-params.tssrc/lib/types.tssrc/routes/+page.svelte
User: "vertically still wrong, it's not refreshed". The chip-out-of- flow CSS fix correctly stopped the row from growing, but drawLines wasn't re-running when showLangMeta toggled or when a displayMeta override changed the chip's content width — so the connectors stayed pinned to the pre-chip word positions and looked vertically off. Two fixes: 1. Add `void showLangMeta` and `void sentences` to the existing drawLines run() block, wrapped in tick() so the chip's DOM mount / unmount completes before we measure. 2. Add a ResizeObserver on the <output> element. Any layout change (chip toggle, font load, browser zoom, viewport reflow) triggers a fresh drawLines call. drawLines is read-only on layout, so no feedback loop.
User: "I still don't like it like it's too long". Drop the family component from the default chip text; "SVO · fusional" carries the typologically useful info and is roughly half the width of the previous "Germanic · SVO · fusional". The full family chain stays available on hover via the title attribute. Applied in both Output (chip rendering) and SentenceInput (placeholder for the meta-row editor) so the default text the user sees is consistent across surfaces.
User: "We should allow unknown language input their info". Previously
the chip toggle was disabled and the editor hidden for any BCP-47 tag
that lang-meta.ts didn't recognise — meaning the user could only show
info for the ~70 hand-curated entries.
Now:
- Output.svelte: the chip renders when `showLangMeta && currentMetaText`
(instead of `showLangMeta && meta`). For unknown languages, that
means the user's displayMeta override is enough to bring the chip
into existence — no curated entry needed.
- SentenceInput.svelte: drop the `disabled={!defaultMetaText}` gate on
the toggle and the `&& defaultMetaText` gate on the editor. The
placeholder is whatever the auto-generated default is (empty for
unknown languages — the input is just blank, ready for typing).
When lang-meta has no entry, the chip's lang/title fallback strips the
`lang="en"` hint and the auto-family-chain title since neither applies.
The chip was gated behind `showLangMeta && currentMetaText`, so a language with no lang-meta coverage and no override rendered no chip at all — there was nothing to click to add custom info directly in the diagram, the same "impossible to edit the empty one" bug fixed for the language tag in #126. Render the chip whenever the feature is on, and give the empty state a clickable box: min-width for a horizontal target and a zero-width no-break space (::before) for line height, plus a dashed hover outline for discoverability. None of it is part of innerText, so save/revert is unaffected and the empty chip stays invisible on export. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…chip # Conflicts: # project.inlang/messages/ar.json # project.inlang/messages/bg.json # project.inlang/messages/bn.json # project.inlang/messages/de.json # project.inlang/messages/eo.json # project.inlang/messages/es.json # project.inlang/messages/fa.json # project.inlang/messages/fi.json # project.inlang/messages/fr.json # project.inlang/messages/grc.json # project.inlang/messages/hi.json # project.inlang/messages/ia.json # project.inlang/messages/id.json # project.inlang/messages/it.json # project.inlang/messages/ja-Hira.json # project.inlang/messages/ja.json # project.inlang/messages/ko-Kore.json # project.inlang/messages/ko.json # project.inlang/messages/la.json # project.inlang/messages/nl.json # project.inlang/messages/pl.json # project.inlang/messages/pt.json # project.inlang/messages/ru.json # project.inlang/messages/th.json # project.inlang/messages/tok.json # project.inlang/messages/tr.json # project.inlang/messages/uk.json # project.inlang/messages/vi.json # project.inlang/messages/zh-HanS.json # project.inlang/messages/zh-HanT.json # src/lib/Output.svelte
The empty chip rendered a clickable box but couldn't really be edited:
- Typing into it doubled the text ("HELLO" → "HELLOHELLO"). Cause: the chip's
content came from a reactive `{currentMetaText}` child. When the chip starts
empty, typing creates a second browser text node; the next reactive update
then writes Svelte's own node too, leaving two. Own the content imperatively
via a `use:editableText` action that keeps a single node and only re-syncs
from state when the field isn't focused and the value differs.
- Clearing the text left a stray <br>, which kept the chip out of :empty and
collapsed its clickable box to zero width, so an emptied chip could never be
edited again. Normalize textContent on blur to restore a clean :empty node.
Also guards Enter/Escape against IME composition so CJK input into the chip
isn't cut off mid-candidate. Verified end-to-end (type / clear / re-edit) with
an uncovered language; bun run check is clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The chip was right-aligned (right: 0) to the tag's right edge, while the tagAlignment merge left the labels center-aligned. Because each tag's width varied with its label, the chips landed at different x-offsets — a jagged column shifted right of the labels. Center the chip on the tag's x-axis instead, and take the (invisible) rename button out of flow so the tag's width tracks the label alone — otherwise the button's width pushed the tag center ~half-a-button right of the label. The chip now lines up exactly under the label for left/center/right tagAlignment. Verified in-browser: chip center == label center in all three modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/lib/Output.svelte (1)
331-338: ⚡ Quick winUse Set-based deduplication to avoid quadratic endpoint processing.
Line 333–338 does linear scans per endpoint (
out.some(...)), which scales poorly during frequent redraws.♻️ Proposed refactor
function uniqueEndpoints(lines: Line[]): Array<{ x: number; y: number; color: string }> { - const out: Array<{ x: number; y: number; color: string }> = []; - const has = (x: number, y: number, color: string) => - out.some((p) => Math.round(p.x) === Math.round(x) && Math.round(p.y) === Math.round(y) && p.color === color); + const out: Array<{ x: number; y: number; color: string }> = []; + const seen = new Set<string>(); + const push = (x: number, y: number, color: string) => { + const key = `${Math.round(x)}:${Math.round(y)}:${color}`; + if (seen.has(key)) return; + seen.add(key); + out.push({ x, y, color }); + }; for (const [x1, y1, x2, y2, color] of lines) { - if (!has(x1, y1, color)) out.push({ x: x1, y: y1, color }); - if (!has(x2, y2, color)) out.push({ x: x2, y: y2, color }); + push(x1, y1, color); + push(x2, y2, color); } return out; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lib/Output.svelte` around lines 331 - 338, The uniqueEndpoints function uses a linear search with out.some(...) inside the loop to check for duplicate endpoints, creating quadratic time complexity. Replace this approach by using a Set-based deduplication strategy where you create a Set that tracks previously added endpoints using a key that combines the rounded x and y coordinates along with the color. Before pushing each endpoint to the out array, check if it exists in the Set using this key instead of the has function. This will reduce the time complexity from O(n²) to O(n) and improve performance during frequent redraws.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/lib/Output.svelte`:
- Around line 331-338: The uniqueEndpoints function uses a linear search with
out.some(...) inside the loop to check for duplicate endpoints, creating
quadratic time complexity. Replace this approach by using a Set-based
deduplication strategy where you create a Set that tracks previously added
endpoints using a key that combines the rounded x and y coordinates along with
the color. Before pushing each endpoint to the out array, check if it exists in
the Set using this key instead of the has function. This will reduce the time
complexity from O(n²) to O(n) and improve performance during frequent redraws.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9223d82b-1aa2-44f0-a602-31fd8e2a63d8
📒 Files selected for processing (37)
project.inlang/messages/ain.jsonproject.inlang/messages/ar.jsonproject.inlang/messages/bg.jsonproject.inlang/messages/bn.jsonproject.inlang/messages/de.jsonproject.inlang/messages/en.jsonproject.inlang/messages/eo.jsonproject.inlang/messages/es.jsonproject.inlang/messages/fa.jsonproject.inlang/messages/fi.jsonproject.inlang/messages/fr.jsonproject.inlang/messages/grc.jsonproject.inlang/messages/hi.jsonproject.inlang/messages/ia.jsonproject.inlang/messages/id.jsonproject.inlang/messages/it.jsonproject.inlang/messages/ja-Hira.jsonproject.inlang/messages/ja.jsonproject.inlang/messages/ko-Kore.jsonproject.inlang/messages/ko.jsonproject.inlang/messages/la.jsonproject.inlang/messages/nl.jsonproject.inlang/messages/pl.jsonproject.inlang/messages/pt.jsonproject.inlang/messages/ru.jsonproject.inlang/messages/th.jsonproject.inlang/messages/tok.jsonproject.inlang/messages/tr.jsonproject.inlang/messages/uk.jsonproject.inlang/messages/vi.jsonproject.inlang/messages/zh-HanS.jsonproject.inlang/messages/zh-HanT.jsonsrc/i18n/i18n-svelte.tssrc/lib/Output.sveltesrc/lib/SentenceInput.sveltesrc/lib/persisted-params.tssrc/routes/+page.svelte
✅ Files skipped from review due to trivial changes (12)
- project.inlang/messages/ru.json
- project.inlang/messages/ain.json
- project.inlang/messages/th.json
- project.inlang/messages/ar.json
- project.inlang/messages/ia.json
- project.inlang/messages/de.json
- project.inlang/messages/pt.json
- project.inlang/messages/en.json
- project.inlang/messages/id.json
- project.inlang/messages/uk.json
- project.inlang/messages/it.json
- project.inlang/messages/tok.json
🚧 Files skipped from review as they are similar to previous changes (18)
- project.inlang/messages/tr.json
- project.inlang/messages/zh-HanT.json
- project.inlang/messages/ja-Hira.json
- project.inlang/messages/ko.json
- project.inlang/messages/es.json
- project.inlang/messages/ja.json
- project.inlang/messages/vi.json
- project.inlang/messages/la.json
- project.inlang/messages/pl.json
- project.inlang/messages/fr.json
- src/lib/persisted-params.ts
- project.inlang/messages/ko-Kore.json
- project.inlang/messages/fa.json
- project.inlang/messages/grc.json
- project.inlang/messages/zh-HanS.json
- project.inlang/messages/fi.json
- src/routes/+page.svelte
- src/lib/SentenceInput.svelte
The "Show family / typology" toggle lived in the Input/edit fieldset; move it to a new "Language info" panel in Parameters, alongside Options / Text / Colors. Reuses the existing i18n keys (params_show_lang_meta as the panel title, _on as the toggle label), so no new translations. Per-sentence meta is now edited inline on the diagram chip (which works after the earlier fix), so the input-side editor — which never propagated to the chip anyway — is removed along with its now-dead state, dispatch, grid row, and styles. This clears all lang-meta UI out of the input/edit area. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Closes #83. Adds a small grey chip beneath each sentence's language tag showing family · typology · morphology — e.g. "Japonic · SOV · agglutinative" under JA, "Romance · SVO · fusional" under FR. A title attribute on hover extends the line with the full family chain and the script name.
This is the standout feature LangMap has and no peer authoring tool does: turns the diagram into an educational artefact, not just an alignment graphic.
Data layer
UI layer
i18n
Test plan
Summary by CodeRabbit