feat: online verification, node removals, and knowledge browser
CI / quality (push) Canceled after 0s

Adds claim-bound online evidence records and citation verification scripts,
node removal transport/adapter, retrieval benchmark and owner-review tooling,
GraphCanvas/SourceDetail knowledge browser UI, and the ingest handoff helper.
Bumps the agents/gewu pack to the online-verification branch.
This commit is contained in:
vasily committed 2026-10-16 20:02:29 +08:00
1 parent a3156f4e08
commit 78033bb830
83 files changed
+13476 -1051

No files matched your search

+789 -57
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -77,7 +77,7 @@ function AccountsSectionComponent({ csrf, projects, onMessage, onError }: {
<AdminDialog open={addOpen} title="Add user" onClose={() => setAddOpen(false)} returnFocusRef={addButtonRef}>
<p className="muted-copy">Users need a password with at least 12 characters. They can change it after signing in.</p>
<form className="admin-form connect-form" onSubmit={create}>
<label>Username<input autoComplete="off" value={username} onChange={event => setUsername(event.target.value)} required maxLength={64} pattern="[a-z0-9][a-z0-9._\\-]*" placeholder="vasily" /></label>
<label>New username<input autoComplete="off" value={username} onChange={event => setUsername(event.target.value)} required maxLength={64} pattern="[a-z0-9][a-z0-9._\\-]*" placeholder="vasily" /></label>
<label>Initial password<input type="password" autoComplete="new-password" value={password} onChange={event => setPassword(event.target.value)} required minLength={12} maxLength={1024} /></label>
<label>Account role<select value={role} onChange={event => { setRole(event.target.value); setGrants([]); }}><option value="editor">Editor</option><option value="viewer">Viewer</option><option value="admin">Administrator</option></select></label>
<fieldset><legend>Project access</legend>{role === 'admin' ? <p>All projects</p> : projects.map(project => <label className="admin-check" key={project.project_id}><input type="checkbox" checked={grants.includes(project.project_id)} onChange={event => setGrants(event.target.checked ? [...grants, project.project_id] : grants.filter(id => id !== project.project_id))} />{project.title}</label>)}</fieldset>
+4 -3
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
export default function AccountLogin({ onLogin, busy, credentialLabel, onError }: {
export default function AccountLogin({ onLogin, busy, credentialLabel, onError, submitLabel = 'Sign in' }: {
onLogin: (session: { csrf_token: string }, passwordAccount: boolean, restored?: boolean) => Promise<void>;
busy: boolean;
credentialLabel: string;
onError: (message: string) => void;
submitLabel?: string;
}) {
const [checking, setChecking] = useState(true);
const callbacks = useRef({ onLogin, onError });
@@ -55,11 +56,11 @@ export default function AccountLogin({ onLogin, busy, credentialLabel, onError }
return <div>
<form className="connect-form" onSubmit={submit}>
{legacy ? <><label htmlFor="login-credential">{credentialLabel}</label><input id="login-credential" type="password" autoComplete="off" value={token} onChange={e => setToken(e.target.value)} required /></> : <>
{legacy ? <><label htmlFor="login-credential">{credentialLabel}</label><input id="login-credential" type="password" autoComplete="off" value={token} onChange={e => setToken(e.target.value)} required /></> : <>\
<label htmlFor="login-username">Username</label><input id="login-username" autoComplete="username" value={username} onChange={e => setUsername(e.target.value)} required maxLength={64} pattern="[a-z0-9][a-z0-9._\-]*" />
<label htmlFor="login-password">Password</label><input id="login-password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required maxLength={1024} />
</>}
<button disabled={busy || working}>{working ? 'Signing in…' : legacy ? 'Connect' : 'Sign in'}</button>
<button disabled={busy || working}>{working ? 'Signing in…' : legacy ? 'Connect' : submitLabel}</button>
</form>
<button className="text-button" disabled={busy || working} onClick={() => { setLegacy(!legacy); setPassword(''); setToken(''); }}>{legacy ? 'Use username and password' : 'Use an existing credential'}</button>
</div>;
+49 -48
View File
@@ -1,69 +1,70 @@
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, it, vi } from 'vitest';
import Admin from './Admin';
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import Admin from "./Admin";
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
it('signs in with a password, creates an owned agent key and changes the password', async () => {
it("signs in with a password, creates an owned agent key and changes the password", async () => {
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
if (path === "/v1/session" && init?.method === "GET") return { ok: false, status: 401, json: async () => ({}) };
let result: unknown = [];
if (path === '/v1/users/session') result = { csrf_token: 'user-csrf' };
if (path === '/v1/projects') result = ['pilot'];
if (path === '/v1/me/agent-credentials' && init?.method === 'POST') result = { credential_id: 'agent:codex:fixture', token: 'agent-fixture-secret' };
if (path === '/v1/me/password') result = { status: 'password_changed' };
if (path === "/v1/users/session") result = { csrf_token: "user-csrf" };
if (path === "/v1/projects") result = ["pilot"];
if (path === "/v1/me/agent-credentials" && init?.method === "POST") result = { credential_id: "agent:codex:fixture", token: "agent-fixture-secret" };
if (path === "/v1/me/password") result = { status: "password_changed" };
return { ok: true, json: async () => result };
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<Admin personal />);
await user.type(await screen.findByLabelText('Username'), 'alice');
await user.type(screen.getByLabelText('Password'), 'initial-password-fixture');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
await screen.findByRole('heading', { name: 'Add an agent' });
expect(fetchMock).toHaveBeenCalledWith('/v1/users/session', expect.objectContaining({
method: 'POST', credentials: 'same-origin', body: JSON.stringify({ username: 'alice', password: 'initial-password-fixture' }),
await user.type(await screen.findByLabelText("Username"), "alice");
await user.type(screen.getByLabelText("Password"), "initial-password-fixture");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await user.click(await screen.findByRole("button", { name: "Add agent key" }));
await screen.findByRole("heading", { name: "Add agent key" });
expect(fetchMock).toHaveBeenCalledWith("/v1/users/session", expect.objectContaining({
method: "POST", credentials: "same-origin", body: JSON.stringify({ username: "alice", password: "initial-password-fixture" }),
}));
expect(screen.queryByRole('heading', { name: 'User accounts' })).toBeNull();
await user.type(screen.getByLabelText('Agent name'), 'codex-laptop');
await user.click(screen.getByLabelText('pilot'));
await user.click(screen.getByRole('button', { name: 'Create agent key' }));
expect(await screen.findByLabelText('New bearer credential')).toHaveValue('agent-fixture-secret');
expect(fetchMock).toHaveBeenCalledWith('/v1/me/agent-credentials', expect.objectContaining({
headers: expect.objectContaining({ 'X-GEWU-CSRF': 'user-csrf' }),
body: JSON.stringify({ name: 'codex-laptop', project_ids: ['pilot'] }),
expect(screen.queryByRole("heading", { name: "User accounts" })).toBeNull();
await user.type(screen.getByLabelText("Agent name"), "codex-laptop");
await user.click(screen.getByLabelText("pilot"));
await user.click(screen.getByRole("button", { name: "Create agent key" }));
expect(await screen.findByLabelText("New bearer credential")).toHaveValue("agent-fixture-secret");
expect(fetchMock).toHaveBeenCalledWith("/v1/me/agent-credentials", expect.objectContaining({
headers: expect.objectContaining({ "X-GEWU-CSRF": "user-csrf" }),
body: JSON.stringify({ name: "codex-laptop", project_ids: ["pilot"] }),
}));
await user.type(screen.getByLabelText('Current password'), 'initial-password-fixture');
await user.type(screen.getByLabelText('New password'), 'changed-password-fixture');
await user.type(screen.getByLabelText('Confirm new password'), 'changed-password-fixture');
await user.click(screen.getByRole('button', { name: 'Change password' }));
expect(await screen.findByLabelText('Username')).toBeVisible();
expect(screen.queryByLabelText('New bearer credential')).toBeNull();
expect(screen.getByRole('status')).toHaveTextContent('Password changed.');
await user.type(screen.getByLabelText("Current password"), "initial-password-fixture");
await user.type(screen.getByLabelText("New password"), "changed-password-fixture");
await user.type(screen.getByLabelText("Confirm new password"), "changed-password-fixture");
await user.click(screen.getByRole("button", { name: "Change password" }));
expect(await screen.findByLabelText("Username")).toBeVisible();
expect(screen.queryByLabelText("New bearer credential")).toBeNull();
expect(screen.getByRole("status")).toHaveTextContent("Password changed.");
});
it('lets the existing administrator create the first password account', async () => {
it("lets the existing administrator create the first password account", async () => {
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
const result = path === '/v1/session' ? { csrf_token: 'operator-csrf' } : [];
if (path === "/v1/session" && init?.method === "GET") return { ok: false, status: 401, json: async () => ({}) };
const result = path === "/v1/session" ? { csrf_token: "operator-csrf" } : [];
return { ok: true, json: async () => result };
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<Admin />);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU administrator credential'), 'operator-fixture');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(await screen.findByRole('tab', { name: 'Users' }));
const accounts = await screen.findByRole('region', { name: 'User accounts' });
await user.type(within(accounts).getByLabelText('New username'), 'vasily');
await user.type(within(accounts).getByLabelText('Initial password'), 'chosen-password-fixture');
await user.selectOptions(within(accounts).getByLabelText('Account role'), 'admin');
await user.click(within(accounts).getByRole('button', { name: 'Create user' }));
await user.click(await screen.findByRole("button", { name: "Use an existing credential" }));
await user.type(screen.getByLabelText("GEWU administrator credential"), "operator-fixture");
await user.click(screen.getByRole("button", { name: "Connect" }));
await user.click(await screen.findByRole("tab", { name: "Users" }));
await user.click(await screen.findByRole("button", { name: "Add user" }));
const dialog = await screen.findByRole("dialog", { name: "Add user" });
await user.type(within(dialog).getByLabelText("New username"), "vasily");
await user.type(within(dialog).getByLabelText("Initial password"), "chosen-password-fixture");
await user.selectOptions(within(dialog).getByLabelText("Account role"), "admin");
await user.click(within(dialog).getByRole("button", { name: "Add user" }));
await screen.findByText(/Account created for vasily/);
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/users', expect.objectContaining({
body: JSON.stringify({ username: 'vasily', password: 'chosen-password-fixture', role: 'admin', project_ids: [] }),
expect(fetchMock).toHaveBeenCalledWith("/v1/admin/users", expect.objectContaining({
body: JSON.stringify({ username: "vasily", password: "chosen-password-fixture", role: "admin", project_ids: [] }),
}));
expect(within(accounts).getByLabelText('Initial password')).toHaveValue('');
});
+96 -91
View File
@@ -1,133 +1,138 @@
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, it, vi } from 'vitest';
import Admin from './Admin';
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import Admin from "./Admin";
const key = { credential_id: 'reader-key', principal_id: 'reader', roles: ['viewer'], project_ids: ['pilot'], revoked: false };
const key = { credential_id: "reader-key", principal_id: "reader", roles: ["viewer"], project_ids: ["pilot"], revoked: false };
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
it('creates a scoped key, dismisses its one-time secret, and confirms revocation with CSRF', async () => {
it("creates a scoped key, dismisses its one-time secret, and confirms revocation with CSRF", async () => {
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
if (path === "/v1/session" && init?.method === "GET") return { ok: false, status: 401, json: async () => ({}) };
let result: unknown = {};
if (path === '/v1/session') result = { csrf_token: 'csrf-fixture' };
if (path.startsWith('/v1/admin/credentials?')) result = [key];
if (path.startsWith('/v1/admin/users?')) result = [];
if (path === '/v1/admin/projects') result = [{ project_id: 'pilot', title: 'Pilot research' }];
if (path === '/v1/admin/events?limit=50') result = [];
if (path === '/v1/admin/credentials' && init?.method === 'POST') result = { credential_id: 'new-key', token: 'one-time-fixture-secret' };
if (path === "/v1/session") result = { csrf_token: "csrf-fixture" };
if (path.startsWith("/v1/admin/credentials?")) result = [key];
if (path.startsWith("/v1/admin/users?")) result = [];
if (path === "/v1/admin/projects") result = [{ project_id: "pilot", title: "Pilot research" }];
if (path === "/v1/admin/events?limit=50") result = [];
if (path === "/v1/admin/credentials" && init?.method === "POST") result = { credential_id: "new-key", token: "one-time-fixture-secret" };
return { ok: true, json: async () => result };
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<Admin />);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU administrator credential'), 'admin-fixture');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(await screen.findByRole('tab', { name: 'Credentials' }));
await screen.findByRole('heading', { name: 'Credentials' });
await user.type(screen.getByLabelText('Credential ID'), 'new-key');
await user.type(screen.getByLabelText('Principal ID'), 'researcher');
await user.click(within(screen.getByRole('region', { name: 'Create a credential' })).getByLabelText('Pilot research'));
await user.click(screen.getByRole('button', { name: 'Create credential' }));
expect(await screen.findByLabelText('New bearer credential')).toHaveValue('one-time-fixture-secret');
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/credentials', expect.objectContaining({
method: 'POST', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'csrf-fixture' }),
body: JSON.stringify({ credential_id: 'new-key', principal_id: 'researcher', role: 'viewer', project_ids: ['pilot'] }),
await user.click(await screen.findByRole("button", { name: "Use an existing credential" }));
await user.type(screen.getByLabelText("GEWU administrator credential"), "admin-fixture");
await user.click(screen.getByRole("button", { name: "Connect" }));
await user.click(await screen.findByRole("tab", { name: "Credentials" }));
await screen.findByRole("heading", { name: "Credentials" });
await user.click(screen.getByRole("button", { name: "Add credential" }));
const dialog = await screen.findByRole("dialog", { name: "Add credential" });
await user.type(within(dialog).getByLabelText("Credential ID"), "new-key");
await user.type(within(dialog).getByLabelText("Principal ID"), "researcher");
await user.click(within(dialog).getByLabelText("Pilot research"));
await user.click(within(dialog).getByRole("button", { name: "Create credential" }));
expect(await screen.findByLabelText("New bearer credential")).toHaveValue("one-time-fixture-secret");
expect(fetchMock).toHaveBeenCalledWith("/v1/admin/credentials", expect.objectContaining({
method: "POST", headers: expect.objectContaining({ "X-GEWU-CSRF": "csrf-fixture" }),
body: JSON.stringify({ credential_id: "new-key", principal_id: "researcher", role: "viewer", project_ids: ["pilot"] }),
}));
await user.click(screen.getByRole('button', { name: 'I have saved it — dismiss' }));
expect(screen.queryByLabelText('New bearer credential')).toBeNull();
await user.click(screen.getByRole('button', { name: 'Revoke reader-key' }));
expect(fetchMock.mock.calls.some(([path]) => path.endsWith('/reader-key'))).toBe(false);
const row = screen.getByRole('row', { name: /reader-key/ });
await user.click(within(row).getByRole('button', { name: 'Confirm revoke' }));
await screen.findByText('Revoked reader-key.');
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/credentials/reader-key', expect.objectContaining({
method: 'DELETE', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'csrf-fixture' }),
await user.click(screen.getByRole("button", { name: "I have saved it — dismiss" }));
expect(screen.queryByLabelText("New bearer credential")).toBeNull();
await user.click(screen.getByRole("button", { name: "Revoke" }));
const confirmBtn = await screen.findByRole("button", { name: "Confirm revoke" });
await user.click(confirmBtn);
await screen.findByText("Revoked reader-key.");
expect(fetchMock).toHaveBeenCalledWith("/v1/admin/credentials/reader-key", expect.objectContaining({
method: "DELETE", headers: expect.objectContaining({ "X-GEWU-CSRF": "csrf-fixture" }),
}));
});
it('reports rejected connections without exposing management controls', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 403, json: async () => ({ detail: 'Administrator role is required' }) })));
it("reports rejected connections without exposing management controls", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 403, json: async () => ({ detail: "Administrator role is required" }) })));
const user = userEvent.setup();
render(<Admin />);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU administrator credential'), 'viewer-fixture');
await user.click(screen.getByRole('button', { name: 'Connect' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Administrator role is required');
expect(screen.queryByRole('heading', { name: 'Create a credential' })).toBeNull();
await user.click(await screen.findByRole("button", { name: "Use an existing credential" }));
await user.type(screen.getByLabelText("GEWU administrator credential"), "viewer-fixture");
await user.click(screen.getByRole("button", { name: "Connect" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Administrator role is required");
expect(screen.queryByRole("heading", { name: "Add credential" })).toBeNull();
});
it('clears private panel state and permits reconnection after session expiry', async () => {
it("clears private panel state and permits reconnection after session expiry", async () => {
let expired = false;
vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
if (expired) return { ok: false, status: 401, json: async () => ({ detail: 'Browser session is invalid or expired' }) };
const result = path === '/v1/session' ? { csrf_token: 'csrf-fixture' }
: path.startsWith('/v1/admin/credentials?') ? [key] : [];
vi.stubGlobal("fetch", vi.fn(async (path: string, init?: RequestInit) => {
if (path === "/v1/session" && init?.method === "GET") return { ok: false, status: 401, json: async () => ({}) };
if (expired) return { ok: false, status: 401, json: async () => ({ detail: "Browser session is invalid or expired" }) };
const result = path === "/v1/session" ? { csrf_token: "csrf-fixture" }
: path.startsWith("/v1/admin/credentials?") ? [key] : [];
return { ok: true, json: async () => result };
}));
const user = userEvent.setup();
render(<Admin />);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU administrator credential'), 'admin-fixture');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(await screen.findByRole('tab', { name: 'Credentials' }));
await screen.findByRole('heading', { name: 'Credentials' });
await user.click(await screen.findByRole("button", { name: "Use an existing credential" }));
await user.type(screen.getByLabelText("GEWU administrator credential"), "admin-fixture");
await user.click(screen.getByRole("button", { name: "Connect" }));
await user.click(await screen.findByRole("tab", { name: "Credentials" }));
await screen.findByRole("heading", { name: "Credentials" });
expired = true;
await user.click(screen.getByRole('button', { name: 'Refresh' }));
expect(await screen.findByLabelText('Username')).toBeVisible();
expect(screen.queryByText('reader-key')).toBeNull();
expect(screen.getByRole('status')).toHaveTextContent('Your session ended.');
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(await screen.findByLabelText("Username")).toBeVisible();
expect(screen.queryByText("reader-key")).toBeNull();
expect(screen.getByRole("status")).toHaveTextContent("Your session ended.");
});
it('restores the admin session after remount and stops restoring after sign-out', async () => {
it("restores the admin session after remount and stops restoring after sign-out", async () => {
let active = true;
const storage = vi.spyOn(Storage.prototype, 'setItem');
const storage = vi.spyOn(Storage.prototype, "setItem");
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') {
return active ? { ok: true, json: async () => ({ csrf_token: 'restored-csrf', password_account: true }) }
if (path === "/v1/session" && init?.method === "GET") {
return active ? { ok: true, json: async () => ({ csrf_token: "restored-csrf", password_account: true }) }
: { ok: false, status: 401, json: async () => ({}) };
}
if (path === '/v1/session' && init?.method === 'DELETE') active = false;
const result = path.startsWith('/v1/admin/credentials?') ? [key]
: path === '/v1/me' ? { principal_id: 'user:admin' } : [];
if (path === "/v1/session" && init?.method === "DELETE") active = false;
const result = path.startsWith("/v1/admin/credentials?") ? [key]
: path === "/v1/me" ? { principal_id: "user:admin" } : [];
return { ok: true, json: async () => result };
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
const first = render(<Admin />);
expect(await screen.findByRole('heading', { name: 'Projects' })).toBeVisible();
expect(screen.queryByLabelText('Username')).toBeNull();
expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Projects', 'Users', 'Credentials', 'Logs', 'Admin Settings']);
expect(screen.getAllByRole('tabpanel')).toHaveLength(1);
expect(screen.queryByRole('heading', { name: 'Credentials' })).toBeNull();
await user.type(screen.getByLabelText('Project name'), 'Unfinished research');
const projectsTab = screen.getByRole('tab', { name: 'Projects' });
expect(await screen.findByRole("heading", { name: "Projects" })).toBeVisible();
expect(screen.queryByLabelText("Username")).toBeNull();
expect(screen.getAllByRole("tab").map(tab => tab.textContent)).toEqual(["Projects", "Ingest review", "Users", "Credentials", "Logs", "Admin Settings"]);
expect(screen.getAllByRole("tabpanel")).toHaveLength(1);
expect(screen.queryByRole("heading", { name: "Credentials" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Create project" }));
await user.type(screen.getByLabelText("Project name"), "Unfinished research");
const projectsTab = screen.getByRole("tab", { name: "Projects" });
projectsTab.focus();
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('tab', { name: 'Users' })).toHaveFocus();
expect(screen.getByRole('heading', { name: 'User accounts' })).toBeVisible();
await user.keyboard('{End}');
expect(screen.getByRole('tab', { name: 'Admin Settings' })).toHaveFocus();
expect(screen.getByRole('heading', { name: 'Change your password' })).toBeVisible();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('heading', { name: 'Recent activity' })).toBeVisible();
await user.keyboard('{Home}');
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "Ingest review" })).toHaveFocus();
expect(screen.getByRole("heading", { name: "Ingest review" })).toBeVisible();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "Users" })).toHaveFocus();
expect(screen.getByRole("heading", { name: "Users" })).toBeVisible();
await user.keyboard("{End}");
expect(screen.getByRole("tab", { name: "Admin Settings" })).toHaveFocus();
expect(screen.getByRole("heading", { name: "Change your password" })).toBeVisible();
await user.keyboard("{ArrowLeft}");
expect(screen.getByRole("heading", { name: "Logs" })).toBeVisible();
await user.keyboard("{Home}");
expect(projectsTab).toHaveFocus();
expect(screen.getByLabelText('Project name')).toHaveValue('Unfinished research');
expect(screen.getByLabelText("Project name")).toHaveValue("Unfinished research");
first.unmount();
render(<Admin />);
expect(await screen.findByRole('heading', { name: 'Projects' })).toBeVisible();
expect(fetchMock.mock.calls.filter(([path, init]) => path === '/v1/session' && init?.method === 'GET')).toHaveLength(2);
expect(fetchMock).toHaveBeenCalledWith('/v1/session', expect.objectContaining({
method: 'GET', credentials: 'same-origin', cache: 'no-store', headers: { 'X-GEWU-Session': 'restore' },
expect(await screen.findByRole("heading", { name: "Projects" })).toBeVisible();
expect(fetchMock.mock.calls.filter(([path, init]) => path === "/v1/session" && init?.method === "GET")).toHaveLength(2);
expect(fetchMock).toHaveBeenCalledWith("/v1/session", expect.objectContaining({
method: "GET", credentials: "same-origin", cache: "no-store", headers: { "X-GEWU-Session": "restore" },
}));
await user.click(screen.getByRole('button', { name: 'Disconnect' }));
expect(await screen.findByLabelText('Username')).toBeVisible();
expect(fetchMock).toHaveBeenCalledWith('/v1/session', expect.objectContaining({
method: 'DELETE', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'restored-csrf' }),
await user.click(screen.getByRole("button", { name: "Disconnect" }));
expect(await screen.findByLabelText("Username")).toBeVisible();
expect(fetchMock).toHaveBeenCalledWith("/v1/session", expect.objectContaining({
method: "DELETE", headers: expect.objectContaining({ "X-GEWU-CSRF": "restored-csrf" }),
}));
expect(storage).not.toHaveBeenCalled();
storage.mockRestore();
+22 -2
View File
@@ -123,8 +123,28 @@ function Admin({ personal = false }: { personal?: boolean }) {
setCurrentPrincipal(identity.principal_id ?? '');
if (!personal) setProjectRecords(availableProjects as Project[]);
}, [personal, offset]);
const refreshAdminData = useCallback(() => load(csrf), [load, csrf]);
const loadAdminPage = useCallback((page: number) => load(csrf, page), [load, csrf]);
const handleSessionError = useCallback((failure: unknown) => {
if (failure instanceof AdminRequestError && failure.status === 401) {
setCsrf(''); setCredentials([]); setEvents([]); setProjects([]); setIssued(null);
setMessage('Your session ended. Connect again to continue.');
}
}, []);
const refreshAdminData = useCallback(async () => {
try {
await load(csrf);
} catch (failure) {
handleSessionError(failure);
throw failure;
}
}, [load, csrf, handleSessionError]);
const loadAdminPage = useCallback(async (page: number) => {
try {
await load(csrf, page);
} catch (failure) {
handleSessionError(failure);
throw failure;
}
}, [load, csrf, handleSessionError]);
async function connect(session: { csrf_token: string }, isPasswordAccount: boolean, restored = false) {
setBusy(true); setError('');
+1 -1
View File
@@ -14,7 +14,7 @@ export function AdminDialog({ open, title, onClose, children, returnFocusRef }:
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return <dialog ref={dialogRef} className="admin-dialog" aria-label={title}
return <dialog ref={dialogRef} open={open ? true : undefined} className="admin-dialog" aria-label={title}
onCancel={event => { event.preventDefault(); onClose(); }}
onClose={() => { onClose(); returnFocusRef?.current?.focus(); }}>
<div className="admin-heading"><h2>{title}</h2><button className="text-button" type="button" onClick={onClose}>Close</button></div>
+126 -58
View File
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from '@testing-library/react';
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, useLocation, useNavigate } from 'react-router';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -60,7 +60,9 @@ function mockFetch(
resultNode = node,
resultCitation = citation,
restoreSession = false,
browseContinuation = false,
) {
let browseRequests = 0;
const fetchMock = vi.fn(async (...args: [RequestInfo | URL, RequestInit?]) => {
const [input] = args;
const path = String(input);
@@ -68,9 +70,30 @@ function mockFetch(
if (path === '/v1/session' && args[1]?.method === 'GET') return restoreSession ? { ok: true, json: async () => ({ csrf_token: 'restored-csrf', password_account: true }) } as Response : { ok: false, status: 401, json: async () => ({}) } as Response;
if (path === '/v1/session') data = { csrf_token: 'gwc_test-token', expires_at: '2026-10-13T08:00:00Z' };
else if (path === '/v1/projects') data = ['dome-performance'];
else if (path === '/v1/search' && searchMode === 'error') {
else if (path === '/v1/knowledge/graph') data = {
schema_version: '1.0', api_version: '1.0', request_id: 'graph-test',
resolved_scope: { projects: ['dome-performance'], shared: false },
nodes: [resultNode], edges: [], truncated: false, center_node_id: null,
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), warnings: [],
};
else if ((path === '/v1/search' || path === '/v1/knowledge/browse') && searchMode === 'error') {
return { ok: false, status: 503, json: async () => ({ detail: 'Search index unavailable.' }) } as Response;
}
else if (path === '/v1/knowledge/browse' && searchMode === 'empty') data = {
nodes: [], project_node_count: 1, project_source_count: 1, shared_node_count: 0, shared_source_count: 0, filtered_node_count: 0,
facets: { tags: { 'installation': 1 }, audience: { developer: 1 }, use_for: { planning: 1 }, domain: { 'interactive systems': 1 } },
warnings: [], canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null,
};
else if (path === '/v1/knowledge/browse') {
const body = args[1]?.body ? JSON.parse(String(args[1].body)) as { cursor?: string } : {};
const restoringPageTwo = browseContinuation;
const isFirstBrowseRequest = browseRequests++ === 0;
data = {
nodes: [resultNode], project_node_count: restoringPageTwo ? 21 : 1, project_source_count: 1, shared_node_count: 0, shared_source_count: 0, filtered_node_count: restoringPageTwo ? 21 : 1,
facets: { tags: { 'real-time': 1, installation: 1 }, audience: { developer: 1 }, use_for: { planning: 1 }, domain: { 'interactive systems': 1 } },
warnings: [], canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: restoringPageTwo && isFirstBrowseRequest && !body.cursor ? 'opaque-browse-cursor' : null,
};
}
else if (path === '/v1/search' && searchMode === 'empty') data = {
data: [], citations: [], warnings: [], omissions: [],
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null,
@@ -100,8 +123,9 @@ function mockFetch(
data: [], citations: [], warnings: [], omissions: [],
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null,
};
else if (path === '/v1/sources') data = {
data: [{
else if (path === '/v1/sources') {
const body = args[1]?.body ? JSON.parse(String(args[1].body)) as { cursor?: string } : {};
const sourcePage = body.cursor ? [] : [{
source: {
source_id: 'dome:pilot-report',
revision_id: 'dome:pilot-report:r1',
@@ -115,10 +139,10 @@ function mockFetch(
reliability_grade: 'medium',
ingested_for: ['dome-performance'],
},
}],
citations: [], warnings: [], omissions: [],
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null,
};
}];
data = { data: sourcePage, filtered_count: 21, citations: [], warnings: [], omissions: [],
canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: body.cursor ? null : 'sources-next' };
}
else if (path.endsWith('/history')) data = {
data: [
{ revision: '1'.repeat(40), node },
@@ -182,21 +206,26 @@ describe('GEWU research reader', () => {
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
expect(await screen.findByLabelText('PROJECT')).toHaveValue('dome-performance');
await user.click(screen.getByRole('link', { name: /stale knowledge/i }));
expect(screen.getByLabelText('Past freshness date')).toBeChecked();
expect(await screen.findByRole('region', { name: 'Selected project collection' })).toHaveTextContent('1 knowledge nodes');
await user.click(screen.getByRole('checkbox', { name: 'Past freshness date' }));
expect(screen.getByRole('checkbox', { name: 'Past freshness date' })).toBeChecked();
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
await user.type(screen.getByLabelText('As of from'), '2024-01-01');
await user.type(screen.getByLabelText('As of to'), '2024-12-31');
await user.click(screen.getByLabelText('medium'));
await user.click(screen.getByRole('button', { name: 'Search' }));
const result = await screen.findByRole('button', { name: /visitor count during the season/i });
const result = await screen.findByRole('link', { name: /visitor count during the season/i });
expect(result).toBeVisible();
await user.click(result);
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('node=dome%3Aseason-visitors');
expect((await screen.findAllByText(citation.quote, { exact: false })).length).toBeGreaterThan(0);
expect(await screen.findByRole('heading', { name: 'Claim assessments' })).toBeVisible();
expect(await screen.findByRole('heading', { name: 'Assumptions and limitations' })).toBeVisible();
const claimHeading = await screen.findByRole('heading', { name: 'Claim assessments' });
expect(claimHeading).toBeVisible();
await user.click(claimHeading);
const assumptionHeading = await screen.findByRole('heading', { name: 'Assumptions and limitations' });
expect(assumptionHeading).toBeVisible();
await user.click(assumptionHeading);
expect(screen.getByText(/The source does not identify unique visitors\./)).toBeVisible();
expect(screen.getByText('The cited sentence states the count.')).toBeVisible();
expect(screen.getByText(/not a truth probability/)).toBeVisible();
@@ -229,13 +258,13 @@ describe('GEWU research reader', () => {
expect.objectContaining({ headers: { Authorization: 'Bearer viewer-secret' } }),
);
expect(fetchMock).toHaveBeenCalledWith(
'/v1/search',
'/v1/knowledge/browse',
expect.objectContaining({
credentials: 'same-origin',
headers: expect.objectContaining({ 'X-GEWU-CSRF': 'gwc_test-token' }),
}),
);
const searchCall = fetchMock.mock.calls.find(([url]) => String(url) === '/v1/search');
const searchCall = [...fetchMock.mock.calls].reverse().find(([url]) => String(url) === '/v1/knowledge/browse');
expect(JSON.parse(String(searchCall?.[1]?.body))).toMatchObject({
stale_only: true,
confidences: ['medium'],
@@ -244,9 +273,15 @@ describe('GEWU research reader', () => {
});
expect(localStorage.length).toBe(0);
expect(screen.queryByRole('button', { name: /approve|edit|merge|generate|capture/i })).not.toBeInTheDocument();
await user.click(screen.getByRole('link', { name: /sources/i }));
await user.click(within(screen.getByRole('navigation', { name: 'Project collections' })).getByRole('button', { name: 'Sources' }));
await user.click(screen.getByRole('button', { name: 'Load source records' }));
expect(await screen.findByRole('heading', { name: 'Sources' })).toBeVisible();
expect(screen.getByText('1–1 of 21')).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Next' }));
expect(await screen.findByText('0 of 21')).toBeVisible();
expect(screen.getByRole('button', { name: 'Previous' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Previous' }));
expect(await screen.findByText('1–1 of 21')).toBeVisible();
expect(
(await screen.findAllByRole('heading', { name: 'Pilot installation report' })).length,
).toBeGreaterThan(0);
@@ -280,7 +315,7 @@ describe('GEWU research reader', () => {
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
await user.click(screen.getByRole('button', { name: 'Search' }));
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
await user.click(await screen.findByRole('link', { name: /visitor count during the season/i }));
expect(await screen.findByText(/Transcript · 00:14:32\.000–00:14:36\.500 · cue-1 · 48–102/)).toBeVisible();
});
@@ -309,17 +344,16 @@ describe('GEWU research reader', () => {
if (mode === 'error') expect(screen.getByText('Search index unavailable.')).toBeVisible();
});
it('routes Problems and Topics through the typed search facets', async () => {
it('shows Problems and Concepts as knowledge type filter options', async () => {
const user = userEvent.setup();
mockFetch();
render(<MemoryRouter><App /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(screen.getByRole('link', { name: /problems/i }));
expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveValue('problem');
await user.click(screen.getByRole('link', { name: /topics/i }));
expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveValue('concept');
expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveDisplayValue('All types');
expect(screen.getByRole('option', { name: 'Problems' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Concepts' })).toBeInTheDocument();
});
it('applies exact tag, audience, use and domain facets and stores them in the URL', async () => {
@@ -330,23 +364,31 @@ describe('GEWU research reader', () => {
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
await user.type(screen.getByLabelText('Tag'), 'installation');
await user.type(screen.getByLabelText('Audience'), 'developer');
await user.type(screen.getByLabelText('Use'), 'planning');
await user.type(screen.getByLabelText('Domain'), 'interactive systems');
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/search')).toHaveLength(0);
const filters = screen.getByText('Filters').closest('details');
if (!filters) throw new Error('Filters disclosure missing');
await user.click(within(filters).getByText('Filters'));
await user.click(within(filters).getByText('installation'));
await user.click(within(filters).getByText('developer'));
await user.click(within(filters).getByText('planning'));
await user.click(within(filters).getByText('interactive systems'));
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/knowledge/browse').length).toBeGreaterThan(0);
await user.click(screen.getByRole('button', { name: 'Search' }));
const searchCall = fetchMock.mock.calls.find(([url]) => String(url) === '/v1/search');
const searchCalls = fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/knowledge/browse');
const searchCall = [...searchCalls].reverse().find(([, init]) => {
const body = JSON.parse(String(init?.body)) as { domain?: string[] };
return body.domain?.includes('interactive systems');
});
expect(JSON.parse(String(searchCall?.[1]?.body))).toMatchObject({
tags: ['installation'],
audience: ['developer'],
use_for: ['planning'],
domain: ['interactive systems'],
});
expect(screen.getByLabelText('Current search URL')).toHaveTextContent(
'tag=installation&audience=developer&use=planning&domain=interactive+systems',
);
expect(screen.getByRole('checkbox', { name: /installation/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /developer/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /planning/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /interactive systems/ })).toBeChecked();
});
it('restores a shared search URL after connecting with its query and exact facets', async () => {
@@ -362,12 +404,12 @@ describe('GEWU research reader', () => {
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
expect(await screen.findByRole('button', { name: /visitor count during the season/i })).toBeVisible();
const searchCalls = fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/search');
expect(searchCalls).toHaveLength(1);
expect(JSON.parse(String(searchCalls[0]?.[1]?.body))).toMatchObject({
expect(await screen.findByRole('link', { name: /visitor count during the season/i })).toBeVisible();
const searchCalls = fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/knowledge/browse');
expect(searchCalls.length).toBeGreaterThan(0);
expect(JSON.parse(String(searchCalls.at(-1)?.[1]?.body))).toMatchObject({
query: 'visitors',
scope: { projects: ['dome-performance'] },
scope: { projects: ['dome-performance'], include_projects: [], include_shared: false, all_projects: false },
tags: ['installation'],
audience: ['developer'],
confidences: ['medium'],
@@ -379,6 +421,20 @@ describe('GEWU research reader', () => {
expect(screen.getByLabelText('As of to')).toHaveValue('2024-12-31');
});
it('restores an opaque-cursor page from the page number in the URL', async () => {
const user = userEvent.setup();
const fetchMock = mockFetch('normal', node, citation, false, true);
render(<MemoryRouter initialEntries={['/?project=dome-performance&page=2']}><App /><CurrentRoute /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await screen.findByRole('link', { name: /visitor count during the season/i });
const calls = fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/knowledge/browse');
expect(calls).toHaveLength(2);
expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ cursor: expect.any(String) });
expect(screen.getByText('21–21 of 21')).toBeVisible();
});
it('restores prior searches with one Back step and reruns them on browser navigation', async () => {
const user = userEvent.setup();
const fetchMock = mockFetch();
@@ -403,30 +459,30 @@ describe('GEWU research reader', () => {
await user.click(screen.getByLabelText('medium'));
await user.type(screen.getByLabelText('As of to'), '2026-12-31');
await user.click(screen.getByRole('button', { name: 'Search' }));
await screen.findByText('Search complete. 1 result.');
await screen.findByText('Showing 1 of 1 matching knowledge nodes.');
await user.click(screen.getByRole('button', { name: 'Back in history' }));
await screen.findByText('Search complete. 1 result.');
await screen.findByText('Showing 1 of 1 matching knowledge nodes.');
expect(queryBox).toHaveValue('visitors');
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('confidence=medium');
expect(screen.getByLabelText('medium')).toBeChecked();
expect(screen.getByLabelText('As of from')).toHaveValue('2024-01-01');
expect(screen.getByLabelText('As of to')).toHaveValue('');
let searchCalls = fetchMock.mock.calls
.filter(([url]) => String(url) === '/v1/search')
.filter(([url]) => String(url) === '/v1/knowledge/browse')
.map(([, init]) => JSON.parse(String(init?.body)).query);
expect(searchCalls).toEqual(['visitors', 'methods', 'visitors']);
expect(searchCalls.slice(-3)).toEqual(['visitors', 'methods', 'visitors']);
await user.click(screen.getByRole('button', { name: 'Forward in history' }));
await screen.findByText('Search complete. 1 result.');
await screen.findByText('Showing 1 of 1 matching knowledge nodes.');
expect(queryBox).toHaveValue('methods');
expect(screen.getByLabelText('medium')).not.toBeChecked();
expect(screen.getByLabelText('As of from')).toHaveValue('2024-01-01');
expect(screen.getByLabelText('As of to')).toHaveValue('2026-12-31');
searchCalls = fetchMock.mock.calls
.filter(([url]) => String(url) === '/v1/search')
.filter(([url]) => String(url) === '/v1/knowledge/browse')
.map(([, init]) => JSON.parse(String(init?.body)).query);
expect(searchCalls).toEqual(['visitors', 'methods', 'visitors', 'methods']);
expect(searchCalls.slice(-4)).toEqual(['visitors', 'methods', 'visitors', 'methods']);
});
it('labels the problem reading view and its missing accepted approaches', async () => {
@@ -438,28 +494,21 @@ describe('GEWU research reader', () => {
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitor problem');
await user.click(screen.getByRole('button', { name: 'Search' }));
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
await user.click(await screen.findByRole('link', { name: /visitor count during the season/i }));
expect(await screen.findByRole('heading', { name: 'Approaches and related knowledge' })).toBeVisible();
expect(screen.getByText(/No accepted approaches or other relationships are visible/)).toBeVisible();
});
it('browses problem records within the selected scope without requiring a text query', async () => {
it('offers problem and concept type filters for project browsing', async () => {
const user = userEvent.setup();
const fetchMock = mockFetch('normal', { ...node, node_type: 'problem' });
mockFetch();
render(<MemoryRouter><App /><CurrentRoute /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(await screen.findByRole('button', { name: 'Browse problems' }));
expect(await screen.findByRole('button', { name: /visitor count during the season/i })).toBeVisible();
const searchCall = fetchMock.mock.calls.find(([url]) => String(url) === '/v1/search');
expect(JSON.parse(String(searchCall?.[1]?.body))).toMatchObject({
query: '',
scope: { projects: ['dome-performance'] },
node_types: ['problem'],
});
expect(screen.getByLabelText('Current search URL')).toHaveTextContent('type=problem');
expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveValue('');
expect(screen.getByRole('option', { name: 'Problems' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Concepts' })).toBeInTheDocument();
});
it('restores a linked canonical record after connecting with a shared node URL', async () => {
@@ -497,7 +546,7 @@ describe('GEWU research reader', () => {
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
await user.click(screen.getByRole('button', { name: 'Search' }));
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
await user.click(await screen.findByRole('link', { name: /visitor count during the season/i }));
expect(await screen.findByRole('heading', { name: 'Claim assessments' })).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Back in history' }));
@@ -517,7 +566,7 @@ describe('GEWU research reader', () => {
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors');
await user.click(screen.getByRole('button', { name: 'Search' }));
await user.click(await screen.findByRole('button', { name: /visitor count during the season/i }));
await user.click(await screen.findByRole('link', { name: /visitor count during the season/i }));
await user.click(await screen.findByText('Revision history (2)'));
const neighborCallsBeforeHistoricalRead = fetchMock.mock.calls.filter(
([url]) => String(url) === '/v1/neighbors',
@@ -553,3 +602,22 @@ it('restores library access from the existing browser session without entering a
method: 'GET', credentials: 'same-origin', headers: { 'X-GEWU-Session': 'restore' },
}));
});
it('loads the authorized project graph when the Graph tab opens', async () => {
const user = userEvent.setup();
const fetchMock = mockFetch();
render(<MemoryRouter initialEntries={['/']}><App /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'Use an existing credential' }));
await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret');
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click((await screen.findAllByRole('button', { name: 'Graph' }))[0]);
expect(await screen.findByText('1 nodes · 0 links')).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Visitor count during the season (finding)' }));
expect(screen.getByRole('dialog', { name: 'Visitor count during the season' })).toBeVisible();
expect(screen.getByRole('link', { name: 'Open node →' })).toBeVisible();
expect(screen.queryByRole('button', { name: 'Zoom in' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Project graph' })).not.toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith('/v1/knowledge/graph', expect.objectContaining({
method: 'POST', credentials: 'same-origin',
}));
});
+1272 -541
View File
File diff suppressed because it is too large. Load diff
+121
View File
@@ -0,0 +1,121 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { MemoryRouter } from 'react-router';
import { afterEach, expect, it, vi } from 'vitest';
import type { components } from '../generated/api';
import { GraphCanvas } from './GraphCanvas';
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
const node = {
node_id: 'node-1', project_id: 'demo', title: 'Shared scenes preserve story state',
summary: 'A scene can be reused when the relevant story state agrees.', node_type: 'technique',
} as components['schemas']['KnowledgeGraphEnvelope']['nodes'][number];
function Fixture() {
const [selected, setSelected] = useState<string | null>(null);
return <MemoryRouter><button>Outside graph</button><GraphCanvas
nodes={[node]} edges={[]} drawing={{ viewBox: '0 0 1000 640', points: new Map([['node-1', { x: 200, y: 200 }]]) }}
selectedId={selected} onSelect={setSelected} nodeHref={() => '/?node=node-1'}
/></MemoryRouter>;
}
it('opens the node link inside a graph popup and restores focus on close', async () => {
const user = userEvent.setup();
render(<Fixture />);
const target = screen.getByRole('button', { name: `${node.title} (technique)` });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await user.click(target);
const popup = screen.getByRole('dialog', { name: node.title });
expect(popup.closest('.graph-stage')).not.toBeNull();
expect(popup).toHaveFocus();
expect(within(popup).getByRole('link', { name: 'Open node →' })).toHaveAttribute('href', '/?node=node-1');
expect(target).toHaveAttribute('aria-expanded', 'true');
await user.click(within(popup).getByRole('button', { name: 'Close node preview' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(target).toHaveFocus();
});
it('supports keyboard activation and Escape without requiring hover', async () => {
const user = userEvent.setup();
render(<Fixture />);
const target = screen.getByRole('button', { name: `${node.title} (technique)` });
target.focus();
await user.keyboard('{Enter}');
expect(screen.getByRole('dialog')).toHaveFocus();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(target).toHaveFocus();
await user.keyboard(' ');
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('dismisses on empty graph space or an outside pointer interaction', async () => {
const user = userEvent.setup();
render(<Fixture />);
const target = screen.getByRole('button', { name: `${node.title} (technique)` });
await user.click(target);
fireEvent.click(document.querySelector('.graph-canvas')!);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await user.click(target);
await user.click(screen.getByRole('button', { name: 'Outside graph' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
function canvas() {
const element = document.querySelector<SVGSVGElement>('.graph-canvas')!;
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 1000, bottom: 640, width: 1000, height: 640, toJSON: () => ({}) });
return element;
}
function camera() {
const [x, y, zoom] = document.querySelector('.graph-camera')!.getAttribute('transform')!.match(/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/g)!.map(Number);
return { x, y, zoom };
}
it('zooms around the mouse position and consumes wheel scrolling only on the graph', () => {
render(<Fixture />);
const graph = canvas();
const wheel = new WheelEvent('wheel', { clientX: 200, clientY: 200, deltaY: -100, bubbles: true, cancelable: true });
fireEvent(graph, wheel);
expect(wheel.defaultPrevented).toBe(true);
const view = camera();
expect(view.zoom).toBeGreaterThan(1);
expect(view.x + 200 * view.zoom).toBeCloseTo(200);
expect(view.y + 200 * view.zoom).toBeCloseTo(200);
});
it('pans by dragging a node without accidentally opening its popup', () => {
render(<Fixture />);
const graph = canvas();
const target = screen.getByRole('button', { name: `${node.title} (technique)` });
fireEvent.pointerDown(target, { pointerId: 1, button: 0, clientX: 200, clientY: 200 });
fireEvent.pointerMove(graph, { pointerId: 1, clientX: 280, clientY: 240 });
fireEvent.pointerUp(graph, { pointerId: 1 });
fireEvent.click(target);
expect(camera().x).toBeCloseTo(80);
expect(camera().y).toBeCloseTo(40);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.pointerDown(target, { pointerId: 2, button: 0, clientX: 280, clientY: 240 });
fireEvent.pointerUp(target, { pointerId: 2 });
fireEvent.click(target);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('supports touch pinch zoom and keyboard navigation without toolbar buttons', () => {
render(<Fixture />);
const graph = canvas();
fireEvent.pointerDown(graph, { pointerId: 1, pointerType: 'touch', button: 0, clientX: 100, clientY: 200 });
fireEvent.pointerDown(graph, { pointerId: 2, pointerType: 'touch', button: 0, clientX: 300, clientY: 200 });
fireEvent.pointerMove(graph, { pointerId: 2, pointerType: 'touch', clientX: 400, clientY: 200 });
expect(camera().zoom).toBeCloseTo(1.5);
fireEvent.pointerCancel(graph, { pointerId: 1 });
fireEvent.pointerCancel(graph, { pointerId: 2 });
fireEvent.keyDown(graph, { key: 'Home' });
expect(camera()).toEqual({ x: 0, y: 0, zoom: 1 });
fireEvent.keyDown(graph, { key: 'ArrowRight' });
expect(camera().x).toBeLessThan(0);
fireEvent.keyDown(graph, { key: '+' });
expect(camera().zoom).toBeGreaterThan(1);
});
+196
View File
@@ -0,0 +1,196 @@
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { Link } from 'react-router';
import type { components } from '../generated/api';
type Graph = components['schemas']['KnowledgeGraphEnvelope'];
export function GraphCanvas({ nodes, edges, drawing, selectedId, onSelect, nodeHref }: {
nodes: Graph['nodes'];
edges: Graph['edges'];
drawing: { viewBox: string; points: Map<string, { x: number; y: number }> };
selectedId: string | null;
onSelect: (id: string | null) => void;
nodeHref: (id: string, project: string) => string;
}) {
const stage = useRef<HTMLDivElement>(null);
const svg = useRef<SVGSVGElement>(null);
const [camera, setCamera] = useState({ x: 0, y: 0, zoom: 1 });
const [panning, setPanning] = useState(false);
const pointers = useRef(new Map<number, { x: number; y: number; startX: number; startY: number }>());
const dragged = useRef(false);
const popup = useRef<HTMLDivElement>(null);
const nodeElements = useRef(new Map<string, SVGGElement>());
const markerId = useId();
const titleId = useId();
const selected = nodes.find(node => node.node_id === selectedId);
const [, , width, height] = drawing.viewBox.split(' ').map(Number);
// Convert screen positions into SVG coordinates, including letterboxing.
function graphPoint(x: number, y: number) {
const bounds = svg.current!.getBoundingClientRect();
const unit = Math.min(bounds.width / width, bounds.height / height) || 1;
return {
x: (x - bounds.left - (bounds.width - width * unit) / 2) / unit,
y: (y - bounds.top - (bounds.height - height * unit) / 2) / unit,
};
}
useEffect(() => {
const canvas = svg.current;
if (!canvas) return;
// React's delegated wheel listener is passive; this surface owns its zoom.
const wheel = (event: WheelEvent) => {
event.preventDefault();
const bounds = canvas.getBoundingClientRect();
const unit = Math.min(bounds.width / width, bounds.height / height) || 1;
const point = {
x: (event.clientX - bounds.left - (bounds.width - width * unit) / 2) / unit,
y: (event.clientY - bounds.top - (bounds.height - height * unit) / 2) / unit,
};
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? bounds.height : 1);
setCamera(previous => {
const zoom = Math.max(.3, Math.min(8, previous.zoom * Math.exp(-Math.max(-100, Math.min(100, delta)) * .002)));
const ratio = zoom / previous.zoom;
return { zoom, x: point.x - (point.x - previous.x) * ratio, y: point.y - (point.y - previous.y) * ratio };
});
};
canvas.addEventListener('wheel', wheel, { passive: false });
return () => canvas.removeEventListener('wheel', wheel);
}, [width, height]);
function dismiss() {
onSelect(null);
if (selectedId) nodeElements.current.get(selectedId)?.focus();
}
useLayoutEffect(() => {
if (!selected) return;
const placePopup = () => {
const card = popup.current;
const container = stage.current;
const circle = nodeElements.current.get(selected.node_id)?.querySelector('circle');
if (!card || !container || !circle) return;
const bounds = container.getBoundingClientRect();
const anchor = circle.getBoundingClientRect();
const gap = 12;
const maxLeft = Math.max(gap, bounds.width - card.offsetWidth - gap);
let left = anchor.right - bounds.left + gap;
if (left > maxLeft) left = anchor.left - bounds.left - card.offsetWidth - gap;
card.style.left = `${Math.max(gap, Math.min(maxLeft, left))}px`;
card.style.top = `${Math.max(gap, Math.min(bounds.height - card.offsetHeight - gap, anchor.top - bounds.top - gap))}px`;
};
placePopup();
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(placePopup);
if (stage.current) observer?.observe(stage.current);
if (popup.current) observer?.observe(popup.current);
window.addEventListener('resize', placePopup);
return () => { observer?.disconnect(); window.removeEventListener('resize', placePopup); };
}, [selected, drawing, camera]);
useEffect(() => { if (selectedId) popup.current?.focus(); }, [selectedId]);
useEffect(() => {
if (!selectedId) return;
const outside = (event: PointerEvent) => {
if (event.target instanceof Node && !stage.current?.contains(event.target)) onSelect(null);
};
document.addEventListener('pointerdown', outside);
return () => document.removeEventListener('pointerdown', outside);
}, [selectedId, onSelect]);
return (
<div className="graph-stage" ref={stage} onKeyDown={event => {
if (event.key === 'Escape' && selectedId) { event.stopPropagation(); dismiss(); }
}}>
<svg ref={svg} className="graph-canvas" data-panning={panning} viewBox={drawing.viewBox} width="100%" height="100%" preserveAspectRatio="xMidYMid meet" aria-label="Knowledge graph. Drag to move, scroll or pinch to zoom. Arrow keys move; plus and minus zoom; Home resets." tabIndex={0}
onPointerDown={event => {
if (event.button !== 0) return;
if (!pointers.current.size) dragged.current = false;
pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY, startX: event.clientX, startY: event.clientY });
}}
onPointerMove={event => {
const pointer = pointers.current.get(event.pointerId);
if (!pointer) return;
const before = [...pointers.current.values()].slice(0, 2).map(item => ({ x: item.x, y: item.y }));
const wasDragged = dragged.current;
pointer.x = event.clientX; pointer.y = event.clientY;
if (!dragged.current && pointers.current.size === 1 && Math.hypot(pointer.x - pointer.startX, pointer.y - pointer.startY) < 4) return;
dragged.current = true;
setPanning(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
const after = [...pointers.current.values()].slice(0, 2);
if (!wasDragged && before.length === 1) before[0] = { x: pointer.startX, y: pointer.startY };
const midpoint = (points: Array<{ x: number; y: number }>) => graphPoint(points.reduce((sum, item) => sum + item.x, 0) / points.length, points.reduce((sum, item) => sum + item.y, 0) / points.length);
const from = midpoint(before); const to = midpoint(after);
const distance = (points: Array<{ x: number; y: number }>) => Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
const factor = before.length === 2 && distance(before) > 0 ? distance(after) / distance(before) : 1;
setCamera(previous => {
const zoom = Math.max(.3, Math.min(8, previous.zoom * factor));
const ratio = zoom / previous.zoom;
return { zoom, x: to.x - (from.x - previous.x) * ratio, y: to.y - (from.y - previous.y) * ratio };
});
}}
onPointerUp={event => {
pointers.current.delete(event.pointerId);
if (!pointers.current.size) setPanning(false);
}}
onPointerCancel={event => { pointers.current.delete(event.pointerId); setPanning(false); }}
onLostPointerCapture={event => { pointers.current.delete(event.pointerId); if (!pointers.current.size) setPanning(false); }}
onClickCapture={event => {
if (dragged.current) { event.preventDefault(); event.stopPropagation(); dragged.current = false; }
}}
onKeyDown={event => {
if (event.target !== event.currentTarget) return;
const movement: Record<string, [number, number]> = { ArrowLeft: [40, 0], ArrowRight: [-40, 0], ArrowUp: [0, 40], ArrowDown: [0, -40] };
if (event.key in movement) {
event.preventDefault();
const [x, y] = movement[event.key];
setCamera(previous => ({ ...previous, x: previous.x + x, y: previous.y + y }));
} else if (['+', '=', '-', '_'].includes(event.key)) {
event.preventDefault();
setCamera(previous => {
const zoom = Math.max(.3, Math.min(8, previous.zoom * (event.key === '+' || event.key === '=' ? 1.2 : 1 / 1.2)));
const ratio = zoom / previous.zoom;
return { zoom, x: width / 2 - (width / 2 - previous.x) * ratio, y: height / 2 - (height / 2 - previous.y) * ratio };
});
} else if (event.key === 'Home') { event.preventDefault(); setCamera({ x: 0, y: 0, zoom: 1 }); }
}}
onClick={event => {
if (!(event.target as Element).closest('.graph-node')) onSelect(null);
}}>
<g className="graph-camera" transform={`translate(${camera.x} ${camera.y}) scale(${camera.zoom})`}>
{edges.map((edge, index) => {
const a = drawing.points.get(edge.source.id); const b = drawing.points.get(edge.target.id);
if (!a || !b) return null;
return <g key={`${edge.edge_id}:${index}`} className="graph-link graph-link--accepted" aria-hidden="true">
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} markerEnd={`url(#${markerId})`} />
<text x={(a.x + b.x) / 2} y={(a.y + b.y) / 2 - 7} className="graph-link-label">{edge.edge_type.replaceAll('_', ' ')}</text>
</g>;
})}
<defs><marker id={markerId} viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" /></marker></defs>
{nodes.map(node => {
const point = drawing.points.get(node.node_id);
if (!point) return null;
return <g key={node.node_id} ref={element => {
if (element) nodeElements.current.set(node.node_id, element);
else nodeElements.current.delete(node.node_id);
}} className={`graph-node graph-node--${node.node_type}${selectedId === node.node_id ? ' graph-node--selected' : ''}`}
role="button" tabIndex={0} aria-label={`${node.title} (${node.node_type})`} aria-haspopup="dialog" aria-expanded={selectedId === node.node_id}
onClick={() => onSelect(node.node_id)} onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onSelect(node.node_id); }
}}>
<circle cx={point.x} cy={point.y} r="12" />
<text className="graph-node__title" x={point.x + 18} y={point.y + 4} aria-hidden="true">{node.title.length > 56 ? `${node.title.slice(0, 56)}…` : node.title}</text>
</g>;
})}
</g>
</svg>
{selected ? <div ref={popup} className="graph-popup" role="dialog" aria-labelledby={titleId} tabIndex={-1}>
<div className="graph-popup__header"><p className="eyebrow">{selected.node_type}</p><button className="graph-popup__close" type="button" aria-label="Close node preview" onClick={dismiss}>×</button></div>
<h3 id={titleId}>{selected.title}</h3>
<p>{selected.summary}</p>
<Link to={nodeHref(selected.node_id, selected.project_id)}>Open node →</Link>
</div> : null}
</div>
);
}
+44 -39
View File
@@ -1,60 +1,65 @@
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, it, vi } from 'vitest';
import Admin from './Admin';
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import Admin from "./Admin";
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
it('creates a project from the admin form and makes it available for account and agent grants', async () => {
it("creates a project from the admin form and makes it available for account and agent grants", async () => {
let created = false;
let conflict = true;
const project = {
schema_version: 1, project_id: 'my-research', title: 'My research', status: 'active',
north_star: 'Investigate useful techniques.', key_questions: ['What works?', 'Under what conditions?'],
owners: ['user:admin'], autonomy_policy_id: 'my-research-policy',
schema_version: 1, project_id: "my-research", title: "My research", status: "active",
north_star: "Investigate useful techniques.", key_questions: ["What works?", "Under what conditions?"],
owners: ["user:admin"], autonomy_policy_id: "my-research-policy",
};
const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) };
if (path === "/v1/session" && init?.method === "GET") return { ok: false, status: 401, json: async () => ({}) };
let result: unknown = [];
if (path === '/v1/users/session') result = { csrf_token: 'csrf-fixture' };
if (path === '/v1/me') result = { principal_id: 'user:admin' };
if (path === '/v1/admin/projects') {
if (init?.method === 'POST') {
if (conflict) return { ok: false, status: 409, json: async () => ({ detail: 'Project ID is already registered' }) };
if (path === "/v1/users/session") result = { csrf_token: "csrf-fixture" };
if (path === "/v1/me") result = { principal_id: "user:admin" };
if (path === "/v1/admin/projects") {
if (init?.method === "POST") {
if (conflict) return { ok: false, status: 409, json: async () => ({ detail: "Project ID is already registered" }) };
created = true; result = project;
} else result = created ? [project] : [];
}
return { ok: true, json: async () => result };
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
render(<Admin />);
await user.type(await screen.findByLabelText('Username'), 'admin');
await user.type(screen.getByLabelText('Password'), 'password-fixture');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
const projects = await screen.findByRole('region', { name: 'Projects' });
expect(within(projects).getByText('No projects yet. Create your first project below.')).toBeVisible();
expect(within(projects).getByLabelText(/Owner ID/)).toHaveValue('user:admin');
await user.type(within(projects).getByLabelText('Project name'), 'My research');
expect(within(projects).getByLabelText(/Project ID/)).toHaveValue('my-research');
await user.type(within(projects).getByLabelText('Project purpose'), 'Investigate useful techniques.');
await user.type(within(projects).getByLabelText('Key research questions'), 'What works?\nUnder what conditions?');
await user.click(within(projects).getByRole('button', { name: 'Create project' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Project ID is already registered');
expect(within(projects).getByLabelText('Project name')).toHaveValue('My research');
await user.type(await screen.findByLabelText("Username"), "admin");
await user.type(screen.getByLabelText("Password"), "password-fixture");
await user.click(screen.getByRole("button", { name: "Sign in" }));
const projects = await screen.findByRole("region", { name: "Projects" });
expect(within(projects).getByText("No projects yet. Create your first project below.")).toBeVisible();
await user.click(within(projects).getByRole("button", { name: "Create project" }));
const dialog = await screen.findByRole("dialog", { name: "Create a project" });
expect(within(dialog).getByLabelText(/Owner ID/)).toHaveValue("user:admin");
await user.type(within(dialog).getByLabelText("Project name"), "My research");
expect(within(dialog).getByLabelText(/Project ID/)).toHaveValue("my-research");
await user.type(within(dialog).getByLabelText("Project purpose"), "Investigate useful techniques.");
await user.type(within(dialog).getByLabelText("Key research questions"), "What works?\nUnder what conditions?");
await user.click(within(dialog).getByRole("button", { name: "Create project" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Project ID is already registered");
expect(within(dialog).getByLabelText("Project name")).toHaveValue("My research");
conflict = false;
await user.click(within(projects).getByRole('button', { name: 'Create project' }));
await screen.findByText('Created My research. It is now available for user access and agent keys.');
expect(fetchMock).toHaveBeenCalledWith('/v1/admin/projects', expect.objectContaining({
method: 'POST', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'csrf-fixture' }),
await user.click(within(dialog).getByRole("button", { name: "Create project" }));
await screen.findByText("Created My research. It is now available for user access and agent keys.");
expect(fetchMock).toHaveBeenCalledWith("/v1/admin/projects", expect.objectContaining({
method: "POST", headers: expect.objectContaining({ "X-GEWU-CSRF": "csrf-fixture" }),
body: JSON.stringify({
project: { project_id: 'my-research', title: 'My research', status: 'active', owners: ['user:admin'], north_star: 'Investigate useful techniques.', key_questions: ['What works?', 'Under what conditions?'], autonomy_policy_id: 'my-research-policy', default_includes: [] },
policy: { policy_id: 'my-research-policy', policy_version: 1, project_id: 'my-research', automatic_source_registration: true, automatic_node_types: ['finding', 'problem', 'technique', 'case', 'entity', 'event', 'concept', 'datapoint', 'synthesis'], require_supported_assessment: true },
project: { project_id: "my-research", title: "My research", status: "active", owners: ["user:admin"], north_star: "Investigate useful techniques.", key_questions: ["What works?", "Under what conditions?"], autonomy_policy_id: "my-research-policy", default_includes: [] },
policy: { policy_id: "my-research-policy", policy_version: 1, project_id: "my-research", automatic_source_registration: true, automatic_node_types: ["finding", "problem", "technique", "case", "entity", "event", "concept", "datapoint", "synthesis"], require_supported_assessment: true },
}),
}));
expect(within(projects).getByLabelText('Project name')).toHaveValue('');
await user.click(screen.getByRole('tab', { name: 'Users' }));
expect(within(screen.getByRole('region', { name: 'User accounts' })).getByLabelText('My research')).toBeVisible();
await user.click(screen.getByRole('tab', { name: 'Credentials' }));
expect(within(screen.getByRole('region', { name: 'Create a credential' })).getByLabelText('My research')).toBeVisible();
await user.click(screen.getByRole("tab", { name: "Users" }));
await user.click(await screen.findByRole("button", { name: "Add user" }));
const usersDialog = await screen.findByRole("dialog", { name: "Add user" });
expect(within(usersDialog).getByLabelText("My research")).toBeVisible();
await user.click(screen.getByRole("tab", { name: "Credentials" }));
await user.click(await screen.findByRole("button", { name: "Add credential" }));
const credsDialog = await screen.findByRole("dialog", { name: "Add credential" });
expect(within(credsDialog).getByLabelText("My research")).toBeVisible();
});
+1 -1
View File
@@ -166,7 +166,7 @@ function ProjectsSection({ csrf, projects, currentOwner, onCreated, onError, onM
<label className="admin-wide">Key research questions<textarea required maxLength={16000} rows={4} value={editQuestions} onChange={event => setEditQuestions(event.target.value)} placeholder="One question per line" /></label>
<div className="admin-wide"><button disabled={busy}>{busy ? 'Saving…' : 'Save changes'}</button> <button className="text-button" type="button" disabled={busy} onClick={() => setEditing(null)}>Cancel</button></div>
</form>}
</li>)}</ul> : <p>No projects yet.</p>}
</li>)}</ul> : <p>No projects yet. Create your first project below.</p>}
<AdminDialog open={Boolean(viewing)} title={viewing?.title ?? 'Project details'} onClose={() => setViewing(null)} returnFocusRef={viewButtonRef}>
{viewing && <>
<p className="muted-copy">{viewing.project_id} · {viewing.status}</p>
+407
View File
@@ -0,0 +1,407 @@
import React, { useCallback, useEffect, useState } from 'react';
import type { components } from '../generated/api';
type SourceRevisionRecord = components['schemas']['SourceRevisionRecord'];
type SourceRelatedNode = components['schemas']['SourceRelatedNode'];
type SourceRelatedNodesEnvelope = components['schemas']['SourceRelatedNodesEnvelope'];
type ReadEnvelope = components['schemas']['ReadEnvelope'];
interface SourceDetailProps {
project: string;
revisionId: string;
onBack: () => void;
onSelectNode: (nodeId: string, projectId: string) => void;
csrfToken?: string;
includeShared?: boolean;
}
function safeExternalUrl(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.toString();
}
} catch {
return null;
}
return null;
}
export function SourceDetail({
project,
revisionId,
onBack,
onSelectNode,
csrfToken,
includeShared,
}: SourceDetailProps): React.JSX.Element {
const [source, setSource] = useState<SourceRevisionRecord | null>(null);
const [sourceLoading, setSourceLoading] = useState<boolean>(true);
const [sourceError, setSourceError] = useState<string | null>(null);
const [nodes, setNodes] = useState<SourceRelatedNode[]>([]);
const [totalNodes, setTotalNodes] = useState<number>(0);
const [nodesLoading, setNodesLoading] = useState<boolean>(true);
const [nodesError, setNodesError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [pageCursors, setPageCursors] = useState<(string | null)[]>([null]);
const [query, setQuery] = useState<string>('');
const [nodeType, setNodeType] = useState<string>('');
const effectiveProject = source?.ingested_for?.includes(project)
? project
: (source?.ingested_for?.[0] ?? project);
// Load source metadata with fallback to shared
useEffect(() => {
let active = true;
setSourceLoading(true);
setSourceError(null);
const headers: Record<string, string> = { Accept: 'application/json' };
if (csrfToken) {
headers['X-GEWU-CSRF'] = csrfToken;
}
const fetchSource = async () => {
let res = await fetch(`/v1/projects/${encodeURIComponent(project)}/sources/${encodeURIComponent(revisionId)}`, {
headers,
credentials: 'same-origin',
});
if (res.status === 404 && project !== 'shared') {
const sharedRes = await fetch(`/v1/projects/shared/sources/${encodeURIComponent(revisionId)}`, {
headers,
credentials: 'same-origin',
});
if (sharedRes.ok) {
res = sharedRes;
}
}
if (!res.ok) {
throw new Error(`Failed to load source (${res.status})`);
}
return res.json() as Promise<ReadEnvelope>;
};
fetchSource()
.then((data) => {
if (!active) return;
const found = (data.data?.[0] as unknown as SourceRevisionRecord) ?? null;
setSource(found);
setSourceLoading(false);
})
.catch((err) => {
if (!active) return;
setSourceError(err instanceof Error ? err.message : 'Error loading source');
setSourceLoading(false);
});
return () => {
active = false;
};
}, [project, revisionId, csrfToken]);
// Load citing/referencing nodes
const loadNodes = useCallback((nextCursor: string | null = null, resetPage = true) => {
setNodesLoading(true);
setNodesError(null);
const headers: Record<string, string> = { Accept: 'application/json' };
if (csrfToken) {
headers['X-GEWU-CSRF'] = csrfToken;
}
const targetProject = effectiveProject;
const params = new URLSearchParams();
params.set('limit', '20');
if (includeShared) {
params.set('include_shared', 'true');
}
if (nextCursor) params.set('cursor', nextCursor);
if (query.trim()) params.set('query', query.trim());
if (nodeType) params.set('node_type', nodeType);
const fetchRelatedNodes = async () => {
let res = await fetch(
`/v1/projects/${encodeURIComponent(targetProject)}/sources/${encodeURIComponent(revisionId)}/nodes?${params.toString()}`,
{ headers, credentials: 'same-origin' },
);
if (res.status === 404 && targetProject !== 'shared') {
const sharedRes = await fetch(
`/v1/projects/shared/sources/${encodeURIComponent(revisionId)}/nodes?${params.toString()}`,
{ headers, credentials: 'same-origin' },
);
if (sharedRes.ok) {
res = sharedRes;
}
}
if (!res.ok) {
throw new Error(`Failed to load related nodes (${res.status})`);
}
return res.json() as Promise<SourceRelatedNodesEnvelope>;
};
fetchRelatedNodes()
.then((data) => {
setNodes(data.nodes ?? []);
setTotalNodes(data.total ?? 0);
setCursor(data.next_cursor ?? null);
if (resetPage) {
setPageCursors([null]);
}
setNodesLoading(false);
})
.catch((err) => {
setNodesError(err instanceof Error ? err.message : 'Error loading related nodes');
setNodesLoading(false);
});
}, [effectiveProject, revisionId, csrfToken, query, nodeType, includeShared]);
useEffect(() => {
loadNodes(null, true);
}, [loadNodes]);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
loadNodes(null, true);
};
const handlePrevPage = () => {
if (pageCursors.length <= 1) return;
const nextCursors = pageCursors.slice(0, -1);
setPageCursors(nextCursors);
loadNodes(nextCursors.at(-1) ?? null, false);
};
const handleNextPage = () => {
if (!cursor) return;
setPageCursors((prev) => [...prev, cursor]);
loadNodes(cursor, false);
};
return (
<article className="source-detail-view" aria-labelledby="source-detail-heading">
<div className="source-detail-header">
<button type="button" className="quiet-button back-button" onClick={onBack}>
← Back to Sources
</button>
</div>
{sourceLoading ? (
<p className="state-card" role="status">Loading source record…</p>
) : sourceError ? (
<div className="state-card" role="alert">
<h3>Source record unavailable</h3>
<p>{sourceError}</p>
<button type="button" onClick={() => onBack()}>Return to sources</button>
</div>
) : source ? (
<>
<header className="source-meta-card">
<div className="source-meta-badges">
<span className="badge badge--type">{source.source_type}</span>
<span className="badge badge--grade">{source.reliability_grade} reliability</span>
<span className="badge badge--status">{source.archive_status}</span>
</div>
<h1 id="source-detail-heading" className="source-detail-title">{source.title}</h1>
<p className="source-detail-publisher">
{source.publisher ? <strong>{source.publisher}</strong> : 'Publisher not recorded'}
{source.captured_at ? ` · Captured on ${source.captured_at.slice(0, 10)}` : ''}
{source.license ? ` · License: ${source.license}` : ''}
</p>
<div className="source-actions">
{safeExternalUrl(source.original_url ?? '') ? (
<a
className="action-button action-button--primary"
href={safeExternalUrl(source.original_url ?? '') ?? undefined}
target="_blank"
rel="noreferrer"
>
Open original URL ↗
</a>
) : null}
{source.archive_status === 'archived' ? (
<a
className="action-button action-button--secondary"
href={`/v1/projects/${encodeURIComponent(effectiveProject)}/sources/${encodeURIComponent(source.revision_id)}/artifact`}
download
>
Download archived artifact
</a>
) : (
<span className="muted-copy">No local artifact available</span>
)}
</div>
<details className="source-tech-details">
<summary>Technical & extraction metadata</summary>
<dl className="tech-meta-grid">
<div>
<dt>Source Revision ID</dt>
<dd><code>{source.revision_id}</code></dd>
</div>
<div>
<dt>Project</dt>
<dd>{project}</dd>
</div>
<div>
<dt>Media Type</dt>
<dd>{source.media_type || 'Unknown'}</dd>
</div>
<div>
<dt>Artifact SHA-256</dt>
<dd><code>{source.artifact_sha256 || 'None'}</code></dd>
</div>
<div>
<dt>Byte Length</dt>
<dd>{source.byte_length ? `${source.byte_length.toLocaleString()} bytes` : '0'}</dd>
</div>
<div>
<dt>Capture Method</dt>
<dd>{source.capture_method || 'Unknown'}</dd>
</div>
</dl>
</details>
</header>
<section className="source-related-nodes" aria-labelledby="related-nodes-heading">
<div className="section-heading">
<div>
<p className="eyebrow">RESEARCH EVIDENCE & CITATIONS</p>
<h2 id="related-nodes-heading">
Knowledge referencing this source
</h2>
<p className="scope-note">
Nodes that cite this source in their evidence claims or connect via knowledge graph edges.
</p>
</div>
<span className="result-count">{totalNodes} referencing {totalNodes === 1 ? 'node' : 'nodes'}</span>
</div>
<form className="source-nodes-filter" onSubmit={handleSearchSubmit}>
<input
type="search"
aria-label="Filter referencing nodes"
placeholder="Filter by keyword…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<select
aria-label="Filter by node type"
value={nodeType}
onChange={(e) => setNodeType(e.target.value)}
>
<option value="">All node types</option>
<option value="observation">Observation</option>
<option value="claim">Claim</option>
<option value="concept">Concept</option>
<option value="problem">Problem</option>
<option value="technique">Technique</option>
<option value="case">Case</option>
<option value="entity">Entity</option>
<option value="event">Event</option>
<option value="synthesis">Synthesis</option>
<option value="datapoint">Datapoint</option>
</select>
<button type="submit" disabled={nodesLoading}>Filter</button>
{(query || nodeType) && (
<button
type="button"
className="quiet-button"
onClick={() => {
setQuery('');
setNodeType('');
}}
>
Reset
</button>
)}
</form>
{nodesLoading ? (
<p className="state-card" role="status">Loading referencing knowledge…</p>
) : nodesError ? (
<div className="state-card" role="alert">
<h3>Could not load referencing nodes</h3>
<p>{nodesError}</p>
<button type="button" onClick={() => loadNodes(null, true)}>Retry</button>
</div>
) : nodes.length === 0 ? (
<div className="state-card">
<h3>No referencing knowledge nodes found</h3>
<p>
{query || nodeType
? 'No nodes match your filter criteria for this source.'
: 'This source record is not yet referenced by any accepted knowledge notes in this project scope.'}
</p>
</div>
) : (
<>
<ul className="result-list" aria-label="Referencing knowledge nodes">
{nodes.map(({ node, relation_basis }) => (
<li key={node.node_id}>
<div
className="result-row"
role="button"
tabIndex={0}
onClick={() => onSelectNode(node.node_id, node.project_id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectNode(node.node_id, node.project_id);
}
}}
>
<div className="result-row__meta">
<span className={`node-type-tag node-type--${node.node_type}`}>
{node.node_type}
</span>
<span>·</span>
<span className={`basis-badge basis-badge--${relation_basis}`}>
{relation_basis === 'citation'
? 'Cited in claim'
: relation_basis === 'edge'
? 'Graph relationship'
: 'Cited & Graph edge'}
</span>
</div>
<strong>{node.title}</strong>
<span className="result-row__summary">{node.summary}</span>
<span className="result-row__foot">
As of {node.as_of ?? 'undated'} · {node.lifecycle}
</span>
</div>
</li>
))}
</ul>
<div className="pagination-controls">
<button
type="button"
disabled={nodesLoading || pageCursors.length < 2}
onClick={handlePrevPage}
>
Previous
</button>
<span>
{nodes.length
? `${(pageCursors.length - 1) * 20 + 1}–${(pageCursors.length - 1) * 20 + nodes.length} of ${totalNodes}`
: `0 of ${totalNodes}`}
</span>
<button
type="button"
disabled={nodesLoading || !cursor}
onClick={handleNextPage}
>
Next
</button>
</div>
</>
)}
</section>
</>
) : null}
</article>
);
}
+642 -115
View File
@@ -1,3 +1,4 @@
[hidden] { display: none !important; }
:root {
font-family: "Source Sans 3", "Segoe UI", sans-serif;
color: var(--text-primary);
@@ -20,10 +21,14 @@
--text-nav: #3e443e;
--text-nav-muted: #62675f;
--text-warning: #67410c;
--border-subtle: #d6d0c3;
--border-control: #777c71;
--accent-link: #315d50;
--accent-strong: #25493f;
--border-subtle: #e4dfd5;
--border-control: #9c978b;
--accent-link: #334155;
--accent-strong: #0f172a;
--obsidian-pill-bg: #ece8df;
--obsidian-pill-text: #374151;
--obsidian-type-tag-bg: #e5e1d8;
--obsidian-type-tag-text: #475569;
--status-caution: #85520b;
--status-error: #a1342b;
--space-1: 4px;
@@ -70,97 +75,201 @@ a:hover { color: var(--accent-strong); }
border-right: 1px solid var(--border-subtle);
}
.brand { display: flex; align-items: center; gap: 12px; color: inherit; font-size: 1.1rem; font-weight: 750; letter-spacing: 0.03em; text-decoration: none; }
.brand__mark { display: grid; width: 38px; height: 38px; place-items: center; color: var(--surface-paper); background: var(--accent-link); font-family: "Source Serif 4", Georgia, serif; font-size: 1.55rem; font-weight: 500; }
.brand__mark { display: grid; width: 36px; height: 36px; place-items: center; color: var(--surface-paper); background: #1e293b; border-radius: 4px; font-family: "Source Serif 4", Georgia, serif; font-size: 1.45rem; font-weight: 600; }
.brand__sub { display: block; margin-top: 3px; color: var(--text-muted); font-size: 0.63rem; letter-spacing: 0.12em; }
.rail-nav { display: grid; gap: 6px; margin-top: 64px; }
.rail-nav__item { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 9px 11px; color: var(--text-nav); font-size: 0.98rem; text-decoration: none; border-radius: 3px; }
.rail-nav__item { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 9px 11px; color: var(--text-nav); font-size: 0.98rem; text-align: left; text-decoration: none; border: 0; background: transparent; border-radius: 4px; }
.rail-nav__item > span:first-child { width: 20px; font-size: 1.2rem; text-align: center; }
.rail-nav__item--active { color: var(--surface-paper); background: var(--accent-link); }
.rail-nav__item--active:hover { color: white; background: var(--accent-strong); }
.rail-nav__item--active { color: var(--text-primary); background: var(--surface-subtle); font-weight: 600; }
.rail-nav__item--active:hover { color: var(--text-primary); background: var(--surface-inset); }
.rail-nav__item--muted { color: var(--text-nav-muted); }
.rail__footer { margin-top: auto; padding-top: 20px; border-top: 1px solid var(--border-subtle); }
.rail__footer > p:nth-child(2) { overflow-wrap: anywhere; margin: 8px 0 4px; font-size: 0.95rem; font-weight: 650; }
.scope-note, .muted-copy { color: var(--text-muted); font-size: 0.88rem; line-height: 1.5; }
.workspace { max-width: 1580px; min-height: 100vh; margin-left: 224px; padding: 42px clamp(24px, 4vw, 64px) 24px; }
.topbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding-bottom: 26px; border-bottom: 1px solid var(--border-subtle); }
.workspace { max-width: 1580px; min-height: 100vh; margin-left: 224px; padding: 32px clamp(20px, 3.5vw, 48px) 24px; }
.topbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding-bottom: 22px; border-bottom: 1px solid var(--border-subtle); }
.topbar--article { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--border-subtle); }
.topbar__left { display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1; }
.topbar__back-btn { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 4px; border: 1px solid var(--border-subtle); background: var(--surface-paper); color: var(--text-secondary); text-decoration: none; font-size: 1.1rem; line-height: 1; flex-shrink: 0; }
.topbar__back-btn:hover { background: var(--surface-subtle); color: var(--text-primary); text-decoration: none; }
.topbar__article-title { font-size: 1.2rem; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin: 0; }
.topbar__project-select { padding: 4px 10px; border-radius: 4px; border: 1px solid var(--border-subtle); background: var(--surface-paper); color: var(--text-secondary); font-size: 0.85rem; font-weight: 500; cursor: pointer; flex-shrink: 0; }
.topbar__project-select:hover { border-color: var(--border-control); }
.eyebrow { margin: 0; color: var(--accent-link); font-size: 0.73rem; font-weight: 750; letter-spacing: 0.1em; }
.topbar h1 { margin: 10px 0 0; font-family: "Source Serif 4", Georgia, serif; font-size: clamp(2rem, 3.2vw, 3.15rem); font-weight: 500; letter-spacing: -0.025em; line-height: 1.08; }
.quiet-button, .text-button, .more-button { min-height: 44px; border: 0; background: transparent; color: var(--accent-link); font-weight: 650; }
.quiet-button { padding: 8px 13px; border: 1px solid var(--border-control); border-radius: 3px; }
.quiet-button:hover, .more-button:hover { background: var(--surface-subtle); }
.quiet-button, .text-button, .more-button { min-height: 34px; border: 0; background: transparent; color: var(--accent-link); font-weight: 500; }
.quiet-button { padding: 5px 12px; border: 1px solid var(--border-subtle); border-radius: 4px; color: var(--text-secondary); }
.quiet-button:hover, .more-button:hover { background: var(--surface-subtle); border-color: var(--border-control); color: var(--text-primary); }
.connect-card { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 390px); gap: 48px; max-width: 940px; margin: 72px auto; padding: clamp(24px, 5vw, 56px); background: var(--surface-paper); border: 1px solid var(--border-subtle); }
.connect-card h2 { max-width: 14ch; margin: 10px 0 14px; font-family: "Source Serif 4", Georgia, serif; font-size: 2.2rem; font-weight: 500; line-height: 1.1; }
.connect-card p:not(.eyebrow) { max-width: 47ch; color: var(--text-muted); line-height: 1.55; }
.connect-form { display: grid; align-content: center; gap: 10px; }
.connect-form label, .scope-field > span { color: var(--text-muted); font-size: 0.8rem; font-weight: 700; letter-spacing: 0.06em; }
.connect-form input, .search-field input, .scope-field select { min-width: 0; min-height: 48px; padding: 10px 12px; color: inherit; background: var(--surface-input); border: 1px solid var(--border-control); border-radius: 2px; }
.connect-form button, .search-form > button { min-height: 48px; padding: 10px 20px; color: white; background: var(--accent-link); border: 1px solid var(--accent-link); border-radius: 2px; font-weight: 700; }
.connect-form button:hover:not(:disabled), .search-form > button:hover:not(:disabled) { background: var(--accent-strong); }
.connect-form input, .search-field input, .scope-field select { min-width: 0; min-height: 38px; padding: 8px 12px; color: inherit; background: var(--surface-input); border: 1px solid var(--border-subtle); border-radius: 4px; font-size: 0.92rem; }
.connect-form button, .search-form > button { min-height: 38px; padding: 7px 18px; color: var(--surface-paper); background: #262c36; border: 1px solid #262c36; border-radius: 4px; font-weight: 600; font-size: 0.9rem; }
.connect-form button:hover:not(:disabled), .search-form > button:hover:not(:disabled) { background: #131820; border-color: #131820; }
.search-panel { margin: 27px 0 31px; padding: 18px 20px 14px; background: var(--surface-paper); border: 1px solid var(--border-subtle); }
.search-panel { margin: 22px 0 26px; padding: 16px 18px 14px; background: var(--surface-paper); border: 1px solid var(--border-subtle); border-radius: 6px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03); }
.search-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(150px, 200px) minmax(150px, 200px) auto auto; gap: 12px; align-items: end; }
.search-field { display: flex; align-items: center; gap: 10px; min-width: 0; min-height: 48px; padding-left: 13px; color: var(--text-muted); background: var(--surface-input); border: 1px solid var(--border-control); }
.search-field { display: flex; align-items: center; gap: 10px; min-width: 0; min-height: 38px; padding-left: 12px; color: var(--text-muted); background: var(--surface-input); border: 1px solid var(--border-subtle); border-radius: 4px; }
.search-field > span { font-size: 1.35rem; }
.search-field input { width: 100%; border: 0; outline: 0; }
.search-clear-btn { background: transparent; border: 0; color: var(--text-muted); font-size: 1.2rem; line-height: 1; padding: 0 8px; cursor: pointer; }
.search-clear-btn:hover { color: var(--text-primary); }
.scope-field { display: grid; gap: 5px; }
.scope-field select { width: 100%; padding-block: 7px; }
.stale-filter { display: flex; min-height: 48px; align-items: center; gap: 8px; color: var(--text-secondary); font-size: 0.9rem; }
.stale-filter input { width: 20px; height: 20px; accent-color: var(--accent-link); }
.facet-filters { display: grid; grid-column: 1 / -1; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 0; padding: 12px; border: 1px solid var(--border-subtle); }
.facet-disclosure { grid-column: 1 / -1; }
.facet-disclosure > summary { cursor: pointer; color: var(--accent-link); font-weight: 700; }
.facet-disclosure > summary span { margin-left: 6px; color: var(--text-muted); }
.facet-choice { min-width: 0; margin: 0; padding: 8px; border: 1px solid var(--border-subtle); }
.facet-choice legend { padding-inline: 4px; color: var(--text-muted); font-weight: 700; }
.facet-choice > input { margin-bottom: 6px; }
.facet-choice__options { max-height: 150px; overflow: auto; }
.facet-choice__options label { display: flex; align-items: center; gap: 7px; padding: 3px 0; font-weight: 500; }
.facet-choice__options input[type="checkbox"] { width: 16px; min-height: 16px; margin: 0; }
.facet-choice__options small { margin-left: auto; color: var(--text-muted); }
.facet-chip { margin: 5px 4px 0 0; padding: 3px 7px; border: 1px solid var(--border-control); border-radius: 20px; color: var(--text-main); background: var(--surface-input); cursor: pointer; }
.facet-filters legend { padding-inline: 5px; color: var(--accent-link); font-size: 0.72rem; font-weight: 750; letter-spacing: 0.1em; }
.facet-filters label { display: grid; gap: 5px; min-width: 0; color: var(--text-muted); font-size: 0.8rem; font-weight: 650; }
.facet-filters input { width: 100%; min-width: 0; min-height: 40px; padding: 8px 10px; color: inherit; background: var(--surface-input); border: 1px solid var(--border-control); border-radius: 2px; }
.confidence-options { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; min-width: 0; margin: 0; padding: 7px 9px; border: 1px solid var(--border-control); }
.confidence-options legend { padding-inline: 3px; color: var(--text-muted); font-size: 0.8rem; font-weight: 650; }
.facet-filters .confidence-options label { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; color: inherit; }
.facet-filters .confidence-options input[type="checkbox"] { width: 18px; min-height: 18px; margin: 0; accent-color: var(--accent-link); }
.facet-filters__hint { grid-column: 1 / -1; margin: 0; color: var(--text-muted); font-size: 0.8rem; }
.search-panel > .scope-note { margin: 10px 0 0; }
.reading-grid { display: grid; grid-template-columns: minmax(300px, 0.85fr) minmax(380px, 1.15fr); align-items: start; gap: 30px; }
.results, .reader { min-width: 0; }
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
.section-heading h2 { margin: 6px 0 0; font-family: "Source Serif 4", Georgia, serif; font-size: 1.75rem; font-weight: 500; }
.section-heading--compact { align-items: center; margin-bottom: 10px; }
.section-heading--compact h3 { margin: 4px 0 0; font-family: "Source Serif 4", Georgia, serif; font-size: 1.28rem; font-weight: 500; }
.result-count { flex: 0 0 auto; color: var(--text-muted); font-size: 0.85rem; }
.warning-list { display: grid; gap: 6px; margin: 0 0 12px; padding: 0; list-style: none; }
.warning-list li { display: grid; gap: 3px; padding: 10px 12px; color: var(--text-warning); background: var(--surface-warning); border-left: 2px solid var(--status-caution); font-size: 0.86rem; line-height: 1.4; }
.warning-list strong { text-transform: capitalize; }
.state-card, .reader-empty { padding: 24px; background: var(--surface-paper); border: 1px solid var(--border-subtle); }
.state-card h3 { margin: 0 0 6px; font-family: "Source Serif 4", Georgia, serif; font-size: 1.32rem; font-weight: 500; }
.state-card p, .reader-empty p { margin: 0; color: var(--text-muted); line-height: 1.55; }
.result-list { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.result-row { display: grid; width: 100%; gap: 8px; padding: 16px 17px; color: inherit; text-align: left; background: var(--surface-paper); border: 1px solid var(--border-subtle); border-left: 3px solid transparent; }
.result-row:hover { border-color: var(--border-control); }
.result-row--selected { border-left-color: var(--accent-link); background: var(--surface-selected); }
.project-overview { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 20px; align-items: center; padding: 18px 22px; background: var(--surface-paper); border: 1px solid var(--border-subtle); border-radius: 6px; }
.project-overview h2 { margin: 4px 0 0; font-family: "Source Serif 4", Georgia, serif; font-size: 1.45rem; font-weight: 500; }
.project-overview > p { margin: 0; color: var(--text-secondary); font-size: 0.95rem; }
.project-picker { min-width: 200px; }
.collection-tabs { display: flex; gap: 4px; margin: 20px 0 0; border-bottom: 1px solid var(--border-subtle); }
.collection-tabs button { padding: 9px 18px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--text-muted); font-weight: 600; font-size: 0.92rem; }
.collection-tabs button[aria-current="page"] { border-bottom-color: var(--text-primary); color: var(--text-primary); }
.collection-tabs button:hover:not([aria-current="page"]) { color: var(--text-secondary); }
.graph-view { margin-top: 24px; padding: 24px; background: var(--surface-paper); border: 1px solid var(--border-subtle); border-radius: 6px; }
.graph-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 18px; }
.graph-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; }
.graph-checkbox { display: inline-flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 0.88rem; cursor: pointer; }
.graph-stage { position: relative; width: 100%; border: 1px solid var(--border-subtle); border-radius: 4px; background: var(--surface-inset); overflow: hidden; }
.graph-canvas { width: 100%; height: min(72vh, 760px); display: block; }
.graph-canvas { cursor: grab; touch-action: none; user-select: none; }
.graph-canvas[data-panning="true"], .graph-canvas[data-panning="true"] .graph-node { cursor: grabbing; }
.graph-canvas .graph-link--shared_source line { stroke: #435b56; stroke-width: 1.25; opacity: .2; }
.graph-canvas .graph-link--accepted line { stroke: #173f39; stroke-width: 2; opacity: .2; }
.graph-canvas .graph-link-label { font-size: 9px; fill: var(--text-muted); letter-spacing: 0.04em; }
.graph-canvas .graph-node { cursor: pointer; }
.graph-canvas .graph-node circle { stroke: var(--surface-paper); stroke-width: 2px; }
.graph-canvas .graph-node--selected circle { stroke: #0f172a; stroke-width: 3.5px; }
.graph-canvas .graph-node text { font-size: 11px; fill: var(--text-primary); font-family: inherit; font-weight: 500; }
.graph-canvas .graph-node__title { opacity: 0; pointer-events: none; paint-order: stroke; stroke: var(--surface-inset); stroke-width: 4px; stroke-linejoin: round; }
.graph-canvas .graph-node:hover .graph-node__title,
.graph-canvas .graph-node:focus-visible .graph-node__title { opacity: 1; }
.graph-canvas .graph-node:focus-visible { outline: none; }
.graph-canvas .graph-node:focus-visible circle { stroke: var(--accent-strong); stroke-width: 4px; }
.graph-popup { position: absolute; z-index: 2; top: 12px; left: 12px; width: min(340px, calc(100% - 24px)); max-height: calc(100% - 24px); overflow: auto; padding: var(--space-4); background: var(--surface-paper); border: 1px solid var(--border-control); border-radius: 6px; box-shadow: 0 6px 24px #24272226; overflow-wrap: anywhere; }
.graph-popup__header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
.graph-popup__close { display: grid; place-items: center; width: 44px; height: 44px; flex-shrink: 0; border: 1px solid var(--border-subtle); border-radius: 4px; color: var(--text-primary); background: var(--surface-paper); font-size: 1.4rem; }
.graph-popup h3 { margin: var(--space-2) 0; font-size: 1.1rem; line-height: 1.35; }
.graph-popup > p { font-size: .9rem; line-height: 1.5; }
.graph-popup > a { display: inline-flex; align-items: center; min-height: 44px; }
.graph-list-disclosure { margin: var(--space-3) 0; font-size: .9rem; }
.graph-list-disclosure > summary { cursor: pointer; color: var(--text-muted); }
.graph-node-list { display: grid; gap: var(--space-2); padding-left: var(--space-5); }
.graph-node-list li > span, .graph-node-list button { margin-right: var(--space-2); }
.graph-node--finding circle, .graph-legend__dot.graph-node--finding { fill: #287a9b; background: #287a9b; }
.graph-node--problem circle, .graph-legend__dot.graph-node--problem { fill: #b4473f; background: #b4473f; }
.graph-node--technique circle, .graph-legend__dot.graph-node--technique { fill: #a56918; background: #a56918; }
.graph-node--case circle, .graph-legend__dot.graph-node--case { fill: #8356a3; background: #8356a3; }
.graph-node--entity circle, .graph-legend__dot.graph-node--entity { fill: #397d59; background: #397d59; }
.graph-node--event circle, .graph-legend__dot.graph-node--event { fill: #bd6d32; background: #bd6d32; }
.graph-node--concept circle, .graph-legend__dot.graph-node--concept { fill: #4370ac; background: #4370ac; }
.graph-node--datapoint circle, .graph-legend__dot.graph-node--datapoint { fill: #238377; background: #238377; }
.graph-node--synthesis circle, .graph-legend__dot.graph-node--synthesis { fill: #ae4f79; background: #ae4f79; }
.graph-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin: 14px 0; padding-bottom: 12px; border-bottom: 1px solid var(--border-subtle); font-size: 0.8rem; color: var(--text-muted); }
.graph-legend__item { display: inline-flex; align-items: center; gap: 6px; }
.graph-legend__dot { width: 10px; height: 10px; border-radius: 50%; }
.graph-legend__line { display: inline-block; width: 18px; border-top: 2px solid; }
.graph-legend__line--shared { border-color: #667a76; opacity: .2; }
.graph-legend__line--accepted { border-color: #173f39; border-top-width: 3px; }
.reading-grid { display: grid; grid-template-columns: minmax(360px, 460px) minmax(0, 1fr); gap: 24px; align-items: start; margin-top: 20px; }
.reading-grid--node-page { grid-template-columns: minmax(0, 1fr); max-width: 960px; margin: 0 auto; }
.results { min-width: 0; }
.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.section-heading h2, .section-heading h3 { margin: 2px 0 0; font-family: "Source Serif 4", Georgia, serif; font-size: 1.45rem; font-weight: 500; }
.section-heading--compact { margin-bottom: 8px; }
.section-heading--compact h3 { font-size: 1.15rem; }
.result-count { color: var(--text-muted); font-size: 0.85rem; }
.result-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.result-row { display: grid; width: 100%; gap: 6px; padding: 14px 16px; color: inherit; text-align: left; background: var(--surface-paper); border: 1px solid var(--border-subtle); border-radius: 6px; text-decoration: none; transition: border-color 0.15s ease, background 0.15s ease; cursor: pointer; }
.result-row:hover { border-color: var(--border-control); background: var(--surface-selected); text-decoration: none; }
.result-row--selected { border-color: var(--text-primary); background: var(--surface-selected); }
.result-row__meta, .result-row__foot { color: var(--text-muted); font-size: 0.82rem; }
.result-row__meta { text-transform: capitalize; }
.result-row__meta span, .result-row__foot span { padding-inline: 4px; }
.result-row strong { font-family: "Source Serif 4", Georgia, serif; font-size: 1.22rem; font-weight: 500; line-height: 1.2; }
.result-row strong { font-family: inherit; font-size: 1.1rem; font-weight: 600; line-height: 1.25; color: var(--text-primary); }
.result-row__summary { display: -webkit-box; overflow: hidden; color: var(--text-secondary); line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
.result-row__foot { margin-top: 1px; }
.stale-label { color: var(--text-warning); font-size: 0.82rem; font-weight: 650; }
.more-button { width: 100%; margin-top: 12px; border: 1px solid var(--border-subtle); }
.pagination-controls { display: flex; align-items: center; justify-content: space-between; margin-top: 16px; gap: 12px; }
.pagination-controls button { padding: 6px 14px; border: 1px solid var(--border-subtle); border-radius: 4px; background: var(--surface-paper); color: var(--text-secondary); font-size: 0.86rem; }
.pagination-controls button:hover:not(:disabled) { background: var(--surface-subtle); color: var(--text-primary); }
.more-button { width: 100%; margin-top: 12px; border: 1px solid var(--border-subtle); border-radius: 4px; }
.reader { padding: 22px; background: var(--surface-paper); border: 1px solid var(--border-subtle); }
.reader__header { padding-bottom: 18px; border-bottom: 1px solid var(--border-subtle); }
.reader__header h2 { margin: 8px 0; font-family: "Source Serif 4", Georgia, serif; font-size: clamp(1.7rem, 2.5vw, 2.2rem); font-weight: 500; line-height: 1.13; overflow-wrap: anywhere; }
.reader__header > p:not(.eyebrow) { margin: 0; color: var(--text-secondary); font-size: 1.05rem; line-height: 1.55; }
.reader__header > p.muted-copy { overflow-wrap: anywhere; }
.reader__header .freshness { margin-top: 12px !important; padding: 9px 11px; color: var(--text-warning) !important; background: var(--surface-warning); font-size: 0.9rem !important; }
.evidence-section { padding-top: 18px; }
.evidence-list { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.evidence-card { padding: 15px; background: var(--surface-inset); border-left: 2px solid var(--accent-link); }
.evidence-card blockquote { margin: 0; font-family: "Source Serif 4", Georgia, serif; font-size: 1.13rem; line-height: 1.55; overflow-wrap: anywhere; }
.citation-location { margin: 9px 0 2px; color: var(--text-muted); font-size: 0.83rem; overflow-wrap: anywhere; }
.text-button { min-height: 36px; padding: 4px 0; text-align: left; text-decoration: underline; text-underline-offset: 3px; }
.source-card { margin-top: 18px; padding: 15px; border: 1px solid var(--border-subtle); }
.source-browser { margin-top: 32px; padding: 20px; background: var(--surface-paper); border: 1px solid var(--border-subtle); }
/* Obsidian Reader style */
.reader { padding: 32px 36px; background: var(--surface-paper); border: 0; border-radius: 6px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03); }
.reader__header { padding-bottom: 20px; border-bottom: 1px solid var(--border-subtle); }
.obsidian-title { margin: 0 0 12px; font-family: inherit; font-size: clamp(1.8rem, 2.5vw, 2.2rem); font-weight: 700; line-height: 1.2; letter-spacing: -0.015em; color: var(--text-primary); overflow-wrap: anywhere; }
.reader__summary { margin: 18px 0 0; font-size: 1.05rem; line-height: 1.7; color: var(--text-primary); }
.confidence-warning { margin-top: 12px !important; padding: 10px 12px; color: var(--text-warning) !important; background: var(--surface-warning); border-left: 3px solid var(--status-caution); font-size: .92rem !important; border-radius: 4px; }
.reader__header .freshness { margin-top: 12px !important; padding: 9px 11px; color: var(--text-warning) !important; background: var(--surface-warning); font-size: 0.9rem !important; border-radius: 4px; }
/* Obsidian Properties block */
.obsidian-properties { margin: 14px 0 16px; padding: 10px 14px 12px; background: var(--surface-inset); border-radius: 4px; border: 1px solid rgba(0, 0, 0, 0.04); }
.obsidian-properties__title { font-size: 0.74rem; font-weight: 600; color: var(--text-muted); margin-bottom: 8px; letter-spacing: 0.06em; text-transform: uppercase; }
.obsidian-properties__grid { display: grid; gap: 6px; margin: 0; padding: 0; }
.obsidian-property { display: grid; grid-template-columns: 85px 1fr; align-items: baseline; gap: 10px; font-size: 0.86rem; margin: 0; }
.obsidian-property__label { color: var(--text-muted); font-weight: 500; font-size: 0.82rem; }
.obsidian-property__label strong { font-weight: 500; }
.obsidian-property__value { margin: 0; color: var(--text-primary); display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.obsidian-type-tag { display: inline-block; padding: 1px 8px; border-radius: 4px; background: var(--obsidian-type-tag-bg, #e5e1d8); border: 1px solid var(--border-subtle); font-size: 0.78rem; font-weight: 500; color: var(--obsidian-type-tag-text, #475569); }
.obsidian-pill { display: inline-block; padding: 2px 9px; border-radius: 999px; background: var(--obsidian-pill-bg, #ece8df); color: var(--obsidian-pill-text, #374151); font-size: 0.78rem; font-weight: 500; text-decoration: none; transition: background 0.15s ease, color 0.15s ease; }
.obsidian-pill:hover { background: #dfdad0; color: #1e293b; text-decoration: none; }
.node-facets { display: grid; gap: 6px; margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(0, 0, 0, 0.05); }
.node-facets__item { display: inline-flex; align-items: center; }
/* Evidences & Citations */
.evidence-section { padding-top: 22px; }
.evidence-list { display: grid; gap: 14px; margin: 0; padding: 0; list-style: none; }
.evidence-card { padding: 6px 0 6px 14px; background: transparent; border: 0; border-left: 2px solid var(--border-control); }
.evidence-card blockquote { margin: 0 0 6px; font-family: inherit; font-size: 0.98rem; line-height: 1.6; color: var(--text-primary); overflow-wrap: anywhere; }
.citation-location { margin: 4px 0 2px; color: var(--text-muted); font-size: 0.82rem; overflow-wrap: anywhere; }
.text-button { min-height: auto; padding: 2px 0; color: var(--accent-link); font-size: 0.85rem; text-align: left; text-decoration: none; }
.text-button:hover { text-decoration: underline; color: var(--accent-strong); }
/* Details / Accordion */
.obsidian-details { margin-top: 20px; padding-top: 14px; border-top: 1px solid var(--border-subtle); }
.obsidian-details__summary { cursor: pointer; font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 8px; user-select: none; }
.obsidian-details__summary h3 { margin: 0; display: inline; font-size: 1.05rem; }
.obsidian-details[open] .obsidian-details__summary { margin-bottom: 12px; }
/* Article Bottom Tabs */
.article-tabs { margin-top: 28px; padding-top: 14px; border-top: 1px solid var(--border-subtle); }
.article-tabs__nav { display: flex; gap: 6px; border-bottom: 1px solid var(--border-subtle); margin-bottom: 16px; }
.article-tabs__tab { padding: 8px 16px; background: transparent; border: 0; border-bottom: 2px solid transparent; color: var(--text-muted); font-size: 0.92rem; font-weight: 500; cursor: pointer; transition: all 0.15s ease; }
.article-tabs__tab:hover { color: var(--text-primary); }
.article-tabs__tab--active { color: var(--text-primary); border-bottom-color: var(--text-primary); font-weight: 600; }
.article-tabs__content { padding: 4px 0; }
.citation-highlight { background-color: #fef08a; color: #1e293b; padding: 1px 4px; border-radius: 2px; font-weight: 500; }
.source-card { margin-top: 18px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: 4px; }
.source-browser { margin-top: 32px; padding: 20px; background: var(--surface-paper); border: 1px solid var(--border-subtle); border-radius: 6px; }
.source-list { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.source-list__item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; padding: 16px; background: var(--surface-inset); border-left: 2px solid var(--accent-link); }
.source-list__item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; padding: 16px; background: var(--surface-inset); border-left: 2px solid var(--border-control); border-radius: 4px; }
.source-list__item h3 { margin: 6px 0; font-family: "Source Serif 4", Georgia, serif; font-size: 1.2rem; font-weight: 500; overflow-wrap: anywhere; }
.source-list__item p { margin: 4px 0; line-height: 1.45; overflow-wrap: anywhere; }
.source-list__revision { color: var(--text-muted); font-size: 0.85rem; }
@@ -170,83 +279,286 @@ a:hover { color: var(--accent-strong); }
.source-card dt { color: var(--text-muted); font-size: 0.78rem; }
.source-card dd { margin: 2px 0 0; font-size: 0.9rem; overflow-wrap: anywhere; }
.source-card a { display: inline-block; margin-top: 5px; }
.location-preview { margin-top: 12px; padding: 12px; background: var(--surface-preview); border: 1px solid var(--border-subtle); }
.artifact-preview__actions { margin-top: 14px; }
.artifact-preview__actions button { min-height: 38px; padding: 6px 14px; color: var(--text-primary); background: var(--surface-paper); border: 1px solid var(--border-control); border-radius: 4px; cursor: pointer; }
.artifact-preview__actions button:disabled { opacity: .65; cursor: wait; }
.artifact-preview { margin-top: 12px; overflow: hidden; background: var(--surface-inset); border: 1px solid var(--border-subtle); border-radius: 4px; }
.artifact-preview iframe { display: block; width: 100%; height: min(72vh, 800px); min-height: 420px; border: 0; background: white; }
.artifact-preview pre { max-height: 70vh; overflow: auto; margin: 0; padding: 18px; white-space: pre-wrap; overflow-wrap: anywhere; font: .95rem/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
.artifact-preview__markdown { max-height: 70vh; overflow: auto; padding: 12px 22px; color: var(--text-primary); line-height: 1.65; }
.artifact-preview__markdown h2, .artifact-preview__markdown h3, .artifact-preview__markdown h4 { margin: 1.1em 0 .45em; font-family: "Source Serif 4", Georgia, serif; font-weight: 500; line-height: 1.25; }
.artifact-preview__markdown h2 { font-size: 1.55rem; }
.artifact-preview__markdown h3 { font-size: 1.3rem; }
.artifact-preview__markdown h4 { font-size: 1.1rem; }
.artifact-preview__markdown p { margin: .55em 0; }
.artifact-preview__markdown ul { padding-left: 1.5em; }
.artifact-preview__markdown blockquote { margin-inline: 0; padding-left: 14px; color: var(--text-secondary); border-left: 2px solid var(--border-control); }
.artifact-preview__markdown code { padding: 1px 4px; background: var(--surface-paper); }
.artifact-preview__download { display: inline-block; margin-top: 10px !important; }
.location-preview { margin-top: 12px; padding: 14px; background: var(--surface-preview); border: 1px solid var(--border-subtle); border-radius: 4px; }
.location-preview__label { margin: 10px 0 4px; color: var(--text-muted); font-size: 0.82rem; font-weight: 650; }
.location-preview pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; line-height: 1.55; }
.history-details { margin-top: 17px; padding-top: 14px; border-top: 1px solid var(--border-subtle); }
.history-details summary { min-height: 32px; color: var(--accent-link); cursor: pointer; font-weight: 650; }
.history-details summary { min-height: 32px; color: var(--text-primary); cursor: pointer; font-weight: 650; }
.history-details ol { display: grid; gap: 8px; padding-left: 22px; color: var(--text-secondary); font-size: 0.88rem; line-height: 1.45; overflow-wrap: anywhere; }
.relationship-section { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-subtle); }
.relationship-list { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.relationship-list li { padding: 12px 14px; background: var(--surface-inset); border-left: 2px solid var(--border-control); }
.relationship-list li p { margin: 4px 0 0; line-height: 1.45; overflow-wrap: anywhere; }
.relationship-list__verb { color: var(--text-muted); font-size: 0.83rem; text-transform: capitalize; }
.relationship-list__target { font-family: "Source Serif 4", Georgia, serif; font-size: 1.08rem; }
.relationship-section { margin-top: 16px; }
.relationship-list { display: grid; gap: 10px; margin: 0; padding-left: 1.25rem; list-style: disc; }
.relationship-list li { padding: 2px 0; background: transparent; border: 0; color: var(--text-secondary); font-size: 0.9rem; }
.relationship-list li p { margin: 2px 0 0; }
.relationship-list__verb { color: var(--text-muted); font-size: 0.78rem; text-transform: capitalize; }
.relationship-list__target a, .relationship-list__target button { color: var(--accent-link); font-weight: 500; font-size: 0.96rem; text-decoration: none; }
.relationship-list__target a:hover, .relationship-list__target button:hover { text-decoration: underline; }
.suggestion-panel { margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--border-control); }
.assessment-section { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-subtle); }
.assessment-list { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.assessment-list li { padding: 14px; background: var(--surface-inset); }
.assessment-list blockquote { margin: 0 0 10px; font-family: "Source Serif 4", Georgia, serif; font-size: 1.06rem; line-height: 1.5; }
.assessment-list p { margin: 5px 0 0; line-height: 1.45; overflow-wrap: anywhere; }
.reader-empty { display: grid; min-height: 310px; align-content: center; justify-items: start; gap: 12px; border: 0; }
.reader-empty__glyph { color: var(--accent-link); font-family: "Source Serif 4", Georgia, serif; font-size: 5rem; line-height: 0.8; }
.reader-empty h2 { max-width: 12ch; margin: 0; font-family: "Source Serif 4", Georgia, serif; font-size: 2rem; font-weight: 500; line-height: 1.1; }
.reader-empty p { max-width: 38ch; }
.assessment-section { margin-top: 20px; }
.assessment-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.assessment-list li { padding: 4px 0 6px 14px; background: transparent; border: 0; border-left: 2px solid var(--border-control); }
.assessment-list blockquote { margin: 0 0 6px; font-family: inherit; font-size: 0.96rem; line-height: 1.55; color: var(--text-primary); }
.assessment-list p { margin: 2px 0 0; line-height: 1.45; font-size: 0.83rem; overflow-wrap: anywhere; }
.status-line { min-height: 24px; margin: 22px 0 10px; color: var(--text-secondary); font-size: 0.88rem; }
.workspace-footer { display: flex; justify-content: space-between; gap: 20px; padding-top: 12px; color: var(--text-muted); border-top: 1px solid var(--border-subtle); font-size: 0.8rem; }
.workspace-footer span:last-child { text-align: right; }
.reader--standalone {
max-width: 880px;
margin: 0 auto;
width: 100%;
}
.results--standalone {
max-width: 960px;
margin: 0 auto;
width: 100%;
}
@media (max-width: 1120px) {
.rail { width: 188px; }
.workspace { margin-left: 188px; padding-inline: 28px; }
.workspace { margin-left: 188px; padding-inline: 24px; }
.reading-grid { grid-template-columns: minmax(260px, 0.9fr) minmax(320px, 1.1fr); gap: 18px; }
.search-form { grid-template-columns: minmax(0, 1fr) minmax(150px, 1fr); }
.search-field { grid-column: 1 / -1; }
}
@media (max-width: 820px) {
.rail { position: static; width: auto; height: auto; padding: 12px 18px; flex-direction: row; align-items: center; justify-content: space-between; border-right: 0; border-bottom: 1px solid var(--border-subtle); }
.rail-nav { display: flex; gap: 4px; margin: 0; }
.rail-nav__item { min-height: 40px; padding: 8px; font-size: 0.86rem; }
.rail-nav__item > span:first-child { display: none; }
.rail__footer { display: none; }
.workspace { margin-left: 0; padding: 26px 22px 20px; }
.search-form { grid-template-columns: minmax(0, 1fr); }
.search-field { grid-column: auto; }
.facet-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.reading-grid { grid-template-columns: minmax(0, 1fr); }
.reader { grid-row: 1; }
.reader-empty { min-height: 180px; }
.rail {
position: static;
width: 100%;
height: auto;
padding: 12px 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
border-right: 0;
border-bottom: 1px solid var(--border-subtle);
}
.brand {
order: 1;
}
.rail__footer {
order: 2;
margin: 0;
padding: 0;
border: 0;
display: flex;
align-items: center;
}
.rail__footer > p {
display: none;
}
.rail__disconnect-btn {
margin-top: 0 !important;
width: auto !important;
padding: 4px 10px !important;
font-size: 0.82rem;
}
.rail-nav {
order: 3;
width: 100%;
margin-top: 10px;
display: flex;
flex-direction: row;
gap: 6px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 2px;
}
.rail-nav__item {
flex: 0 0 auto;
min-height: 36px;
padding: 6px 12px;
font-size: 0.86rem;
border-radius: 4px;
}
.rail-nav__item > span:first-child {
display: inline-block;
}
.workspace {
margin-left: 0;
padding: 16px 14px 24px;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.topbar {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
padding-bottom: 14px;
}
.topbar h1 {
font-size: 1.45rem;
margin: 4px 0 0;
}
.topbar--article {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
padding-bottom: 14px;
}
.topbar__left {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-width: 0;
}
.topbar__article-title {
font-size: 1.15rem;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar__right {
display: flex;
align-items: center;
justify-content: flex-end;
width: 100%;
}
.topbar__project-select {
width: auto;
max-width: 100%;
}
.project-overview {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 14px 16px;
}
.project-overview h2 {
font-size: 1.3rem;
}
.project-picker {
width: 100%;
min-width: 0;
}
.project-picker select {
width: 100%;
}
.collection-tabs {
margin-top: 14px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.collection-tabs button {
flex: 0 0 auto;
padding: 8px 14px;
font-size: 0.9rem;
}
.search-panel {
margin: 16px 0 20px;
padding: 14px 14px 12px;
}
.search-form {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
.search-field {
grid-column: auto;
}
.facet-filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.connect-card {
grid-template-columns: minmax(0, 1fr);
gap: 20px;
}
.reader {
padding: 18px 16px;
border-radius: 4px;
}
.obsidian-properties {
padding: 10px 12px;
margin: 12px 0 14px;
}
.obsidian-property {
grid-template-columns: 75px 1fr;
gap: 8px;
font-size: 0.84rem;
}
.reader__summary {
font-size: 0.98rem;
line-height: 1.6;
}
.article-tabs__nav {
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
.article-tabs__tab {
flex: 0 0 auto;
padding: 8px 12px;
font-size: 0.88rem;
}
.source-list__item {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
.pagination-controls {
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
}
@media (max-width: 560px) {
.rail { flex-wrap: wrap; padding-inline: 12px; }
.rail-nav { width: 100%; max-width: 100%; overflow-x: auto; }
.rail-nav__item { flex: 0 0 auto; }
.brand { gap: 8px; font-size: 0.98rem; }
.brand__mark { width: 32px; height: 32px; font-size: 1.3rem; }
.brand__sub { font-size: 0.55rem; }
.rail-nav__item { font-size: 0.78rem; }
.workspace { padding: 22px 14px 18px; }
.topbar { gap: 10px; padding-bottom: 18px; }
.topbar h1 { font-size: 2rem; }
.quiet-button { min-height: 40px; padding-inline: 9px; font-size: 0.83rem; }
.connect-card { grid-template-columns: 1fr; gap: 22px; margin: 30px auto; padding: 22px; }
.connect-card h2 { font-size: 1.8rem; }
.search-panel { margin-block: 18px 22px; padding: 14px; }
.search-form { grid-template-columns: minmax(0, 1fr); gap: 10px; }
.search-form > button { width: 100%; }
.facet-filters { grid-template-columns: minmax(0, 1fr); }
.reader { padding: 16px; }
.source-card dl { grid-template-columns: minmax(0, 1fr); }
.source-list__item { grid-template-columns: minmax(0, 1fr); }
.workspace-footer { flex-direction: column; gap: 5px; }
.workspace-footer span:last-child { text-align: left; }
.facet-filters {
grid-template-columns: minmax(0, 1fr);
}
.reading-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; }
}
.admin-dialog { width: min(760px, calc(100vw - 32px)); max-height: min(90vh, 900px); overflow-y: auto; padding: var(--space-5); color: var(--text-primary); background: var(--surface-paper); border: 1px solid var(--border-subtle); box-shadow: 0 16px 48px #24272233; }
.admin-dialog::backdrop { background: #24272288; }
.admin-dialog form { display: grid; gap: var(--space-3); }
.admin-dialog label { display: grid; gap: var(--space-1); font-weight: 600; }
.admin-dialog select, .admin-dialog input, .admin-dialog textarea { width: 100%; padding: var(--space-2); border: 1px solid var(--border-control); border-radius: 4px; background: var(--surface-input); color: inherit; font: inherit; }
.admin-dialog menu { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-4); padding: 0; }
.admin-dialog button { padding: var(--space-2) var(--space-4); border: 1px solid var(--border-control); border-radius: 4px; background: var(--surface-input); color: inherit; }
.admin-dialog button[value=\"confirm\"] { background: var(--accent-link); color: white; border-color: var(--accent-link); }
.admin-dialog fieldset { border: 1px solid var(--border-subtle); padding: var(--space-3); border-radius: 4px; }
.admin-dialog fieldset legend { padding: 0 var(--space-2); font-weight: 600; }
.recently-removed{width:100%;padding:1rem 1.1rem;border:1px solid var(--line,#d8ddd8);border-radius:.8rem;background:var(--surface,#fff)}
.recently-removed h3{display:flex;align-items:baseline;justify-content:space-between;margin:0 0 .75rem;font-size:.95rem}
.recently-removed h3 span{font-size:.8rem;font-weight:400;color:var(--muted,#68736c)}
.recently-removed ul{margin:0;padding:0;list-style:none;display:grid;gap:.5rem}
.recently-removed li{display:flex;align-items:center;justify-content:space-between;gap:1rem;font-size:.9rem}
.recently-removed button,.node-remove-button{border:1px solid var(--line,#d8ddd8);border-radius:999px;background:transparent;padding:.45rem .8rem;color:inherit;cursor:pointer}
.removal-confirm{max-width:34rem;margin:1rem 0;padding:1rem 1.1rem;border:1px solid #c98f78;border-radius:.8rem;background:#fffaf7}
.removal-confirm p{margin:0 0 .5rem}
.removal-confirm small{display:block;margin-bottom:.75rem;color:var(--muted,#68736c)}
.removal-confirm .actions{display:flex;gap:.5rem}
.removal-confirm button{border:1px solid var(--line,#d8ddd8);border-radius:999px;background:transparent;padding:.5rem .85rem;cursor:pointer}
.removal-confirm .danger-button{background:#963f2d;border-color:#963f2d;color:white}
.admin-card { margin-block: var(--space-5); padding: var(--space-5); background: var(--surface-paper); border: 1px solid var(--border-subtle); }
.admin-card h2 { margin-top: 0; font-family: "Source Serif 4", Georgia, serif; font-weight: 500; }
@@ -280,9 +592,6 @@ a:hover { color: var(--accent-strong); }
.admin-tabs [role="tab"]:hover { color: var(--accent-link); background: var(--surface-inset); }
.admin-tabs [aria-selected="true"] { color: var(--accent-link); border-bottom-color: var(--accent-link); background: var(--surface-selected); }
.admin-dialog { width: min(760px, calc(100vw - 32px)); max-height: min(90vh, 900px); overflow-y: auto; padding: var(--space-5); color: var(--text-primary); background: var(--surface-paper); border: 1px solid var(--border-subtle); box-shadow: 0 16px 48px #24272233; }
.admin-dialog::backdrop { background: #24272288; }
.admin-dialog .admin-heading { margin-top: 0; }
.project-edit-form { margin-top: var(--space-4); padding: var(--space-4); background: var(--surface-inset); }
@media (max-width: 700px) { .admin-dialog { padding: var(--space-4); } }
@@ -300,3 +609,221 @@ a:hover { color: var(--accent-strong); }
.admin-filter-bar label { display: grid; gap: var(--space-1); min-width: 140px; }
.admin-filter-bar input, .admin-filter-bar select { min-height: 44px; padding: 8px 10px; border: 1px solid var(--border-control); background: var(--surface-input); color: var(--text-primary); }
@media (max-width: 700px) { .admin-dialog { padding: var(--space-4); } .admin-record__top { align-items: flex-start; flex-direction: column; } .admin-record__actions { min-height: 44px; } }
.facet-filters label:has(input[type="checkbox"]),
.facet-filters label.checkbox-label {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
gap: 8px !important;
}
/* Source Detail View Styles */
.source-detail-view {
max-width: 960px;
margin: 0 auto;
padding: 16px 0;
}
.source-detail-header {
margin-bottom: 16px;
}
.back-button {
padding: 8px 14px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.source-meta-card {
padding: 24px;
background: var(--surface-paper);
border: 1px solid var(--border-subtle);
border-radius: 6px;
margin-bottom: 28px;
}
.source-meta-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.78rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge--type {
background: var(--surface-subtle);
color: var(--text-primary);
border: 1px solid var(--border-control);
}
.badge--grade {
background: #e0f2fe;
color: #0369a1;
border: 1px solid #bae6fd;
}
.badge--status {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
.source-detail-title {
margin: 0 0 10px 0;
font-family: "Source Serif 4", Georgia, serif;
font-size: 1.85rem;
line-height: 1.25;
color: var(--text-primary);
}
.source-detail-publisher {
margin: 0 0 18px 0;
color: var(--text-secondary);
font-size: 0.95rem;
}
.source-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
}
.action-button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.action-button--primary {
background: var(--accent-link);
color: white;
border: 1px solid var(--accent-link);
}
.action-button--primary:hover {
background: #1d4ed8;
color: white;
text-decoration: none;
}
.action-button--secondary {
background: var(--surface-paper);
color: var(--text-primary);
border: 1px solid var(--border-control);
}
.action-button--secondary:hover {
background: var(--surface-subtle);
text-decoration: none;
}
.source-tech-details {
border-top: 1px solid var(--border-subtle);
padding-top: 14px;
font-size: 0.88rem;
}
.source-tech-details summary {
cursor: pointer;
font-weight: 600;
color: var(--text-muted);
}
.tech-meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px;
margin-top: 14px;
}
.tech-meta-grid dt {
color: var(--text-muted);
font-size: 0.78rem;
text-transform: uppercase;
margin-bottom: 2px;
}
.tech-meta-grid dd {
margin: 0;
font-size: 0.9rem;
color: var(--text-primary);
word-break: break-all;
}
.source-related-nodes {
margin-top: 24px;
}
.source-nodes-filter {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 18px;
}
.source-nodes-filter input[type="search"] {
min-width: 220px;
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border-control);
border-radius: 4px;
background: var(--surface-input);
}
.source-nodes-filter select {
padding: 8px 12px;
border: 1px solid var(--border-control);
border-radius: 4px;
background: var(--surface-input);
}
.source-nodes-filter button {
padding: 8px 16px;
}
.basis-badge {
display: inline-block;
padding: 2px 7px;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 600;
}
.basis-badge--citation {
background: #fef3c7;
color: #92400e;
}
.basis-badge--edge {
background: #ede9fe;
color: #6d28d9;
}
.basis-badge--both {
background: #dcfce7;
color: #166534;
}
.node-type-tag {
font-weight: 600;
text-transform: capitalize;
}
.active-filters-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 10px 0;
padding: 8px 0;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--surface-subtle);
border: 1px solid var(--border-control);
border-radius: 16px;
font-size: 0.82rem;
color: var(--text-primary);
}
.filter-chip__remove {
background: transparent;
border: none;
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0 2px;
color: var(--text-muted);
}
.filter-chip__remove:hover {
color: var(--text-primary);
}