feat: online verification, node removals, and knowledge browser
CI / quality (push) Canceled after 0s
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:
1 parent
a3156f4e08
commit
78033bb830
83 files changed
+13476
-1051
No files matched your search
+789
-57
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
@@ -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
@@ -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('');
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user