Initial release: Do Better Norge Legal Tools Hub

Five MVP tools (Ask, Search, Summarize, Timeline, Redact) with
email+password auth, Azure OpenAI gateway, evidence trail panel,
and process-and-forget privacy default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 00:01:07 +02:00
commit 2d8d1c7409
16 changed files with 2554 additions and 0 deletions
+513
View File
@@ -0,0 +1,513 @@
:root {
--bg: #f7f8fb;
--panel: #ffffff;
--ink: #1b2330;
--muted: #667085;
--line: #d8dde7;
--teal: #0f766e;
--teal-dark: #115e59;
--coral: #c2410c;
--amber: #b7791f;
--soft-teal: #e7f5f2;
--soft-coral: #fff0e8;
--shadow: 0 18px 45px rgba(25, 35, 52, 0.10);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--ink);
}
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 rgba(15, 118, 110, 0.35);
outline-offset: 2px;
}
.is-hidden {
display: none !important;
}
.gate {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background:
linear-gradient(135deg, rgba(15, 118, 110, 0.10), transparent 36%),
linear-gradient(315deg, rgba(194, 65, 12, 0.10), transparent 34%),
var(--bg);
}
.gate-panel {
width: min(460px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
padding: 32px;
}
.eyebrow {
margin: 0 0 6px;
color: var(--teal);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
h1,
h2,
h3,
h4,
p {
margin-top: 0;
}
.gate-panel h1,
.topbar h1,
.tool-heading h2,
.reasoning-head h2 {
margin-bottom: 0;
}
.gate-copy {
color: var(--muted);
line-height: 1.5;
}
.passcode-form label,
.input-label,
.control-label {
display: block;
font-weight: 700;
color: var(--ink);
}
.passcode-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
margin-top: 8px;
}
.passcode-row input,
.tool-form textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--ink);
}
.passcode-row input {
min-height: 44px;
padding: 0 12px;
}
.passcode-row button,
#runButton,
.secondary-button {
min-height: 44px;
border-radius: 8px;
padding: 0 18px;
background: var(--teal);
color: #fff;
font-weight: 700;
}
.secondary-button {
background: #263344;
}
.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-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(--soft-teal);
color: var(--teal-dark);
font-size: 0.84rem;
font-weight: 700;
white-space: nowrap;
}
.status-pill.is-warning {
background: var(--soft-coral);
color: var(--coral);
}
.disclaimer {
max-width: 1500px;
margin: 0 auto 12px;
padding: 10px 12px;
border: 1px solid #f0d6bc;
border-radius: 8px;
background: #fff9f2;
color: #68420d;
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(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.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(--ink);
text-align: left;
border: 1px solid transparent;
}
.tool-tab span {
display: block;
font-weight: 800;
}
.tool-tab small {
display: block;
margin-top: 4px;
color: var(--muted);
}
.tool-tab.is-active {
background: var(--soft-teal);
border-color: rgba(15, 118, 110, 0.30);
}
.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-form {
display: grid;
gap: 12px;
}
.control-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
min-height: 34px;
}
.control-row label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
font-weight: 700;
}
.tool-form textarea {
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(--muted);
}
.results {
margin-top: 18px;
display: grid;
gap: 12px;
}
.empty-state,
.result-section {
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
background: #fbfcfe;
}
.empty-state p,
.result-section p,
.result-section li {
color: var(--muted);
line-height: 1.55;
}
.answer {
color: var(--ink) !important;
font-size: 1.03rem;
}
.result-section h3 {
margin-bottom: 8px;
font-size: 0.98rem;
}
.source-list {
display: grid;
gap: 10px;
}
.source-card {
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #fff;
}
.source-card h4,
.detail-block h4 {
margin-bottom: 5px;
}
.source-meta {
color: var(--teal-dark) !important;
font-size: 0.84rem;
font-weight: 700;
}
.detail-block {
margin-top: 12px;
}
.timeline-list {
display: grid;
gap: 10px;
padding-left: 20px;
}
.timeline-list li {
padding-left: 4px;
}
.timeline-list span {
display: block;
color: var(--teal-dark);
font-weight: 700;
}
.timeline-list small {
display: block;
margin-top: 5px;
color: var(--muted);
}
.redacted-output {
max-height: 420px;
overflow: auto;
white-space: pre-wrap;
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #fff;
color: var(--ink);
line-height: 1.55;
}
.pill-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-left: 0;
list-style: none;
}
.pill-list li {
border-radius: 999px;
background: var(--soft-teal);
color: var(--teal-dark);
padding: 6px 10px;
}
.result-disclaimer {
margin: 0;
color: #68420d;
}
.muted {
color: var(--muted);
}
.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;
}
.trace-list p {
margin: 0;
color: var(--muted);
line-height: 1.45;
}
.trace-status {
width: 10px;
height: 10px;
margin-top: 5px;
border-radius: 999px;
background: var(--teal);
}
.trace-status.running {
background: var(--amber);
}
.trace-status.warning {
background: var(--coral);
}
.trace-status.waiting {
background: #98a2b3;
}
@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) {
.app-shell {
padding: 12px;
}
.topbar,
.form-footer {
align-items: stretch;
flex-direction: column;
}
.workspace {
grid-template-columns: 1fr;
}
.tool-rail {
flex-direction: row;
overflow-x: auto;
}
.tool-tab {
min-width: 150px;
}
.tool-panel,
.reasoning-panel {
min-height: auto;
}
.passcode-row {
grid-template-columns: 1fr;
}
}
+385
View File
@@ -0,0 +1,385 @@
const state = {
activeTool: 'ask',
authenticated: Boolean(window.DBN_TOOLS_AUTHENTICATED),
};
const tools = {
ask: {
kind: 'Source-grounded Legal Ask',
title: 'Ask a legal question',
label: 'Question',
endpoint: 'api/ask.php',
payloadKey: 'question',
placeholder: 'Example: What evidence is needed before asking for changes in custody arrangements?',
usesLanguage: true,
badge: 'family-legal',
},
search: {
kind: 'Legal Source Search',
title: 'Search legal sources',
label: 'Search query',
endpoint: 'api/search.php',
payloadKey: 'query',
placeholder: 'Example: barnets beste samvær foreldreansvar',
usesLanguage: true,
badge: 'family-legal',
},
summarize: {
kind: 'Document Summarizer',
title: 'Summarize pasted text',
label: 'Pasted text',
endpoint: 'api/summarize.php',
payloadKey: 'text',
placeholder: 'Paste a case note, letter, or excerpt.',
usesLanguage: true,
badge: 'process-and-forget',
},
timeline: {
kind: 'Timeline Builder',
title: 'Build a timeline',
label: 'Pasted text',
endpoint: 'api/timeline.php',
payloadKey: 'text',
placeholder: 'Paste case notes with dates, actors, and events.',
usesLanguage: true,
badge: 'process-and-forget',
},
redact: {
kind: 'Redaction Assistant',
title: 'Redact sensitive details',
label: 'Pasted text',
endpoint: 'api/redact.php',
payloadKey: 'text',
placeholder: 'Paste text containing names, phone numbers, emails, addresses, or fødselsnummer-like values.',
usesLanguage: false,
badge: 'deterministic first',
},
};
const els = {};
document.addEventListener('DOMContentLoaded', () => {
Object.assign(els, {
gate: document.querySelector('#passcodeGate'),
app: document.querySelector('#appShell'),
passcodeForm: document.querySelector('#passcodeForm'),
loginEmail: document.querySelector('#loginEmail'),
loginPassword: document.querySelector('#loginPassword'),
gateStatus: document.querySelector('#gateStatus'),
tabs: Array.from(document.querySelectorAll('.tool-tab')),
toolKind: document.querySelector('#toolKind'),
toolTitle: document.querySelector('#toolTitle'),
toolBadge: document.querySelector('#toolBadge'),
form: document.querySelector('#toolForm'),
inputLabel: document.querySelector('#inputLabel'),
input: document.querySelector('#toolInput'),
languageControl: document.querySelector('#languageControl'),
redactionControl: document.querySelector('#redactionControl'),
status: document.querySelector('#toolStatus'),
results: document.querySelector('#results'),
traceList: document.querySelector('#traceList'),
healthButton: document.querySelector('#healthButton'),
healthPill: document.querySelector('#healthPill'),
});
els.tabs.forEach((button) => {
button.addEventListener('click', () => setTool(button.dataset.tool));
});
els.form.addEventListener('submit', runTool);
els.passcodeForm.addEventListener('submit', submitPasscode);
els.healthButton.addEventListener('click', checkHealth);
setTool(state.activeTool);
if (state.authenticated) {
checkHealth();
} else {
els.loginEmail?.focus();
}
});
function setTool(toolName) {
state.activeTool = toolName;
const tool = tools[toolName];
els.tabs.forEach((button) => {
const active = button.dataset.tool === toolName;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', String(active));
});
els.toolKind.textContent = tool.kind;
els.toolTitle.textContent = tool.title;
els.toolBadge.textContent = tool.badge;
els.inputLabel.textContent = tool.label;
els.input.value = '';
els.input.placeholder = tool.placeholder;
els.languageControl.classList.toggle('is-hidden', !tool.usesLanguage);
els.redactionControl.classList.toggle('is-hidden', toolName !== 'redact');
els.status.textContent = '';
renderTrace([]);
}
async function submitPasscode(event) {
event.preventDefault();
els.gateStatus.textContent = 'Signing in…';
try {
const data = await postJson('api/session.php', {
email: els.loginEmail.value.trim(),
password: els.loginPassword.value,
});
if (!data.ok) {
throw new Error(data.error?.message || 'Credentials were not accepted.');
}
state.authenticated = true;
els.gate.classList.add('is-hidden');
els.app.classList.remove('is-hidden');
els.loginPassword.value = '';
els.healthPill.textContent = 'Session active';
checkHealth();
els.input.focus();
} catch (error) {
els.gateStatus.textContent = error.message;
}
}
async function runTool(event) {
event.preventDefault();
const tool = tools[state.activeTool];
const text = els.input.value.trim();
if (!text) {
els.status.textContent = 'Add text before running the tool.';
els.input.focus();
return;
}
const payload = { [tool.payloadKey]: text };
if (tool.usesLanguage) {
payload.language = currentLanguage();
}
if (state.activeTool === 'search') {
payload.limit = 7;
}
if (state.activeTool === 'redact') {
payload.mode = currentRedactionMode();
}
setBusy(true);
renderTrace([
{ label: 'Query interpretation', detail: 'Preparing request.', status: 'running' },
]);
try {
const data = await postJson(tool.endpoint, payload);
if (!data.ok) {
throw new Error(data.error?.message || 'Tool request failed.');
}
renderResults(data);
renderTrace(data.trace || []);
els.status.textContent = `Done in ${data.latency_ms || 0} ms.`;
} catch (error) {
els.status.textContent = error.message;
renderTrace([
{ label: 'Tool error', detail: error.message, status: 'warning' },
]);
} finally {
setBusy(false);
}
}
async function checkHealth() {
els.healthPill.textContent = 'Checking...';
try {
const response = await fetch('api/health.php', {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
const data = await response.json();
els.healthPill.textContent = data.ok ? 'Healthy' : 'Needs config';
els.healthPill.classList.toggle('is-warning', !data.ok);
if (!data.ok && data.checks) {
renderHealth(data);
}
} catch (error) {
els.healthPill.textContent = 'Health failed';
els.healthPill.classList.add('is-warning');
}
}
async function postJson(url, payload) {
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error?.message || `Request failed with HTTP ${response.status}.`);
}
return data;
}
function setBusy(isBusy) {
const button = document.querySelector('#runButton');
button.disabled = isBusy;
button.textContent = isBusy ? 'Running...' : 'Run Tool';
}
function currentLanguage() {
return document.querySelector('input[name="language"]:checked')?.value || 'en';
}
function currentRedactionMode() {
return document.querySelector('input[name="redactionMode"]:checked')?.value || 'standard';
}
function renderResults(data) {
const sections = [];
sections.push(sectionHtml('What We Found', renderMainFinding(data)));
sections.push(sectionHtml('Evidence Trail', renderEvidence(data)));
sections.push(sectionHtml('What Remains Uncertain', renderListish(data.what_remains_uncertain)));
sections.push(sectionHtml('Next Practical Step', `<p>${escapeHtml(data.next_practical_step || 'Review the evidence trail.')}</p>`));
if (data.disclaimer) {
sections.push(`<p class="result-disclaimer">${escapeHtml(data.disclaimer)}</p>`);
}
els.results.innerHTML = sections.join('');
}
function renderMainFinding(data) {
if (data.tool === 'ask') {
return `<p class="answer">${escapeHtml(data.answer || data.what_we_found || '')}</p>`;
}
if (data.tool === 'redact') {
return `<pre class="redacted-output">${escapeHtml(data.redacted_text || '')}</pre>${renderEntityCounts(data.entity_counts)}`;
}
if (data.tool === 'timeline') {
return `<p>${escapeHtml(data.what_we_found || '')}</p>${renderTimeline(data.events || [])}`;
}
if (data.tool === 'summarize') {
return [
`<p>${escapeHtml(data.what_we_found || '')}</p>`,
detailList('Key Facts', data.key_facts),
detailList('Dates', data.dates),
detailList('Parties', data.parties),
detailList('Legal References Detected', data.legal_references_detected),
].join('');
}
if (data.tool === 'search') {
return `<p>${escapeHtml(data.what_we_found || '')}</p>`;
}
return `<p>${escapeHtml(data.what_we_found || '')}</p>`;
}
function renderEvidence(data) {
const items = data.evidence_trail || data.sources || data.hits || [];
if (!items.length) {
return '<p>No evidence trail was available for this request.</p>';
}
return `<div class="source-list">${items.map(renderEvidenceItem).join('')}</div>`;
}
function renderEvidenceItem(item) {
const title = item.title || item.citation || 'Source';
const body = item.excerpt || item.why_it_matters || item.citation || '';
const meta = [
item.package_or_corpus,
item.section,
item.score !== undefined && item.score !== null ? `score ${item.score}` : '',
].filter(Boolean).join(' · ');
return `
<article class="source-card">
<h4>${escapeHtml(title)}</h4>
${meta ? `<p class="source-meta">${escapeHtml(meta)}</p>` : ''}
<p>${escapeHtml(body)}</p>
</article>
`;
}
function renderTimeline(events) {
if (!events.length) {
return '<p>No events were identified.</p>';
}
return `<ol class="timeline-list">${events.map((event) => `
<li>
<strong>${escapeHtml(event.date || 'unknown')}</strong>
<span>${escapeHtml(event.actor || 'unknown actor')}</span>
<p>${escapeHtml(event.event || '')}</p>
${event.source_excerpt ? `<small>${escapeHtml(event.source_excerpt)}</small>` : ''}
</li>
`).join('')}</ol>`;
}
function renderEntityCounts(counts = {}) {
const entries = Object.entries(counts).filter(([, count]) => Number(count) > 0);
if (!entries.length) {
return '<p class="muted">No deterministic sensitive categories detected.</p>';
}
return `<ul class="pill-list">${entries.map(([name, count]) => `<li>${escapeHtml(name)} <strong>${Number(count)}</strong></li>`).join('')}</ul>`;
}
function detailList(title, values = []) {
if (!Array.isArray(values) || !values.length) {
return '';
}
return `<div class="detail-block"><h4>${escapeHtml(title)}</h4><ul>${values.map((item) => `<li>${escapeHtml(String(item))}</li>`).join('')}</ul></div>`;
}
function renderListish(value) {
if (Array.isArray(value)) {
if (!value.length) {
return '<p>No uncertainty listed.</p>';
}
return `<ul>${value.map((item) => `<li>${escapeHtml(String(item))}</li>`).join('')}</ul>`;
}
return `<p>${escapeHtml(value || 'No uncertainty listed.')}</p>`;
}
function sectionHtml(title, content) {
return `<section class="result-section"><h3>${escapeHtml(title)}</h3>${content}</section>`;
}
function renderTrace(trace) {
if (!trace.length) {
els.traceList.innerHTML = `
<li>
<span class="trace-status waiting"></span>
<div><strong>Waiting</strong><p>Run a tool to see interpretation, retrieval, confidence, uncertainty, and next step.</p></div>
</li>
`;
return;
}
els.traceList.innerHTML = trace.map((item) => `
<li>
<span class="trace-status ${escapeHtml(item.status || 'complete')}"></span>
<div>
<strong>${escapeHtml(item.label || 'Step')}</strong>
<p>${escapeHtml(item.detail || '')}</p>
</div>
</li>
`).join('');
}
function renderHealth(data) {
const checks = Object.entries(data.checks || {}).map(([name, check]) => ({
label: name.replaceAll('_', ' '),
detail: check.detail || '',
status: check.ok ? 'complete' : 'warning',
}));
renderTrace(checks);
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}