Korrespond: stop mixing UI languages — all chrome follows user UI lang

Drafts still come back in Norwegian + working language (that is intentional),
but every piece of *chrome* now respects the user's UI lang consistently:

- Pass 1 classify LLM now writes missing-fact questions in the user's language
  (not always Norwegian), fixing the case where an English-UI user got "Hva er
  saksnummeret?" in the clarify panel.
- All PHP-emitted progress/status messages go through DbnKorrespondAgent::L()
  with en/no/pl/uk variants instead of hardcoded Norwegian.
- JS introduces an I18N dictionary + t() helper covering status messages,
  button labels, column headers, flag labels, refine panel title/hint,
  jurisdiction radio labels, clarify panel title/hint/buttons, the empty-state
  "Ready" block, and Copy/Copied/Download .txt.
- Static clarify and empty-state chrome use [data-i18n] attributes resolved at
  init and re-applied on every lang-switcher click.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 12:11:16 +02:00
parent 5d8ae6b447
commit dfb9692f45
4 changed files with 256 additions and 87 deletions
+152 -68
View File
@@ -15,6 +15,77 @@
const LANG_LABELS = { en: 'English', no: 'Norsk', uk: 'Українська', pl: 'Polski' };
// All user-facing chrome strings, localized. Drafts themselves stay in NO + working lang.
const I18N = {
pick_recipient: { en: 'Pick a recipient body before drafting.', no: 'Velg en mottaker før utkast.', pl: 'Wybierz odbiorcę przed sporządzeniem projektu.', uk: 'Виберіть отримувача перед чернеткою.' },
initiate_narrative: { en: 'Describe the situation in "What happened" first.', no: 'Beskriv situasjonen i "Hva skjedde" først.', pl: 'Najpierw opisz sytuację w polu "Co się stało".', uk: 'Спочатку опишіть ситуацію у полі "Що сталося".' },
reply_no_input: { en: 'Reply mode needs the received letter (upload or paste it).', no: 'Svarmodus krever det mottatte brevet (last opp eller lim inn).', pl: 'Tryb odpowiedzi wymaga otrzymanego pisma (prześlij lub wklej).', uk: 'Режим відповіді потребує отриманого листа (завантажте або вставте).' },
analyzing: { en: 'Analyzing…', no: 'Analyserer…', pl: 'Analiza…', uk: 'Аналізую…' },
refining: { en: 'Refining…', no: 'Skriver om…', pl: 'Przepisuję…', uk: 'Переписую…' },
started: { en: 'Started — {body} / {output}', no: 'Startet — {body} / {output}', pl: 'Rozpoczęto — {body} / {output}', uk: 'Розпочато — {body} / {output}' },
fetched_sources: { en: 'Fetched {n} sources for {acts}…', no: 'Hentet {n} kilder for {acts}…', pl: 'Pobrano {n} źródeł dla {acts}…', uk: 'Завантажено {n} джерел для {acts}…' },
need_clarify: { en: 'Need clarification before drafting.', no: 'Trenger avklaring før utkast.', pl: 'Wymaga wyjaśnienia przed sporządzeniem projektu.', uk: 'Потрібне уточнення перед чернеткою.' },
network_error: { en: 'Network error: {msg}', no: 'Nettverksfeil: {msg}', pl: 'Błąd sieci: {msg}', uk: 'Помилка мережі: {msg}' },
stream_error: { en: 'Stream error: {msg}', no: 'Strømfeil: {msg}', pl: 'Błąd strumienia: {msg}', uk: 'Помилка потоку: {msg}' },
request_failed: { en: 'Request failed ({status}).', no: 'Forespørselen mislyktes ({status}).', pl: 'Żądanie nie powiodło się ({status}).', uk: 'Запит не вдалося ({status}).' },
stream_no_draft: { en: 'Stream ended without a draft.', no: 'Strømmen sluttet uten utkast.', pl: 'Strumień zakończył się bez projektu.', uk: 'Потік завершився без чернетки.' },
done_summary: { en: 'Done in {s} s · {n} cited source(s)', no: 'Ferdig på {s} s · {n} sitert(e) kilde(r)', pl: 'Gotowe w {s} s · {n} cytowanych źródeł', uk: 'Готово за {s} с · {n} цитованих джерел' },
refining_status: { en: 'Refining draft with {jur} authorities…', no: 'Skriver om utkast med {jur}-kilder…', pl: 'Przepisuję projekt ze źródłami {jur}…', uk: 'Переписую чернетку з джерелами {jur}…' },
refining_short: { en: 'Refining ({jur})…', no: 'Skriver om ({jur})…', pl: 'Przepisuję ({jur})…', uk: 'Переписую ({jur})…' },
refined_summary: { en: 'Refined in {s} s · {n} cited authority(ies) · {jur}', no: 'Omskrevet på {s} s · {n} sitert(e) rettskilde(r) · {jur}', pl: 'Przepisano w {s} s · {n} cytowanych autorytetów · {jur}', uk: 'Переписано за {s} с · {n} цитованих джерел · {jur}' },
refine_failed: { en: 'Refine failed ({status}).', no: 'Omskriving mislyktes ({status}).', pl: 'Przepisanie nie powiodło się ({status}).', uk: 'Переписування не вдалося ({status}).' },
refine_no_result: { en: 'Refine stream ended without a result.', no: 'Strømmen sluttet uten resultat.', pl: 'Strumień zakończył się bez wyniku.', uk: 'Потік завершився без результату.' },
refine_fetched: { en: 'Fetched {n} authorities for {jur}…', no: 'Hentet {n} rettskilder for {jur}…', pl: 'Pobrano {n} autorytetów dla {jur}…', uk: 'Завантажено {n} джерел для {jur}…' },
working: { en: 'Working…', no: 'Arbeider…', pl: 'Pracuję…', uk: 'Працюю…' },
working_long: { en: 'Pass 1 extracts facts. If anything is missing we will ask. Then Pass 2 runs hard-RAG retrieval, draft, self-check, and translation.', no: 'Pass 1 henter fakta. Hvis noe mangler spør vi. Deretter kjører Pass 2: kilder, utkast, kontroll og oversettelse.', pl: 'Pass 1 wyodrębnia fakty. Jeśli czegoś brak, zapytamy. Następnie Pass 2: źródła, projekt, kontrola, tłumaczenie.', uk: 'Pass 1 витягує факти. Якщо чогось бракує, ми спитаємо. Потім Pass 2: джерела, чернетка, перевірка, переклад.' },
no_cited: { en: 'No cited law sources — draft is plain-language (no § references available from corpus).', no: 'Ingen siterte lovkilder — utkastet er på vanlig språk (ingen §-henvisninger fra korpus).', pl: 'Brak cytowanych źródeł prawa — projekt w zwykłym języku (brak odniesień § z korpusu).', uk: 'Немає цитованих джерел права — чернетка звичайною мовою (без § посилань з корпусу).' },
no_draft_yet: { en: 'No draft to refine. Run a draft first.', no: 'Ingen utkast å skrive om. Lag et utkast først.', pl: 'Brak projektu do przepisania. Najpierw stwórz projekt.', uk: 'Немає чернетки для переписування. Спочатку створіть чернетку.' },
// Section/header chrome
ready_title: { en: 'Ready', no: 'Klar', pl: 'Gotowe', uk: 'Готовий' },
ready_desc: { en: 'Pick a recipient body, describe the situation, choose an output type and tone, then run. Drafts always come back in Norwegian bokmål + your working language, side-by-side, with verified law citations.', no: 'Velg en mottaker, beskriv situasjonen, velg utdataformat og tone, og kjør. Utkast leveres alltid på norsk bokmål + arbeidsspråket, side ved side, med verifiserte lovhenvisninger.', pl: 'Wybierz odbiorcę, opisz sytuację, wybierz format i ton, uruchom. Projekty zawsze zwracane są po norwesku (bokmål) + w Twoim języku, obok siebie, ze zweryfikowanymi cytatami prawa.', uk: 'Виберіть отримувача, опишіть ситуацію, виберіть формат і тон, запустіть. Чернетки повертаються норвезькою (bokmål) + вашою мовою, поряд, з перевіреними посиланнями на закон.' },
summary_pass1: { en: 'Summary (Pass 1)', no: 'Sammendrag (Pass 1)', pl: 'Podsumowanie (Pass 1)', uk: 'Резюме (Pass 1)' },
assumed_legal: { en: 'Presumed legal basis:', no: 'Antatt rettslig grunnlag:', pl: 'Domniemana podstawa prawna:', uk: 'Передбачувана правова основа:' },
deadlines_label: { en: 'Deadlines:', no: 'Frister:', pl: 'Terminy:', uk: 'Терміни:' },
canonical: { en: 'canonical', no: 'kanonisk', pl: 'kanoniczny', uk: 'канонічний' },
reference: { en: 'reference', no: 'referanse', pl: 'referencja', uk: 'довідка' },
refined_label: { en: 'refined', no: 'omskrevet', pl: 'przepisany', uk: 'переписаний' },
copy: { en: 'Copy', no: 'Kopier', pl: 'Kopiuj', uk: 'Копіювати' },
copied: { en: 'Copied ✓', no: 'Kopiert ✓', pl: 'Skopiowano ✓', uk: 'Скопійовано ✓' },
download_txt: { en: 'Download .txt', no: 'Last ned .txt', pl: 'Pobierz .txt', uk: 'Завантажити .txt' },
cited_law_n: { en: 'Cited law ({n})', no: 'Siterte kilder ({n})', pl: 'Cytowane prawo ({n})', uk: 'Цитоване право ({n})' },
cited_law_hint: { en: 'each reference traces to a real corpus passage', no: 'hver henvisning kan spores til en reell korpuspassasje', pl: 'każde odniesienie pochodzi z prawdziwego fragmentu korpusu', uk: 'кожне посилання простежується до реального уривка корпусу' },
cited_authorities_n:{ en: 'Cited authorities ({n})', no: 'Siterte rettskilder ({n})', pl: 'Cytowane autorytety ({n})', uk: 'Цитовані джерела ({n})' },
view_source: { en: 'View source', no: 'Se kilde', pl: 'Zobacz źródło', uk: 'Переглянути джерело' },
flag_cites: { en: 'Citations verified', no: 'Henvisninger verifisert', pl: 'Cytaty zweryfikowane', uk: 'Цитати перевірено' },
flag_deadline: { en: 'Deadline', no: 'Frist', pl: 'Termin', uk: 'Термін' },
flag_goal: { en: 'Goal addressed', no: 'Mål adressert', pl: 'Cel uwzględniony', uk: 'Мета врахована' },
flag_tone: { en: 'Tone', no: 'Tone', pl: 'Ton', uk: 'Тон' },
refine_title: { en: 'Refine with formal citations', no: 'Skriv om med formelle henvisninger', pl: 'Przepisz z formalnymi cytatami', uk: 'Переписати з формальними цитатами' },
one_extra_credit: { en: '(+1 credit)', no: '(+1 kreditt)', pl: '(+1 kredyt)', uk: '(+1 кредит)' },
refine_hint: { en: 'Optional 2nd pass: pull fresh authorities, rewrite citations in formal style ("jf. forvaltningsloven § 17", "jf. Strand Lobben m.fl. mot Norge, EMD-37283/13, §§ 207214"), and append a Rettskilder block at the bottom.', no: 'Valgfri 2. omgang: hent ferske rettskilder, skriv om henvisningene formelt ("jf. forvaltningsloven § 17", "jf. Strand Lobben m.fl. mot Norge, EMD-37283/13, §§ 207214"), og legg ved en Rettskilder-blokk nederst.', pl: 'Opcjonalna 2. tura: pobierz świeże autorytety, przepisz cytaty w stylu formalnym, dołącz blok Rettskilder na końcu.', uk: 'Опційний 2-й прохід: завантажити свіжі джерела, переписати цитати у формальному стилі, додати блок Rettskilder в кінці.' },
jur_norwegian: { en: 'Norwegian law only', no: 'Kun norsk rett', pl: 'Tylko prawo norweskie', uk: 'Лише норвезьке право' },
jur_echr: { en: 'ECHR (EMK + HUDOC)', no: 'EMK (EMK + HUDOC)', pl: 'EKPC (EMK + HUDOC)', uk: 'ЄКПЛ (EMK + HUDOC)' },
jur_both: { en: 'Both', no: 'Begge', pl: 'Oba', uk: 'Обидва' },
refine_btn: { en: 'Refine citations', no: 'Skriv om henvisninger', pl: 'Przepisz cytaty', uk: 'Переписати цитати' },
refine_btn_busy: { en: 'Refining…', no: 'Skriver om…', pl: 'Przepisuję…', uk: 'Переписую…' },
// Clarify chrome
clarify_title: { en: 'Before we draft, clarify:', no: 'Før vi skriver, avklar:', pl: 'Zanim sporządzimy projekt, wyjaśnij:', uk: 'Перш ніж писати, уточніть:' },
clarify_hint: { en: 'Answer what you can, then click Continue draft. Or click Draft anyway to proceed with what we have.', no: 'Svar på det du kan, og klikk Fortsett utkast. Eller klikk Skriv likevel for å fortsette med det vi har.', pl: 'Odpowiedz, co możesz, i kliknij Kontynuuj projekt. Albo kliknij Mimo to napisz, aby kontynuować z tym, co mamy.', uk: 'Дайте відповідь, що можете, потім натисніть Продовжити. Або натисніть Все одно написати, щоб продовжити з тим, що є.' },
clarify_continue: { en: 'Continue draft', no: 'Fortsett utkast', pl: 'Kontynuuj projekt', uk: 'Продовжити' },
clarify_force: { en: 'Draft anyway', no: 'Skriv likevel', pl: 'Mimo to napisz', uk: 'Все одно написати' },
optional: { en: '(optional)', no: '(valgfritt)', pl: '(opcjonalnie)', uk: '(необов’язково)' },
// Bracket label inside drafts column header — value-only (the language NAME stays its own name)
no_column: { en: 'Norsk (bokmål)', no: 'Norsk (bokmål)', pl: 'Norsk (bokmål)', uk: 'Norsk (bokmål)' },
};
function t(key, vars) {
const m = I18N[key];
if (!m) return key;
let s = m[lang] || m.en || key;
if (vars) for (const k in vars) s = s.replaceAll('{' + k + '}', String(vars[k]));
return s;
}
document.addEventListener('DOMContentLoaded', () => {
if (document.body.dataset.activeTool !== 'korrespond') return;
@@ -53,9 +124,18 @@
bindGoalChips();
bindUpload();
bindClarify();
applyStaticI18n();
els.form.addEventListener('submit', (e) => { e.preventDefault(); runRequest(false); });
});
// Apply [data-i18n] attributes on static DOM (and re-apply after lang switch).
function applyStaticI18n() {
document.querySelectorAll('[data-i18n]').forEach((el) => {
const key = el.getAttribute('data-i18n');
if (key) el.textContent = t(key);
});
}
// ── Language switcher ───────────────────────────────────────────────────────
function bindLang() {
els.langButtons.forEach((b) => {
@@ -65,6 +145,7 @@
b.classList.add('is-active');
lang = b.dataset.lang || 'en';
localStorage.setItem('dbn-ui-lang', lang);
applyStaticI18n();
});
});
}
@@ -199,21 +280,21 @@
async function runRequest(forceDraft) {
const payload = buildPayload(forceDraft);
if (!payload.recipient_body) {
setStatus('Pick a recipient body before drafting.', 'error');
setStatus(t('pick_recipient'), 'error');
return;
}
if (payload.mode === 'initiate' && !payload.narrative) {
setStatus('Describe the situation in "What happened" first.', 'error');
setStatus(t('initiate_narrative'), 'error');
return;
}
if (payload.mode === 'reply' && !uploadFiles.length && !payload.narrative) {
setStatus('Reply mode needs the received letter (upload or paste it).', 'error');
setStatus(t('reply_no_input'), 'error');
return;
}
setStatus('Analyserer…', 'busy');
setStatus(t('analyzing'), 'busy');
els.runButton.disabled = true;
els.results.innerHTML = `<div class="empty-state"><h3>Working…</h3><p>Pass 1 extracts facts. If anything is missing we'll ask for clarification. Otherwise Pass 2 runs hard-RAG retrieval + draft + self-check + translate.</p></div>`;
els.results.innerHTML = `<div class="empty-state"><h3>${esc(t('working'))}</h3><p>${esc(t('working_long'))}</p></div>`;
const form = new FormData();
form.append('payload', JSON.stringify(payload));
@@ -223,7 +304,7 @@
try {
response = await fetch('api/korrespond.php', { method: 'POST', body: form, credentials: 'same-origin' });
} catch (err) {
setStatus(`Network error: ${err.message || err}`, 'error');
setStatus(t('network_error', { msg: err.message || err }), 'error');
els.runButton.disabled = false;
return;
}
@@ -233,7 +314,7 @@
const d = await response.json().catch(() => ({}));
if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d);
} else {
setStatus(`Request failed (${response.status}).`, 'error');
setStatus(t('request_failed', { status: response.status }), 'error');
}
els.runButton.disabled = false;
return;
@@ -254,7 +335,7 @@
let chunk;
try { chunk = await reader.read(); }
catch (err) {
setStatus(`Stream error: ${err.message || err}`, 'error');
setStatus(t('stream_error', { msg: err.message || err }), 'error');
els.runButton.disabled = false;
return;
}
@@ -268,10 +349,10 @@
if (!trimmed) continue;
let evt; try { evt = JSON.parse(trimmed); } catch (_) { continue; }
if (!evt || !evt.event) continue;
if (evt.event === 'progress') { setStatus(evt.detail || 'Working', 'busy'); continue; }
if (evt.event === 'start') { setStatus(`Started — ${evt.body} / ${evt.output_type}`, 'busy'); continue; }
if (evt.event === 'progress') { setStatus(evt.detail || t('working'), 'busy'); continue; }
if (evt.event === 'start') { setStatus(t('started', { body: evt.body, output: evt.output_type }), 'busy'); continue; }
if (evt.event === 'classify') { lastClassify = evt.result; renderClassifySummary(evt.result); continue; }
if (evt.event === 'retrieval'){ setStatus(`Hentet ${evt.sources_count} lovkilder for ${(evt.applicable_acts || []).join(', ')}`, 'busy'); continue; }
if (evt.event === 'retrieval'){ setStatus(t('fetched_sources', { n: evt.sources_count, acts: (evt.applicable_acts || []).join(', ') }), 'busy'); continue; }
if (evt.event === 'clarify') { clarifyEvent = evt; continue; }
if (evt.event === 'final') { finalResult = evt.result; continue; }
if (evt.event === 'error') { errorEvent = evt; continue; }
@@ -287,16 +368,16 @@
return;
}
if (clarifyEvent) {
setStatus('Need clarification before drafting.', 'busy');
setStatus(t('need_clarify'), 'busy');
showClarify(clarifyEvent.questions);
return;
}
if (!finalResult) {
setStatus('Stream ended without a draft.', 'error');
setStatus(t('stream_no_draft'), 'error');
return;
}
setStatus(`Done in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${(finalResult.cited_law || []).length} cited source(s)`, 'ok');
setStatus(t('done_summary', { s: Math.round((finalResult.latency_ms || 0) / 1000), n: (finalResult.cited_law || []).length }), 'ok');
lastFinal = finalResult;
renderFinal(finalResult);
pendingClarifications = {}; // reset for next run
@@ -305,12 +386,15 @@
// ── Pass 3: refine with jurisdiction-scoped formal citations ────────────────
async function runRefine(jurisdiction) {
if (!lastFinal || !lastClassify) {
setStatus('No draft to refine. Run a draft first.', 'error');
setStatus(t('no_draft_yet'), 'error');
return;
}
const jurLabel = jurisdiction === 'echr' ? t('jur_echr')
: jurisdiction === 'both' ? t('jur_both')
: t('jur_norwegian');
const refineBtn = document.getElementById('korrRefineBtn');
if (refineBtn) { refineBtn.disabled = true; refineBtn.textContent = 'Refining…'; }
setStatus(`Refining draft with ${jurisdiction} authorities…`, 'busy');
if (refineBtn) { refineBtn.disabled = true; refineBtn.textContent = t('refine_btn_busy'); }
setStatus(t('refining_status', { jur: jurLabel }), 'busy');
const payload = {
jurisdiction,
@@ -333,8 +417,8 @@
body: JSON.stringify(payload),
});
} catch (err) {
setStatus(`Network error: ${err.message || err}`, 'error');
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
setStatus(t('network_error', { msg: err.message || err }), 'error');
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); }
return;
}
@@ -343,9 +427,9 @@
const d = await response.json().catch(() => ({}));
if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d);
} else {
setStatus(`Refine failed (${response.status}).`, 'error');
setStatus(t('refine_failed', { status: response.status }), 'error');
}
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); }
return;
}
const creditsRemaining = response.headers.get('X-Credits-Remaining');
@@ -363,8 +447,8 @@
let chunk;
try { chunk = await reader.read(); }
catch (err) {
setStatus(`Stream error: ${err.message || err}`, 'error');
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
setStatus(t('stream_error', { msg: err.message || err }), 'error');
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); }
return;
}
const { done, value } = chunk;
@@ -377,9 +461,9 @@
if (!trimmed) continue;
let evt; try { evt = JSON.parse(trimmed); } catch (_) { continue; }
if (!evt || !evt.event) continue;
if (evt.event === 'progress') { setStatus(evt.detail || 'Refining', 'busy'); continue; }
if (evt.event === 'start') { setStatus(`Refining (${evt.jurisdiction})…`, 'busy'); continue; }
if (evt.event === 'retrieval') { setStatus(`Hentet ${evt.sources_count} rettskilder for ${evt.jurisdiction}`, 'busy'); continue; }
if (evt.event === 'progress') { setStatus(evt.detail || t('refining'), 'busy'); continue; }
if (evt.event === 'start') { setStatus(t('refining_short', { jur: jurLabel }), 'busy'); continue; }
if (evt.event === 'retrieval') { setStatus(t('refine_fetched', { n: evt.sources_count, jur: jurLabel }), 'busy'); continue; }
if (evt.event === 'final') { finalResult = evt.result; continue; }
if (evt.event === 'error') { errorEvent = evt; continue; }
}
@@ -387,18 +471,18 @@
if (done) break;
}
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); }
if (errorEvent) {
setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error');
return;
}
if (!finalResult) {
setStatus('Refine stream ended without a result.', 'error');
setStatus(t('refine_no_result'), 'error');
return;
}
setStatus(`Refined in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${(finalResult.cited_law || []).length} cited authority(ies) · ${finalResult.jurisdiction}`, 'ok');
setStatus(t('refined_summary', { s: Math.round((finalResult.latency_ms || 0) / 1000), n: (finalResult.cited_law || []).length, jur: jurLabel }), 'ok');
renderRefined(finalResult);
}
@@ -414,10 +498,10 @@
els.results.appendChild(block);
}
block.innerHTML = `
<h3 style="margin:0 0 10px;font-size:0.95rem;color:var(--ink)">Sammendrag (Pass 1)</h3>
<h3 style="margin:0 0 10px;font-size:0.95rem;color:var(--ink)">${esc(t('summary_pass1'))}</h3>
<p>${esc(c.summary)}</p>
${c.applicable_acts && c.applicable_acts.length ? `<p class="upload-hint"><strong>Antatt rettslig grunnlag:</strong> ${c.applicable_acts.map(esc).join(', ')}</p>` : ''}
${c.deadlines && c.deadlines.length ? `<p class="upload-hint"><strong>Frister:</strong> ${c.deadlines.map(esc).join(', ')}</p>` : ''}
${c.applicable_acts && c.applicable_acts.length ? `<p class="upload-hint"><strong>${esc(t('assumed_legal'))}</strong> ${c.applicable_acts.map(esc).join(', ')}</p>` : ''}
${c.deadlines && c.deadlines.length ? `<p class="upload-hint"><strong>${esc(t('deadlines_label'))}</strong> ${c.deadlines.map(esc).join(', ')}</p>` : ''}
`;
}
@@ -442,20 +526,20 @@
<div class="korr-result-head">
<span class="tool-badge">${esc(data.recipient_body || '')} · ${esc(data.output_type || '')} · ${esc(data.tone || '')}</span>
<div class="korr-flags">
${flagBadge('citations_verified', 'Citations verified')}
${flagBadge('deadline_mentioned', 'Deadline')}
${flagBadge('goal_addressed', 'Goal addressed')}
${flagBadge('tone', 'Tone')}
${flagBadge('citations_verified', t('flag_cites'))}
${flagBadge('deadline_mentioned', t('flag_deadline'))}
${flagBadge('goal_addressed', t('flag_goal'))}
${flagBadge('tone', t('flag_tone'))}
</div>
</div>
<div class="korr-drafts ${isSameLang ? 'is-single' : ''}">
<div class="korr-draft-col">
<div class="korr-draft-head">
<h3>Norsk (bokmål) — kanonisk</h3>
<h3>${esc(t('no_column'))}${esc(t('canonical'))}</h3>
<div class="korr-draft-actions">
<button type="button" class="secondary-button" data-copy="no">Copy</button>
<button type="button" class="secondary-button" data-download="no">Download .txt</button>
<button type="button" class="secondary-button" data-copy="no">${esc(t('copy'))}</button>
<button type="button" class="secondary-button" data-download="no">${esc(t('download_txt'))}</button>
</div>
</div>
<pre class="korr-draft-body" id="korrDraftNo">${esc(draftNo)}</pre>
@@ -463,10 +547,10 @@
${isSameLang ? '' : `
<div class="korr-draft-col">
<div class="korr-draft-head">
<h3>${esc(userLangLabel)}reference</h3>
<h3>${esc(userLangLabel)}${esc(t('reference'))}</h3>
<div class="korr-draft-actions">
<button type="button" class="secondary-button" data-copy="user">Copy</button>
<button type="button" class="secondary-button" data-download="user">Download .txt</button>
<button type="button" class="secondary-button" data-copy="user">${esc(t('copy'))}</button>
<button type="button" class="secondary-button" data-download="user">${esc(t('download_txt'))}</button>
</div>
</div>
<pre class="korr-draft-body" id="korrDraftUser">${esc(draftUser)}</pre>
@@ -475,29 +559,29 @@
${cited.length ? `
<details class="korr-cited" open>
<summary><strong>Cited law (${cited.length})</strong> — each reference traces to a real corpus passage</summary>
<summary><strong>${esc(t('cited_law_n', { n: cited.length }))}</strong> — ${esc(t('cited_law_hint'))}</summary>
<div class="korr-cited-list">
${cited.map((s) => `
<div class="korr-cited-item">
<div class="korr-cited-head"><strong>[${s.n}] ${esc(s.title)}</strong>${s.section ? ' — ' + esc(s.section) : ''}</div>
<p class="korr-cited-excerpt">${esc(s.excerpt || '')}</p>
${s.source_url ? `<a href="${esc(s.source_url)}" target="_blank" rel="noopener">View source</a>` : ''}
${s.source_url ? `<a href="${esc(s.source_url)}" target="_blank" rel="noopener">${esc(t('view_source'))}</a>` : ''}
</div>
`).join('')}
</div>
</details>
` : '<p class="upload-hint"><em>No cited law sources — draft is plain-language (no § references available from corpus).</em></p>'}
` : `<p class="upload-hint"><em>${esc(t('no_cited'))}</em></p>`}
${data.disclaimer ? `<p class="upload-hint" style="margin-top:16px;font-style:italic">${esc(data.disclaimer)}</p>` : ''}
<section class="korr-refine-panel" id="korrRefinePanel" aria-labelledby="korrRefineTitle">
<h3 id="korrRefineTitle">Refine with formal citations <small>(+1 credit)</small></h3>
<p class="upload-hint">Optional 2nd pass: pull fresh authorities, rewrite citations in formal style ("jf. forvaltningsloven § 17", "jf. Strand Lobben m.fl. mot Norge, EMD-37283/13, §§ 207214"), and append a <em>Rettskilder</em> block at the bottom.</p>
<h3 id="korrRefineTitle">${esc(t('refine_title'))} <small>${esc(t('one_extra_credit'))}</small></h3>
<p class="upload-hint">${esc(t('refine_hint'))}</p>
<div class="korr-refine-controls" role="radiogroup" aria-label="Jurisdiction">
<label><input type="radio" name="korrJurisdiction" value="norwegian" checked> Norwegian law only</label>
<label><input type="radio" name="korrJurisdiction" value="echr"> ECHR (EMK + HUDOC)</label>
<label><input type="radio" name="korrJurisdiction" value="both"> Both</label>
<button type="button" id="korrRefineBtn" class="primary-button">Refine citations</button>
<label><input type="radio" name="korrJurisdiction" value="norwegian" checked> ${esc(t('jur_norwegian'))}</label>
<label><input type="radio" name="korrJurisdiction" value="echr"> ${esc(t('jur_echr'))}</label>
<label><input type="radio" name="korrJurisdiction" value="both"> ${esc(t('jur_both'))}</label>
<button type="button" id="korrRefineBtn" class="primary-button">${esc(t('refine_btn'))}</button>
</div>
<div id="korrRefinedSlot"></div>
</section>
@@ -508,7 +592,7 @@
btn.addEventListener('click', () => {
const target = btn.dataset.copy === 'no' ? draftNo : draftUser;
navigator.clipboard?.writeText(target).then(
() => { btn.textContent = 'Copied'; setTimeout(() => btn.textContent = 'Copy', 1500); },
() => { btn.textContent = t('copied'); setTimeout(() => btn.textContent = t('copy'), 1500); },
() => { btn.textContent = 'Failed'; }
);
});
@@ -539,9 +623,9 @@
const isSameLang = userLang === 'no';
const draftNo = data.draft_no || '';
const draftUser = data.draft_user || '';
const jurLabel = data.jurisdiction === 'echr' ? 'ECHR (EMK + HUDOC)'
: data.jurisdiction === 'both' ? 'Norwegian + ECHR'
: 'Norwegian law';
const jurLabel = data.jurisdiction === 'echr' ? t('jur_echr')
: data.jurisdiction === 'both' ? t('jur_both')
: t('jur_norwegian');
const flagBadge = (key, label) => {
const v = flags[key] || 'ok';
@@ -553,21 +637,21 @@
slot.innerHTML = `
<div class="korr-refined">
<div class="korr-result-head">
<span class="tool-badge">Refined · ${esc(jurLabel)}</span>
<span class="tool-badge">${esc(t('refined_label'))} · ${esc(jurLabel)}</span>
<div class="korr-flags">
${flagBadge('citations_verified', 'Citations verified')}
${flagBadge('deadline_mentioned', 'Deadline')}
${flagBadge('goal_addressed', 'Goal addressed')}
${flagBadge('citations_verified', t('flag_cites'))}
${flagBadge('deadline_mentioned', t('flag_deadline'))}
${flagBadge('goal_addressed', t('flag_goal'))}
</div>
</div>
<div class="korr-drafts ${isSameLang ? 'is-single' : ''}">
<div class="korr-draft-col">
<div class="korr-draft-head">
<h3>Norsk (bokmål) — refined</h3>
<h3>${esc(t('no_column'))}${esc(t('refined_label'))}</h3>
<div class="korr-draft-actions">
<button type="button" class="secondary-button" data-rcopy="no">Copy</button>
<button type="button" class="secondary-button" data-rdownload="no">Download .txt</button>
<button type="button" class="secondary-button" data-rcopy="no">${esc(t('copy'))}</button>
<button type="button" class="secondary-button" data-rdownload="no">${esc(t('download_txt'))}</button>
</div>
</div>
<pre class="korr-draft-body">${esc(draftNo)}</pre>
@@ -575,10 +659,10 @@
${isSameLang ? '' : `
<div class="korr-draft-col">
<div class="korr-draft-head">
<h3>${esc(userLangLabel)}refined</h3>
<h3>${esc(userLangLabel)}${esc(t('refined_label'))}</h3>
<div class="korr-draft-actions">
<button type="button" class="secondary-button" data-rcopy="user">Copy</button>
<button type="button" class="secondary-button" data-rdownload="user">Download .txt</button>
<button type="button" class="secondary-button" data-rcopy="user">${esc(t('copy'))}</button>
<button type="button" class="secondary-button" data-rdownload="user">${esc(t('download_txt'))}</button>
</div>
</div>
<pre class="korr-draft-body">${esc(draftUser)}</pre>
@@ -587,13 +671,13 @@
${cited.length ? `
<details class="korr-cited" open>
<summary><strong>Cited authorities (${cited.length})</strong> — ${esc(jurLabel)}</summary>
<summary><strong>${esc(t('cited_authorities_n', { n: cited.length }))}</strong> — ${esc(jurLabel)}</summary>
<div class="korr-cited-list">
${cited.map((s) => `
<div class="korr-cited-item">
<div class="korr-cited-head"><strong>[${s.n}] ${esc(s.title)}</strong>${s.section ? ' — ' + esc(s.section) : ''}${s.authority_type ? ' <small>(' + esc(s.authority_type) + ')</small>' : ''}</div>
<p class="korr-cited-excerpt">${esc(s.excerpt || '')}</p>
${s.source_url ? `<a href="${esc(s.source_url)}" target="_blank" rel="noopener">View source</a>` : ''}
${s.source_url ? `<a href="${esc(s.source_url)}" target="_blank" rel="noopener">${esc(t('view_source'))}</a>` : ''}
</div>
`).join('')}
</div>
@@ -605,7 +689,7 @@
btn.addEventListener('click', () => {
const target = btn.dataset.rcopy === 'no' ? draftNo : draftUser;
navigator.clipboard?.writeText(target).then(
() => { btn.textContent = 'Copied'; setTimeout(() => btn.textContent = 'Copy', 1500); },
() => { btn.textContent = t('copied'); setTimeout(() => btn.textContent = t('copy'), 1500); },
() => { btn.textContent = 'Failed'; }
);
});