Compare commits
3 Commits
278c66897a
...
bdae539e9e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdae539e9e | ||
|
|
643c3c39f9 | ||
|
|
adbcfcc7ba |
@@ -1,31 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Source } from '../api/engine'
|
||||
import { buildGraph, HEIGHT, WIDTH } from './graph'
|
||||
import type { FoundSource, Material } from './results'
|
||||
import type { Schedule } from './schedules'
|
||||
import type { Material } from './results'
|
||||
|
||||
const doc = (docId: number, title: string) => ({ docId, title, originalName: `${title}.pdf` }) as Source
|
||||
const doc = (docId: number, title: string, status = 'completed') => ({ docId, title, originalName: `${title}.pdf`, status }) as Source
|
||||
const material = (id: number, status: Material['status'], sources: string[]) =>
|
||||
({ id, agentId: 7, kind: 'summary', status, planTitle: `Lesson ${id}`, sources }) as Material
|
||||
const schedule = { agent: { id: 7, name: 'Express · 2 summary per week' } } as Schedule
|
||||
|
||||
describe('buildGraph', () => {
|
||||
const data = {
|
||||
docs: [doc(1, 'Onboarding'), doc(2, 'Safety')],
|
||||
materials: [material(10, 'accepted', ['Onboarding.pdf', 'Safety']), material(11, 'generating', [])],
|
||||
sources: [{ id: 5, agentId: 7, title: 'Article', documentId: 2 } as FoundSource],
|
||||
schedules: [schedule],
|
||||
docs: [doc(1, 'Onboarding'), doc(2, 'Safety'), doc(3, 'Broken', 'failed')],
|
||||
materials: [
|
||||
material(10, 'accepted', ['Onboarding.pdf', 'Safety']), material(11, 'generating', []),
|
||||
material(12, 'failed', ['Safety']), material(13, 'ready', ['Safety']),
|
||||
],
|
||||
}
|
||||
const graph = buildGraph(data, c => c)
|
||||
|
||||
it('puts results in progress in their own cluster', () => {
|
||||
expect(graph.nodes.find(n => n.id === 'mat-7-10')?.cluster).toBe('made')
|
||||
expect(graph.nodes.find(n => n.id === 'mat-7-11')?.cluster).toBe('working')
|
||||
it('holds only the knowledge base and the content made from it', () => {
|
||||
expect(graph.nodes.filter(n => !n.hub).map(n => `${n.cluster}:${n.id}`).sort())
|
||||
.toEqual(['made:mat-7-10', 'made:mat-7-13', 'sources:doc-1', 'sources:doc-2'])
|
||||
})
|
||||
|
||||
it('links results to their sources and schedule, and found sources to their document', () => {
|
||||
it('links content to its sources', () => {
|
||||
const cross = graph.links.filter(l => !l.hub).map(l => `${l.source}>${l.target}`)
|
||||
expect(cross).toEqual(expect.arrayContaining(['mat-7-10>doc-1', 'mat-7-10>doc-2', 'mat-7-10>agent-7', 'found-7-5>doc-2']))
|
||||
expect(cross.sort()).toEqual(['mat-7-10>doc-1', 'mat-7-10>doc-2', 'mat-7-13>doc-2'])
|
||||
})
|
||||
|
||||
it('lays out the same data the same way, inside the canvas', () => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// The Production knowledge graph: every Brain document, result, found source and schedule as a node,
|
||||
// grouped around one hub per cluster and linked where the engine says they are related (a result's
|
||||
// sources, the schedule that made it, the document a found source became). Laid out once with a
|
||||
// small seeded force simulation, so the same data always draws the same picture.
|
||||
// The Production knowledge graph: what the agent's Brain holds, its knowledge base (the documents)
|
||||
// and the content made from it, grouped around one hub per cluster and linked where a piece of
|
||||
// content names a document as its source. Work in progress, failed runs and schedules are status,
|
||||
// not knowledge, so they stay out. Laid out once with a small seeded force simulation, so the same
|
||||
// data always draws the same picture.
|
||||
import type { Source } from '../api/engine'
|
||||
import { isWorking, type FoundSource, type Material } from './results'
|
||||
import type { Schedule } from './schedules'
|
||||
import { isWorking, type Material } from './results'
|
||||
|
||||
export type Cluster = 'sources' | 'made' | 'working' | 'research' | 'schedules'
|
||||
export const CLUSTERS: Cluster[] = ['sources', 'made', 'working', 'research', 'schedules']
|
||||
export type Cluster = 'sources' | 'made'
|
||||
export const CLUSTERS: Cluster[] = ['sources', 'made']
|
||||
|
||||
export interface GraphNode {
|
||||
id: string
|
||||
@@ -27,13 +27,16 @@ export const HEIGHT = 640
|
||||
|
||||
// Where each cluster settles, around the middle of the canvas.
|
||||
const ANCHOR: Record<Cluster, [number, number]> = {
|
||||
sources: [560, 360], made: [450, 430], working: [330, 300], research: [440, 200], schedules: [700, 230],
|
||||
sources: [580, 320], made: [420, 320],
|
||||
}
|
||||
|
||||
const norm = (s: string) => s.trim().toLowerCase()
|
||||
|
||||
// Content still being made, failed or turned down is not part of the Brain.
|
||||
const inBrain = (m: Material) => !isWorking(m) && m.status !== 'failed' && m.status !== 'rejected'
|
||||
|
||||
export function buildGraph(
|
||||
{ docs, materials, sources, schedules }: { docs: Source[]; materials: Material[]; sources: FoundSource[]; schedules: Schedule[] },
|
||||
{ docs, materials }: { docs: Source[]; materials: Material[] },
|
||||
hubLabel: (c: Cluster) => string,
|
||||
): Graph {
|
||||
const nodes: GraphNode[] = CLUSTERS.map(c => ({ id: `hub-${c}`, cluster: c, label: hubLabel(c), hub: true, x: 0, y: 0 }))
|
||||
@@ -46,30 +49,19 @@ export function buildGraph(
|
||||
|
||||
// Results name their sources by title, so documents are found by title or file name.
|
||||
const docByName = new Map<string, string>()
|
||||
for (const d of docs) {
|
||||
for (const d of docs.filter(d => d.status !== 'failed' && d.knowledge !== 'failed')) {
|
||||
add(`doc-${d.docId}`, 'sources', d.title)
|
||||
docByName.set(norm(d.title), `doc-${d.docId}`)
|
||||
docByName.set(norm(d.originalName), `doc-${d.docId}`)
|
||||
}
|
||||
for (const s of schedules) add(`agent-${s.agent.id}`, 'schedules', s.agent.name.split(' · ').slice(1).join(' · ') || s.agent.name)
|
||||
const agents = new Set(schedules.map(s => `agent-${s.agent.id}`))
|
||||
|
||||
for (const m of materials) {
|
||||
for (const m of materials.filter(inBrain)) {
|
||||
const id = `${m.kind === 'video' ? 'video' : 'mat'}-${m.agentId}-${m.id}`
|
||||
add(id, isWorking(m) ? 'working' : 'made', m.planTitle ?? m.kind)
|
||||
if (agents.has(`agent-${m.agentId}`)) link(id, `agent-${m.agentId}`)
|
||||
add(id, 'made', m.planTitle ?? m.kind)
|
||||
for (const title of new Set(m.sources)) {
|
||||
const doc = docByName.get(norm(title))
|
||||
if (doc) link(id, doc)
|
||||
}
|
||||
}
|
||||
for (const s of sources) {
|
||||
const id = `found-${s.agentId}-${s.id}`
|
||||
add(id, 'research', s.title)
|
||||
if (agents.has(`agent-${s.agentId}`)) link(id, `agent-${s.agentId}`)
|
||||
if (s.documentId != null && docs.some(d => d.docId === s.documentId)) link(id, `doc-${s.documentId}`)
|
||||
}
|
||||
|
||||
layout(nodes, links)
|
||||
return { nodes, links, width: WIDTH, height: HEIGHT }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { correctIndex, questionsOf, toMarkdown, withPlanItem, type Material } from './results'
|
||||
import { correctIndex, keepSettled, questionsOf, toMarkdown, withPlanItem, type Material } from './results'
|
||||
import { sentencesOf, scriptLang } from '../components/AudioPlayer'
|
||||
|
||||
describe('correctIndex', () => {
|
||||
@@ -65,3 +65,20 @@ describe('withPlanItem', () => {
|
||||
expect(withPlanItem('preparing', undefined)).toBe('preparing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('keepSettled', () => {
|
||||
const ok = (v: number[]): PromiseSettledResult<number[]> => ({ status: 'fulfilled', value: v })
|
||||
const bad = (message: string): PromiseSettledResult<number[]> => ({ status: 'rejected', reason: new Error(message) })
|
||||
|
||||
it('leaves out a schedule that failed', () => {
|
||||
expect(keepSettled([ok([1, 2]), bad('404'), ok([3])])).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('fails when every schedule failed', () => {
|
||||
expect(() => keepSettled([bad('500'), bad('502')])).toThrow('500')
|
||||
})
|
||||
|
||||
it('is empty without schedules', () => {
|
||||
expect(keepSettled([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,14 +172,24 @@ async function sourcesOf({ agent, rule }: Schedule): Promise<FoundSource[]> {
|
||||
}))
|
||||
}
|
||||
|
||||
// One schedule the engine cannot answer for (e.g. an archived agent) leaves its results out
|
||||
// instead of failing them all; only when every schedule fails is that an error.
|
||||
export function keepSettled<T>(results: PromiseSettledResult<T[]>[]): T[] {
|
||||
const kept = results.flatMap(r => (r.status === 'fulfilled' ? r.value : []))
|
||||
const failed = results.filter(r => r.status === 'rejected')
|
||||
if (failed.length) console.warn(`${failed.length} of ${results.length} schedules could not be loaded`, failed.map(r => r.reason))
|
||||
if (results.length && failed.length === results.length) throw failed[0].reason
|
||||
return kept
|
||||
}
|
||||
|
||||
export async function listResults(): Promise<{ materials: Material[]; sources: FoundSource[] }> {
|
||||
const schedules = await expressAgents({ archived: true })
|
||||
const [materials, sources] = await Promise.all([
|
||||
Promise.all(schedules.filter(s => s.rule.type === 'content').map(materialsOf)),
|
||||
Promise.all(schedules.filter(s => s.rule.type === 'research').map(sourcesOf)),
|
||||
Promise.allSettled(schedules.filter(s => s.rule.type === 'content').map(materialsOf)),
|
||||
Promise.allSettled(schedules.filter(s => s.rule.type === 'research').map(sourcesOf)),
|
||||
])
|
||||
const newest = <T extends { createdAt: string }>(a: T, b: T) => b.createdAt.localeCompare(a.createdAt)
|
||||
return { materials: materials.flat().sort(newest), sources: sources.flat().sort(newest) }
|
||||
return { materials: keepSettled(materials).sort(newest), sources: keepSettled(sources).sort(newest) }
|
||||
}
|
||||
|
||||
const draftContent = (m: Material, id: number) =>
|
||||
|
||||
14
src/i18n.ts
14
src/i18n.ts
@@ -77,11 +77,8 @@ const en = {
|
||||
'prod.exitFullscreen': 'Exit full screen',
|
||||
'prod.loadingGraph': 'Drawing the knowledge graph…',
|
||||
'prod.graphLabel': 'Knowledge graph of {nodes} nodes',
|
||||
'prod.hub.sources': 'Sources',
|
||||
'prod.hub.made': 'Made',
|
||||
'prod.hub.working': 'In progress',
|
||||
'prod.hub.research': 'Research',
|
||||
'prod.hub.schedules': 'Repeats',
|
||||
'prod.hub.sources': 'Knowledge base',
|
||||
'prod.hub.made': 'Content',
|
||||
'prod.nodes': 'Nodes',
|
||||
'prod.nodesSub': 'in this Brain',
|
||||
'prod.links': 'Links',
|
||||
@@ -537,11 +534,8 @@ const id: Record<Key, string> = {
|
||||
'prod.exitFullscreen': 'Keluar layar penuh',
|
||||
'prod.loadingGraph': 'Menggambar knowledge graph…',
|
||||
'prod.graphLabel': 'Knowledge graph berisi {nodes} node',
|
||||
'prod.hub.sources': 'Sumber',
|
||||
'prod.hub.made': 'Dibuat',
|
||||
'prod.hub.working': 'Sedang dibuat',
|
||||
'prod.hub.research': 'Riset',
|
||||
'prod.hub.schedules': 'Berulang',
|
||||
'prod.hub.sources': 'Basis pengetahuan',
|
||||
'prod.hub.made': 'Konten',
|
||||
'prod.nodes': 'Node',
|
||||
'prod.nodesSub': 'di Otak ini',
|
||||
'prod.links': 'Tautan',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Icon, ruleIcon } from '../components/Icon'
|
||||
import { PageHead } from '../components/Page'
|
||||
import { BrainPanel, choosable, useDocuments } from './Brain'
|
||||
import { choosable, useDocuments } from './Brain'
|
||||
import {
|
||||
dayName, describeRule, kindLabel, KINDS, LANGUAGES, languageLabel, monthNote, videoCadence, weeklyTarget,
|
||||
type ContentKind, type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
|
||||
@@ -36,14 +36,7 @@ export function InstructionPage() {
|
||||
const [agent, setAgent] = useState<AgentState>(EMPTY)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
// Brain material the schedules are made from; none chosen means the whole Brain.
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const docs = useDocuments()
|
||||
// A chosen document that was deleted, or failed since, no longer counts.
|
||||
const chosenDocs = choosable(docs.data).filter(d => selected.has(d.docId))
|
||||
const chosen = chosenDocs.map(d => d.docId)
|
||||
// How the chat and the plan name the chosen material: by title when there is one.
|
||||
const sources = chosenDocs.length === 1 ? `“${chosenDocs[0].title}”` : chosenDocs.length ? t('brainPanel.nChosen', { n: chosenDocs.length }) : null
|
||||
const [phase, setPhase] = useState<Phase>('idle')
|
||||
// The live question whose typing has finished, so its reply options can show.
|
||||
const [typedLive, setTypedLive] = useState('')
|
||||
@@ -55,19 +48,16 @@ export function InstructionPage() {
|
||||
const later = (fn: () => void, ms: number) => { timers.current.push(window.setTimeout(fn, ms)) }
|
||||
useEffect(() => () => timers.current.forEach(clearTimeout), [])
|
||||
|
||||
// The agent sees the current choice, so ticking material in the Brain panel updates its question and plan.
|
||||
const view: AgentState = { ...agent, sources }
|
||||
const question = nextQuestion(view)
|
||||
const question = nextQuestion(agent)
|
||||
const ready = agent.items.filter(i => canSchedule(i.rule))
|
||||
// Without a choice the whole Brain is attached, so every ready document counts.
|
||||
const sourceCount = chosen.length || choosable(docs.data).length
|
||||
const tooFewSources = sourceCount < MIN_PRODUCTION_SOURCES && ready.some(i => needsTwoSources(i.rule))
|
||||
// The whole Brain is attached, so every ready document counts.
|
||||
const tooFewSources = choosable(docs.data).length < MIN_PRODUCTION_SOURCES && ready.some(i => needsTwoSources(i.rule))
|
||||
const hasContent = agent.items.some(i => i.rule.type === 'content')
|
||||
// The agent's current turn: its question, or what it is waiting for. It stays live, so an edit in the plan updates it.
|
||||
const live = inLang(lang, () => saving
|
||||
? { text: t('agent.approving'), options: [] as string[] }
|
||||
: question
|
||||
? ask(question, view)
|
||||
? ask(question, agent)
|
||||
: agent.items.length
|
||||
? {
|
||||
text: hasContent && agent.language
|
||||
@@ -89,7 +79,7 @@ export function InstructionPage() {
|
||||
// A picked option is in the conversation's language already; typed text may switch it.
|
||||
const replyLang = live.options.includes(input) ? lang : chatLangOf(input) ?? lang
|
||||
setChatLang(replyLang)
|
||||
const turn = inLang(replyLang, () => respond(view, input))
|
||||
const turn = inLang(replyLang, () => respond(agent, input))
|
||||
// The question being answered is kept in the history, with the reply after it.
|
||||
setMessages(ms => [...ms, ...(live.text ? [{ role: 'agent' as const, text: live.text }] : []), { role: 'user', text: input.trim() }])
|
||||
setText('')
|
||||
@@ -120,8 +110,8 @@ export function InstructionPage() {
|
||||
// One at a time so a failure (e.g. not an organization owner) is reported per schedule.
|
||||
for (const { id, rule } of items) {
|
||||
try {
|
||||
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule, chosen)
|
||||
done.push({ rule: rule.type === 'content' && chosen.length ? { ...rule, chosen: true } : rule, ok: true })
|
||||
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule)
|
||||
done.push({ rule, ok: true })
|
||||
created.add(id)
|
||||
} catch (e) {
|
||||
done.push({ rule, ok: false, message: (e as Error).message })
|
||||
@@ -142,9 +132,7 @@ export function InstructionPage() {
|
||||
<>
|
||||
<PageHead title={t('ins.title')} description={t('ins.description')} />
|
||||
|
||||
<div className="unified-workspace">
|
||||
<BrainPanel selected={selected} onSelect={setSelected} />
|
||||
<div className="ai-column">
|
||||
<div className="ai-workspace">
|
||||
<div className="card agent-chat">
|
||||
<div className="card-head">
|
||||
<div className="assistant-title">
|
||||
@@ -196,15 +184,15 @@ export function InstructionPage() {
|
||||
)}
|
||||
<div className="rule-list">
|
||||
{agent.items.map(({ id, rule }) => (
|
||||
<RuleCard key={id} rule={rule} from={sources ?? undefined} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
|
||||
<RuleCard key={id} rule={rule} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
|
||||
))}
|
||||
</div>
|
||||
{ready.length > 0 && (
|
||||
<div className="rule-approve">
|
||||
<small>
|
||||
<b className="rule-sources"><Icon name="brain" />{chosen.length ? t('ins.sourcesChosen', { n: chosen.length }) : t('ins.sourcesAll')}</b>
|
||||
<b className="rule-sources"><Icon name="brain" />{t('ins.sourcesAll')}</b>
|
||||
{t('ins.approveNote')}
|
||||
{tooFewSources && <span className="error">{t(chosen.length ? 'ins.tooFewChosen' : 'ins.tooFewInBrain', { n: MIN_PRODUCTION_SOURCES })}</span>}
|
||||
{tooFewSources && <span className="error">{t('ins.tooFewInBrain', { n: MIN_PRODUCTION_SOURCES })}</span>}
|
||||
</small>
|
||||
<button className="btn primary" disabled={saving || !idle || tooFewSources} onClick={() => approve()}>
|
||||
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
|
||||
@@ -213,7 +201,6 @@ export function InstructionPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { locale, t, type Key } from '../i18n'
|
||||
import { ContentCard, Preview } from './Results'
|
||||
|
||||
const HUB_LABEL: Record<Cluster, Key> = {
|
||||
sources: 'prod.hub.sources', made: 'prod.hub.made', working: 'prod.hub.working', research: 'prod.hub.research', schedules: 'prod.hub.schedules',
|
||||
sources: 'prod.hub.sources', made: 'prod.hub.made',
|
||||
}
|
||||
|
||||
// Uses the Fullscreen API on the whole stage (graph + panel); where it is missing (iPhone Safari)
|
||||
@@ -67,10 +67,8 @@ export function ProductionPage() {
|
||||
// No LMS is connected yet: an approved SCORM module is the package that goes into one.
|
||||
const lms = materials.filter(m => m.status === 'accepted' && m.kind === 'scorm')
|
||||
const graph = useMemo(
|
||||
() => (docs.data && results.data && schedules.data
|
||||
? buildGraph({ docs: docs.data, materials: results.data.materials, sources: results.data.sources, schedules: schedules.data }, c => t(HUB_LABEL[c]))
|
||||
: null),
|
||||
[docs.data, results.data, schedules.data],
|
||||
() => (docs.data && results.data ? buildGraph({ docs: docs.data, materials: results.data.materials }, c => t(HUB_LABEL[c])) : null),
|
||||
[docs.data, results.data],
|
||||
)
|
||||
|
||||
const num = (n: number, ready: boolean, unit: Key) => (ready
|
||||
@@ -92,7 +90,8 @@ export function ProductionPage() {
|
||||
</button>
|
||||
{graph
|
||||
? <KnowledgeGraph graph={graph} />
|
||||
: <div className="graph-loading" role="status">{docs.isError || results.isError ? t('brain.engineDown') : t('prod.loadingGraph')}</div>}
|
||||
// The engine's own message: an unreachable engine says so, any other failure says what it was.
|
||||
: <div className="graph-loading" role="status">{(docs.error ?? results.error)?.message ?? t('prod.loadingGraph')}</div>}
|
||||
|
||||
<aside className="graph-panel">
|
||||
<div className="graph-stats">
|
||||
@@ -296,7 +295,7 @@ function KnowledgeGraph({ graph }: { graph: Graph }) {
|
||||
</g>
|
||||
<g>
|
||||
{graph.nodes.map((n, i) => {
|
||||
const r = n.hub ? 9 : n.cluster === 'schedules' ? 5.5 : 3.2 + Math.min(3, (degree.get(n.id) ?? 0) * 0.7)
|
||||
const r = n.hub ? 9 : 3.2 + Math.min(3, (degree.get(n.id) ?? 0) * 0.7)
|
||||
return (
|
||||
<g key={n.id} ref={el => { nodeEls.current[i] = el }}
|
||||
className={`graph-node c-${n.cluster}${n.hub ? ' hub' : ''}${neighbours && !neighbours.has(n.id) ? ' dim' : ''}${hover === n.id ? ' hover' : ''}${selected?.id === n.id ? ' sel' : ''}`}
|
||||
|
||||
@@ -304,7 +304,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
||||
.live-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#4ade80;box-shadow:0 0 0 0 rgba(74,222,128,.6);animation:live-pulse 1.8s ease-out infinite;flex-shrink:0}
|
||||
@keyframes live-pulse{70%{box-shadow:0 0 0 8px rgba(74,222,128,0)}100%{box-shadow:0 0 0 0 rgba(74,222,128,0)}}
|
||||
|
||||
.graph-stage{--g-sources:#6ce9a6;--g-made:#fde272;--g-working:#c3b5fd;--g-research:#84caff;--g-schedules:#fdb022;
|
||||
.graph-stage{--g-sources:#6ce9a6;--g-made:#fde272;--g-working:#c3b5fd;
|
||||
position:relative;min-height:640px;border-radius:22px;overflow:hidden;color:#e4e7ec;
|
||||
background:radial-gradient(ellipse at 45% 50%,#0f2a4d 0%,#0a1628 55%,#060d1a 100%);box-shadow:0 24px 60px -28px rgba(16,24,40,.6)}
|
||||
.graph-svg{position:absolute;inset:0;width:calc(100% - 300px);height:100%;display:block}
|
||||
@@ -341,9 +341,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
||||
.graph-node text{fill:#fff;font-size:13px;font-weight:700;letter-spacing:.01em;text-anchor:middle;paint-order:stroke;stroke:#08142a;stroke-width:4px;stroke-linejoin:round;pointer-events:none}
|
||||
.graph-node:not(.hub) text{font-size:11.5px;font-weight:600;opacity:0;transform:translateY(4px);transition:opacity .2s,transform .2s}
|
||||
.graph-node:not(.hub):is(.hover,.sel) text{opacity:1;transform:none}
|
||||
.graph-node.c-working:not(.hub) .core{animation:node-pulse 1.6s ease-in-out infinite}
|
||||
@keyframes node-pulse{50%{opacity:.4}}
|
||||
.c-sources{--c:var(--g-sources)}.c-made{--c:var(--g-made)}.c-working{--c:var(--g-working)}.c-research{--c:var(--g-research)}.c-schedules{--c:var(--g-schedules)}
|
||||
.c-sources{--c:var(--g-sources)}.c-made{--c:var(--g-made)}
|
||||
|
||||
.graph-note{position:absolute;top:16px;left:16px;z-index:2;display:flex;align-items:center;gap:8px;max-width:min(460px,calc(100% - 340px));
|
||||
padding:8px 14px;border-radius:999px;background:rgba(10,22,40,.8);border:1px solid rgba(255,255,255,.12);font-size:12.5px;backdrop-filter:blur(8px)}
|
||||
@@ -354,7 +352,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
||||
.graph-legend{position:absolute;left:16px;bottom:16px;z-index:2;display:flex;flex-wrap:wrap;gap:6px 14px;list-style:none;margin:0;padding:0;font-size:11.5px;color:#98a2b3}
|
||||
.graph-legend li{display:flex;align-items:center;gap:6px}
|
||||
.node-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--c);box-shadow:0 0 6px var(--c);flex-shrink:0}
|
||||
.node-dot.sources{--c:var(--g-sources)}.node-dot.made{--c:var(--g-made)}.node-dot.working{--c:var(--g-working)}.node-dot.research{--c:var(--g-research)}.node-dot.schedules{--c:var(--g-schedules)}
|
||||
.node-dot.sources{--c:var(--g-sources)}.node-dot.made{--c:var(--g-made)}.node-dot.working{--c:var(--g-working)}
|
||||
|
||||
.graph-panel{position:absolute;top:16px;right:16px;bottom:16px;width:284px;z-index:2;overflow:auto;padding:14px;border-radius:18px;
|
||||
background:rgba(10,22,40,.72);border:1px solid rgba(255,255,255,.1);backdrop-filter:blur(10px)}
|
||||
|
||||
Reference in New Issue
Block a user