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
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Do Better Norge · Legal Tools — Case Workbench</title>
<meta name="description" content="Authenticated AI legal-research workbench for parents, lawyers, advocates and journalists. ECHR Art. 8 monitoring. Source-cited. In-memory.">
<link rel="stylesheet" href="design/inter.css">
<link rel="stylesheet" href="design/colors_and_type.css">
<link rel="stylesheet" href="design/tools-shell.css">
<link rel="stylesheet" href="design/distress.css">
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="data.js"></script>
</head>
<body class="dossier" data-authenticated="true">
<div id="root"></div>
<script type="text/babel" src="vendor/tweaks-panel.jsx"></script>
<script type="text/babel" src="shell.jsx"></script>
<script type="text/babel" src="views.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>
+101
View File
@@ -0,0 +1,101 @@
/* global React, ReactDOM,
Topbar, Manifesto, Disclaimer, ToolRail, ReasoningPanel, FootStrip, TRACE_WAITING,
AskView, SearchView, AdvocateView, RedactView, TimelineView, CasebookView, VoicesView,
TweaksPanel, useTweaks, TweakSection, TweakSlider, TweakRadio, TweakToggle, TweakSelect
*/
const { useState, useEffect, useMemo } = React;
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"distress": 0.6,
"headline": "twelve",
"surface": "light"
}/*EDITMODE-END*/;
const TOOL_INSTRUMENT = {
ask: "ECHR Art. 8 · Barneloven §43",
search: "Lovdata + Strasbourg + UNCRC",
advocate: "ECHR Art. 8 (partisan)",
redact: "GDPR · Personopplysningsloven",
timeline: "Pedersen v. Norway pattern",
cases: "ECHR Art. 8 · 23 judgments",
voices: "Verified testimony · court documents on file",
};
function App() {
const [tool, setTool] = useState("ask");
const [trace, setTrace] = useState(TRACE_WAITING);
const [t, setT] = useTweaks(TWEAK_DEFAULTS);
// Apply visual layer to <html>
useEffect(() => {
const root = document.documentElement;
root.style.setProperty("--distress", String(t.distress));
root.classList.toggle("surface-dark", t.surface === "dark");
}, [t.distress, t.surface]);
const headline = window.DBN_DATA.headlines[t.headline] || window.DBN_DATA.headlines.twelve;
const stats = window.DBN_DATA.stats;
// Reset trace when tool changes
useEffect(() => { setTrace(TRACE_WAITING); }, [tool]);
const view = useMemo(() => {
switch (tool) {
case "ask": return <AskView onTrace={setTrace}/>;
case "search": return <SearchView onTrace={setTrace}/>;
case "advocate": return <AdvocateView onTrace={setTrace}/>;
case "redact": return <RedactView onTrace={setTrace}/>;
case "timeline": return <TimelineView onTrace={setTrace}/>;
case "cases": return <CasebookView onTrace={setTrace}/>;
case "voices": return <VoicesView onTrace={setTrace}/>;
default: return <AskView onTrace={setTrace}/>;
}
}, [tool]);
return (
<main className="app-shell" data-screen-label="00 Tools Workspace">
<Topbar caseNo="case ELENA-K-2024 · session #a1c8" status="Live · 23 violations indexed"/>
<Disclaimer />
<Manifesto headline={headline} stats={stats} intensity={t.distress}/>
<section className="workspace" aria-label="Tools workspace">
<ToolRail active={tool} onSelect={setTool}/>
<section className="tool-panel" aria-labelledby="toolTitle">
<div className="corner-fold" aria-hidden="true"></div>
{view}
</section>
<ReasoningPanel steps={trace} caseNo="ELENA-K-2024" instrument={TOOL_INSTRUMENT[tool]}/>
</section>
<FootStrip />
<TweaksPanel title="Tweaks · Case workbench">
<TweakSection title="Distress treatment">
<TweakSlider tweak="distress" label="Intensity" min={0} max={1} step={0.05}
value={t.distress} onChange={(v)=>setT("distress", v)}
help="0 = somber & restrained · 1 = defiant editorial layout (bigger headline, deeper bleed, larger fold)."/>
</TweakSection>
<TweakSection title="Headline variant">
<TweakSelect tweak="headline" label="Manifesto" value={t.headline} onChange={(v)=>setT("headline", v)}
options={[
{ value: "twelve", label: "Twelve minutes" },
{ value: "twentythree", label: "Twenty-three violations" },
{ value: "members", label: "Numbers · 9,482 / 20,000" },
{ value: "compliance", label: "Compliance is love" },
]}/>
</TweakSection>
<TweakSection title="Surface">
<TweakRadio tweak="surface" label="Mode" value={t.surface} onChange={(v)=>setT("surface", v)}
options={[
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
]}/>
</TweakSection>
</TweaksPanel>
</main>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
+161
View File
@@ -0,0 +1,161 @@
/* Real data sourced from dobetternorge.no
Used by the tool views to back every claim with a real citation. */
window.DBN_DATA = {
/* ── Header stats ──────────────────────────────────────────────── */
stats: {
echrViolations: 23,
echrLossRate: 64, // % ECHR cases lost 2017-22
tribunalDecisions: 1731,
pendingStrasbourg: 20,
members: 9482,
memberTarget: 20000,
targetDate: "Dec 2026",
},
/* ── ECHR cases against Norway ─────────────────────────────────── */
cases: [
{
no: "01",
name: "Strand Lobben & Others v. Norway",
year: 2019,
ruling: "Art. 8",
summary: "Adoption by foster parents authorised despite biological mother's objections. Contact limited to 4-6 hrs/yr.",
chamber: "Grand Chamber",
vote: "134",
},
{
no: "02",
name: "Abdi Ibrahim v. Norway",
year: 2021,
ruling: "Art. 8 + 9",
summary: "Somali mother; authorities failed to consider religious upbringing before authorising adoption by Christian foster parents.",
chamber: "Grand Chamber",
vote: "Unanimous",
},
{
no: "03",
name: "A.S. v. Norway",
year: 2019,
ruling: "Art. 8",
summary: "Long-term foster placement treated as permanent from outset; parenting assessment based on vague, subjective criteria.",
chamber: "Chamber",
vote: "Unanimous",
},
{
no: "04",
name: "Pedersen & Others v. Norway",
year: 2020,
ruling: "Art. 8",
summary: "Contact between parents and child reduced to two short visits per year. Strict regime of visits cemented separation.",
chamber: "Chamber",
vote: "61",
},
{
no: "05",
name: "Hernehult v. Norway",
year: 2020,
ruling: "Art. 8",
summary: "Emergency removal of three children. Insufficient evidentiary basis and inadequate reunification efforts.",
chamber: "Chamber",
vote: "Unanimous",
},
{
no: "06",
name: "K.O. & V.M. v. Norway",
year: 2019,
ruling: "Art. 8",
summary: "Restrictions on contact after care order not supported by convincing reasons; authorities failed the reunification duty.",
chamber: "Chamber",
vote: "Unanimous",
},
{
no: "07",
name: "Johansen v. Norway",
year: 1996,
ruling: "Art. 8",
summary: "Mutual enjoyment by parent and child of each other's company is a fundamental element of family life.",
chamber: "Chamber",
vote: "125",
},
],
/* ── Contact-visit benchmarks (visits/year after care order) ───── */
contactBenchmarks: [
{ jurisdiction: "Norway", visits: "46", note: "post-removal regime" },
{ jurisdiction: "Sweden", visits: "12", note: "comparable Nordic" },
{ jurisdiction: "Germany", visits: "24", note: "EU benchmark" },
{ jurisdiction: "Poland", visits: "26+", note: "EU benchmark" },
{ jurisdiction: "ECHR benchmark",visits: "~40", note: "Art. 8 jurisprudence" },
],
/* ── Voices ───────────────────────────────────────────────────── */
voices: [
{
initials: "EK",
name: "Elena K.",
role: "Polish mother — case before Oslo tingrett",
quote: "The meeting lasted less than a quarter of an hour. I was told I could see my daughter four times a year, two hours each time. Three years later they said she no longer had a bond with me. I am still her mother.",
},
{
initials: "TR",
name: "Tomasz R.",
role: "Verified member · 2023",
quote: "Fathers are treated as secondary caregivers by default. My lawyer warned me before the first meeting: Do not expect to be heard. She was right.",
},
{
initials: "GT",
name: "Gro Hillestad Thune",
role: "Former ECHR judge (17 yrs)",
quote: "This is an extremely serious warning from Strasbourg to the Norwegian authorities. We are talking about a systemic failure, not individual mistakes.",
},
{
initials: "SS",
name: "Stephanos Stavros",
role: "Human rights lawyer, Council of Europe",
quote: "It is much easier in Norway for child welfare to take children from their parents and cut any contact than to have a real reunification plan. The Court is deeply critical of this.",
},
],
/* ── International instruments cited across the tools ──────────── */
instruments: [
{ code: "ECHR Art. 8", full: "European Convention on Human Rights, Article 8 — Right to respect for private and family life" },
{ code: "ECHR Art. 9", full: "European Convention on Human Rights, Article 9 — Freedom of thought, conscience and religion" },
{ code: "ECHR Art. 6", full: "European Convention on Human Rights, Article 6 — Right to a fair trial" },
{ code: "ECHR Art. 14", full: "European Convention on Human Rights, Article 14 — Prohibition of discrimination" },
{ code: "UNCRC Art. 3", full: "UN Convention on the Rights of the Child, Article 3 — Best interests of the child" },
{ code: "UNCRC Art. 9", full: "UN Convention on the Rights of the Child, Article 9 — Separation from parents" },
{ code: "UNCRC Art. 12", full: "UN Convention on the Rights of the Child, Article 12 — Right of the child to be heard" },
{ code: "ICCPR Art. 23", full: "International Covenant on Civil & Political Rights, Article 23 — Protection of the family" },
{ code: "EU CFR Art. 24", full: "EU Charter of Fundamental Rights, Article 24 — Rights of the child" },
{ code: "EU CFR Art. 7", full: "EU Charter of Fundamental Rights, Article 7 — Respect for private and family life" },
{ code: "Barneloven §31", full: "Lov om barn og foreldre §31 — Child's right to be heard from age 7; great weight from age 12" },
{ code: "Barneloven §42", full: "Lov om barn og foreldre §42 — Samværsrett ved samlivsbrudd (contact rights)" },
{ code: "Barneloven §43", full: "Lov om barn og foreldre §43 — Avtale eller avgjerd om samvær (contact agreement)" },
{ code: "Barnevernloven §4-19", full: "Lov om barnevernstjenester §4-19 — Samvær etter omsorgsovertakelse" },
],
/* ── Headline variants (used by Tweaks: headline_variant) ──────── */
headlines: {
twelve: {
eyebrow: "Family rights · Norway · since 2019",
title: "They took her child in twelve minutes.",
sub: "Open a tool. Every answer is cited. Every claim cross-checked.",
},
twentythree: {
eyebrow: "European Court of Human Rights · 2015 present",
title: "Twenty-three violations. Every one says the same.",
sub: "Article 8. Family life. Norway, condemned in Strasbourg.",
},
members: {
eyebrow: "9,482 of 20,000 · target Dec 2026",
title: "Numbers are how courts listen.",
sub: "Every member is one more reason the Storting cannot look away.",
},
compliance: {
eyebrow: "Oslo Syndrome · investigative series",
title: "Compliance is love. Trust the process.",
sub: "This workbench asks what happens when the process becomes the story.",
},
},
};
@@ -0,0 +1,262 @@
/* =============================================================================
Do Better Norge — AI Legal Tools
Color & Typography tokens
Source of truth: dobetternorge-tools/assets/css/tools.css :root + showcase
============================================================================= */
:root {
/* ── Surface / structural ───────────────────────────────────────────── */
--dbn-bg: #f7f8fb; /* app & page background */
--dbn-panel: #ffffff; /* cards, rails, panels */
--dbn-panel-tinted: #fbfcfe; /* nested cards, source rows, empty states */
--dbn-line: #d8dde7; /* hairline borders, dividers */
/* ── Ink (foreground text) ──────────────────────────────────────────── */
--dbn-ink: #1b2330; /* primary body / headings */
--dbn-ink-soft: #314158; /* secondary headings, numeric chips */
--dbn-muted: #667085; /* secondary copy, labels, helper text */
--dbn-muted-2: #98a2b3; /* tertiary / waiting status */
/* ── Brand primary — Norway flag BLUE ───────────────────────────────── */
/* Pantone 281 C. Primary action, links, eyebrow, active state. The depth
of the navy is intentional — it carries the "official / institutional"
register a legal-research product needs. */
--dbn-teal: #00205B; /* PRIMARY · Norway flag blue */
--dbn-teal-dark: #001A4D; /* hover, dark-on-light text */
--dbn-teal-soft: #E6EAF2; /* tinted bg, active tab, pill bg */
--dbn-teal-soft-2: #C9D2E5; /* hover tint of soft */
--dbn-teal-ring: rgba(0, 32, 91, 0.35); /* focus outline */
/* ── Brand secondary — Norway flag RED (citations, partisan, CTA) ────── */
/* Pantone 200 C. Reserved for citation chips, source numbers, the
showcase CTA, the Advocate "take a side" signal, and the running-trace
pulse. Never use red as a generic primary — that's flag-blue's job. */
--dbn-coral: #BA0C2F; /* ACCENT · Norway flag red */
--dbn-coral-dark: #8E0926; /* hover */
--dbn-coral-soft: #FBE5E9;
--dbn-coral-edge: #E7B7C0; /* red hairline */
/* Convenience aliases — prefer these in new code, the *-teal/*-coral
names are kept only so existing components keep working. */
--dbn-flag-blue-primary: var(--dbn-teal);
--dbn-flag-blue-dark: var(--dbn-teal-dark);
--dbn-flag-blue-soft: var(--dbn-teal-soft);
--dbn-flag-red: var(--dbn-coral);
--dbn-flag-red-dark: var(--dbn-coral-dark);
--dbn-flag-red-soft: var(--dbn-coral-soft);
--dbn-flag-white: #FFFFFF;
/* ── Brand tertiary — Amber (notice, "running", disclaimers) ────────── */
--dbn-amber: #b7791f;
--dbn-amber-soft: #fff9f2;
--dbn-amber-edge: #f0d6bc;
--dbn-amber-ink: #68420d; /* dark text on amber bg */
/* ── Semantic — Confidence / status palette (used in timeline, badges) */
--dbn-success: #16a34a;
--dbn-success-soft: #dcfce7;
--dbn-success-ink: #15803d;
--dbn-warning: #d97706;
--dbn-warning-soft: #fef3c7;
--dbn-warning-ink: #b45309;
--dbn-danger: #b41e1e;
--dbn-danger-soft: #fee2e2;
--dbn-danger-ink: #991b1b;
--dbn-neutral-chip: #eef0f5; /* low confidence / muted badge bg */
/* ── Speaker / categorical palette (transcribe tool) ────────────────── */
--dbn-cat-blue-bg: #dbeafe; --dbn-cat-blue-ink: #1d4ed8;
--dbn-cat-violet-bg: #ede9fe; --dbn-cat-violet-ink: #6d28d9;
--dbn-cat-green-bg: #dcfce7; --dbn-cat-green-ink: #166534;
--dbn-cat-yellow-bg: #fef9c3; --dbn-cat-yellow-ink: #854d0e;
--dbn-cat-red-bg: #fee2e2; --dbn-cat-red-ink: #991b1b;
/* ── Parent-site bridge ─────────────────────────────────────────────── */
/* Historically a separate token; now the primary brand colour is itself
flag-blue, so this alias just points at the primary. The SSO bridge
card on the gate uses the same blue with a 6% alpha bg. */
--dbn-flag-blue: var(--dbn-teal);
/* ── Elevation ──────────────────────────────────────────────────────── */
--dbn-shadow-card: 0 18px 45px rgba(25, 35, 52, 0.10);
--dbn-shadow-soft: 0 4px 16px rgba(0, 32, 91, 0.10);
--dbn-shadow-modal: 0 28px 92px rgba(0, 0, 0, 0.34);
--dbn-overlay: rgba(23, 32, 51, 0.62);
/* ── Radius ─────────────────────────────────────────────────────────── */
--dbn-r-xs: 4px; /* date-type badge, small chip */
--dbn-r-sm: 6px; /* dense controls, alias inputs */
--dbn-r-md: 8px; /* default — buttons, panels, cards, inputs */
--dbn-r-pill: 999px; /* status pill, tag, cite chip */
/* ── Spacing (Inter-friendly 4/6/8/10/12/14/16/18/24/32/48/64 rhythm) */
--dbn-space-1: 4px;
--dbn-space-2: 8px;
--dbn-space-3: 12px;
--dbn-space-4: 16px;
--dbn-space-5: 18px; /* app-shell side padding */
--dbn-space-6: 24px;
--dbn-space-8: 32px;
--dbn-space-10: 48px;
--dbn-space-12: 64px;
/* ── Typography — family ────────────────────────────────────────────── */
/* SUBSTITUTION NOTE: Tools site declares the system stack
"Inter, ui-sans-serif, system-ui, -apple-system, …". We pull Inter from
Google Fonts in fonts/inter.css so cards render identically out-of-org. */
--dbn-font-sans: Inter, ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", sans-serif;
--dbn-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas,
"Liberation Mono", monospace;
/* ── Typography — weight scale ──────────────────────────────────────── */
--dbn-w-regular: 400;
--dbn-w-medium: 500;
--dbn-w-semibold: 600;
--dbn-w-bold: 700;
--dbn-w-heavy: 800;
--dbn-w-black: 900;
/* ── Typography — size scale (rem on a 16px root) ───────────────────── */
--dbn-fs-3xs: 0.66rem; /* trace marker number */
--dbn-fs-2xs: 0.70rem; /* eyebrow on stat, table TH, stack TAG */
--dbn-fs-xs: 0.78rem; /* status pill, badge, hint */
--dbn-fs-sm: 0.84rem; /* table TD, table caption, queue item */
--dbn-fs-body: 0.92rem; /* default body, disclaimer */
--dbn-fs-md: 1.00rem; /* answer / brief copy */
--dbn-fs-lg: 1.05rem; /* corpus section title */
--dbn-fs-xl: 1.20rem; /* source modal title */
--dbn-fs-2xl: 1.50rem; /* section-heading (h2 on showcase) */
--dbn-fs-3xl: 2.00rem; /* stat value, hiw step number */
--dbn-fs-display: clamp(2rem, 5vw, 3rem); /* showcase hero */
/* ── Typography — line-height ───────────────────────────────────────── */
--dbn-lh-tight: 1.10; /* hero, big display */
--dbn-lh-snug: 1.25; /* modal title */
--dbn-lh-normal: 1.45;
--dbn-lh-relaxed: 1.55; /* body, gate copy */
--dbn-lh-loose: 1.65; /* brief / answer */
/* ── Typography — letter-spacing ────────────────────────────────────── */
--dbn-tracking-tight: -0.02em; /* big numbers (stat value) */
--dbn-tracking-cap: 0.06em; /* eyebrow / cap labels */
--dbn-tracking-cap-2: 0.04em;
--dbn-tracking-cap-sm: 0.03em; /* badge */
}
/* =============================================================================
Base — use these on bare elements within the design system
============================================================================= */
html {
font-family: var(--dbn-font-sans);
color: var(--dbn-ink);
background: var(--dbn-bg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body { margin: 0; }
h1, h2, h3, h4, p { margin-top: 0; }
/* =============================================================================
Semantic type tokens — apply via class (.dbn-eyebrow, .dbn-h1, etc.)
Mirrors the implicit type stack derived from tools.css.
============================================================================= */
.dbn-eyebrow {
margin: 0 0 var(--dbn-space-1);
color: var(--dbn-teal);
font-size: var(--dbn-fs-xs);
font-weight: var(--dbn-w-bold);
text-transform: uppercase;
letter-spacing: var(--dbn-tracking-cap-2);
}
.dbn-display { /* showcase hero h1 */
font-size: var(--dbn-fs-display);
font-weight: var(--dbn-w-heavy);
line-height: var(--dbn-lh-tight);
color: #fff;
margin: 0;
}
.dbn-h1 { /* topbar h1, "Legal Tools" */
font-size: 1.5rem;
font-weight: var(--dbn-w-heavy);
line-height: 1.2;
color: var(--dbn-ink);
}
.dbn-h2 { /* section heading, tool title */
font-size: var(--dbn-fs-2xl);
font-weight: var(--dbn-w-heavy);
line-height: 1.2;
color: var(--dbn-ink);
}
.dbn-h3 { /* card title, source title */
font-size: var(--dbn-fs-md);
font-weight: var(--dbn-w-bold);
line-height: 1.35;
color: var(--dbn-ink);
}
.dbn-h4 { /* category card / source card title */
font-size: 0.88rem;
font-weight: var(--dbn-w-bold);
line-height: 1.3;
color: var(--dbn-ink);
}
.dbn-body { /* default paragraph */
font-size: var(--dbn-fs-body);
line-height: var(--dbn-lh-relaxed);
color: var(--dbn-ink);
}
.dbn-body-soft { /* muted secondary copy */
font-size: var(--dbn-fs-body);
line-height: var(--dbn-lh-relaxed);
color: var(--dbn-muted);
}
.dbn-answer { /* result/brief body (longer reading) */
font-size: var(--dbn-fs-md);
line-height: var(--dbn-lh-loose);
color: var(--dbn-ink);
}
.dbn-small {
font-size: var(--dbn-fs-sm);
line-height: var(--dbn-lh-normal);
color: var(--dbn-muted);
}
.dbn-cap { /* uppercase badge / table TH text */
font-size: var(--dbn-fs-2xs);
font-weight: var(--dbn-w-bold);
text-transform: uppercase;
letter-spacing: var(--dbn-tracking-cap);
color: var(--dbn-muted);
}
.dbn-code,
.dbn-mono {
font-family: var(--dbn-font-mono);
font-size: 0.86em;
background: var(--dbn-teal-soft);
color: var(--dbn-ink);
padding: 1px 5px;
border-radius: var(--dbn-r-xs);
}
.dbn-num { /* big stat number */
font-size: var(--dbn-fs-3xl);
font-weight: var(--dbn-w-heavy);
letter-spacing: var(--dbn-tracking-tight);
line-height: 1;
color: var(--dbn-teal);
font-variant-numeric: tabular-nums;
}
+605
View File
@@ -0,0 +1,605 @@
/* ============================================================================
distress.css — editorial overlay on top of the Do Better Norge design system
The base design system stays clean and institutional. This file layers
the "Norway in distress" treatment: cracked-flag motif, half-mast tint,
redaction marks, dossier stamps, manifesto type, evidence-trail-as-protest.
Distress intensity is driven by --distress (0..1) set on <html>.
Surface mode is driven by .surface-dark on <html>.
============================================================================ */
:root {
/* Set by the Tweaks panel. */
--distress: 0.6; /* 0 = somber, 1 = defiant */
--paper: #f4ecd8; /* dossier paper tone (used on stamps) */
--paper-2: #e9dfc5;
--stamp: #6e1421; /* deep red ink stamp */
--ink-stamp: #1b1b1b; /* black ink stamp / typewriter */
--bleed: var(--dbn-coral); /* red bleed accent */
/* Derived metrics */
--bleed-h: calc(2px + var(--distress) * 6px);
--flag-fade: calc(0.35 + var(--distress) * 0.65);
/* Mono / typewriter family for case-file marks */
--type-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* ─── Surface mode: dark ────────────────────────────────────────────────── */
html.surface-dark {
--dbn-bg: #0c1018;
--dbn-panel: #11161f;
--dbn-panel-tinted: #161c27;
--dbn-line: #2a313f;
--dbn-ink: #ecf0f6;
--dbn-ink-soft: #c4cad5;
--dbn-muted: #8a93a3;
--dbn-muted-2: #5b6271;
--dbn-teal-soft: rgba(78, 124, 220, 0.18);
--dbn-teal-soft-2: rgba(78, 124, 220, 0.28);
--dbn-coral-soft: rgba(186, 12, 47, 0.20);
--dbn-amber-soft: rgba(183, 121, 31, 0.14);
--dbn-amber-edge: rgba(183, 121, 31, 0.38);
--dbn-amber-ink: #e2b87a;
--paper: #1a1f2a;
--paper-2: #232938;
}
html.surface-dark .tool-tab { color: var(--dbn-ink); }
html.surface-dark .tool-tab.is-active { background: rgba(78,124,220,0.22); border-color: rgba(78,124,220,0.45); }
html.surface-dark .secondary-button { background: #1f2735; }
html.surface-dark .answer p strong,
html.surface-dark .source-title { color: var(--dbn-ink); }
/* ─── Body — fine grain noise + paper tone (subtle, scaled by --distress) */
body.dossier {
background-color: var(--dbn-bg);
background-image:
/* paper grain */
radial-gradient(circle at 23% 47%, rgba(0,0,0,calc(0.03 * var(--distress))) 0 1px, transparent 1px),
radial-gradient(circle at 71% 19%, rgba(0,0,0,calc(0.025 * var(--distress))) 0 1px, transparent 1px),
radial-gradient(circle at 8% 81%, rgba(0,0,0,calc(0.02 * var(--distress))) 0 1px, transparent 1px),
radial-gradient(circle at 88% 71%, rgba(0,0,0,calc(0.03 * var(--distress))) 0 1px, transparent 1px);
background-size: 7px 7px, 13px 13px, 11px 11px, 17px 17px;
}
html.surface-dark body.dossier {
background-image:
radial-gradient(circle at 23% 47%, rgba(255,255,255,calc(0.025 * var(--distress))) 0 1px, transparent 1px),
radial-gradient(circle at 71% 19%, rgba(255,255,255,calc(0.02 * var(--distress))) 0 1px, transparent 1px);
}
/* ─── App shell: top bleed bar + side numbering ─────────────────────────── */
.app-shell { position: relative; }
.app-shell::before {
/* the bleed: red bar at top, mimicking the flag colour eroding */
content: "";
position: absolute; left: 0; right: 0; top: 0;
height: var(--bleed-h);
background:
linear-gradient(90deg,
var(--bleed) 0%,
var(--bleed) 38%,
#00205B 38%,
#00205B 41%,
#fff 41%,
#fff 49%,
#00205B 49%,
#00205B 52%,
var(--bleed) 52%,
var(--bleed) 100%);
opacity: calc(0.55 + var(--distress) * 0.45);
}
/* ─── Manifesto strip (between disclaimer and workspace) ────────────────── */
.manifesto {
max-width: 1500px;
margin: 0 auto 14px;
display: grid;
grid-template-columns: 1fr auto;
gap: 18px;
align-items: end;
padding: calc(14px + var(--distress) * 18px) 22px calc(14px + var(--distress) * 14px) 26px;
border: 1px solid var(--dbn-line);
border-left: 6px solid var(--dbn-coral);
border-radius: 8px;
background: var(--dbn-panel);
position: relative;
overflow: hidden;
}
.manifesto::before {
/* the half-mast flag — faded, desaturated, sliced */
content: "";
position: absolute;
right: -40px; top: -10px; bottom: -10px; width: 320px;
background:
linear-gradient(180deg,
transparent 0%,
transparent 32%,
var(--dbn-coral) 32%,
var(--dbn-coral) 38%,
#fff 38%,
#fff 42%,
#00205B 42%,
#00205B 50%,
#fff 50%,
#fff 54%,
var(--dbn-coral) 54%,
var(--dbn-coral) 60%,
transparent 60%);
opacity: calc(0.10 + var(--distress) * 0.35);
filter: grayscale(calc(0.6 - var(--distress) * 0.4)) saturate(calc(0.4 + var(--distress) * 0.6));
transform: skewX(-8deg);
pointer-events: none;
mask-image: linear-gradient(90deg, transparent 0%, #000 35%, #000 100%);
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 35%, #000 100%);
}
html.surface-dark .manifesto::before { opacity: calc(0.20 + var(--distress) * 0.40); }
.manifesto-copy { position: relative; z-index: 2; max-width: 880px; }
.manifesto-eyebrow {
font-family: var(--type-mono);
font-size: 0.74rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--dbn-coral);
margin: 0 0 8px;
display: flex; align-items: center; gap: 10px;
}
.manifesto-eyebrow::before {
content: "";
display: inline-block;
width: 28px; height: 2px; background: var(--dbn-coral);
}
.manifesto-title {
margin: 0;
font-weight: 900;
letter-spacing: -0.025em;
line-height: 1.02;
color: var(--dbn-ink);
font-size: clamp(1.7rem, calc(2rem + var(--distress) * 1.6rem), 3.4rem);
text-wrap: balance;
}
.manifesto-sub {
margin: 12px 0 0;
color: var(--dbn-muted);
font-size: 1rem;
line-height: 1.5;
max-width: 56ch;
}
.manifesto-stats {
display: grid;
grid-template-columns: repeat(2, minmax(110px, auto));
gap: 16px 28px;
position: relative; z-index: 2;
align-self: center;
}
.manifesto-stat { text-align: right; min-width: 110px; }
.manifesto-stat-num {
font-size: clamp(1.6rem, 2.2rem, 2.6rem);
font-weight: 900;
letter-spacing: -0.04em;
color: var(--dbn-coral);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.manifesto-stat-num.is-blue { color: var(--dbn-teal); }
.manifesto-stat-label {
font-family: var(--type-mono);
font-size: 0.66rem;
font-weight: 600;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--dbn-muted);
margin-top: 4px;
max-width: 22ch;
margin-left: auto;
}
/* ─── Dossier stamp ─────────────────────────────────────────────────────── */
.stamp {
display: inline-flex; align-items: center; gap: 7px;
font-family: var(--type-mono);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: 5px 9px 4px;
color: var(--stamp);
border: 1.5px solid var(--stamp);
border-radius: 4px;
background: transparent;
transform: rotate(-1.5deg);
opacity: calc(0.45 + var(--distress) * 0.55);
}
.stamp.is-ink { color: var(--ink-stamp); border-color: var(--ink-stamp); }
html.surface-dark .stamp { color: #e8b6b9; border-color: rgba(232,182,185,0.55); }
html.surface-dark .stamp.is-ink { color: #c9cfdb; border-color: rgba(201,207,219,0.45); }
.stamp::before {
content: "";
width: 8px; height: 8px; border-radius: 999px;
border: 1.5px solid currentColor;
}
/* ─── Topbar overrides ──────────────────────────────────────────────────── */
.topbar { padding-top: calc(10px + var(--distress) * 4px); }
.topbar .case-no {
font-family: var(--type-mono);
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--dbn-muted);
margin-top: 6px;
display: inline-flex; align-items: center; gap: 10px;
}
.topbar .case-no .pulse {
width: 8px; height: 8px; border-radius: 999px;
background: var(--dbn-coral);
box-shadow: 0 0 0 0 rgba(186,12,47,0.6);
animation: caseno-pulse 1.6s ease-out infinite;
}
@keyframes caseno-pulse {
0%,100% { box-shadow: 0 0 0 0 rgba(186,12,47,0.55); }
60% { box-shadow: 0 0 0 9px rgba(186,12,47,0); }
}
/* ─── Tool rail: stat severity ──────────────────────────────────────────── */
.tool-rail { padding: 12px; gap: 6px; position: relative; }
.tool-rail::before {
content: "TOOLS · CASE WORK";
display: block;
font-family: var(--type-mono);
font-size: 0.6rem;
letter-spacing: 0.16em;
color: var(--dbn-muted);
margin: 4px 6px 8px;
}
.tool-tab {
position: relative;
padding: 12px 12px 12px 14px;
border-left: 2px solid transparent !important;
transition: background .15s, border-color .15s, color .15s;
}
.tool-tab span { font-size: 0.95rem; }
.tool-tab small { font-family: var(--type-mono); font-size: 0.66rem; letter-spacing: 0.06em; text-transform: uppercase; }
.tool-tab .tag {
position: absolute; top: 8px; right: 8px;
font-family: var(--type-mono); font-size: 0.6rem; font-weight: 700;
color: var(--dbn-coral); letter-spacing: 0.06em;
}
.tool-tab.is-active { border-left-color: var(--dbn-coral) !important; }
.tool-tab.is-active::before {
content: "▶";
position: absolute; left: -10px; top: 50%; transform: translateY(-50%);
color: var(--dbn-coral); font-size: 8px;
}
/* ─── Tool panel header: tab title + dossier corner fold ────────────────── */
.tool-panel { position: relative; padding: 20px 22px 22px; }
.tool-panel .corner-fold {
position: absolute; top: 0; right: 0;
width: calc(28px + var(--distress) * 22px);
height: calc(28px + var(--distress) * 22px);
background: linear-gradient(225deg, var(--paper) 50%, transparent 50%);
border-bottom-left-radius: 6px;
opacity: calc(0.35 + var(--distress) * 0.5);
pointer-events: none;
}
.tool-panel .corner-fold::after {
content: "";
position: absolute; right: 0; top: 0;
width: 100%; height: 100%;
background: linear-gradient(225deg, transparent 49%, rgba(0,0,0,0.08) 50%, transparent 51%);
}
html.surface-dark .tool-panel .corner-fold { opacity: calc(0.18 + var(--distress) * 0.32); }
/* ─── Reasoning panel: case-file aesthetic ──────────────────────────────── */
.reasoning-panel { padding: 18px 18px 20px; position: relative; }
.reasoning-panel .file-label {
font-family: var(--type-mono);
font-size: 0.62rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--dbn-muted);
margin: 0 0 4px;
}
.reasoning-panel .case-meta {
font-family: var(--type-mono);
font-size: 0.74rem;
color: var(--dbn-ink-soft);
border-top: 1px dashed var(--dbn-line);
margin-top: 18px; padding-top: 14px;
display: grid; grid-template-columns: 1fr 1fr; gap: 6px 12px;
}
.reasoning-panel .case-meta span:nth-child(odd) { color: var(--dbn-muted); }
/* ─── Citation chip — richer evidence treatment ─────────────────────────── */
.cite-chip {
background: transparent !important;
border: 1px solid var(--dbn-coral) !important;
color: var(--dbn-coral) !important;
}
.cite-chip:hover { background: var(--dbn-coral) !important; color: #fff !important; }
/* ─── Redaction motif ───────────────────────────────────────────────────── */
.redact {
background: #0a0c11;
color: transparent !important;
border-radius: 1px;
padding: 1px 4px;
margin: 0 1px;
text-shadow: none !important;
letter-spacing: 0.02em;
user-select: none;
cursor: help;
position: relative;
display: inline-block;
vertical-align: baseline;
transition: background .2s;
}
.redact::after {
/* faint typewriter strike-through */
content: "";
position: absolute; left: 2px; right: 2px; top: 50%; height: 0.6px;
background: rgba(255,255,255,0.06);
}
.redact:hover { background: #6e1421; }
/* ─── Headline / numbers ────────────────────────────────────────────────── */
.bignum {
font-weight: 900;
font-variant-numeric: tabular-nums;
letter-spacing: -0.03em;
line-height: 1;
color: var(--dbn-coral);
}
/* ─── Article 8 chip (used across tools) ────────────────────────────────── */
.art-chip {
display: inline-flex; align-items: center; gap: 6px;
background: var(--dbn-coral-soft);
color: var(--dbn-coral);
border: 1px solid var(--dbn-coral);
border-radius: 999px;
padding: 3px 10px;
font-family: var(--type-mono);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
}
.art-chip.is-blue {
background: var(--dbn-teal-soft);
color: var(--dbn-teal);
border-color: var(--dbn-teal);
}
.art-chip.is-amber {
background: var(--dbn-amber-soft);
color: var(--dbn-amber-ink);
border-color: var(--dbn-amber-edge);
}
/* ─── Bench/Bar — visit-rights chart ────────────────────────────────────── */
.bench-row { display: grid; grid-template-columns: 130px 1fr 50px; gap: 10px; align-items: center; padding: 6px 0; }
.bench-row + .bench-row { border-top: 1px dashed var(--dbn-line); }
.bench-row.is-no { color: var(--dbn-coral); font-weight: 800; }
.bench-name { font-family: var(--type-mono); font-size: 0.78rem; letter-spacing: 0.04em; text-transform: uppercase; }
.bench-bar {
position: relative; height: 18px; background: var(--dbn-panel-tinted); border: 1px solid var(--dbn-line); border-radius: 2px;
}
.bench-fill {
position: absolute; left: 0; top: 0; bottom: 0;
background: var(--dbn-teal);
width: var(--w, 20%);
}
.bench-row.is-no .bench-fill { background: var(--dbn-coral); }
.bench-num { text-align: right; font-family: var(--type-mono); font-weight: 800; font-size: 0.95rem; font-variant-numeric: tabular-nums; }
/* ─── Advocate side-picker ──────────────────────────────────────────────── */
.side-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.side-tile {
border: 1px solid var(--dbn-line);
background: var(--dbn-panel-tinted);
border-radius: 8px;
padding: 12px;
text-align: left;
cursor: pointer;
transition: all .15s;
}
.side-tile:hover { border-color: var(--dbn-coral); }
.side-tile.is-on { border-color: var(--dbn-coral); background: var(--dbn-coral-soft); box-shadow: inset 0 0 0 1px var(--dbn-coral); }
.side-tile-glyph { font-family: var(--type-mono); font-size: 0.66rem; color: var(--dbn-muted); letter-spacing: 0.1em; }
.side-tile-name { font-weight: 800; margin: 4px 0 2px; font-size: 1rem; }
.side-tile-desc { font-size: 0.78rem; color: var(--dbn-muted); line-height: 1.4; }
/* ─── Timeline strip ─────────────────────────────────────────────────────── */
.tl {
list-style: none; padding: 0; margin: 0;
position: relative;
}
.tl::before {
content: ""; position: absolute; left: 9px; top: 6px; bottom: 6px;
width: 1px; background: var(--dbn-line);
}
.tl li { position: relative; padding: 8px 0 14px 28px; }
.tl-dot {
position: absolute; left: 4px; top: 12px;
width: 12px; height: 12px; border-radius: 999px;
background: var(--dbn-panel); border: 2px solid var(--dbn-coral);
}
.tl li.is-bad .tl-dot { background: var(--dbn-coral); }
.tl-time { font-family: var(--type-mono); font-size: 0.7rem; letter-spacing: 0.04em; color: var(--dbn-muted); }
.tl-event { font-weight: 700; color: var(--dbn-ink); margin: 2px 0; }
.tl-detail { color: var(--dbn-muted); font-size: 0.88rem; line-height: 1.45; }
/* ─── Cases table ───────────────────────────────────────────────────────── */
.cases-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
.cases-table th {
text-align: left; padding: 8px 10px 8px 0;
font-family: var(--type-mono); font-size: 0.66rem;
letter-spacing: 0.10em; text-transform: uppercase; color: var(--dbn-muted);
border-bottom: 1px solid var(--dbn-line);
}
.cases-table td { padding: 10px 10px 10px 0; border-bottom: 1px dashed var(--dbn-line); vertical-align: top; }
.cases-table tr:hover td { background: var(--dbn-panel-tinted); }
.cases-table .c-no { font-family: var(--type-mono); font-weight: 800; color: var(--dbn-coral); width: 36px; }
.cases-table .c-name { font-weight: 800; color: var(--dbn-ink); }
.cases-table .c-year { color: var(--dbn-muted); font-variant-numeric: tabular-nums; width: 50px; }
.cases-table .c-art {
display: inline-block;
font-family: var(--type-mono); font-size: 0.7rem; font-weight: 800;
padding: 2px 7px; border-radius: 3px;
background: var(--dbn-coral-soft); color: var(--dbn-coral);
}
/* ─── Voice card (Elena K. style quote) ─────────────────────────────────── */
.voice {
border-left: 4px solid var(--dbn-coral);
padding: 12px 0 12px 16px;
margin: 0;
}
.voice q {
display: block;
font-size: clamp(0.98rem, calc(0.92rem + var(--distress) * 0.4rem), 1.32rem);
font-weight: 600;
line-height: 1.45;
color: var(--dbn-ink);
letter-spacing: -0.005em;
quotes: "“" "”" "" "";
text-wrap: pretty;
}
.voice q::before { content: open-quote; color: var(--dbn-coral); font-weight: 900; }
.voice q::after { content: close-quote; color: var(--dbn-coral); font-weight: 900; }
.voice-by { margin-top: 8px; font-size: 0.78rem; color: var(--dbn-muted); display: flex; gap: 10px; align-items: center; }
.voice-initials {
width: 28px; height: 28px; border-radius: 999px;
background: var(--dbn-coral-soft); color: var(--dbn-coral);
display: inline-flex; align-items: center; justify-content: center;
font-family: var(--type-mono); font-weight: 800; font-size: 0.7rem;
border: 1px solid var(--dbn-coral);
}
/* ─── Source card extra polish ───────────────────────────────────────────── */
.source-card { background: var(--dbn-panel); }
.source-card.is-highlight { background: var(--dbn-coral-soft); }
/* ─── Section heading (mini, inside tool panels) ─────────────────────────── */
.h-section {
font-family: var(--type-mono);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--dbn-muted);
margin: 18px 0 10px;
display: flex; align-items: center; gap: 10px;
}
.h-section::after {
content: ""; flex: 1; height: 1px; background: var(--dbn-line);
}
/* ─── Hairline divider with mono caption ────────────────────────────────── */
.hairline {
border: 0; border-top: 1px solid var(--dbn-line); margin: 18px 0;
}
/* ─── Tweaks panel — pin to bottom-right ─────────────────────────────────── */
.tweaks-panel-host { z-index: 1000; }
/* ─── Animations: cracked-flag pulse (used in topbar mark) ──────────────── */
@keyframes flag-half-mast {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(2px); }
}
/* ─── Footer strip ───────────────────────────────────────────────────────── */
.foot-strip {
max-width: 1500px;
margin: 24px auto 12px;
padding: 12px 4px;
display: flex; justify-content: space-between; gap: 16px;
border-top: 1px dashed var(--dbn-line);
font-family: var(--type-mono);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--dbn-muted);
}
/* ─── Source modal-lite (inline detail panel) ───────────────────────────── */
.source-detail {
margin-top: 10px; padding: 14px 16px;
border: 1px solid var(--dbn-coral);
background: var(--dbn-coral-soft);
border-radius: 8px;
position: relative;
}
.source-detail .close {
position: absolute; top: 6px; right: 8px;
background: transparent; border: 0; color: var(--dbn-coral); cursor: pointer;
font-family: var(--type-mono); font-size: 0.9rem;
}
.source-detail .head { font-family: var(--type-mono); font-size: 0.7rem; letter-spacing: 0.1em; color: var(--dbn-coral); text-transform: uppercase; }
.source-detail blockquote { margin: 8px 0 0; font-size: 0.95rem; line-height: 1.55; color: var(--dbn-ink); border-left: 2px solid var(--dbn-coral); padding-left: 12px; }
/* ─── Search input row used in Search & Ask ─────────────────────────────── */
.search-row { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
.search-input {
width: 100%; border: 1px solid var(--dbn-line); border-radius: 8px;
padding: 12px 14px; background: var(--dbn-panel); color: var(--dbn-ink);
font-size: 0.98rem;
}
.search-input:focus-visible { outline: 3px solid var(--dbn-teal-ring); outline-offset: 2px; }
/* ─── Pre-set filter pills ──────────────────────────────────────────────── */
.pill-row { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0 4px; }
.pill-row .pill {
border: 1px solid var(--dbn-line);
background: var(--dbn-panel);
color: var(--dbn-muted);
border-radius: 999px;
padding: 5px 11px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
transition: all .12s;
}
.pill-row .pill:hover { border-color: var(--dbn-teal); color: var(--dbn-teal); }
.pill-row .pill.is-on { background: var(--dbn-teal); color: #fff; border-color: var(--dbn-teal); }
/* ─── Redaction tool — case-file paper ──────────────────────────────────── */
.redact-paper {
background: var(--paper);
color: #1a1a1a;
border: 1px solid var(--paper-2);
border-radius: 4px;
padding: 22px 26px;
font-family: "Times New Roman", Georgia, serif;
font-size: 0.96rem;
line-height: 1.7;
position: relative;
box-shadow: 0 1px 0 var(--paper-2);
}
.redact-paper::after {
content: "CONFIDENTIAL";
position: absolute; top: 14px; right: 18px;
font-family: var(--type-mono); font-size: 0.7rem;
letter-spacing: 0.16em; color: rgba(110,20,33,0.7);
border: 1.5px solid rgba(110,20,33,0.7);
padding: 4px 8px; border-radius: 3px;
transform: rotate(2deg);
}
html.surface-dark .redact-paper { color: #d8d0b6; }
/* ─── Misc utility ──────────────────────────────────────────────────────── */
.mono { font-family: var(--type-mono); }
.muted { color: var(--dbn-muted); }
.danger-ink { color: var(--dbn-coral); }
.tinted-card { background: var(--dbn-panel-tinted); border: 1px solid var(--dbn-line); border-radius: 8px; padding: 14px 16px; }
/* ─── Hide manifesto half-mast on narrow screens ────────────────────────── */
@media (max-width: 860px) {
.manifesto { grid-template-columns: 1fr; }
.manifesto-stats { justify-content: flex-start; }
.manifesto::before { display: none; }
.manifesto-stat, .manifesto-stat-label { text-align: left; margin-left: 0; }
}
+6
View File
@@ -0,0 +1,6 @@
/* Inter — pulled from Google Fonts.
The tools.css :root specifies "Inter, ui-sans-serif, system-ui, …".
No webfont @font-face is shipped with the codebase, so we substitute with
the official Google Fonts CDN copy at the standard weights used (400/500/
600/700/800/900). If you have an in-house woff2, swap this file. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
+204
View File
@@ -0,0 +1,204 @@
/* Tools workspace — local styles only what the UI kit needs.
Tokens come from /colors_and_type.css; this file translates them into the
class names the production CSS uses, so the markup matches the live site. */
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--dbn-bg);
color: var(--dbn-ink);
font-family: var(--dbn-font-sans);
-webkit-font-smoothing: antialiased;
}
button, input, textarea { font: inherit; }
button { border: 0; cursor: pointer; }
button:disabled { cursor: progress; opacity: 0.7; }
button:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 3px solid var(--dbn-teal-ring);
outline-offset: 2px;
}
/* eyebrow / shared */
.eyebrow {
margin: 0 0 6px;
color: var(--dbn-teal);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
h1, h2, h3, h4, p { margin-top: 0; }
/* ── App shell ─────────────────────────────────────────────────────────── */
.app-shell { min-height: 100vh; padding: 18px; }
.topbar {
display: flex; align-items: center; justify-content: space-between; gap: 18px;
max-width: 1500px; margin: 0 auto 12px; padding: 10px 2px;
}
.topbar h1 { margin: 0; font-size: 1.5rem; font-weight: 800; color: var(--dbn-ink); line-height: 1.2; }
.topbar-actions { display: flex; align-items: center; gap: 10px; }
.status-pill, .tool-badge {
display: inline-flex; align-items: center; min-height: 30px; padding: 0 10px;
border-radius: 999px; background: var(--dbn-teal-soft); color: var(--dbn-teal-dark);
font-size: 0.84rem; font-weight: 700; white-space: nowrap;
}
.status-pill.is-warning { background: var(--dbn-coral-soft); color: var(--dbn-coral); }
.secondary-button {
min-height: 44px; border-radius: 8px; padding: 0 18px;
background: #263344; color: #fff; font-weight: 700;
}
.disclaimer {
max-width: 1500px; margin: 0 auto 12px; padding: 10px 12px;
border: 1px solid var(--dbn-amber-edge); border-radius: 8px;
background: var(--dbn-amber-soft); color: var(--dbn-amber-ink); font-size: 0.92rem;
}
.workspace {
max-width: 1500px; margin: 0 auto;
display: grid; grid-template-columns: 190px minmax(0, 1fr) 370px;
gap: 14px; align-items: stretch;
}
.tool-rail, .tool-panel, .reasoning-panel {
background: var(--dbn-panel); border: 1px solid var(--dbn-line); border-radius: 8px;
}
/* ── Tool rail ─────────────────────────────────────────────────────────── */
.tool-rail { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
.tool-tab {
width: 100%; min-height: 68px; border-radius: 8px; padding: 11px;
background: transparent; color: var(--dbn-ink); text-align: left;
border: 1px solid transparent; cursor: pointer;
}
.tool-tab span { display: block; font-weight: 800; }
.tool-tab small { display: block; margin-top: 4px; color: var(--dbn-muted); }
.tool-tab.is-active { background: var(--dbn-teal-soft); border-color: rgba(0, 32, 91,0.30); }
.tool-tab:hover:not(.is-active) { background: var(--dbn-panel-tinted); }
/* ── Tool panel ─────────────────────────────────────────────────────────── */
.tool-panel { min-height: 720px; padding: 18px; }
.tool-heading, .reasoning-head {
display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
margin-bottom: 16px;
}
.tool-heading h2, .reasoning-head h2 { margin: 0; font-size: 1.5rem; font-weight: 800; color: var(--dbn-ink); }
.tool-form { display: grid; gap: 12px; }
.control-row {
display: flex; align-items: center; flex-wrap: wrap; gap: 12px; min-height: 34px;
}
.control-row .control-label,
.control-row label {
display: inline-flex; align-items: center; gap: 6px;
color: var(--dbn-muted); font-weight: 700; font-size: 0.92rem;
}
.control-label { color: var(--dbn-ink) !important; }
.tool-form textarea {
width: 100%; border: 1px solid var(--dbn-line); border-radius: 8px;
background: #fff; color: var(--dbn-ink);
resize: vertical; min-height: 220px; padding: 14px; line-height: 1.55;
}
.form-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.form-status { min-height: 22px; margin: 0; color: var(--dbn-muted); font-size: 0.88rem; }
#runButton {
min-height: 44px; border-radius: 8px; padding: 0 18px;
background: var(--dbn-teal); color: #fff; font-weight: 700;
}
/* ── Results / sources ─────────────────────────────────────────────────── */
.results { margin-top: 18px; display: grid; gap: 12px; }
.empty-state, .result-section {
border: 1px solid var(--dbn-line); border-radius: 8px; padding: 16px; background: var(--dbn-panel-tinted);
}
.empty-state h3, .result-section h3 { margin: 0 0 8px; font-size: 0.98rem; color: var(--dbn-ink); }
.empty-state p, .result-section p, .result-section li { color: var(--dbn-muted); line-height: 1.55; margin: 0; }
.answer { color: var(--dbn-ink) !important; font-size: 1.03rem; line-height: 1.65; }
.answer p { margin: 0 0 12px; }
.source-list { display: grid; gap: 10px; }
.source-card {
display: grid; grid-template-columns: 34px 1fr auto; gap: 12px; align-items: start;
border: 1px solid var(--dbn-line); border-radius: 8px; padding: 12px;
background: var(--dbn-panel-tinted); cursor: pointer; text-align: left; width: 100%;
}
.source-card:hover { border-color: rgba(0, 32, 91,0.40); }
.source-card.is-highlight { border-color: var(--dbn-coral); background: var(--dbn-coral-soft); }
.source-number {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 999px;
background: var(--dbn-coral-soft); color: var(--dbn-coral);
font-weight: 900; font-variant-numeric: tabular-nums;
}
.source-title { font-weight: 800; color: var(--dbn-ink); line-height: 1.35; }
.source-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.source-tag {
background: var(--dbn-teal-soft); color: var(--dbn-teal-dark);
border-radius: 999px; font-size: 0.7rem; font-weight: 800; padding: 3px 8px;
text-transform: uppercase; letter-spacing: 0.03em;
}
.source-tag--score { background: #eef3fb; color: var(--dbn-ink-soft); }
.source-excerpt { color: var(--dbn-muted); margin-top: 8px; line-height: 1.5; font-size: 0.92rem; }
.source-aside {
align-self: stretch; display: grid; grid-template-rows: auto auto; gap: 6px;
font-size: 0.78rem; color: var(--dbn-muted); text-align: right; min-width: 90px;
}
.source-aside b { color: var(--dbn-ink); font-variant-numeric: tabular-nums; font-size: 0.92rem; }
.cite-chip {
display: inline-flex; align-items: center; justify-content: center;
min-width: 18px; height: 18px; margin: 0 1px; padding: 0 5px;
border-radius: 999px; background: var(--dbn-coral-soft); color: var(--dbn-coral);
font-size: 0.72rem; font-weight: 800; font-variant-numeric: tabular-nums;
cursor: pointer; border: 1px solid rgba(186, 12, 47,0.25); vertical-align: 1px;
}
.cite-chip:hover { background: var(--dbn-coral); color: #fff; }
/* ── Reasoning panel ───────────────────────────────────────────────────── */
.reasoning-panel { min-height: 720px; padding: 16px; }
.trace-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; }
.trace-list li { display: grid; grid-template-columns: 14px 1fr; gap: 10px; }
.trace-list strong { display: block; margin-bottom: 3px; color: var(--dbn-ink); }
.trace-list p { margin: 0; color: var(--dbn-muted); line-height: 1.45; font-size: 0.88rem; }
.trace-status { width: 10px; height: 10px; margin-top: 5px; border-radius: 999px; background: var(--dbn-teal); }
.trace-status.running { background: var(--dbn-amber); animation: trace-pulse 1.4s ease-in-out infinite; }
.trace-status.warning { background: var(--dbn-coral); }
.trace-status.waiting { background: var(--dbn-muted-2); }
@keyframes trace-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
/* ── Upload zone ───────────────────────────────────────────────────────── */
.upload-zone {
border: 2px dashed var(--dbn-line); border-radius: 8px; padding: 18px 14px;
text-align: center; cursor: pointer; transition: border-color .15s, background .15s;
}
.upload-zone:hover { border-color: var(--dbn-teal); background: #f7fdfb; }
.upload-icon { display: block; font-size: 1.8rem; line-height: 1; color: var(--dbn-teal); opacity: 0.55; margin-bottom: 6px; }
.upload-prompt p { margin: 4px 0 0; color: var(--dbn-muted); font-size: 0.88rem; }
.upload-browse { color: var(--dbn-teal); font-weight: 700; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
.upload-hint { font-size: 0.76rem !important; opacity: 0.7; }
/* ── Lang switcher / pills ─────────────────────────────────────────────── */
.lang-switcher { display: flex; align-items: center; gap: 6px; }
.lang-btn {
background: var(--dbn-bg); border: 1px solid var(--dbn-line); border-radius: 999px;
color: var(--dbn-muted); cursor: pointer; font-size: 0.7rem; font-weight: 600;
letter-spacing: 0.03em; padding: 4px 10px; transition: all .15s;
}
.lang-btn.is-active { background: var(--dbn-teal); border-color: var(--dbn-teal); color: #fff; }
@media (max-width: 1120px) {
.workspace { grid-template-columns: 170px minmax(0,1fr); }
.reasoning-panel { grid-column: 1 / -1; min-height: auto; }
}
@media (max-width: 760px) {
.workspace { grid-template-columns: 1fr; }
.tool-rail { flex-direction: row; overflow-x: auto; }
.tool-tab { min-width: 150px; }
}
+212
View File
@@ -0,0 +1,212 @@
/* global React */
const { useState, useEffect, useRef, useMemo } = React;
/* ─── Stamp ─────────────────────────────────────────────────────── */
function Stamp({ children, ink }) {
return <span className={"stamp" + (ink ? " is-ink" : "")}>{children}</span>;
}
/* ─── Art-chip (instrument badge) ───────────────────────────────── */
function ArtChip({ code, tone = "red", title }) {
const cls = "art-chip" + (tone === "blue" ? " is-blue" : tone === "amber" ? " is-amber" : "");
return <span className={cls} title={title}>{code}</span>;
}
/* ─── Redact span ───────────────────────────────────────────────── */
function Redact({ children, len }) {
// render fixed-width blocks if `len` is passed; otherwise show children styled as bar
const text = len ? "█".repeat(len) : (children || "████");
return <span className="redact" title="Redacted under GDPR / Personopplysningsloven">{text}</span>;
}
/* ─── Cite chip (richer) ─────────────────────────────────────────── */
function CiteChip({ n, active, onClick }) {
return (
<span className="cite-chip" role="button" onClick={onClick}
style={active ? { background: "var(--dbn-coral)", color: "#fff" } : {}}>
{n}
</span>
);
}
/* ─── Topbar with case-number ───────────────────────────────────── */
function Topbar({ caseNo, status }) {
return (
<header className="topbar">
<div>
<p className="eyebrow">Do Better Norge · tools.dobetternorge.no</p>
<h1>Legal Tools <span style={{color: "var(--dbn-coral)"}}>·</span> Case Workbench</h1>
<div className="case-no">
<span className="pulse"></span>
<span>{caseNo}</span>
<span style={{opacity: 0.5}}>·</span>
<span>session in memory · nothing stored</span>
</div>
</div>
<div className="topbar-actions" style={{gap: 10}}>
<Stamp>ECHR Art. 8 monitoring</Stamp>
<span className="status-pill" style={{background: "var(--dbn-coral-soft)", color: "var(--dbn-coral)"}}>{status}</span>
<button className="secondary-button" type="button">Sign out</button>
</div>
</header>
);
}
/* ─── Manifesto strip — replaces vanilla disclaimer with a headline + stats */
function Manifesto({ headline, stats, intensity }) {
return (
<section className="manifesto" role="banner" data-screen-label="00 Manifesto Strip">
<div className="manifesto-copy">
<p className="manifesto-eyebrow">{headline.eyebrow}</p>
<h2 className="manifesto-title">{headline.title}</h2>
<p className="manifesto-sub">{headline.sub}</p>
</div>
<div className="manifesto-stats" aria-label="Headline statistics">
<div className="manifesto-stat">
<div className="manifesto-stat-num">{stats.echrViolations}</div>
<div className="manifesto-stat-label">ECHR violations since 2015</div>
</div>
<div className="manifesto-stat">
<div className="manifesto-stat-num">{stats.echrLossRate}%</div>
<div className="manifesto-stat-label">cases lost 201722</div>
</div>
<div className="manifesto-stat">
<div className="manifesto-stat-num is-blue">{stats.tribunalDecisions.toLocaleString()}</div>
<div className="manifesto-stat-label">tribunal decisions analysed</div>
</div>
<div className="manifesto-stat">
<div className="manifesto-stat-num">{stats.pendingStrasbourg}+</div>
<div className="manifesto-stat-label">pending Strasbourg</div>
</div>
</div>
</section>
);
}
/* ─── Disclaimer (kept, but reskinned amber) ────────────────────── */
function Disclaimer() {
return (
<div className="disclaimer" role="note">
<strong style={{color:"var(--dbn-amber-ink)"}}>Notice.</strong>&nbsp;
Legal information and preparation support, not final legal advice. Pasted text is processed in memory by default
under <span className="mono">ECHR Art. 8</span> and Norwegian Personopplysningsloven principles.
</div>
);
}
/* ─── Tool rail (themed) ────────────────────────────────────────── */
const TOOLS = [
{ slug: "ask", label: "Ask", sub: "source-grounded", tag: "Art. 8" },
{ slug: "search", label: "Search", sub: "lovdata + echr", tag: "1.7K" },
{ slug: "advocate", label: "Advocate", sub: "take a side", tag: "★" },
{ slug: "redact", label: "Redact", sub: "in-memory only", tag: "GDPR" },
{ slug: "timeline", label: "Timeline", sub: "events extracted", tag: "12 min" },
{ slug: "cases", label: "Casebook", sub: "23 violations", tag: "23" },
{ slug: "voices", label: "Voices", sub: "verified accounts", tag: "4" },
];
function ToolRail({ active, onSelect }) {
return (
<nav className="tool-rail" aria-label="Tools">
{TOOLS.map(t => (
<button
key={t.slug}
className={"tool-tab" + (t.slug === active ? " is-active" : "")}
onClick={() => onSelect(t.slug)}
type="button"
aria-current={t.slug === active ? "page" : undefined}
>
<span>{t.label}</span>
<small>{t.sub}</small>
<span className="tag">{t.tag}</span>
</button>
))}
</nav>
);
}
/* ─── Reasoning panel (Evidence Trail) ──────────────────────────── */
const TRACE_WAITING = [
{ label: "Waiting for query", detail: "Open a tool on the left. The evidence trail is built as the answer is generated.", status: "waiting" },
];
function TraceList({ steps }) {
return (
<ol className="trace-list">
{steps.map((s, i) => (
<li key={i}>
<span className={"trace-status " + (s.status || "")}></span>
<div>
<strong>{s.label}</strong>
<p>{s.detail}</p>
</div>
</li>
))}
</ol>
);
}
function ReasoningPanel({ steps, caseNo, instrument }) {
return (
<aside className="reasoning-panel" aria-labelledby="reasoningTitle">
<div className="reasoning-head">
<div>
<p className="file-label">File · Evidence trail</p>
<h2 id="reasoningTitle" style={{fontSize: "1.2rem", margin: 0}}>Reasoning</h2>
</div>
<Stamp ink>In memory</Stamp>
</div>
<TraceList steps={steps && steps.length ? steps : TRACE_WAITING} />
<div className="case-meta" aria-label="Session metadata">
<span>Case file</span><span>{caseNo}</span>
<span>Primary instrument</span><span>{instrument}</span>
<span>Reviewer</span><span>azure · mini · pass</span>
<span>Retention</span><span>in-memory · 0 b stored</span>
</div>
</aside>
);
}
/* ─── Footer strip ──────────────────────────────────────────────── */
function FootStrip() {
return (
<div className="foot-strip" role="contentinfo">
<span>Do Better Norge · independent advocacy · founded by affected parents</span>
<span>Powered by CaveauAI · Blue Note Logic · in-memory RAG · no telemetry</span>
</div>
);
}
/* ─── Run helper (timed promise sequence for fake tool runs) ────── */
function useRunSequence() {
const timers = useRef([]);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
function clear() { timers.current.forEach(clearTimeout); timers.current = []; }
function run(sequence, onStep, onDone) {
clear();
let cum = 0;
sequence.forEach((entry, idx) => {
cum += entry.delay;
const t = setTimeout(() => {
onStep(entry.step, idx, idx === sequence.length - 1);
if (idx === sequence.length - 1) onDone && onDone();
}, cum);
timers.current.push(t);
});
}
return { run, clear };
}
/* ─── Headline labeller / instrument helper ─────────────────────── */
function lookupInstrument(code) {
const m = (window.DBN_DATA.instruments || []).find(i => i.code === code);
return m ? m.full : code;
}
/* ─── Expose ────────────────────────────────────────────────────── */
Object.assign(window, {
Stamp, ArtChip, Redact, CiteChip,
Topbar, Manifesto, Disclaimer, ToolRail, TOOLS,
ReasoningPanel, TraceList, TRACE_WAITING,
FootStrip, useRunSequence, lookupInstrument,
});
+568
View File
@@ -0,0 +1,568 @@
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
.twk-chips{display:flex;gap:6px}
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
.twk-chip:hover{transform:translateY(-1px);
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
0 2px 6px rgba(0,0,0,.15)}
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
.twk-chip>span>i:first-child{box-shadow:none}
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
// can react — the parent message only reaches the host, not peers.
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', noDeckControls = false, children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
// Auto-inject a rail toggle when a <deck-stage> is on the page. The
// toggle drives the deck's per-viewer _railVisible via window message;
// state is mirrored from the same localStorage key the deck reads so
// the control reflects reality across reloads. The mechanism is the
// message — authors who want custom placement can post it directly
// and pass noDeckControls to suppress this one.
const hasDeckStage = React.useMemo(
() => typeof document !== 'undefined' && !!document.querySelector('deck-stage'),
[],
);
// deck-stage enables its rail in connectedCallback, but this panel can
// mount before that element has upgraded. The initial read catches the
// common case; the listener covers mounting first. (Older deck-stage.js
// copies still wait for the host's __omelette_rail_enabled postMessage —
// same listener handles those.)
const [railEnabled, setRailEnabled] = React.useState(
() => hasDeckStage && !!document.querySelector('deck-stage')?._railEnabled,
);
React.useEffect(() => {
if (!hasDeckStage || railEnabled) return undefined;
const onMsg = (e) => {
if (e.data && e.data.type === '__omelette_rail_enabled') setRailEnabled(true);
};
window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg);
}, [hasDeckStage, railEnabled]);
const [railVisible, setRailVisible] = React.useState(() => {
try { return localStorage.getItem('deck-stage.railVisible') !== '0'; } catch (e) { return true; }
});
const toggleRail = (on) => {
setRailVisible(on);
window.postMessage({ type: '__deck_rail_visible', on }, '*');
};
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-noncommentable=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
{hasDeckStage && railEnabled && !noDeckControls && (
<TweakSection label="Deck">
<TweakToggle label="Thumbnail rail" value={railVisible} onChange={toggleRail} />
</TweakSection>
)}
</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
// Segments wrap mid-word once per-segment width runs out. The track is
// ~248px (280 panel 28 body pad 4 seg pad), each button loses 12px
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
// back to a dropdown rather than wrap.
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
if (!fitsAsSegments) {
// <select> emits strings — map back to the original option value so the
// fallback stays type-preserving (numbers, booleans) like the segment path.
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return <TweakSelect label={label} value={value} options={options}
onChange={(s) => onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
const h = String(hex).replace('#', '');
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
const n = parseInt(x.slice(0, 6), 16);
if (Number.isNaN(n)) return true;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return r * 299 + g * 587 + b * 114 > 148000;
}
const __TwkCheck = ({ light }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
if (!options || !options.length) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> emits lowercase hex per the HTML spec, so
// compare case-insensitively. String() guards JSON.stringify(undefined),
// which returns the primitive undefined (no .toLowerCase).
const key = (o) => String(JSON.stringify(o)).toLowerCase();
const cur = key(value);
return (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{options.map((o, i) => {
const colors = Array.isArray(o) ? o : [o];
const [hero, ...rest] = colors;
const sup = rest.slice(0, 4);
const on = key(o) === cur;
return (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});
+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,
});
+139 -182
View File
@@ -17,9 +17,8 @@ if (!in_array($language, $validLangs, true)) $language = 'auto';
$diarize = !empty($_POST['diarize']) && $_POST['diarize'] !== '0'; $diarize = !empty($_POST['diarize']) && $_POST['diarize'] !== '0';
$numSpeakers = isset($_POST['num_speakers']) ? max(0, min(20, (int)$_POST['num_speakers'])) : 0; $numSpeakers = isset($_POST['num_speakers']) ? max(0, min(20, (int)$_POST['num_speakers'])) : 0;
$engine = in_array($_POST['engine'] ?? '', ['gpu', 'openai', 'azure'], true) ? $_POST['engine'] : 'gpu';
$validModels = ['tiny', 'base', 'small', 'medium', 'large-v2', 'large-v3']; $validModels = ['tiny', 'base', 'small', 'medium', 'large-v2', 'large-v3'];
$model = in_array($_POST['model'] ?? '', $validModels, true) ? $_POST['model'] : 'small'; $gpuModel = in_array($_POST['model'] ?? '', $validModels, true) ? $_POST['model'] : 'large-v3';
$beamSize = max(1, min(5, (int)($_POST['beam_size'] ?? 5))); $beamSize = max(1, min(5, (int)($_POST['beam_size'] ?? 5)));
$task = ($_POST['task'] ?? 'transcribe') === 'translate' ? 'translate' : 'transcribe'; $task = ($_POST['task'] ?? 'transcribe') === 'translate' ? 'translate' : 'transcribe';
$vadFilter = !empty($_POST['vad_filter']) && $_POST['vad_filter'] !== '0'; $vadFilter = !empty($_POST['vad_filter']) && $_POST['vad_filter'] !== '0';
@@ -51,36 +50,56 @@ if (!in_array($ext, $allowedExts, true)) {
dbnToolsError("Unsupported format: .{$ext}. Use MP3, WAV, OGG, M4A, FLAC, or WebM.", 415, 'unsupported_format'); dbnToolsError("Unsupported format: .{$ext}. Use MP3, WAV, OGG, M4A, FLAC, or WebM.", 415, 'unsupported_format');
} }
// OpenAI has a 25 MB file limit $detectedMime = mime_content_type($file['tmp_name']) ?: 'application/octet-stream';
if ($engine === 'openai' && $file['size'] > 25 * 1024 * 1024) {
dbnToolsError('OpenAI Whisper API has a 25 MB file limit. Use the GPU engine for larger files.', 413, 'openai_file_too_large');
}
$timeOffset = max(0.0, (float)($_POST['time_offset'] ?? 0)); $timeOffset = max(0.0, (float)($_POST['time_offset'] ?? 0));
$t0 = microtime(true); $t0 = microtime(true);
// ── Route to engine ─────────────────────────────────────────────────────────── // ── Auto-cascade: Azure → GCP → Whisper GPU ───────────────────────────────────
if ($engine === 'openai') { $result = null;
$apiKey = trim((string)($_POST['openai_key'] ?? '')); $engineUsed = 'whisper-gpu';
if (!$apiKey || !str_starts_with($apiKey, 'sk-')) {
dbnToolsError('A valid OpenAI API key (sk-…) is required for the OpenAI engine.', 400, 'missing_openai_key'); // 1. Microsoft Azure Speech — fast path for short, non-diarize audio clips
$azureKey = (string)(dbnToolsEnv('DBN_AZURE_SPEECH_KEY') ?? '');
$azureRegion = preg_replace('/[^a-z0-9]/', '', strtolower(
(string)(dbnToolsEnv('DBN_AZURE_SPEECH_REGION') ?? 'norwayeast')
));
if ($azureKey !== '' && !$diarize && $file['size'] <= 1024 * 1024 && str_starts_with($detectedMime, 'audio/')) {
$result = transcribeViaAzureServer($file, $language, $azureKey, $azureRegion);
if ($result !== null) {
$engineUsed = 'azure';
} else {
error_log('STT: Azure Speech skipped or failed, trying Google Cloud');
} }
$result = transcribeViaOpenAI($file, $language, $task, $apiKey); }
} elseif ($engine === 'azure') { // 2. Google Cloud Speech v2 — long audio, diarization, everything Azure can't handle
$apiKey = trim((string)($_POST['azure_key'] ?? '')); if ($result === null) {
if ($apiKey === '') $apiKey = (string)(dbnToolsEnv('DBN_AZURE_SPEECH_KEY') ?? ''); $gcpPath = dbnToolsAiPortalRoot() . '/lib/ai/GcpSpeechClient.php';
$region = preg_replace('/[^a-z0-9]/', '', strtolower(trim((string)($_POST['azure_region'] ?? '')))); if (is_file($gcpPath)) {
if ($region === '') $region = preg_replace('/[^a-z0-9]/', '', strtolower((string)(dbnToolsEnv('DBN_AZURE_SPEECH_REGION') ?? 'norwayeast'))); require_once $gcpPath;
if (!$apiKey) { $gcp = GcpSpeechClient::fromConfig();
dbnToolsError('An Azure Speech API key is required for the Azure engine.', 400, 'missing_azure_key'); if ($gcp) {
$gcpLang = ($language === 'auto') ? '' : $language;
$result = $gcp->transcribe(
$file['tmp_name'], $detectedMime, $gcpLang,
$diarize,
$numSpeakers > 1 ? $numSpeakers : 2,
$numSpeakers > 1 ? max($numSpeakers, 2) : 6
);
if ($result !== null) {
$engineUsed = 'gcp';
} else {
error_log('STT: Google Cloud Speech failed, falling back to Whisper');
} }
$result = transcribeViaAzure($file, $language, $apiKey, $region, $diarize); }
}
}
} else { // 3. Whisper GPU — local fallback
// GPU (default) if ($result === null) {
$result = transcribeViaWhisperGpu($file, $language, $diarize, $numSpeakers, $model, $beamSize, $task, $vadFilter, $initPrompt); $result = transcribeViaWhisperGpu($file, $language, $diarize, $numSpeakers, $gpuModel, $beamSize, $task, $vadFilter, $initPrompt);
$engineUsed = 'whisper-gpu';
} }
$latencyMs = (int)round((microtime(true) - $t0) * 1000); $latencyMs = (int)round((microtime(true) - $t0) * 1000);
@@ -95,7 +114,7 @@ if ($timeOffset > 0.0 && !empty($result['segments'])) {
unset($seg); unset($seg);
} }
// ── Speaker role labelling (GPU + diarize only) ─────────────────────────────── // ── Speaker role labelling (diarize + multiple speakers only) ─────────────────
$segments = $result['segments'] ?? []; $segments = $result['segments'] ?? [];
$numDetected = (int)($result['num_speakers'] ?? 1); $numDetected = (int)($result['num_speakers'] ?? 1);
@@ -110,12 +129,20 @@ if ($diarize && $numDetected > 1 && $segments) {
$speakerRoles = dbnLabelSpeakerRoles($segments); $speakerRoles = dbnLabelSpeakerRoles($segments);
} }
// ── Friendly engine label ─────────────────────────────────────────────────────
$engineLabel = match($engineUsed) {
'azure' => 'Microsoft Azure Speech',
'gcp' => 'Google Cloud Speech',
default => 'OpenAI Whisper ' . $gpuModel,
};
// ── Log + respond ───────────────────────────────────────────────────────────── // ── Log + respond ─────────────────────────────────────────────────────────────
dbnToolsLogMetadata([ dbnToolsLogMetadata([
'tool' => 'transcribe', 'tool' => 'transcribe',
'engine' => $engine, 'engine' => $engineUsed,
'model' => $model, 'model' => $engineLabel,
'language' => $language, 'language' => $language,
'ok' => true, 'ok' => true,
'latency_ms' => $latencyMs, 'latency_ms' => $latencyMs,
@@ -129,16 +156,98 @@ dbnToolsRespond([
'speaker_roles' => $speakerRoles, 'speaker_roles' => $speakerRoles,
'num_speakers' => $numDetected, 'num_speakers' => $numDetected,
'language' => (string)($result['language'] ?? $language), 'language' => (string)($result['language'] ?? $language),
'duration_sec' => round((float)($result['duration_seconds'] ?? 0), 2), 'duration_sec' => round((float)($result['duration_seconds'] ?? $result['duration'] ?? 0), 2),
'processing_sec'=> round((float)($result['processing_seconds'] ?? 0), 2), 'processing_sec'=> round((float)($result['processing_seconds'] ?? 0), 2),
'model' => (string)($result['model'] ?? ($engine === 'gpu' ? $model : $engine)), 'model' => $engineLabel,
'engine' => $engine, 'engine' => $engineUsed,
'latency_ms' => $latencyMs, 'latency_ms' => $latencyMs,
]); ]);
// ── Engine implementations ──────────────────────────────────────────────────── // ── Engine implementations ────────────────────────────────────────────────────
/**
* Microsoft Azure Speech — short clips (≤1MB, no diarization).
* Returns null on any failure so the caller can cascade to the next engine.
*/
function transcribeViaAzureServer(array $file, string $language, string $apiKey, string $region): ?array
{
$langCode = match($language) {
'no', 'nb' => 'nb-NO',
'nn' => 'nn-NO',
'en' => 'en-US',
'sv' => 'sv-SE',
'da' => 'da-DK',
'de' => 'de-DE',
'fr' => 'fr-FR',
'es' => 'es-ES',
'pl' => 'pl-PL',
'fi' => 'fi-FI',
'nl' => 'nl-NL',
'it' => 'it-IT',
'pt' => 'pt-PT',
default => 'nb-NO',
};
$mimeMap = [
'wav' => 'audio/wav', 'mp3' => 'audio/mpeg', 'ogg' => 'audio/ogg',
'oga' => 'audio/ogg', 'm4a' => 'audio/mp4', 'mp4' => 'audio/mp4',
'flac' => 'audio/flac', 'webm' => 'audio/webm', 'aac' => 'audio/aac',
];
$fileExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$mimeType = $mimeMap[$fileExt] ?? 'audio/wav';
$endpoint = "https://{$region}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1"
. "?language={$langCode}&format=detailed";
$fileContents = @file_get_contents($file['tmp_name']);
if ($fileContents === false) return null;
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $fileContents,
CURLOPT_HTTPHEADER => [
"Ocp-Apim-Subscription-Key: {$apiKey}",
"Content-Type: {$mimeType}",
'Accept: application/json',
],
CURLOPT_TIMEOUT => 60,
]);
$responseBody = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($responseBody === false || $httpCode !== 200) {
error_log("STT Azure HTTP {$httpCode}: " . substr((string)$responseBody, 0, 200));
return null;
}
$data = json_decode($responseBody, true);
if (!is_array($data) || empty($data['DisplayText'])) return null;
$text = (string)($data['DisplayText'] ?? '');
$segs = [];
foreach (($data['NBest'][0]['Words'] ?? []) as $i => $word) {
$segs[] = [
'id' => $i,
'start' => round((float)($word['Offset'] ?? 0) / 10_000_000, 3),
'end' => round(((float)($word['Offset'] ?? 0) + (float)($word['Duration'] ?? 0)) / 10_000_000, 3),
'text' => (string)($word['Word'] ?? ''),
];
}
return [
'text' => $text,
'language' => strtolower(explode('-', $langCode)[0]),
'duration_seconds' => 0,
'processing_seconds' => 0,
'segments' => $segs,
];
}
function transcribeViaWhisperGpu(array $file, string $language, bool $diarize, int $numSpeakers, function transcribeViaWhisperGpu(array $file, string $language, bool $diarize, int $numSpeakers,
string $model, int $beamSize, string $task, string $model, int $beamSize, string $task,
bool $vadFilter, string $initPrompt): array bool $vadFilter, string $initPrompt): array
@@ -204,158 +313,6 @@ function transcribeViaWhisperGpu(array $file, string $language, bool $diarize, i
} }
function transcribeViaOpenAI(array $file, string $language, string $task, string $apiKey): array
{
$boundary = '----DBN' . bin2hex(random_bytes(8));
$body = "--{$boundary}\r\n";
$body .= 'Content-Disposition: form-data; name="file"; filename="' . addslashes(basename($file['name'])) . '"' . "\r\n";
$body .= "Content-Type: application/octet-stream\r\n\r\n";
$body .= file_get_contents($file['tmp_name']) . "\r\n";
$body .= "--{$boundary}\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\nwhisper-1\r\n";
$body .= "--{$boundary}\r\nContent-Disposition: form-data; name=\"response_format\"\r\n\r\nverbose_json\r\n";
if ($language !== 'auto') {
$body .= "--{$boundary}\r\nContent-Disposition: form-data; name=\"language\"\r\n\r\n{$language}\r\n";
}
if ($task === 'translate') {
$body .= "--{$boundary}\r\nContent-Disposition: form-data; name=\"task\"\r\n\r\ntranslation\r\n";
}
$body .= "--{$boundary}--\r\n";
$ch = curl_init('https://api.openai.com/v1/audio/transcriptions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$apiKey}",
"Content-Type: multipart/form-data; boundary={$boundary}",
'Accept: application/json',
],
CURLOPT_TIMEOUT => 300,
]);
$responseBody = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($responseBody === false || $httpCode !== 200) {
$detail = $curlErr ?: (is_string($responseBody) ? substr(strip_tags($responseBody), 0, 300) : '');
dbnToolsError('OpenAI API error (HTTP ' . $httpCode . '): ' . $detail, 502, 'openai_error');
}
$data = json_decode($responseBody, true);
if (!is_array($data)) {
dbnToolsError('Invalid response from OpenAI.', 502, 'openai_empty');
}
// Normalise to internal shape
return [
'text' => (string)($data['text'] ?? ''),
'language' => (string)($data['language'] ?? $language),
'duration_seconds' => (float)($data['duration'] ?? 0),
'processing_seconds' => 0,
'segments' => array_map(fn($s) => [
'id' => $s['id'] ?? 0,
'start' => $s['start'] ?? 0,
'end' => $s['end'] ?? 0,
'text' => $s['text'] ?? '',
'speaker' => 'SPEAKER_00',
], $data['segments'] ?? []),
'model' => 'openai/whisper-1',
];
}
function transcribeViaAzure(array $file, string $language, string $apiKey,
string $region, bool $diarize): array
{
// Azure Batch Transcription — POST audio directly for short-form (<60 min)
// Uses the simple REST endpoint for synchronous short audio transcription.
$langCode = match($language) {
'no', 'nb' => 'nb-NO',
'nn' => 'nn-NO',
'en' => 'en-US',
'sv' => 'sv-SE',
'da' => 'da-DK',
'de' => 'de-DE',
'fr' => 'fr-FR',
'es' => 'es-ES',
'pl' => 'pl-PL',
'fi' => 'fi-FI',
'nl' => 'nl-NL',
'it' => 'it-IT',
'pt' => 'pt-PT',
default => 'nb-NO',
};
// Mime type map
$mimeMap = [
'wav' => 'audio/wav', 'mp3' => 'audio/mpeg', 'ogg' => 'audio/ogg',
'oga' => 'audio/ogg', 'm4a' => 'audio/mp4', 'mp4' => 'audio/mp4',
'flac' => 'audio/flac', 'webm' => 'audio/webm', 'aac' => 'audio/aac',
];
$fileExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$mimeType = $mimeMap[$fileExt] ?? 'audio/wav';
$endpoint = "https://{$region}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1"
. "?language={$langCode}&format=detailed";
$fileContents = file_get_contents($file['tmp_name']);
if ($fileContents === false) {
dbnToolsError('Could not read uploaded file.', 500, 'file_read_error');
}
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $fileContents,
CURLOPT_HTTPHEADER => [
"Ocp-Apim-Subscription-Key: {$apiKey}",
"Content-Type: {$mimeType}",
'Accept: application/json',
],
CURLOPT_TIMEOUT => 300,
]);
$responseBody = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($responseBody === false || $httpCode !== 200) {
$detail = $curlErr ?: (is_string($responseBody) ? substr(strip_tags($responseBody), 0, 300) : '');
dbnToolsError('Azure Speech error (HTTP ' . $httpCode . '): ' . $detail, 502, 'azure_error');
}
$data = json_decode($responseBody, true);
if (!is_array($data) || empty($data['DisplayText'])) {
dbnToolsError('Empty or invalid response from Azure Speech.', 502, 'azure_empty');
}
// Normalise to internal shape
$text = (string)($data['DisplayText'] ?? '');
$segs = [];
foreach (($data['NBest'][0]['Words'] ?? []) as $i => $word) {
$segs[] = [
'id' => $i,
'start' => round((float)($word['Offset'] ?? 0) / 10_000_000, 3),
'end' => round(((float)($word['Offset'] ?? 0) + (float)($word['Duration'] ?? 0)) / 10_000_000, 3),
'text' => (string)($word['Word'] ?? ''),
'speaker' => 'SPEAKER_00',
];
}
return [
'text' => $text,
'language' => $langCode,
'duration_seconds' => 0,
'processing_seconds' => 0,
'segments' => $segs,
'model' => "azure/{$langCode}",
];
}
function dbnLabelSpeakerRoles(array $segments): array function dbnLabelSpeakerRoles(array $segments): array
{ {
$sample = array_slice( $sample = array_slice(
+25 -182
View File
@@ -402,17 +402,6 @@ function syncOutputLanguage(lang) {
const TRANSCRIBE_I18N = { const TRANSCRIBE_I18N = {
en: { en: {
engine: 'Engine',
engineGpuLabel: 'GPU (cuttlefish RTX 3060)',
engineOpenaiLabel: 'OpenAI Whisper API',
engineAzureLabel: 'Azure AI Speech (nb-NO)',
apiKey: 'API Key',
apiKeyHint: 'Used for this request only, never stored. Max 25MB.',
region: 'Region',
model: 'Model',
modelFastest: 'Fastest',
modelBalanced: 'Balanced',
modelBest: 'Best quality',
transcribeLang: 'Audio language', transcribeLang: 'Audio language',
autoDetectHint: '(may confuse nb/da/sv)', autoDetectHint: '(may confuse nb/da/sv)',
speakers: 'Speakers', speakers: 'Speakers',
@@ -433,26 +422,12 @@ const TRANSCRIBE_I18N = {
uploadHint: 'max 200MB per file', uploadHint: 'max 200MB per file',
uploadAddFiles: '+ Add files', uploadAddFiles: '+ Add files',
uploadClearQueue: '× Clear queue', uploadClearQueue: '× Clear queue',
expertSettings: 'Advanced settings',
task: 'Task',
taskTranscribe: 'Transcribe',
taskTranslate: 'Translate to English',
beamSize: 'Beam size',
beamFastest: '(fastest)',
beamBest: '(best)',
beamSizeHint: 'Controls search breadth — higher values improve accuracy but take longer. 5 is recommended for legal recordings.',
vadFilter: 'VAD filter',
vadFilterLabel: 'Remove silence',
vadFilterHint: 'Voice Activity Detection — skips silent passages before transcribing. Speeds up processing and prevents the model hallucinating on silence.',
run: 'Run', run: 'Run',
running: 'Transcribing…', running: 'Transcribing…',
runningOther: 'Running…', runningOther: 'Running…',
readyTitle: 'Ready', readyTitle: 'Ready',
readyDesc: 'Select a tool, run a request, and the result appears here.', readyDesc: 'Select a tool, run a request, and the result appears here.',
noFileSelected: 'Select at least one audio file before transcribing.', noFileSelected: 'Select at least one audio file before transcribing.',
missingOpenaiKey: 'Enter a valid OpenAI API key (sk-…) before running.',
openaiFileTooLarge: (f) => `OpenAI Whisper has a 25 MB limit. Use the GPU engine for ${f}.`,
missingAzureKey: 'Enter an Azure Speech API key before running.',
clipLabel: (i, total) => total > 1 ? `Clip ${i}/${total}` : 'Transcribing', clipLabel: (i, total) => total > 1 ? `Clip ${i}/${total}` : 'Transcribing',
transcribeFailed: (s) => `Transcription failed (HTTP ${s}).`, transcribeFailed: (s) => `Transcription failed (HTTP ${s}).`,
errorLabel: (clip) => `Error ${clip}`, errorLabel: (clip) => `Error ${clip}`,
@@ -460,25 +435,14 @@ const TRANSCRIBE_I18N = {
fileSizeExceeded: (name, mb) => `${name} (${mb} MB — max 200 MB)`, fileSizeExceeded: (name, mb) => `${name} (${mb} MB — max 200 MB)`,
filesInQueue: (n) => `${n} file${n !== 1 ? 's' : ''} in queue.`, filesInQueue: (n) => `${n} file${n !== 1 ? 's' : ''} in queue.`,
done: (n, dur) => n > 1 ? `Done · ${n} clips · Total audio: ${dur}` : `Done · Audio: ${dur}`, done: (n, dur) => n > 1 ? `Done · ${n} clips · Total audio: ${dur}` : `Done · Audio: ${dur}`,
traceUploadLabel: (clip, eng) => `${clip} — uploading to ${eng}`, traceUploadLabel: (clip) => `${clip} — uploading`,
traceUploadDetail: (eng) => eng === 'gpu' ? 'Sending audio to cuttlefish GPU…' : `Sending audio to ${eng}`, traceUploadDetail: () => 'Sending to transcription service…',
traceProcessingLabel: (clip, eng) => `${clip} ${eng} transcribing`, traceProcessingLabel: (clip) => `${clip} — transcribing`,
traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper transcribing. Large files take 13 minutes.' : `${eng} processing audio.`, traceProcessingDetail: () => 'Processing audio. Large files may take 13 minutes.',
traceStillLabel: (clip) => `${clip} — still processing…`, traceStillLabel: (clip) => `${clip} — still processing…`,
traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `${m}m ${s}s elapsed — working through the audio.` : `${e}s elapsed — processing.`; }, traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `${m}m ${s}s elapsed — working through the audio.` : `${e}s elapsed — processing.`; },
}, },
no: { no: {
engine: 'Motor',
engineGpuLabel: 'GPU (cuttlefish RTX 3060)',
engineOpenaiLabel: 'OpenAI Whisper API',
engineAzureLabel: 'Azure AI Speech (nb-NO)',
apiKey: 'API-nøkkel',
apiKeyHint: 'Brukes kun for denne forespørselen, lagres aldri. Maks 25MB.',
region: 'Region',
model: 'Modell',
modelFastest: 'Raskest',
modelBalanced: 'Balansert',
modelBest: 'Beste kvalitet',
transcribeLang: 'Språk i lydfil', transcribeLang: 'Språk i lydfil',
autoDetectHint: '(kan forveksle nb/da/sv)', autoDetectHint: '(kan forveksle nb/da/sv)',
speakers: 'Talere', speakers: 'Talere',
@@ -499,26 +463,12 @@ const TRANSCRIBE_I18N = {
uploadHint: 'maks 200MB per fil', uploadHint: 'maks 200MB per fil',
uploadAddFiles: '+ Legg til filer', uploadAddFiles: '+ Legg til filer',
uploadClearQueue: '× Tøm kø', uploadClearQueue: '× Tøm kø',
expertSettings: 'Ekspertinnstillinger',
task: 'Oppgave',
taskTranscribe: 'Transkriber',
taskTranslate: 'Oversett til engelsk',
beamSize: 'Beam size',
beamFastest: '(raskest)',
beamBest: '(best)',
beamSizeHint: 'Styrer søkebredde — høyere verdier gir bedre nøyaktighet men tar lengre tid. 5 anbefales for juridiske opptak.',
vadFilter: 'VAD-filter',
vadFilterLabel: 'Fjern stillhet',
vadFilterHint: 'Taleaktivitetsdeteksjon — hopper over stille partier før transkripsjon. Raskere behandling og forhindrer hallusinasjon på stillhet.',
run: 'Kjør', run: 'Kjør',
running: 'Transkriberer…', running: 'Transkriberer…',
runningOther: 'Kjører…', runningOther: 'Kjører…',
readyTitle: 'Klar', readyTitle: 'Klar',
readyDesc: 'Velg et verktøy, kjør en forespørsel, og svaret vises her.', readyDesc: 'Velg et verktøy, kjør en forespørsel, og svaret vises her.',
noFileSelected: 'Velg minst én lydfil før transkripsjon.', noFileSelected: 'Velg minst én lydfil før transkripsjon.',
missingOpenaiKey: 'Legg inn en gyldig OpenAI API-nøkkel (sk-…) før du kjører.',
openaiFileTooLarge: (f) => `OpenAI Whisper har 25 MB-grense. Bruk GPU-motor for ${f}.`,
missingAzureKey: 'Legg inn Azure Speech API-nøkkel før du kjører.',
clipLabel: (i, total) => total > 1 ? `Klipp ${i}/${total}` : 'Transkriberer', clipLabel: (i, total) => total > 1 ? `Klipp ${i}/${total}` : 'Transkriberer',
transcribeFailed: (s) => `Transkripsjon feilet (HTTP ${s}).`, transcribeFailed: (s) => `Transkripsjon feilet (HTTP ${s}).`,
errorLabel: (clip) => `Feil ${clip}`, errorLabel: (clip) => `Feil ${clip}`,
@@ -526,25 +476,13 @@ const TRANSCRIBE_I18N = {
fileSizeExceeded: (name, mb) => `${name} (${mb} MB — maks 200 MB)`, fileSizeExceeded: (name, mb) => `${name} (${mb} MB — maks 200 MB)`,
filesInQueue: (n) => `${n} fil${n !== 1 ? 'er' : ''} i køen.`, filesInQueue: (n) => `${n} fil${n !== 1 ? 'er' : ''} i køen.`,
done: (n, dur) => n > 1 ? `Ferdig · ${n} klipp · Total lyd: ${dur}` : `Ferdig · Lyd: ${dur}`, done: (n, dur) => n > 1 ? `Ferdig · ${n} klipp · Total lyd: ${dur}` : `Ferdig · Lyd: ${dur}`,
traceUploadLabel: (clip, eng) => `${clip} — laster opp til ${eng}`, traceUploadLabel: (clip) => `${clip} — laster opp`,
traceUploadDetail: (eng) => eng === 'gpu' ? 'Sender lyd til cuttlefish GPU…' : `Sender lyd til ${eng}`, traceUploadDetail: () => 'Sender til transkripsjonsleverandør…',
traceProcessingLabel: (clip, eng) => `${clip} ${eng} transkriberer`, traceProcessingLabel: (clip) => `${clip} — transkriberer`,
traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper transkriberer. Store filer tar 13 minutter.' : `${eng} behandler lyden.`, traceProcessingDetail: () => 'Behandler lyden. Store filer tar 13 minutter.', traceStillLabel: (clip) => `${clip} behandler fortsatt…`,
traceStillLabel: (clip) => `${clip} — behandler fortsatt…`,
traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `${m} min ${s}s gått — jobber gjennom lyden.` : `${e}s gått — behandler.`; }, traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `${m} min ${s}s gått — jobber gjennom lyden.` : `${e}s gått — behandler.`; },
}, },
uk: { uk: {
engine: 'Рушій',
engineGpuLabel: 'GPU (cuttlefish RTX 3060)',
engineOpenaiLabel: 'OpenAI Whisper API',
engineAzureLabel: 'Azure AI Speech (nb-NO)',
apiKey: 'API-ключ',
apiKeyHint: 'Використовується лише для цього запиту, ніколи не зберігається. Макс 25 МБ.',
region: 'Регіон',
model: 'Модель',
modelFastest: 'Найшвидша',
modelBalanced: 'Збалансована',
modelBest: 'Найкраща якість',
transcribeLang: 'Мова аудіо', transcribeLang: 'Мова аудіо',
autoDetectHint: '(може плутати nb/da/sv)', autoDetectHint: '(може плутати nb/da/sv)',
speakers: 'Мовці', speakers: 'Мовці',
@@ -565,26 +503,12 @@ const TRANSCRIBE_I18N = {
uploadHint: 'макс 200 МБ на файл', uploadHint: 'макс 200 МБ на файл',
uploadAddFiles: '+ Додати файли', uploadAddFiles: '+ Додати файли',
uploadClearQueue: '× Очистити чергу', uploadClearQueue: '× Очистити чергу',
expertSettings: 'Розширені налаштування',
task: 'Завдання',
taskTranscribe: 'Транскрибувати',
taskTranslate: 'Перекласти англійською',
beamSize: 'Розмір пучка',
beamFastest: '(найшвидший)',
beamBest: '(найкращий)',
beamSizeHint: 'Ширина пошуку — більше значення підвищує точність, але займає більше часу. 5 рекомендовано для юридичних записів.',
vadFilter: 'VAD-фільтр',
vadFilterLabel: 'Видалити тишу',
vadFilterHint: 'Виявлення мовної активності — пропускає тихі ділянки перед транскрипцією. Прискорює обробку та запобігає галюцинаціям на тиші.',
run: 'Запустити', run: 'Запустити',
running: 'Транскрибування…', running: 'Транскрибування…',
runningOther: 'Виконання…', runningOther: 'Виконання…',
readyTitle: 'Готово', readyTitle: 'Готово',
readyDesc: 'Виберіть інструмент, запустіть запит — результат з\'явиться тут.', readyDesc: 'Виберіть інструмент, запустіть запит — результат з\'явиться тут.',
noFileSelected: 'Виберіть хоч б один аудіофайл перед транскрибуванням.', noFileSelected: 'Виберіть хоч б один аудіофайл перед транскрибуванням.',
missingOpenaiKey: 'Введіть дійсний ключ OpenAI API (sk-…) перед запуском.',
openaiFileTooLarge: (f) => `OpenAI Whisper має обмеження 25 МБ. Використовуйте GPU для ${f}.`,
missingAzureKey: 'Введіть ключ Azure Speech API перед запуском.',
clipLabel: (i, total) => total > 1 ? `Кліп ${i}/${total}` : 'Транскрибування', clipLabel: (i, total) => total > 1 ? `Кліп ${i}/${total}` : 'Транскрибування',
transcribeFailed: (s) => `Транскрибування не вдалося (HTTP ${s}).`, transcribeFailed: (s) => `Транскрибування не вдалося (HTTP ${s}).`,
errorLabel: (clip) => `Помилка ${clip}`, errorLabel: (clip) => `Помилка ${clip}`,
@@ -592,25 +516,13 @@ const TRANSCRIBE_I18N = {
fileSizeExceeded: (name, mb) => `${name} (${mb} МБ — макс 200 МБ)`, fileSizeExceeded: (name, mb) => `${name} (${mb} МБ — макс 200 МБ)`,
filesInQueue: (n) => `${n} файл${n !== 1 ? 'ів' : ''} у черзі.`, filesInQueue: (n) => `${n} файл${n !== 1 ? 'ів' : ''} у черзі.`,
done: (n, dur) => n > 1 ? `Готово · ${n} кліпи · Загальне аудіо: ${dur}` : `Готово · Аудіо: ${dur}`, done: (n, dur) => n > 1 ? `Готово · ${n} кліпи · Загальне аудіо: ${dur}` : `Готово · Аудіо: ${dur}`,
traceUploadLabel: (clip, eng) => `${clip} — завантаження до ${eng}`, traceUploadLabel: (clip) => `${clip} — завантаження`,
traceUploadDetail: (eng) => eng === 'gpu' ? 'Відправка аудіо на cuttlefish GPU…' : `Відправка аудіо до ${eng}`, traceUploadDetail: () => 'Відправка до сервісу транскрипції…',
traceProcessingLabel: (clip, eng) => `${clip} ${eng} транскрибує`, traceProcessingLabel: (clip) => `${clip} — транскрибування`,
traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper транскрибує. Великі файли займають 1–3 хвилини.' : `${eng} обробляє аудіо.`, traceProcessingDetail: () => 'Обробка аудіо. Великі файли займають 1–3 хвилини.', traceStillLabel: (clip) => `${clip} — ще обробляється…`,
traceStillLabel: (clip) => `${clip} — ще обробляється…`,
traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `Минуло ${m} хв ${s} с — обробка.` : `Минуло ${e} с — обробка.`; }, traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `Минуло ${m} хв ${s} с — обробка.` : `Минуло ${e} с — обробка.`; },
}, },
pl: { pl: {
engine: 'Silnik',
engineGpuLabel: 'GPU (cuttlefish RTX 3060)',
engineOpenaiLabel: 'OpenAI Whisper API',
engineAzureLabel: 'Azure AI Speech (nb-NO)',
apiKey: 'Klucz API',
apiKeyHint: 'Używany tylko dla tego żądania, nigdy nie przechowywany. Maks 25MB.',
region: 'Region',
model: 'Model',
modelFastest: 'Najszybszy',
modelBalanced: 'Zrównoważony',
modelBest: 'Najlepsza jakość',
transcribeLang: 'Język audio', transcribeLang: 'Język audio',
autoDetectHint: '(może mylić nb/da/sv)', autoDetectHint: '(może mylić nb/da/sv)',
speakers: 'Mówcy', speakers: 'Mówcy',
@@ -631,26 +543,12 @@ const TRANSCRIBE_I18N = {
uploadHint: 'maks 200MB na plik', uploadHint: 'maks 200MB na plik',
uploadAddFiles: '+ Dodaj pliki', uploadAddFiles: '+ Dodaj pliki',
uploadClearQueue: '× Wyczyść kolejkę', uploadClearQueue: '× Wyczyść kolejkę',
expertSettings: 'Ustawienia zaawansowane',
task: 'Zadanie',
taskTranscribe: 'Transkrybuj',
taskTranslate: 'Przetłumacz na angielski',
beamSize: 'Rozmiar wiązki',
beamFastest: '(najszybszy)',
beamBest: '(najlepszy)',
beamSizeHint: 'Kontroluje szerokość wyszukiwania — wyższe wartości poprawiają dokładność, ale wydłużają czas. 5 zalecane dla nagrań prawnych.',
vadFilter: 'Filtr VAD',
vadFilterLabel: 'Usuń ciszę',
vadFilterHint: 'Wykrywanie aktywności głosowej — pomija ciche fragmenty przed transkrypcją. Przyspiesza przetwarzanie i zapobiega halucynacjom na ciszy.',
run: 'Uruchom', run: 'Uruchom',
running: 'Transkrybowanie…', running: 'Transkrybowanie…',
runningOther: 'Uruchamianie…', runningOther: 'Uruchamianie…',
readyTitle: 'Gotowe', readyTitle: 'Gotowe',
readyDesc: 'Wybierz narzędzie, uruchom żądanie — wynik pojawi się tutaj.', readyDesc: 'Wybierz narzędzie, uruchom żądanie — wynik pojawi się tutaj.',
noFileSelected: 'Wybierz co najmniej jeden plik audio przed transkrypcją.', noFileSelected: 'Wybierz co najmniej jeden plik audio przed transkrypcją.',
missingOpenaiKey: 'Wprowadź prawidłowy klucz API OpenAI (sk-…) przed uruchomieniem.',
openaiFileTooLarge: (f) => `OpenAI Whisper ma limit 25 MB. Użyj silnika GPU dla ${f}.`,
missingAzureKey: 'Wprowadź klucz Azure Speech API przed uruchomieniem.',
clipLabel: (i, total) => total > 1 ? `Klip ${i}/${total}` : 'Transkrybowanie', clipLabel: (i, total) => total > 1 ? `Klip ${i}/${total}` : 'Transkrybowanie',
transcribeFailed: (s) => `Transkrypcja nie powiodła się (HTTP ${s}).`, transcribeFailed: (s) => `Transkrypcja nie powiodła się (HTTP ${s}).`,
errorLabel: (clip) => `Błąd ${clip}`, errorLabel: (clip) => `Błąd ${clip}`,
@@ -658,11 +556,10 @@ const TRANSCRIBE_I18N = {
fileSizeExceeded: (name, mb) => `${name} (${mb} MB — maks 200 MB)`, fileSizeExceeded: (name, mb) => `${name} (${mb} MB — maks 200 MB)`,
filesInQueue: (n) => `${n} plik${n !== 1 ? 'i' : ''} w kolejce.`, filesInQueue: (n) => `${n} plik${n !== 1 ? 'i' : ''} w kolejce.`,
done: (n, dur) => n > 1 ? `Gotowe · ${n} klipy · Łączne audio: ${dur}` : `Gotowe · Audio: ${dur}`, done: (n, dur) => n > 1 ? `Gotowe · ${n} klipy · Łączne audio: ${dur}` : `Gotowe · Audio: ${dur}`,
traceUploadLabel: (clip, eng) => `${clip} — przesyłanie do ${eng}`, traceUploadLabel: (clip) => `${clip} — przesyłanie`,
traceUploadDetail: (eng) => eng === 'gpu' ? 'Wysyłanie audio do cuttlefish GPU…' : `Wysyłanie audio do ${eng}`, traceUploadDetail: () => 'Wysyłanie do serwisu transkrypcji…',
traceProcessingLabel: (clip, eng) => `${clip} ${eng} transkrybuje`, traceProcessingLabel: (clip) => `${clip} — transkrybowanie`,
traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper transkrybuje. Duże pliki zajmują 13 minuty.' : `${eng} przetwarza audio.`, traceProcessingDetail: () => 'Przetwarzanie audio. Duże pliki zajmują 13 minuty.', traceStillLabel: (clip) => `${clip} — nadal przetwarza`,
traceStillLabel: (clip) => `${clip} — nadal przetwarza…`,
traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `Minęło ${m} min ${s} s — przetwarzanie audio.` : `Minęło ${e} s — przetwarzanie.`; }, traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `Minęło ${m} min ${s} s — przetwarzanie audio.` : `Minęło ${e} s — przetwarzanie.`; },
}, },
}; };
@@ -1556,18 +1453,6 @@ function exportTimelineCSV(events) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function currentTranscribeEngine() {
const el = document.querySelector('input[name="engine"]:checked');
return el ? el.value : 'gpu';
}
function currentTranscribeModel() {
const el = document.querySelector('input[name="model"]:checked');
return el ? el.value : 'small';
}
function currentBeamSize() {
const el = document.querySelector('input[name="beam_size"]:checked');
return el ? el.value : '5';
}
function currentTask() { function currentTask() {
const el = document.querySelector('input[name="task"]:checked'); const el = document.querySelector('input[name="task"]:checked');
return el ? el.value : 'transcribe'; return el ? el.value : 'transcribe';
@@ -1579,28 +1464,6 @@ async function runTranscribe() {
return; return;
} }
const engine = currentTranscribeEngine();
if (engine === 'openai') {
const key = document.getElementById('openaiKeyInput')?.value?.trim();
if (!key || !key.startsWith('sk-')) {
els.status.textContent = currentUiT('missingOpenaiKey');
return;
}
const oversized = audioQueue.find((item) => item.file.size > 25 * 1024 * 1024);
if (oversized) {
els.status.textContent = currentUiT('openaiFileTooLarge', oversized.file.name);
return;
}
}
if (engine === 'azure' && !window.DBN_AZURE_SPEECH_CONFIGURED) {
const key = document.getElementById('azureKeyInput')?.value?.trim();
if (!key) {
els.status.textContent = currentUiT('missingAzureKey');
return;
}
}
setBusy(true); setBusy(true);
const initPrompt = els.initPromptInput?.value?.trim() || ''; const initPrompt = els.initPromptInput?.value?.trim() || '';
@@ -1635,16 +1498,13 @@ async function runTranscribe() {
const s = elapsed % 60; const s = elapsed % 60;
const t = m > 0 ? `${m}:${pad2(s)}` : `${s}s`; const t = m > 0 ? `${m}:${pad2(s)}` : `${s}s`;
els.status.textContent = `${clipLabel}${t}`; els.status.textContent = `${clipLabel}${t}`;
updateTranscribeTrace(elapsed, engine, clipLabel); updateTranscribeTrace(elapsed, clipLabel);
}, 1000); }, 1000);
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('audio', item.file); formData.append('audio', item.file);
formData.append('engine', engine);
formData.append('language', currentTranscribeLang()); formData.append('language', currentTranscribeLang());
formData.append('model', currentTranscribeModel());
formData.append('beam_size', currentBeamSize());
formData.append('task', currentTask()); formData.append('task', currentTask());
formData.append('time_offset', String(cumulativeOffset)); formData.append('time_offset', String(cumulativeOffset));
if (vadFilter) formData.append('vad_filter', '1'); if (vadFilter) formData.append('vad_filter', '1');
@@ -1653,13 +1513,6 @@ async function runTranscribe() {
formData.append('diarize', '1'); formData.append('diarize', '1');
if (numSpeakers >= 2) formData.append('num_speakers', String(numSpeakers)); if (numSpeakers >= 2) formData.append('num_speakers', String(numSpeakers));
} }
if (engine === 'openai') {
formData.append('openai_key', document.getElementById('openaiKeyInput')?.value?.trim());
}
if (engine === 'azure') {
formData.append('azure_key', document.getElementById('azureKeyInput')?.value?.trim());
formData.append('azure_region', document.getElementById('azureRegionInput')?.value?.trim() || 'norwayeast');
}
const resp = await fetch('api/transcribe.php', { const resp = await fetch('api/transcribe.php', {
method: 'POST', method: 'POST',
@@ -1721,23 +1574,21 @@ async function runTranscribe() {
setBusy(false); setBusy(false);
} }
function updateTranscribeTrace(elapsed, engine, clipLabel) { function updateTranscribeTrace(elapsed, clipLabel) {
if (!clipLabel) clipLabel = currentUiT('clipLabel', 1, 1); if (!clipLabel) clipLabel = currentUiT('clipLabel', 1, 1);
const engineLabel = engine === 'openai' ? 'OpenAI API' : engine === 'azure' ? 'Azure Speech' : 'Whisper GPU';
let label, detail; let label, detail;
if (elapsed < 10) { if (elapsed < 10) {
label = currentUiT('traceUploadLabel', clipLabel, engineLabel); label = currentUiT('traceUploadLabel', clipLabel);
detail = currentUiT('traceUploadDetail', engine); detail = currentUiT('traceUploadDetail');
} else if (elapsed < 60) { } else if (elapsed < 60) {
label = currentUiT('traceProcessingLabel', clipLabel, engineLabel); label = currentUiT('traceProcessingLabel', clipLabel);
detail = currentUiT('traceProcessingDetail', engine); detail = currentUiT('traceProcessingDetail');
} else { } else {
label = currentUiT('traceStillLabel', clipLabel); label = currentUiT('traceStillLabel', clipLabel);
detail = currentUiT('traceStillDetail', elapsed); detail = currentUiT('traceStillDetail', elapsed);
} }
renderTrace([{ label, detail, status: 'running' }]); renderTrace([{ label, detail, status: 'running' }]);
} }
function renderTranscriptResults(data) { function renderTranscriptResults(data) {
const speakerRoles = data.speaker_roles || {}; const speakerRoles = data.speaker_roles || {};
const segments = data.segments || []; const segments = data.segments || [];
@@ -1776,6 +1627,7 @@ function renderTranscriptResults(data) {
els.results.innerHTML = ` els.results.innerHTML = `
<section class="result-section"> <section class="result-section">
<h3>Transcript</h3> <h3>Transcript</h3>
${data.model ? `<p class="transcript-engine-badge">Transcribed with <strong>${escapeHtml(data.model)}</strong></p>` : ''}
${rolesHtml} ${rolesHtml}
<div class="transcript-box"><pre class="transcript-text">${escapeHtml(data.transcript)}</pre></div> <div class="transcript-box"><pre class="transcript-text">${escapeHtml(data.transcript)}</pre></div>
${segmentsHtml} ${segmentsHtml}
@@ -1791,7 +1643,7 @@ function renderTranscriptResults(data) {
if (data.duration_sec) traceMeta.push({ label: `Duration: ${Math.round(data.duration_sec)}s`, detail: '', status: 'complete' }); if (data.duration_sec) traceMeta.push({ label: `Duration: ${Math.round(data.duration_sec)}s`, detail: '', status: 'complete' });
if (data.language) traceMeta.push({ label: `Language: ${data.language}`, detail: '', status: 'complete' }); if (data.language) traceMeta.push({ label: `Language: ${data.language}`, detail: '', status: 'complete' });
if (data.num_speakers > 1) traceMeta.push({ label: `Speakers detected: ${data.num_speakers}`, detail: Object.entries(speakerRoles).map(([id, r]) => `${id}: ${r}`).join(', ') || '', status: 'complete' }); if (data.num_speakers > 1) traceMeta.push({ label: `Speakers detected: ${data.num_speakers}`, detail: Object.entries(speakerRoles).map(([id, r]) => `${id}: ${r}`).join(', ') || '', status: 'complete' });
if (data.model) traceMeta.push({ label: `Model: ${data.model}`, detail: '', status: 'complete' }); if (data.model) traceMeta.push({ label: data.model, detail: '', status: 'complete' });
renderTrace(traceMeta.length ? traceMeta : [{ label: 'Transcribed', detail: '', status: 'complete' }]); renderTrace(traceMeta.length ? traceMeta : [{ label: 'Transcribed', detail: '', status: 'complete' }]);
} }
@@ -1935,16 +1787,7 @@ function setupAudio() {
} }
function setupTranscribeControls() { function setupTranscribeControls() {
document.querySelectorAll('input[name="engine"]').forEach((radio) => { // engine auto-selected server-side
radio.addEventListener('change', () => {
const engine = currentTranscribeEngine();
document.getElementById('openaiKeyControl')?.classList.toggle('is-hidden', engine !== 'openai');
// Hide azure key row if server has a pre-configured key
const azureNeedsKey = engine === 'azure' && !window.DBN_AZURE_SPEECH_CONFIGURED;
document.getElementById('azureKeyControl')?.classList.toggle('is-hidden', !azureNeedsKey);
document.getElementById('modelControl')?.classList.toggle('is-hidden', engine === 'openai' || engine === 'azure');
});
});
} }
function setupVocabPresets() { function setupVocabPresets() {
Binary file not shown.
+1 -48
View File
@@ -3,11 +3,9 @@ declare(strict_types=1);
$toolName = 'transcribe'; $toolName = 'transcribe';
$toolTitle = 'Transcribe audio'; $toolTitle = 'Transcribe audio';
$toolKind = 'Audio Transcription'; $toolKind = 'Audio Transcription';
$toolBadge = 'Whisper / GPU'; $toolBadge = 'Azure · Google · Whisper';
require_once __DIR__ . '/includes/layout.php'; require_once __DIR__ . '/includes/layout.php';
$azureConfigured = !empty(dbnToolsEnv('DBN_AZURE_SPEECH_KEY'));
?> ?>
<script>window.DBN_AZURE_SPEECH_CONFIGURED = <?= $azureConfigured ? 'true' : 'false' ?>;</script>
<form id="toolForm" class="tool-form"> <form id="toolForm" class="tool-form">
<div class="lang-switcher" id="uiLangSwitcher" role="group" aria-label="UI language"> <div class="lang-switcher" id="uiLangSwitcher" role="group" aria-label="UI language">
@@ -17,33 +15,6 @@ $azureConfigured = !empty(dbnToolsEnv('DBN_AZURE_SPEECH_KEY'));
<button type="button" class="lang-btn" data-lang="pl">&#127477;&#127473; PL</button> <button type="button" class="lang-btn" data-lang="pl">&#127477;&#127473; PL</button>
</div> </div>
<div class="control-row" id="engineControl">
<span class="control-label" data-i18n="engine">Engine</span>
<label><input type="radio" name="engine" value="gpu" checked id="engineGpu"> <span data-i18n="engineGpuLabel">GPU (cuttlefish RTX 3060)</span></label>
<label><input type="radio" name="engine" value="openai" id="engineOpenai"> <span data-i18n="engineOpenaiLabel">OpenAI Whisper API</span></label>
<label><input type="radio" name="engine" value="azure" id="engineAzure"> <span data-i18n="engineAzureLabel">Azure AI Speech (nb-NO)</span></label>
</div>
<div class="control-row is-hidden" id="openaiKeyControl">
<span class="control-label" data-i18n="apiKey">API Key</span>
<input type="password" id="openaiKeyInput" name="openai_key" placeholder="sk-…" class="byok-input" autocomplete="off">
<small class="control-hint inline-hint" data-i18n="apiKeyHint">Used for this request only, never stored. Max 25&thinsp;MB.</small>
</div>
<div class="control-row is-hidden" id="azureKeyControl">
<span class="control-label" data-i18n="apiKey">API Key</span>
<input type="password" id="azureKeyInput" name="azure_key" placeholder="Azure Speech key" class="byok-input" autocomplete="off">
<span class="control-label" style="margin-left:1.25rem" data-i18n="region">Region</span>
<input type="text" id="azureRegionInput" name="azure_region" placeholder="norwayeast" class="byok-input byok-input--short" value="norwayeast">
</div>
<div class="control-row" id="modelControl">
<span class="control-label" data-i18n="model">Model</span>
<label><input type="radio" name="model" value="small"> <span data-i18n="modelFastest">Fastest</span> <small class="control-hint">(small)</small></label>
<label><input type="radio" name="model" value="medium"> <span data-i18n="modelBalanced">Balanced</span> <small class="control-hint">(medium)</small></label>
<label><input type="radio" name="model" value="large-v3" checked> <span data-i18n="modelBest">Best quality</span> &#9733; <small class="control-hint">(large-v3)</small></label>
</div>
<div class="control-row" id="transcribeLangControl"> <div class="control-row" id="transcribeLangControl">
<span class="control-label" data-i18n="transcribeLang">Audio language</span> <span class="control-label" data-i18n="transcribeLang">Audio language</span>
<label><input type="radio" name="transcribeLang" value="no" checked> Norsk (nb)</label> <label><input type="radio" name="transcribeLang" value="no" checked> Norsk (nb)</label>
@@ -93,24 +64,6 @@ $azureConfigured = !empty(dbnToolsEnv('DBN_AZURE_SPEECH_KEY'));
</div> </div>
</div> </div>
<details class="expert-settings" id="expertSettings">
<summary class="expert-summary" data-i18n="expertSettings">Advanced settings</summary>
<div class="expert-body">
<div class="control-row">
<span class="control-label" data-i18n="beamSize">Beam size</span>
<label><input type="radio" name="beam_size" value="1"> 1 <small class="control-hint" data-i18n="beamFastest">(fastest)</small></label>
<label><input type="radio" name="beam_size" value="3"> 3</label>
<label><input type="radio" name="beam_size" value="5" checked> 5 <small class="control-hint" data-i18n="beamBest">(best)</small></label>
</div>
<p class="upload-hint" data-i18n="beamSizeHint">Controls search breadth — higher values improve accuracy but take longer. 5 is recommended for legal recordings.</p>
<div class="control-row">
<span class="control-label" data-i18n="vadFilter">VAD filter</span>
<label><input type="checkbox" name="vad_filter" id="vadFilterCheck" value="1" checked> <span data-i18n="vadFilterLabel">Remove silence</span></label>
</div>
<p class="upload-hint" data-i18n="vadFilterHint">Voice Activity Detection — skips silent passages before transcribing. Speeds up processing and prevents the model hallucinating on silence.</p>
</div>
</details>
<!-- Hidden stubs so tools.js refs don't crash on this page --> <!-- Hidden stubs so tools.js refs don't crash on this page -->
<div class="is-hidden" id="languageControl" aria-hidden="true"> <div class="is-hidden" id="languageControl" aria-hidden="true">
<input type="radio" name="language" value="en" checked> <input type="radio" name="language" value="en" checked>