feat: auto-select STT engine (Azure → Google Cloud → Whisper) and show provider in results

Removes user-facing engine/model/key/beam controls. The server now picks
the best available engine automatically:
1. Microsoft Azure Speech — short clips (≤1MB, no diarization, audio/*)
2. Google Cloud Speech v2 — long audio, diarization, all languages
3. OpenAI Whisper GPU — local fallback

Results display which provider was used (e.g. "Transcribed with Google
Cloud Speech") via transcript-engine-badge and traceMeta.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 13:22:24 +02:00
parent c6a9cc9199
commit 08d1e3cee3
14 changed files with 2937 additions and 416 deletions
+621
View File
@@ -0,0 +1,621 @@
/* global React, CiteChip, ArtChip, Stamp, Redact, useRunSequence */
const { useState, useEffect, useRef, useMemo } = React;
/* ============================================================================
ASK — source-grounded legal question
============================================================================ */
const ASK_SUGGESTIONS = [
"What weight does Barneloven §31 give to a child's stated preference?",
"Under ECHR Art. 8, what reunification duty does Norway owe after a care order?",
"Does Abdi Ibrahim v. Norway (2021) require religious matching of foster parents?",
"What contact frequency post-care-order has Strasbourg found compatible with Art. 8?",
];
function AskView({ onTrace, lang, setLang }) {
const [q, setQ] = useState(ASK_SUGGESTIONS[0]);
const [running, setRunning] = useState(false);
const [done, setDone] = useState(false);
const [highlight, setHighlight] = useState(null);
const { run } = useRunSequence();
function go() {
if (running) return;
setRunning(true); setDone(false); setHighlight(null);
onTrace([]);
const seq = [
{ delay: 400, step: { label: "Query interpreted", detail: "ECHR Art. 8 reunification duty · Barneloven §43 · post-care-order contact", status: "" } },
{ delay: 1200, step: { label: "Hybrid retrieval", detail: "Vector + BM25 · 24 candidates · reciprocal rank fusion · 7 selected", status: "" } },
{ delay: 1000, step: { label: "Reviewer pass running", detail: "Cross-checking every claim against retrieved Strasbourg + Lovdata chunks", status: "running" } },
{ delay: 1400, step: { label: "Reviewer: approved", detail: "All claims supported. Strand Lobben (2019) and Pedersen (2020) cited.", status: "" } },
{ delay: 700, step: { label: "Uncertainty noted", detail: "Post-2024 amendments to Barnevernsloven may not yet be indexed.", status: "warning" } },
];
let acc = [];
run(seq, (st, idx, last) => {
acc = [...acc.map(s => s.status === "running" ? { ...s, status: "" } : s), st];
onTrace([...acc]);
if (last) { setRunning(false); setDone(true); }
});
}
return (
<div data-screen-label="01 Ask">
<div className="tool-heading">
<div>
<p className="eyebrow">Source-grounded ask · ECHR + Lovdata</p>
<h2>Ask a legal question</h2>
</div>
<div style={{display:"flex", gap:8, alignItems:"center"}}>
<ArtChip code="ECHR Art. 8" />
<span className="status-pill">Reviewer on</span>
</div>
</div>
<div className="tool-form">
<label className="control-label" htmlFor="askInput">Question</label>
<textarea id="askInput" value={q} onChange={(e)=>setQ(e.target.value)} rows={4} />
<div className="pill-row" role="list" aria-label="Suggested questions">
{ASK_SUGGESTIONS.map((s, i) => (
<button type="button" key={i} className={"pill" + (s === q ? " is-on" : "")} onClick={()=>setQ(s)}>{s.length > 64 ? s.slice(0,64)+"…" : s}</button>
))}
</div>
<div className="form-footer">
<p className="form-status">{running ? "Running reviewer pass…" : done ? "Done · 3 sources cited · reviewer approved." : "Press Run to retrieve cited sources."}</p>
<button id="runButton" type="button" onClick={go} disabled={running}>{running ? "Running…" : "Run Ask →"}</button>
</div>
</div>
<div className="results">
{!done ? (
<div className="empty-state">
<h3>Standing by.</h3>
<p>Each answer below cites a real Strasbourg judgment, a Lovdata statute, or a UN instrument. The reviewer pass refuses to ship a claim it cannot back.</p>
</div>
) : (
<>
<div className="result-section">
<h3>Answer</h3>
<div className="answer">
<p>
Norway's reunification duty under <ArtChip code="ECHR Art. 8" /> is procedural and substantive: authorities must take active steps to restore family life
unless reunification would expose the child to harm. In <strong>Strand Lobben &amp; Others v. Norway</strong> the Grand Chamber held that limiting contact to
four to six hours per year — combined with treating placement as permanent from the outset — was incompatible with the Convention<CiteChip n={1} active={highlight===0} onClick={()=>setHighlight(0)} />.
</p>
<p>
Subsequent cases extended this. <strong>Pedersen &amp; Others v. Norway</strong> condemned a regime of two short visits per year as designed to sever bonds rather than restore them<CiteChip n={2} active={highlight===1} onClick={()=>setHighlight(1)} />.
<strong> K.O. &amp; V.M. v. Norway</strong> confirmed that restrictions on contact after a care order require "convincing reasons" — vague welfare language is not enough<CiteChip n={3} active={highlight===2} onClick={()=>setHighlight(2)} />.
</p>
<p style={{color:"var(--dbn-muted)", fontSize:"0.92rem"}}>
<em>Uncertainty.</em> Post-2024 amendments to Barnevernsloven may not yet be indexed. The corpus is current to <span className="mono">2024-12-08</span>.
</p>
</div>
</div>
<div className="result-section">
<h3>Sources</h3>
<div className="source-list">
{[
{ n: 1, title: "Strand Lobben & Others v. Norway · ECHR 37283/13", tags: ["ECHR", "Grand Chamber", "Art. 8"], score: 0.91, section: "¶205–¶226", year: "2019", excerpt: "The mutual enjoyment by parent and child of each other's company constitutes a fundamental element of family life the Court considers that there was insufficient regard for the aim of family reunification." },
{ n: 2, title: "Pedersen & Others v. Norway · ECHR 39710/15", tags: ["ECHR", "Chamber", "Art. 8"], score: 0.86, section: "6072", year: "2020", excerpt: "A strict contact regime which from the outset effectively cements the placement is, in the circumstances, incompatible with the State's positive obligations." },
{ n: 3, title: "K.O. & V.M. v. Norway · ECHR 64808/16", tags: ["ECHR", "Chamber", "Art. 8"], score: 0.81, section: "¶67", year: "2019", excerpt: "Restrictions on contact must be supported by convincing reasons; an effective parenting effort by the authorities must accompany the temporary measure." },
].map((s, i) => (
<button key={i} className={"source-card" + (highlight === i ? " is-highlight" : "")} onClick={()=>setHighlight(highlight === i ? null : i)} type="button">
<span className="source-number">{s.n}</span>
<div>
<div className="source-title">{s.title}</div>
<div className="source-meta">
{s.tags.map((t,j)=><span key={j} className="source-tag">{t}</span>)}
<span className="source-tag source-tag--score">{s.score.toFixed(2)}</span>
</div>
<p className="source-excerpt">{s.excerpt}</p>
</div>
<div className="source-aside">
<span>Section</span><b>{s.section}</b>
<span>{s.year}</span>
</div>
</button>
))}
</div>
</div>
</>
)}
</div>
</div>
);
}
/* ============================================================================
SEARCH — hybrid retrieval over corpus
============================================================================ */
const SEARCH_FILTERS = ["All", "ECHR", "Lovdata", "UNCRC", "EU CFR", "Tribunal"];
const SEARCH_CORPUS = [
{ n: 1, title: "Strand Lobben & Others v. Norway", body: "adoption contact reunification grand chamber article 8", tag: "ECHR", year: 2019, section: "¶205226", score: 0.91, excerpt: "Treating long-term placement as permanent from the outset, combined with a contact regime of 46 hours per year, was incompatible with the State's reunification duty.", art: "Art. 8" },
{ n: 2, title: "Abdi Ibrahim v. Norway", body: "religion freedom muslim adoption foster christian article 9", tag: "ECHR", year: 2021, section: "139164", score: 0.88, excerpt: "Authorities failed to consider the child's religious and cultural background before authorising adoption.", art: "Art. 8+9" },
{ n: 3, title: "A.S. v. Norway", body: "foster permanent placement vague subjective assessment", tag: "ECHR", year: 2019, section: "¶6467", score: 0.83, excerpt: "Parenting assessment based on vague, subjective criteria; long-term foster placement treated as permanent from outset.", art: "Art. 8" },
{ n: 4, title: "Pedersen & Others v. Norway", body: "two visits per year severance regime contact", tag: "ECHR", year: 2020, section: "¶6072", score: 0.86, excerpt: "Contact reduced to two short visits per year; the regime functioned to cement separation rather than restore family life.", art: "Art. 8" },
{ n: 5, title: "Hernehult v. Norway", body: "emergency removal three children evidentiary basis", tag: "ECHR", year: 2020, section: "¶6780", score: 0.80, excerpt: "Emergency removal of three children with insufficient evidentiary basis and inadequate reunification efforts.", art: "Art. 8" },
{ n: 6, title: "Barneloven §42 — Samværsrett ved samlivsbrudd", body: "samvær contact lovdata statute parental responsibility", tag: "Lovdata", year: 2023, section: "§42", score: 0.78, excerpt: "Foreldre som ikke bor sammen, kan inngå avtale om at barnet skal bo hos begge, eller hos én av dem. Avtalen skal være til beste for barnet.", art: "§42" },
{ n: 7, title: "UNCRC Article 9 — Separation from parents", body: "uncrc separation child best interests review", tag: "UNCRC", year: 1989, section: "Art. 9", score: 0.74, excerpt: "States Parties shall ensure that a child shall not be separated from his or her parents against their will, except when competent authorities subject to judicial review determine such separation is necessary for the best interests of the child.", art: "Art. 9" },
{ n: 8, title: "K.O. & V.M. v. Norway", body: "restrictions contact convincing reasons reunification", tag: "ECHR", year: 2019, section: "¶67", score: 0.79, excerpt: "Restrictions on contact after a care order must be supported by convincing reasons and accompanied by effective reunification efforts.", art: "Art. 8" },
];
function SearchView({ onTrace }) {
const [q, setQ] = useState("reunification duty Article 8");
const [filter, setFilter] = useState("All");
const [highlight, setHighlight] = useState(null);
const [running, setRunning] = useState(false);
const [done, setDone] = useState(true);
const { run } = useRunSequence();
function go() {
if (running) return;
setRunning(true); setDone(false); setHighlight(null);
onTrace([]);
const seq = [
{ delay: 250, step: { label: "Vector encode", detail: "OpenAI text-embedding-3-large · 3072 dims", status: "" } },
{ delay: 700, step: { label: "Hybrid retrieval", detail: "Vector + BM25 · 18 candidates fused (RRF)", status: "" } },
{ delay: 600, step: { label: "Re-rank", detail: "Cross-encoder · top 7 selected", status: "" } },
{ delay: 500, step: { label: "Ready", detail: filter === "All" ? "All authority types in scope." : `Filter: ${filter}`, status: "" } },
];
let acc = [];
run(seq, (st, idx, last) => {
acc = [...acc, st]; onTrace([...acc]);
if (last) { setRunning(false); setDone(true); }
});
}
const visible = useMemo(() => {
const ql = q.trim().toLowerCase();
return SEARCH_CORPUS.filter(s =>
(filter === "All" || s.tag === filter) &&
(!ql || (s.title + " " + s.body).toLowerCase().includes(ql.split(/\s+/)[0] || ""))
).slice(0, 7);
}, [q, filter, done]);
return (
<div data-screen-label="02 Search">
<div className="tool-heading">
<div>
<p className="eyebrow">Hybrid retrieval · top 7</p>
<h2>Search legal sources</h2>
</div>
<Stamp>~220 K passages indexed</Stamp>
</div>
<form className="tool-form" onSubmit={(e)=>{e.preventDefault(); go();}}>
<div className="search-row">
<input className="search-input" value={q} onChange={(e)=>setQ(e.target.value)} placeholder="Lovdata · ECHR · UNCRC · tribunal decisions"/>
<button id="runButton" type="submit" disabled={running}>{running ? "Running…" : "Search →"}</button>
</div>
<div className="pill-row" role="tablist" aria-label="Filter">
{SEARCH_FILTERS.map(f => (
<button type="button" key={f} className={"pill" + (filter === f ? " is-on" : "")} onClick={()=>setFilter(f)}>{f}</button>
))}
</div>
</form>
<div className="results">
<div className="result-section">
<h3>Sources <span className="muted mono" style={{fontWeight:400, fontSize:"0.78rem", letterSpacing:"0.06em"}}>· {visible.length} of 220K</span></h3>
<div className="source-list">
{visible.map((s, i) => (
<button key={s.n} className={"source-card" + (highlight === i ? " is-highlight" : "")} onClick={()=>setHighlight(highlight === i ? null : i)} type="button">
<span className="source-number">{s.n}</span>
<div>
<div className="source-title">{s.title}</div>
<div className="source-meta">
<span className="source-tag">{s.tag}</span>
<span className="source-tag">{s.art}</span>
<span className="source-tag source-tag--score">{s.score.toFixed(2)}</span>
</div>
<p className="source-excerpt">{s.excerpt}</p>
</div>
<div className="source-aside">
<span>Section</span><b>{s.section}</b>
<span>{s.year}</span>
</div>
</button>
))}
</div>
</div>
</div>
</div>
);
}
/* ============================================================================
ADVOCATE — pick a side, generate a partisan brief
============================================================================ */
const SIDES = [
{ id: "mother", glyph: "PARTY-A", name: "Mother", desc: "Custodial parent · contact severed post-care-order" },
{ id: "father", glyph: "PARTY-B", name: "Father", desc: "Non-resident parent · ECHR Art. 8 standing" },
{ id: "child", glyph: "PARTY-C", name: "Child", desc: "UNCRC Art. 12 · right to be heard from age 7" },
{ id: "family", glyph: "PARTY-D", name: "Family", desc: "Joint applicants · Strand Lobben framing" },
];
function AdvocateView({ onTrace }) {
const [side, setSide] = useState("mother");
const [q, setQ] = useState("Contact reduced from weekly to 4 supervised hours per year. Child has stated she wants overnight visits. Authority cites 'attachment risk' without evidence.");
const [running, setRunning] = useState(false);
const [done, setDone] = useState(false);
const { run } = useRunSequence();
function go() {
if (running) return;
setRunning(true); setDone(false); onTrace([]);
const seq = [
{ delay: 350, step: { label: "Side assigned", detail: `Agent takes the position of: ${SIDES.find(s=>s.id===side).name}`, status: "" } },
{ delay: 900, step: { label: "Sub-questions generated", detail: "35 adversarial angles · Strand Lobben framing · Art. 8 reunification duty", status: "" } },
{ delay: 1300, step: { label: "Cross-rank retrieval", detail: "Lovdata + Strasbourg + UNCRC · 12 chunks selected", status: "" } },
{ delay: 1100, step: { label: "Brief generated", detail: "Partisan brief · written from the assigned side's perspective", status: "running" } },
{ delay: 1200, step: { label: "Reviewer: approved with caveat", detail: "All claims supported. Opposing-party position summarised at end.", status: "warning" } },
];
let acc = [];
run(seq, (st, idx, last) => {
acc = [...acc.map(s => s.status === "running" ? { ...s, status: "" } : s), st];
onTrace([...acc]);
if (last) { setRunning(false); setDone(true); }
});
}
const sideObj = SIDES.find(s => s.id === side);
return (
<div data-screen-label="03 Advocate">
<div className="tool-heading">
<div>
<p className="eyebrow">Partisan brief · agent takes a side</p>
<h2>Case Advocate</h2>
</div>
<Stamp>★ recommended for hearings</Stamp>
</div>
<p className="muted" style={{margin:"0 0 12px"}}>Pick who you represent. The agent will frame every retrieval and every claim from that side's perspective — and surface the opposing position at the end so you're not surprised in court.</p>
<p className="h-section">01 — Choose representation</p>
<div className="side-grid">
{SIDES.map(s => (
<button key={s.id} type="button" className={"side-tile" + (side === s.id ? " is-on" : "")} onClick={()=>setSide(s.id)}>
<div className="side-tile-glyph">{s.glyph}</div>
<div className="side-tile-name">{s.name}</div>
<div className="side-tile-desc">{s.desc}</div>
</button>
))}
</div>
<p className="h-section">02 — Case description</p>
<form className="tool-form" onSubmit={(e)=>{e.preventDefault(); go();}}>
<textarea value={q} onChange={(e)=>setQ(e.target.value)} rows={5}/>
<div className="form-footer">
<p className="form-status">{running ? "Generating partisan brief" : done ? `Brief ready · taken from ${sideObj.name}'s side.` : "Press Run to generate a brief."}</p>
<button id="runButton" type="submit" disabled={running}>{running ? "Running" : "Generate brief "}</button>
</div>
</form>
{done && (
<div className="results">
<div className="result-section">
<h3>Brief · from {sideObj.name}'s side</h3>
<div className="answer">
<p><strong>Position.</strong> The contact reduction is not justified by the record. Under <ArtChip code="ECHR Art. 8" /> Norway owes a positive reunification duty;
<em> Strand Lobben</em> (Grand Chamber, 2019) explicitly condemns a contact regime of 46 hours per year as treating placement as <em>de facto</em> permanent
<CiteChip n={1} />.</p>
<p><strong>Procedural defects.</strong> The decision cites "attachment risk" without an evidentiary basis. <em>K.O. &amp; V.M. v. Norway</em> requires "convincing reasons"
for any restriction on contact <CiteChip n={2} /> — a generic welfare phrase does not meet that bar. The child's stated preference (overnight visits) was not weighed under
<ArtChip code="UNCRC Art. 12" /> as required for children of her age.</p>
<p><strong>Remedy sought.</strong> Restoration of weekly contact pending reunification assessment by an independent expert. <em>Pedersen v. Norway</em> (2020) gives this remedy a clear textual basis <CiteChip n={3} />.</p>
<hr className="hairline"/>
<p style={{color:"var(--dbn-muted)", fontSize:"0.92rem"}}>
<strong>Opposing-party position (not adopted).</strong> The Authority is likely to argue settled placement and developmental risk of disruption.
Counter using the Strand Lobben framing: settled placement <em>caused by</em> a restrictive contact regime cannot itself justify continuing that regime.
</p>
</div>
</div>
</div>
)}
</div>
);
}
/* ============================================================================
REDACT — black bars over PII
============================================================================ */
const REDACT_PROFILES = ["Nordic", "ECHR-safe", "Global"];
const REDACT_DOC_RAW = `Sak nr. 24-0114987-TVI
Klager: Elena K[ARLINSKA], polsk statsborger, født 12.03.1987, bosatt Oslo.
Motpart: Bydel St. Hanshaugen, Barneverntjenesten, ved sakshandsamer Tor Nyberg.
Barnet: O. K., født 14.09.2019 (5 år), aktuell bostatus: fosterfamilie Karlsen, Bærum.
I møte 04.02.2024 kl. 09:12 — varighet 14 minutter — fattet barneverntjenesten
hastevedtak om omsorgsovertakelse. Samvær fastsatt til 4 ganger per år, 2 timer per gang, under tilsyn.
Mors fastlege Dr. Inger Solberg (HPR 0982 1342) har ikke vært konsultert. Mors
psykolog Dr. Mariusz Wojcik har dokumentert tilknytning og foreldreevne i rapport datert 18.01.2024.
Kontakt: elena.karlinska@protonmail.com · +47 901 23 456
Adresse: Bjerregaards gate 64, 0173 Oslo
Statsforvalteren mottok klage 11.02.2024. Saksbehandler hos Statsforvalteren:
juridisk seniorrådgiver Anne-Lise Hagen.`;
function redactText(raw, profile) {
// simple PII pattern redaction
const patterns = [
{ p: /K\[ARLINSKA\]|Karlinska|Karlsen|Nyberg|Solberg|Wojcik|Hagen|Tor\s|Anne-Lise|Inger|Mariusz/g }, // names
{ p: /\b(O\.\s?K\.)\b/g }, // child initials
{ p: /(\b\d{2}\.\d{2}\.\d{4}\b)/g, only: ["ECHR-safe","Global"] }, // dates → only stricter profiles
{ p: /(\+47\s?\d{3}\s?\d{2}\s?\d{3})/g }, // phone
{ p: /\b[\w.-]+@[\w.-]+\.[A-Za-z]{2,}\b/g }, // email
{ p: /(Bjerregaards gate \d+|Bærum|Bydel St\. Hanshaugen|Oslo|HPR\s?\d{3,}\s?\d{0,5})/g, only: ["Global"] }, // address / district / HPR — only global
{ p: /(Sak nr\.\s?[\d-]+(?:-TVI)?)/g, only: ["Global"] }, // case nr → only global
];
let out = raw;
patterns.forEach(({p, only}) => {
if (only && !only.includes(profile)) return;
out = out.replace(p, (m) => `__R__${m.length}__R__`);
});
// turn markers into React-renderable spans (we'll split below)
return out;
}
function RedactView({ onTrace }) {
const [profile, setProfile] = useState("Nordic");
const [running, setRunning] = useState(false);
const [done, setDone] = useState(true);
const { run } = useRunSequence();
function go() {
if (running) return;
setRunning(true); setDone(false); onTrace([]);
const seq = [
{ delay: 250, step: { label: "Document parsed", detail: "734 chars · single page · norsk bokmål", status: "" } },
{ delay: 700, step: { label: `Profile: ${profile}`, detail: `Applying ${profile} redaction profile (names, IDs, contact)`, status: "" } },
{ delay: 700, step: { label: "PII matched", detail: "9 entities redacted · names, emails, phones, identifiers", status: "" } },
{ delay: 400, step: { label: "Done · ready to export", detail: "Output remains in memory unless you download.", status: "" } },
];
let acc = [];
run(seq, (st, idx, last) => {
acc = [...acc, st]; onTrace([...acc]);
if (last) { setRunning(false); setDone(true); }
});
}
const out = useMemo(() => redactText(REDACT_DOC_RAW, profile), [profile]);
const parts = useMemo(() => {
const arr = [];
const re = /__R__(\d+)__R__/g;
let last = 0; let m;
while ((m = re.exec(out)) !== null) {
if (m.index > last) arr.push({ t: "txt", v: out.slice(last, m.index) });
arr.push({ t: "red", n: parseInt(m[1], 10) });
last = re.lastIndex;
}
if (last < out.length) arr.push({ t: "txt", v: out.slice(last) });
return arr;
}, [out]);
return (
<div data-screen-label="04 Redact">
<div className="tool-heading">
<div>
<p className="eyebrow">In-memory PII redaction · GDPR-compliant</p>
<h2>Redact</h2>
</div>
<Stamp>Nothing leaves the browser</Stamp>
</div>
<p className="muted" style={{margin:"0 0 12px"}}>Names, dates, identifiers, emails, phones are removed before you share a brief with a journalist, an advocate, or a court. Pick a stricter profile for ECHR submissions.</p>
<div className="pill-row" role="radiogroup" aria-label="Redaction profile">
{REDACT_PROFILES.map(p => (
<button type="button" key={p} className={"pill" + (profile === p ? " is-on" : "")} onClick={()=>setProfile(p)} role="radio" aria-checked={profile === p}>{p}</button>
))}
<button type="button" className="pill" onClick={go} disabled={running} style={{marginLeft:"auto", borderColor:"var(--dbn-coral)", color:"var(--dbn-coral)"}}>{running ? "Re-running" : "Re-run redaction "}</button>
</div>
<p className="h-section">Preview · {profile} profile</p>
<pre className="redact-paper" style={{whiteSpace:"pre-wrap", fontFamily:'"Times New Roman", Georgia, serif'}}>
{parts.map((p, i) => p.t === "txt" ? <span key={i}>{p.v}</span> : <Redact key={i} len={p.n}/>)}
</pre>
<p className="muted" style={{fontSize:"0.84rem", marginTop:12}}>
Hover any black bar to see what category was redacted. Toggle profile to add/remove categories.
<span className="mono"> 9 entities matched.</span>
</p>
</div>
);
}
/* ============================================================================
TIMELINE — "Twelve minutes" — event extraction
============================================================================ */
const TIMELINE_EVENTS = [
{ time: "09:00", event: "Mother arrives at Barnevernet office", detail: "Notified by phone the previous evening. No lawyer present.", bad: false },
{ time: "09:04", event: "Meeting begins", detail: "Two case workers, one observer. Polish-language interpreter not requested in advance.", bad: false },
{ time: "09:08", event: "Concerns read from prepared statement", detail: "School note · health visitor report · neighbour complaint. Mother allowed to respond.", bad: false },
{ time: "09:11", event: "Emergency removal decision", detail: "Akuttvedtak signed under Barnevernsloven §4-6 · ground: 'risk of serious harm'.", bad: true },
{ time: "09:12", event: "Decision read aloud", detail: "Mother told contact will be 'arranged later'. Child collected from kindergarten by foster carer at 11:40.", bad: true },
{ time: "Day 14", event: "Contact set: 4 visits/year × 2 hours", detail: "Statsforvalteren confirms the regime on appeal. Mother applies to Oslo tingrett.", bad: true },
{ time: "Year 3", event: "Statement of no attachment", detail: "Authority report: 'child no longer has a bond with the biological mother'. Cited as ground to authorise adoption.", bad: true },
{ time: "", event: "ECHR cross-reference", detail: "Pattern matches Pedersen v. Norway (2020). Reunification duty under Art. 8 not honoured.", bad: false },
];
function TimelineView({ onTrace }) {
const [running, setRunning] = useState(false);
const [done, setDone] = useState(true);
const { run } = useRunSequence();
function rerun() {
if (running) return;
setRunning(true); onTrace([]);
const seq = [
{ delay: 300, step: { label: "Document scanned", detail: "Akuttvedtak · meeting minutes · 3 follow-up reports", status: "" } },
{ delay: 700, step: { label: "Events extracted", detail: "8 events identified · timestamps normalised", status: "" } },
{ delay: 600, step: { label: "Pattern match", detail: "Pedersen-type contact reduction detected (ECHR 39710/15)", status: "warning" } },
];
let acc = [];
run(seq, (st, idx, last) => {
acc = [...acc, st]; onTrace([...acc]);
if (last) setRunning(false);
});
}
return (
<div data-screen-label="05 Timeline">
<div className="tool-heading">
<div>
<p className="eyebrow">Event extraction · chronological</p>
<h2>Timeline</h2>
</div>
<Stamp>12 min · file ELENA-K-2024</Stamp>
</div>
<div className="tinted-card" style={{marginBottom:14, borderLeft:"3px solid var(--dbn-coral)"}}>
<p style={{margin:0, fontWeight:700, color:"var(--dbn-ink)"}}>
<span className="bignum" style={{fontSize:"1.8rem"}}>12</span>{" "}
<span style={{fontWeight:600}}>minutes between the meeting opening and the removal decision.</span>
</p>
<p className="muted" style={{margin:"6px 0 0", fontSize:"0.92rem"}}>
The event sequence below is the canonical pattern Strasbourg has condemned in <em>Strand Lobben</em>, <em>Pedersen</em>, <em>K.O. &amp; V.M.</em>, and four further cases.
</p>
</div>
<div className="form-footer" style={{marginBottom:14}}>
<p className="form-status">{running ? "Re-extracting events" : "8 events · last extracted 14:32 · Pedersen pattern matched."}</p>
<button id="runButton" type="button" onClick={rerun} disabled={running}>{running ? "Running" : "Re-extract "}</button>
</div>
<ul className="tl">
{TIMELINE_EVENTS.map((e, i) => (
<li key={i} className={e.bad ? "is-bad" : ""}>
<span className="tl-dot"></span>
<div className="tl-time">{e.time}</div>
<div className="tl-event">{e.event}</div>
<div className="tl-detail">{e.detail}</div>
</li>
))}
</ul>
</div>
);
}
/* ============================================================================
CASEBOOK — all 23 ECHR violations, browsable
============================================================================ */
function CasebookView({ onTrace }) {
const cases = window.DBN_DATA.cases;
const [open, setOpen] = useState(null);
return (
<div data-screen-label="06 Casebook">
<div className="tool-heading">
<div>
<p className="eyebrow">European Court of Human Rights · since 2015</p>
<h2>23 violations. Every one says the same.</h2>
</div>
<Stamp>Art. 8 — family life</Stamp>
</div>
<p className="muted" style={{margin:"0 0 14px", maxWidth:"68ch"}}>
Since 2015 the European Court of Human Rights has found Norway in violation of <ArtChip code="ECHR Art. 8" /> — the right to private and family life — in twenty-three separate child-welfare cases. The Committee of Ministers is still monitoring execution. Six are reproduced here. The full register lives on dobetternorge.no.
</p>
<table className="cases-table">
<thead>
<tr><th>#</th><th>Case</th><th>Year</th><th>Ruling</th><th>Vote</th></tr>
</thead>
<tbody>
{cases.map((c, i) => (
<React.Fragment key={c.no}>
<tr onClick={()=>setOpen(open === c.no ? null : c.no)} style={{cursor:"pointer"}}>
<td className="c-no">{c.no}</td>
<td>
<div className="c-name">{c.name}</div>
{open !== c.no && <div className="muted" style={{fontSize:"0.85rem", marginTop:3, maxWidth:"60ch"}}>{c.summary}</div>}
</td>
<td className="c-year">{c.year}</td>
<td><span className="c-art">{c.ruling}</span></td>
<td className="muted mono" style={{fontSize:"0.78rem"}}>{c.vote}</td>
</tr>
{open === c.no && (
<tr><td colSpan={5} style={{padding:"4px 0 12px"}}>
<div className="source-detail">
<button className="close" onClick={()=>setOpen(null)} aria-label="Close">×</button>
<div className="head">{c.chamber} · {c.year} · {c.ruling}</div>
<blockquote>{c.summary}</blockquote>
<p className="mono" style={{marginTop:10, fontSize:"0.78rem", color:"var(--dbn-muted)"}}>
Vote: {c.vote} · Status: Committee of Ministers · execution monitoring open
</p>
</div>
</td></tr>
)}
</React.Fragment>
))}
<tr>
<td className="c-no">+17</td>
<td className="muted" colSpan={4}>further verified judgments — full register on dobetternorge.no / research</td>
</tr>
</tbody>
</table>
<p className="h-section">Contact-visit benchmarks — what Strasbourg expects vs. what Norway delivers</p>
<div className="tinted-card">
{window.DBN_DATA.contactBenchmarks.map((b, i) => {
// visits as a number for the bar
const n = parseInt(String(b.visits).replace(/[^0-9]/g, ""), 10) || 0;
const w = Math.min(100, (n / 45) * 100);
return (
<div key={i} className={"bench-row" + (b.jurisdiction === "Norway" ? " is-no" : "")}>
<div className="bench-name">{b.jurisdiction}</div>
<div className="bench-bar"><div className="bench-fill" style={{"--w": w+"%", width: w+"%"}}></div></div>
<div className="bench-num">{b.visits}</div>
</div>
);
})}
<p className="muted mono" style={{fontSize:"0.7rem", letterSpacing:"0.08em", marginTop:10}}>
SOURCE · Tandfonline meta-analysis 2025 · CoE Family-Life Working Group 2023
</p>
</div>
</div>
);
}
/* ============================================================================
VOICES — verified accounts
============================================================================ */
function VoicesView() {
const voices = window.DBN_DATA.voices;
return (
<div data-screen-label="07 Voices">
<div className="tool-heading">
<div>
<p className="eyebrow">Verified — court documents on file</p>
<h2>The people the statistics describe.</h2>
</div>
<Stamp>4 of {voices.length}+ verified</Stamp>
</div>
<p className="muted" style={{margin:"0 0 16px", maxWidth:"68ch"}}>
Every testimony below has been cross-referenced with a court document, a case file, or a supporting statement before publication. Names of children are withheld. Names of adults are published only with consent.
</p>
<div style={{display:"grid", gap:18}}>
{voices.map((v, i) => (
<figure key={i} className="voice">
<q>{v.quote}</q>
<figcaption className="voice-by">
<span className="voice-initials">{v.initials}</span>
<span><strong style={{color:"var(--dbn-ink)"}}>{v.name}</strong> &mdash; {v.role}</span>
</figcaption>
</figure>
))}
</div>
</div>
);
}
/* ─── Expose ────────────────────────────────────────────────────────────── */
Object.assign(window, {
AskView, SearchView, AdvocateView, RedactView, TimelineView, CasebookView, VoicesView,
});