From b5b6f6bc000eb390fa0aa20b516df7cf1f3b7f43 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 10:00:20 +0200 Subject: [PATCH] Add a bounded relation graph and knowledge journeys. CONTENT-WP-0004 keeps outgoing and incoming labels apart, limits the graph to two hops, and stores focus and detail in the URL. Assistant: grok Assistant-Session: 01a1531b-6698-7732-8587-f28ea604bcdf --- .../dataspaces/systems-thinking/snapshot.json | 33 ++- ui/app/[dataspace]/graph/page.tsx | 37 +++ ui/components/EntityDetail.test.tsx | 85 ++++++- ui/components/EntityDetail.tsx | 113 +++++++-- ui/components/GraphExplorer.history.test.tsx | 177 +++++++++++++ ui/components/GraphExplorer.tsx | 143 +++++++++++ ui/components/RecordLink.tsx | 28 +++ ui/components/SiteHeader.tsx | 1 + ui/fixtures/large-graph.json | 24 ++ ui/lib/catalog.ts | 173 +++++++++++-- ui/lib/graph.test.ts | 202 +++++++++++++++ ui/lib/graph.ts | 234 ++++++++++++++++++ .../CONTENT-WP-0004-relations-journeys.md | 22 +- .../CONTENT-WP-0004-ImplementationNotes.md | 59 +++++ workplans/evidence/CONTENT-WP-0004-Review.md | 11 + 15 files changed, 1290 insertions(+), 52 deletions(-) create mode 100644 ui/app/[dataspace]/graph/page.tsx create mode 100644 ui/components/GraphExplorer.history.test.tsx create mode 100644 ui/components/GraphExplorer.tsx create mode 100644 ui/components/RecordLink.tsx create mode 100644 ui/fixtures/large-graph.json create mode 100644 ui/lib/graph.test.ts create mode 100644 ui/lib/graph.ts create mode 100644 workplans/evidence/CONTENT-WP-0004-ImplementationNotes.md create mode 100644 workplans/evidence/CONTENT-WP-0004-Review.md diff --git a/examples/dataspaces/systems-thinking/snapshot.json b/examples/dataspaces/systems-thinking/snapshot.json index 9092b59..816d379 100644 --- a/examples/dataspaces/systems-thinking/snapshot.json +++ b/examples/dataspaces/systems-thinking/snapshot.json @@ -52,7 +52,29 @@ "asOf": "2026-10-12", "claimRefs": [ "claim:feedback:tldr" - ] + ], + "properties": { + "glossary": "A loop that returns an action's result.", + "preview": { + "title": "Feedback", + "summary": "The result of an action changes the next action." + }, + "collection": { + "title": "Ways to draw feedback", + "members": [ + "urn:ce:systems-thinking:method:causal-loop", + "urn:ce:systems-thinking:method:stock-flow" + ] + }, + "related": [ + "urn:ce:systems-thinking:concept:emergence" + ], + "readingPath": [ + "urn:ce:systems-thinking:concept:system-boundary", + "urn:ce:systems-thinking:concept:feedback", + "urn:ce:systems-thinking:method:causal-loop" + ] + } }, { "id": "urn:ce:systems-thinking:concept:emergence", @@ -171,7 +193,14 @@ "asOf": "2026-10-12", "claimRefs": [ "claim:causal-loop:tldr" - ] + ], + "properties": { + "glossary": "Arrows that show what influences what.", + "preview": { + "title": "Causal loop diagram", + "summary": "Influence arrows marked as reinforcing or balancing." + } + } }, { "id": "urn:ce:systems-thinking:method:stock-flow", diff --git a/ui/app/[dataspace]/graph/page.tsx b/ui/app/[dataspace]/graph/page.tsx new file mode 100644 index 0000000..9957561 --- /dev/null +++ b/ui/app/[dataspace]/graph/page.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { GraphExplorer } from "@/components/GraphExplorer"; +import { findGraph } from "@/lib/catalog"; + +function first(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +export async function generateMetadata({ params }: { params: Promise<{ dataspace: string }> }): Promise { + const { dataspace } = await params; + const model = findGraph(dataspace); + if (!model) return { title: "Content Explorer" }; + return { title: `Graph · ${model.dataspaceTitle} · Content Explorer` }; +} + +export default async function GraphPage({ + params, + searchParams, +}: { + params: Promise<{ dataspace: string }>; + searchParams: Promise>; +}) { + const { dataspace } = await params; + const query = await searchParams; + const model = findGraph(dataspace); + if (!model) notFound(); + const initial = new URLSearchParams(); + const focus = first(query.focus); + const detail = first(query.detail); + const hops = first(query.hops); + if (focus) initial.set("focus", focus); + if (detail) initial.set("detail", detail); + if (hops) initial.set("hops", hops); + const initialSearch = initial.size > 0 ? `?${initial.toString()}` : ""; + return ; +} diff --git a/ui/components/EntityDetail.test.tsx b/ui/components/EntityDetail.test.tsx index c56d5fb..659debc 100644 --- a/ui/components/EntityDetail.test.tsx +++ b/ui/components/EntityDetail.test.tsx @@ -1,9 +1,15 @@ +// @vitest-environment happy-dom +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { findEntity, findKind } from "@/lib/catalog"; +import { findEntity, findGraph, findKind, type EntityView } from "@/lib/catalog"; +import { partitionIncident } from "@/lib/graph"; import { EntityDetail } from "./EntityDetail"; import { KindIndex } from "./KindIndex"; +const systemsRoot = join(process.cwd(), "../examples/dataspaces/systems-thinking"); + describe("kind and detail views", () => { it("renders the registry plural and a linked record", () => { const kind = findKind("software-architecture", "concept"); @@ -24,5 +30,82 @@ describe("kind and detail views", () => { expect(html).toContain("formal-measure"); expect(html).toContain("unknown"); expect(html).toContain("Stock and flow model"); + expect(html).not.toContain("glossary-tip"); + expect(html).not.toContain("link-preview"); + expect(html).toContain('href="/systems-thinking/method/stock-flow/"'); + }); + + it("renders both relation blocks, journeys, and optional hints for feedback", () => { + const snapshot = JSON.parse(readFileSync(join(systemsRoot, "snapshot.json"), "utf8")) as { + entities: { id: string; properties?: { glossary?: string; preview?: { title: string; summary: string }; readingPath?: string[] } }[]; + relations: { id: string; from: string; to: string }[]; + }; + const manifest = JSON.parse(readFileSync(join(systemsRoot, "manifest.json"), "utf8")) as { + relationLabels: Record; + }; + const entity = findEntity("systems-thinking", "concept", "feedback"); + expect(entity).not.toBeNull(); + const html = renderToStaticMarkup(); + const host = documentFrom(html); + const incident = snapshot.relations.filter((relation) => relation.from === entity!.id || relation.to === entity!.id); + const rendered = [...host.querySelectorAll("[data-relation]")].map((node) => node.getAttribute("data-relation")); + expect(rendered.sort()).toEqual(incident.map((relation) => relation.id).sort()); + expect(host.querySelector('[aria-labelledby="outgoing-relations"] .text-muted')?.textContent).toBe( + manifest.relationLabels["represented-by"].forward, + ); + const incomingWordings = [...host.querySelectorAll('[aria-labelledby="incoming-relations"] .text-muted')].map( + (node) => node.textContent, + ); + expect(incomingWordings).toContain(manifest.relationLabels.models.inverse); + expect(incomingWordings).not.toContain(manifest.relationLabels.models.forward); + const causal = host.querySelector('[data-relation="r1"]'); + const causalEntity = snapshot.entities.find((item) => item.id.endsWith(":causal-loop")); + expect(causal?.querySelector(".glossary-tip")?.textContent).toBe(causalEntity?.properties?.glossary); + expect(causal?.querySelector(".preview-title")?.textContent).toBe(causalEntity?.properties?.preview?.title); + expect(causal?.querySelector(".preview-summary")?.textContent).toBe(causalEntity?.properties?.preview?.summary); + expect(causal?.querySelector("a")?.getAttribute("href")).toBe("/systems-thinking/method/causal-loop/"); + const modeled = host.querySelector('[data-relation="r6"]'); + expect(modeled?.querySelector("a")?.getAttribute("href")).toBe("/systems-thinking/method/stock-flow/"); + expect(modeled?.querySelector(".glossary-tip")).toBeNull(); + expect(modeled?.querySelector(".link-preview")).toBeNull(); + const members = [...host.querySelectorAll("#collection-heading")]; + expect(members.length).toBe(1); + const collection = members[0]?.parentElement; + expect(collection?.querySelector('a[href="/systems-thinking/method/causal-loop/"]')).toBeTruthy(); + expect(collection?.querySelector('a[href="/systems-thinking/method/stock-flow/"]')).toBeTruthy(); + expect(host.querySelector('[aria-labelledby="related-heading"] a')?.getAttribute("href")).toBe( + "/systems-thinking/concept/emergence/", + ); + const crumbs = [...host.querySelectorAll('[aria-label="Reading path"] li')]; + const declared = snapshot.entities.find((item) => item.id === entity!.id)?.properties?.readingPath ?? []; + expect(crumbs).toHaveLength(declared.length); + expect(crumbs[0]?.querySelector("a")?.getAttribute("href")).toBe("/systems-thinking/concept/system-boundary/"); + expect(crumbs[1]?.querySelector("[aria-current='page']")?.textContent).toBe(entity!.title); + expect(crumbs[2]?.querySelector("a")?.getAttribute("href")).toBe("/systems-thinking/method/causal-loop/"); + }); + + it("shows a remainder when the shipped partition leaves relations out", () => { + const entity = findEntity("systems-thinking", "concept", "feedback"); + const graph = findGraph("systems-thinking"); + expect(entity && graph).toBeTruthy(); + const blocks = partitionIncident(entity!.id, graph!.edges, graph!.labels, 1); + const listed = new Set([...blocks.outgoing, ...blocks.incoming].map((relation) => relation.id)); + const view: EntityView = { + ...entity!, + outgoing: entity!.outgoing.filter((relation) => listed.has(relation.id)), + incoming: entity!.incoming.filter((relation) => listed.has(relation.id)), + relationRemainder: blocks.remainder, + }; + const html = renderToStaticMarkup(); + expect(blocks.remainder).toBeGreaterThan(0); + expect(html).toContain(`${blocks.remainder} more relations are not listed.`); + const host = documentFrom(html); + expect([...host.querySelectorAll("[data-relation]")].map((node) => node.getAttribute("data-relation")).sort()).toEqual( + [...listed].sort(), + ); }); }); + +function documentFrom(html: string): Document { + return new DOMParser().parseFromString(html, "text/html"); +} diff --git a/ui/components/EntityDetail.tsx b/ui/components/EntityDetail.tsx index 3d9b4f9..1d70745 100644 --- a/ui/components/EntityDetail.tsx +++ b/ui/components/EntityDetail.tsx @@ -1,15 +1,52 @@ import { PageHeader } from "@/components/PageHeader"; -import type { EntityView } from "@/lib/catalog"; +import { RecordLink } from "@/components/RecordLink"; +import type { EntityView, RelationItem } from "@/lib/catalog"; /** * Generic record page: title, summary, sources, claims, and neighbouring relations. * OnCo EntityDetail was not copied. That file special-cases domain panels. */ + +function RelationBlock({ + headingId, + title, + relations, +}: { + headingId: string; + title: string; + relations: RelationItem[]; +}) { + return ( +
+

{title}

+ {relations.length === 0 ? ( +

No {title.toLowerCase()} relations.

+ ) : ( +
    + {relations.map((relation) => ( +
  • + {relation.wording} + {" · "} + +
  • + ))} +
+ )} +
+ ); +} + export function EntityDetail({ entity }: { entity: EntityView }) { + const currentIndex = entity.readingPath.findIndex((step) => step.id === entity.id); return ( <>
+ {entity.glossary ? ( +

+ {entity.glossary} +

+ ) : null}

{entity.summary}

{entity.tags.length > 0 ? (
    @@ -18,6 +55,28 @@ export function EntityDetail({ entity }: { entity: EntityView }) { ))}
) : null} + {entity.readingPath.length > 0 ? ( + + ) : null}

Sources

{entity.sources.length === 0 ? ( @@ -49,27 +108,47 @@ export function EntityDetail({ entity }: { entity: EntityView }) { )}
-
-

Relations

- {entity.relations.length === 0 ? ( -

No relations.

- ) : ( +
+

Relations

+ + + {entity.relationRemainder > 0 ? ( +

+ {entity.relationRemainder} more relations are not listed. +

+ ) : null} +
+ {entity.collection ? ( +
+

{entity.collection.title}

+ {entity.collection.members.length === 0 ? ( +

No members.

+ ) : ( +
    + {entity.collection.members.map((member) => ( +
  • + +
  • + ))} +
+ )} +
+ ) : null} + {entity.related.length > 0 ? ( +
+
    - {entity.relations.map((relation) => ( -
  • - {relation.wording} - {" · "} - {relation.otherHref ? ( - {relation.otherTitle} - ) : ( - relation.otherTitle - )} + {entity.related.map((item) => ( +
  • +
  • ))}
- )} -
+
+ ) : null}

+ Open graph + {" · "} Back to {entity.kindLabel}

diff --git a/ui/components/GraphExplorer.history.test.tsx b/ui/components/GraphExplorer.history.test.tsx new file mode 100644 index 0000000..ee9a212 --- /dev/null +++ b/ui/components/GraphExplorer.history.test.tsx @@ -0,0 +1,177 @@ +// @vitest-environment happy-dom +import { createRoot, type Root } from "react-dom/client"; +import { act } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { findGraph } from "@/lib/catalog"; +import { browserGraphHistory, type GraphHistory } from "@/lib/graph"; +import { GraphExplorer } from "./GraphExplorer"; + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean; +} + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root | null = null; + +afterEach(() => { + act(() => root?.unmount()); + root = null; + document.body.innerHTML = ""; +}); + +function sessionHistory(): GraphHistory & { back: () => void; forward: () => void; location: () => string } { + const stack = [""]; + let index = 0; + const listeners = new Set<() => void>(); + const notify = () => { + for (const listener of listeners) listener(); + }; + return { + read: () => (stack[index] ? `?${stack[index]}` : ""), + push: (search) => { + stack.splice(index + 1); + stack.push(search); + index = stack.length - 1; + }, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + back: () => { + if (index === 0) return; + index -= 1; + notify(); + }, + forward: () => { + if (index >= stack.length - 1) return; + index += 1; + notify(); + }, + location: () => (stack[index] ? `?${stack[index]}` : ""), + }; +} + +function focusTitle(): string { + return document.querySelector("[data-focus]")?.textContent ?? ""; +} + +function neighborhoodIds(): string[] { + return [...document.querySelectorAll("[data-neighborhood]")].map((node) => node.getAttribute("data-neighborhood") ?? ""); +} + +async function renderGraph(history: GraphHistory, initialSearch = "") { + const model = findGraph("systems-thinking"); + if (!model) throw new Error("missing graph"); + const host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => { + root?.render(); + }); + return model; +} + +describe("graph explorer history", () => { + it("changes the visible neighborhood with focus and hop distance", async () => { + const history = sessionHistory(); + const model = await renderGraph(history); + const feedback = model.nodes.find((node) => node.id.endsWith(":feedback")); + const causal = model.nodes.find((node) => node.id.endsWith(":causal-loop")); + const boundary = model.nodes.find((node) => node.id.endsWith(":system-boundary")); + if (!feedback || !causal || !boundary) throw new Error("missing fixture nodes"); + const wide = neighborhoodIds(); + expect(wide).toContain(feedback.id); + expect(wide).toContain(boundary.id); + const excluded = Number(document.querySelector("[data-excluded-count]")?.getAttribute("data-excluded-count")); + expect(excluded).toBeGreaterThan(0); + expect(document.body.textContent).toContain(`${excluded} relations outside this neighborhood`); + + await act(async () => { + document.querySelector("button[aria-pressed='false']")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(neighborhoodIds()).not.toContain(boundary.id); + expect(Number(document.querySelector("[data-excluded-count]")?.getAttribute("data-excluded-count"))).toBeGreaterThan(excluded); + + await act(async () => { + document.querySelector(`button[data-address="${causal.id}"]`)?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(focusTitle()).toBe(causal.title); + expect(neighborhoodIds()).toContain(causal.id); + expect(neighborhoodIds()).not.toEqual(wide); + const adjacency = document.querySelector("[data-adjacency]")?.textContent ?? ""; + const exported = JSON.parse(adjacency) as { edges: { id: string; predicate: string; direction: string }[] }; + expect(exported.edges.length).toBeGreaterThan(0); + expect(exported.edges.every((edge) => edge.id && edge.predicate && edge.direction === "out")).toBe(true); + }); + + it("restores focus and detail on reload, back, and forward", async () => { + const history = sessionHistory(); + const model = await renderGraph(history); + const feedback = model.nodes.find((node) => node.id.endsWith(":feedback")); + const causal = model.nodes.find((node) => node.id.endsWith(":causal-loop")); + const boundary = model.nodes.find((node) => node.id.endsWith(":system-boundary")); + if (!feedback || !causal || !boundary) throw new Error("missing fixture nodes"); + expect(focusTitle()).toBe(feedback.title); + expect(document.body.textContent).toContain("Represented by"); + expect(document.body.textContent).toContain("Modeled by"); + + await act(async () => { + document.querySelector(`button[data-focus-node="${causal.id}"]`)?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(focusTitle()).toBe(causal.title); + expect(decodeURIComponent(history.location())).toContain(causal.id); + + await act(async () => { + document.querySelector(`button[data-open-detail="${feedback.id}"]`)?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(document.querySelector("[data-detail]")?.getAttribute("data-detail")).toBe(feedback.id); + expect(focusTitle()).toBe(causal.title); + const saved = history.location(); + expect(decodeURIComponent(saved)).toContain("detail="); + expect(decodeURIComponent(saved)).toContain(causal.id); + + act(() => root?.unmount()); + root = null; + document.body.innerHTML = ""; + await renderGraph(history, saved); + expect(focusTitle()).toBe(causal.title); + expect(document.querySelector("[data-detail]")?.getAttribute("data-detail")).toBe(feedback.id); + + await act(async () => { + history.back(); + }); + expect(focusTitle()).toBe(causal.title); + expect(document.querySelector("[data-detail]")).toBeNull(); + + await act(async () => { + history.back(); + }); + expect(focusTitle()).toBe(feedback.title); + expect(neighborhoodIds()).toContain(boundary.id); + + await act(async () => { + history.forward(); + }); + expect(focusTitle()).toBe(causal.title); + expect(document.querySelector("[data-detail]")).toBeNull(); + }); + + it("reads a pushed query and a popstate traversal", () => { + const model = findGraph("systems-thinking"); + if (!model) throw new Error("missing graph"); + window.history.replaceState(null, "", "/systems-thinking/graph/"); + const history = browserGraphHistory(); + const seen: string[] = []; + const stop = history.subscribe(() => seen.push(history.read())); + const focus = model.defaultFocusId; + history.push(`focus=${encodeURIComponent(focus)}&detail=${encodeURIComponent(focus)}`); + expect(window.location.search).toContain("focus="); + expect(window.location.search).toContain("detail="); + expect(seen).toEqual([]); + window.history.pushState(null, "", `/systems-thinking/graph/?focus=${encodeURIComponent(focus)}`); + window.dispatchEvent(new PopStateEvent("popstate")); + expect(seen).toEqual([`?focus=${encodeURIComponent(focus)}`]); + stop(); + }); +}); diff --git a/ui/components/GraphExplorer.tsx b/ui/components/GraphExplorer.tsx new file mode 100644 index 0000000..d5f8b2d --- /dev/null +++ b/ui/components/GraphExplorer.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import type { GraphDataset } from "@/lib/catalog"; +import { + browserGraphHistory, + exportAdjacency, + parseGraphSearch, + partitionIncident, + selectNeighborhood, + serializeGraphSearch, + type GraphHistory, + type GraphLocation, + type GraphModel, +} from "@/lib/graph"; + +export function GraphExplorer({ + model, + initialSearch = "", + history, +}: { + model: GraphDataset; + initialSearch?: string; + history?: GraphHistory; +}) { + const viewHistory = useMemo(() => history ?? browserGraphHistory(), [history]); + const [search, setSearch] = useState(initialSearch); + const knownIds = useMemo(() => model.nodes.map((node) => node.id), [model.nodes]); + const graph: GraphModel = useMemo( + () => ({ + nodes: model.nodes.map((node) => ({ id: node.id, title: node.title })), + edges: model.edges, + }), + [model.edges, model.nodes], + ); + const byId = useMemo(() => new Map(model.nodes.map((node) => [node.id, node])), [model.nodes]); + const location = parseGraphSearch(search, knownIds, model.defaultFocusId); + const neighborhood = selectNeighborhood(graph, location.focus, location.hops); + const blocks = partitionIncident(location.focus, model.edges, model.labels); + const adjacency = exportAdjacency(neighborhood); + const focus = byId.get(location.focus); + const detail = location.detail ? byId.get(location.detail) : undefined; + + useEffect(() => { + const read = () => setSearch(viewHistory.read()); + read(); + return viewHistory.subscribe(read); + }, [viewHistory]); + + function commit(next: GraphLocation) { + const query = serializeGraphSearch(next); + viewHistory.push(query); + setSearch(query ? `?${query}` : ""); + } + + return ( +
+
+

{model.dataspaceTitle}

+

Graph

+

Focus: {focus?.title ?? location.focus}

+
+
+ + +
+

+ {neighborhood.excludedCount} relations outside this neighborhood +

+
+

Neighborhood

+
    + {neighborhood.nodeIds.map((id) => { + const node = byId.get(id); + return ( +
  • + + +
  • + ); + })} +
+
+
+

Records

+
    + {model.nodes.map((node) => ( +
  • + +
  • + ))} +
+
+
+

Outgoing

+ {blocks.outgoing.length === 0 ?

No outgoing relations.

: ( +
    + {blocks.outgoing.map((relation) => ( +
  • {relation.wording}
  • + ))} +
+ )} +
+
+

Incoming

+ {blocks.incoming.length === 0 ?

No incoming relations.

: ( +
    + {blocks.incoming.map((relation) => ( +
  • {relation.wording}
  • + ))} +
+ )} +
+ {blocks.remainder > 0 ? ( +

{blocks.remainder} more relations are not listed.

+ ) : null} + {detail ? ( +
+

{detail.title}

+

{detail.summary}

+ Open record + +
+ ) : null} +
+

Adjacency

+
{JSON.stringify(adjacency)}
+
+
+ ); +} diff --git a/ui/components/RecordLink.tsx b/ui/components/RecordLink.tsx new file mode 100644 index 0000000..3ebe591 --- /dev/null +++ b/ui/components/RecordLink.tsx @@ -0,0 +1,28 @@ +import type { EntityLink } from "@/lib/catalog"; + +/** A record link. Glossary and preview render only when that optional data is present. */ +export function RecordLink({ link }: { link: EntityLink }) { + const anchor = link.href ? ( + {link.title} + ) : ( + {link.title} + ); + return ( + + {link.glossary ? ( + + {anchor} + {link.glossary} + + ) : ( + anchor + )} + {link.preview ? ( + + {link.preview.title} + {link.preview.summary} + + ) : null} + + ); +} diff --git a/ui/components/SiteHeader.tsx b/ui/components/SiteHeader.tsx index b769c7b..4fa5314 100644 --- a/ui/components/SiteHeader.tsx +++ b/ui/components/SiteHeader.tsx @@ -22,6 +22,7 @@ export function SiteHeader({ dataspaces }: { dataspaces: DataspaceNav[] }) { {active ? (