diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e45c7e6..524eb92 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -124,6 +124,10 @@ hosts typed configuration as a modular layer over the same core. integration suite in CI but remains a pre-1.0 production candidate. Cloudflare and FoundationDB are private experimental workspace packages; shared core changes must keep them compiling, but their semantics are not part of the default conformance claim. +- `@bjacobso/triplex-workbench` is a private browser application over the public core, config, + and derivation surfaces. It composes an app-lifetime Effect layer for a workforce demo, + evaluates hypothetical edits with `Derivation.Overlay`, and commits attributed transactions. + Its product UI and workforce model stay above core; no core or backend package depends on it. - `@bjacobso/triplex-testkit` is the public home for reusable backend conformance helpers. - `test/integration` owns tests that intentionally compose multiple publishable packages. - `test/stress` owns opt-in performance and scale tests. diff --git a/README.md b/README.md index ffa20c3..fca2857 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,13 @@ hypothetical facts without mutating the source store or journal. - [Compliance host demo](examples/compliance-host) — config, facts, feed catch-up, reconciliation, hypothetical planning, and expiry-driven reopening in one standalone scenario +## Workbench + +Run the workforce operations app with `pnpm workbench` and open . +Workbench combines spreadsheet, board, and graph views with eligibility explanations, +read-only change previews, and attributed transactions over a real browser-local Triplex database. +The demo resets on reload. See [`packages/workbench`](packages/workbench) for setup and scope. + ## Explorer dashboard Run the standalone Foldkit dashboard to inspect a real browser-local Triplex database: diff --git a/package.json b/package.json index 736525b..836993e 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "test:foundationdb:integration": "pnpm --filter @bjacobso/triplex-foundationdb test:integration", "test:postgres:integration": "pnpm --filter @bjacobso/triplex-postgres test:integration", "test:watch": "turbo watch test", - "typecheck": "tsc --noEmit -p tsconfig.alchemy.json && turbo run typecheck" + "typecheck": "tsc --noEmit -p tsconfig.alchemy.json && turbo run typecheck", + "workbench": "pnpm --filter @bjacobso/triplex-workbench dev" }, "devDependencies": { "@changesets/cli": "catalog:", diff --git a/packages/workbench/README.md b/packages/workbench/README.md new file mode 100644 index 0000000..fd05c1e --- /dev/null +++ b/packages/workbench/README.md @@ -0,0 +1,36 @@ +# Workbench + +A workforce operations app over Triplex: spreadsheet views, linked objects, and explainable decisions. + +```sh +pnpm install +pnpm --filter @bjacobso/triplex build +pnpm workbench +``` + +Open . The app seeds 12 workers, three organizations, and seven tasks in a +browser-local in-memory Triplex database. Reloading starts a fresh demo. + +- Search, filter, sort, export CSV, and switch between table, I-9 board, and employer graph views. +- Click an eligibility cell to inspect source checks, the pinned rule definition, facts, and history. +- Click a state, employment status, or I-9 cell to preview an edit through `Derivation.Overlay`. +- Apply the preview as an attributed atomic transaction with a compare-and-retract precondition. +- Create a worker and explore linked organizations, tasks, the Datalog rule, and causal activity. + +The app composes public `Triples` and `ConfigStore` services in one app-lifetime Effect layer. +Eligibility is a real Datalog derivation with source provenance and a committed config release. +For non-matches, the inspector shows the individual source checks; these are not a synthesized +negative derivation proof. Previewing does not mutate the source store. A stale edited source fact +causes the commit to fail atomically. + +Workbench is a private application package above core. The existing domain-independent dashboard +remains available through `pnpm dashboard`. Workbench currently uses a fixed workforce model; +it does not yet offer arbitrary ontology authoring, durable storage, authentication, collaborative +editing, or automatic task reconciliation. Demo tasks are independent stored objects, so completing +an I-9 changes eligibility but does not automatically close its linked tasks. + +```sh +pnpm --filter @bjacobso/triplex-workbench test +pnpm --filter @bjacobso/triplex-workbench typecheck +pnpm --filter @bjacobso/triplex-workbench build +``` diff --git a/packages/workbench/index.html b/packages/workbench/index.html new file mode 100644 index 0000000..1f553df --- /dev/null +++ b/packages/workbench/index.html @@ -0,0 +1,22 @@ + + + + + + + + Workbench · Workforce operations + + + +
Opening Workbench…
+ + + diff --git a/packages/workbench/package.json b/packages/workbench/package.json new file mode 100644 index 0000000..7c57a9a --- /dev/null +++ b/packages/workbench/package.json @@ -0,0 +1,26 @@ +{ + "name": "@bjacobso/triplex-workbench", + "version": "0.1.0", + "private": true, + "description": "An operational workspace over the Triplex knowledge graph.", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 4174", + "build": "vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@bjacobso/triplex": "workspace:^", + "@fontsource-variable/ibm-plex-sans": "catalog:", + "@fontsource/ibm-plex-mono": "catalog:", + "effect": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/workbench/src/data.test.ts b/packages/workbench/src/data.test.ts new file mode 100644 index 0000000..f81a820 --- /dev/null +++ b/packages/workbench/src/data.test.ts @@ -0,0 +1,76 @@ +import { Effect, ManagedRuntime } from "effect"; +import { describe, expect, it } from "vitest"; +import { + applyChange, + createWorker, + initialize, + previewChange, + readWorkbench, + WorkbenchLayer, +} from "./data.js"; + +const makeRuntime = () => ManagedRuntime.make(WorkbenchLayer); +const withWorkspace = async (test: (runtime: ReturnType) => Promise) => { + const runtime = ManagedRuntime.make(WorkbenchLayer); + try { + await runtime.runPromise(initialize); + await test(runtime); + } finally { + await runtime.dispose(); + } +}; + +describe("Workbench operational data", () => { + it("derives eligibility from stored facts and links worker tasks", async () => { + await withWorkspace(async (runtime) => { + const data = await runtime.runPromise(readWorkbench); + expect(data.workers).toHaveLength(12); + expect(data.evaluation.candidates).toHaveLength(7); + const bob = data.workers.find((worker) => worker.name === "Bob Martinez")!; + expect(bob.eligible).toBe(false); + expect(bob.tasks).toBe(2); + expect(data.evaluation.candidates[0]!.sources.length).toBe(4); + expect(data.definition.configSnapshot).toBe(data.release); + }); + }); + it("previews without writes, applies atomically, and rejects a stale preview", async () => { + await withWorkspace(async (runtime) => { + const before = await runtime.runPromise(readWorkbench); + const bob = before.workers.find((worker) => worker.name === "Bob Martinez")!; + const preview = await runtime.runPromise(previewChange(before, bob, "i9", "Complete")); + expect(preview.eligible).toBe(true); + const untouched = await runtime.runPromise(readWorkbench); + expect(untouched.workers.find((worker) => worker.id === bob.id)!.i9).toBe("Missing"); + expect(untouched.journal).toHaveLength(before.journal.length); + const receipt = await runtime.runPromise(applyChange(preview)); + const after = await runtime.runPromise(readWorkbench); + expect(after.workers.find((worker) => worker.id === bob.id)!.eligible).toBe(true); + expect(after.journal[0]!.txId).toBe(receipt.txId); + expect(after.journal[0]!.changes.map((change) => change.op).sort()).toEqual([ + "assert", + "retract", + ]); + expect(after.journal[0]!.configSnapshot).toBe(before.release); + const stale = await runtime.runPromise(applyChange(preview).pipe(Effect.result)); + expect(stale._tag).toBe("Failure"); + expect((await runtime.runPromise(readWorkbench)).journal).toHaveLength(after.journal.length); + }); + }); + it("creates a linked worker and validates input before writing", async () => { + await withWorkspace(async (runtime) => { + const id = await runtime.runPromise(createWorker("Alex Morgan", "Analyst", "org:acme", "CA")); + const data = await runtime.runPromise(readWorkbench); + expect(data.workers.find((worker) => worker.id === id)).toMatchObject({ + name: "Alex Morgan", + employer: "org:acme", + i9: "Missing", + eligible: false, + }); + const invalid = await runtime.runPromise( + createWorker(" ", "Analyst", "org:acme", "CA").pipe(Effect.result), + ); + expect(invalid._tag).toBe("Failure"); + expect((await runtime.runPromise(readWorkbench)).workers).toHaveLength(13); + }); + }); +}); diff --git a/packages/workbench/src/data.ts b/packages/workbench/src/data.ts new file mode 100644 index 0000000..0121b71 --- /dev/null +++ b/packages/workbench/src/data.ts @@ -0,0 +1,271 @@ +import { + EntityId, + KvTriples, + Triples, + boolean, + ref, + string, + type DatalogQuery, + type Triple, + type TripleInput, +} from "@bjacobso/triplex"; +import { ConfigNode, ConfigStore, TypeExpr } from "@bjacobso/triplex/config"; +import * as Derivation from "@bjacobso/triplex/derivation"; +import { Effect, Layer, Option } from "effect"; + +export const WorkbenchLayer = ConfigStore.layer.pipe( + Layer.provideMerge(KvTriples.layerWithScope("workbench")), +); +export const eligibilityQuery: DatalogQuery = { + find: ["?worker", "?state"], + where: [ + ["?worker", ":worker/status", "Active"], + ["?worker", ":worker/i9", "Complete"], + ["?worker", ":worker/blocked", false], + ["?worker", ":worker/state", "?state"], + ], +}; + +const people = [ + ["Alice Chen", "Product designer", "acme", "CA", "Complete", "Active", false], + ["Bob Martinez", "Operations specialist", "acme", "NY", "Missing", "Active", false], + ["Maria Garcia", "Software engineer", "globex", "TX", "Complete", "Active", false], + ["James Wilson", "Account executive", "acme", "CA", "Complete", "Active", false], + ["Priya Sharma", "Data analyst", "vertex", "NY", "In review", "Active", false], + ["Noah Williams", "Customer success", "globex", "WA", "Complete", "Active", false], + ["Olivia Brown", "Marketing manager", "vertex", "CA", "Complete", "Active", false], + ["Ethan Davis", "Finance associate", "acme", "TX", "Missing", "Active", false], + ["Sofia Rodriguez", "People partner", "globex", "NY", "Complete", "Active", false], + ["Liam Anderson", "Product manager", "vertex", "WA", "Complete", "On leave", false], + ["Ava Thompson", "Software engineer", "acme", "CA", "Complete", "Active", false], + ["Oliver Kim", "Security engineer", "globex", "TX", "Complete", "Active", true], +] as const; +export const organizations = [ + { id: "org:acme", name: "Acme Corporation", sector: "Technology", color: "purple" }, + { id: "org:globex", name: "Globex Industries", sector: "Manufacturing", color: "blue" }, + { id: "org:vertex", name: "Vertex Labs", sector: "Research & development", color: "orange" }, +]; +const fact = ( + id: string, + type: string, + attribute: string, + value: TripleInput["value"], +): TripleInput => ({ entityId: EntityId.make(id), entityType: type, attribute, value }); +export const initialize = Effect.gen(function* () { + const triples = yield* Triples; + const config = yield* ConfigStore.ConfigStore; + if (yield* config.resolveRef("live")) return; + const rule = yield* ConfigNode.makeTyped({ + kind: "derivation", + key: "worker.eligible", + type: TypeExpr.any, + attrs: { label: "Worker eligibility", query: eligibilityQuery, version: 1 }, + }); + const release = yield* config.commit({ label: "workforce-v1", objects: [rule], ref: "live" }); + const facts: TripleInput[] = organizations.flatMap((org) => [ + fact(org.id, "Organization", ":org/name", string(org.name)), + fact(org.id, "Organization", ":org/sector", string(org.sector)), + ]); + people.forEach(([name, role, employer, state, i9, status, blocked], index) => { + const id = `worker:${index + 1}`; + facts.push( + ...Object.entries({ name, role, state, i9, status }).map(([key, value]) => + fact(id, "Worker", `:worker/${key}`, string(value)), + ), + fact(id, "Worker", ":worker/employer", ref(EntityId.make(`org:${employer}`))), + fact(id, "Worker", ":worker/blocked", boolean(blocked)), + ); + const titles = + i9 === "Missing" + ? ["Collect I-9 documentation", "Verify employment authorization"] + : i9 === "In review" + ? ["Review I-9 documentation"] + : blocked + ? ["Resolve blocking violation"] + : index === 2 + ? ["Complete welcome orientation"] + : []; + titles.forEach((title, taskIndex) => { + const task = `task:${index + 1}:${taskIndex}`; + facts.push( + fact(task, "Task", ":task/title", string(title)), + fact(task, "Task", ":task/worker", ref(EntityId.make(id))), + fact(task, "Task", ":task/status", string("Open")), + ); + }); + }); + yield* triples.transact( + facts.map((input) => ({ op: "assert" as const, ...input })), + { + actor: "workbench/demo", + commandId: "workbench/seed/v1", + configSnapshot: release.snapshot.id, + }, + ); +}); + +export const textValue = (fact: Triple | undefined): string => + fact ? String(fact.value.value) : ""; +export interface Worker { + id: string; + name: string; + role: string; + employer: string; + state: string; + i9: string; + status: string; + blocked: boolean; + eligible: boolean; + tasks: number; + facts: readonly Triple[]; +} +export const readWorkbench = Effect.gen(function* () { + const triples = yield* Triples; + const config = yield* ConfigStore.ConfigStore; + const release = yield* config.resolveRef("live"); + if (!release) return yield* Effect.fail(new Error("The workforce configuration is missing.")); + const basis = { validAt: Date.now(), recordedAt: Date.now() }; + const definition = yield* Derivation.make({ + name: "worker.eligible", + query: eligibilityQuery, + identity: ["?worker"], + configSnapshot: release.id, + }); + const [facts, evaluation, journal] = yield* Effect.all([ + triples.match({}, basis), + Derivation.evaluate(triples, definition, { basis }), + triples.transactions({ after: 0, limit: 1000 }), + ]); + const get = (rows: readonly Triple[], attr: string) => + textValue(rows.find((row) => row.attribute === attr)); + const tasks = [ + ...new Set( + facts.filter((row) => Option.getOrNull(row.entityType) === "Task").map((row) => row.entityId), + ), + ].map((id) => { + const rows = facts.filter((row) => row.entityId === id); + return { + id, + title: get(rows, ":task/title"), + worker: get(rows, ":task/worker"), + status: get(rows, ":task/status"), + }; + }); + const workers: Worker[] = [ + ...new Set( + facts + .filter((row) => Option.getOrNull(row.entityType) === "Worker") + .map((row) => row.entityId), + ), + ] + .map((id) => { + const rows = facts.filter((row) => row.entityId === id); + return { + id, + name: get(rows, ":worker/name"), + role: get(rows, ":worker/role"), + employer: get(rows, ":worker/employer"), + state: get(rows, ":worker/state"), + i9: get(rows, ":worker/i9"), + status: get(rows, ":worker/status"), + blocked: get(rows, ":worker/blocked") === "true", + eligible: evaluation.candidates.some((candidate) => candidate.identity["?worker"] === id), + tasks: tasks.filter((task) => task.worker === id && task.status === "Open").length, + facts: rows, + }; + }) + .sort((a, b) => Number(a.id.split(":")[1]) - Number(b.id.split(":")[1])); + return { + workers, + tasks, + evaluation, + definition, + journal: [...journal.transactions].reverse(), + basis, + release: release.id, + }; +}); +export type WorkbenchData = Effect.Success; +export type EditableField = "state" | "i9" | "status"; +export const fieldOptions: Record = { + state: ["CA", "NY", "TX", "WA"], + i9: ["Complete", "In review", "Missing"], + status: ["Active", "On leave"], +}; +export const previewChange = ( + data: WorkbenchData, + worker: Worker, + field: EditableField, + value: string, +) => + Effect.gen(function* () { + if (!fieldOptions[field].includes(value)) + return yield* Effect.fail(new Error("Choose a supported value.")); + const previous = worker.facts.find((row) => row.attribute === `:worker/${field}`); + if (!previous) + return yield* Effect.fail(new Error("The source fact is missing. Refresh and try again.")); + const assertion = fact(worker.id, "Worker", `:worker/${field}`, string(value)); + const triples = yield* Triples; + const evaluation = yield* Derivation.Overlay.evaluateOverlay(triples, data.definition, { + basis: data.basis, + overlay: { retractions: [previous.id], assertions: [assertion] }, + }); + return { + worker, + field, + value, + previous, + assertion, + eligible: evaluation.candidates.some( + (candidate) => candidate.identity["?worker"] === worker.id, + ), + configSnapshot: data.release, + }; + }); +export type ChangePreview = Effect.Success>; +export const applyChange = (preview: ChangePreview) => + Effect.gen(function* () { + const triples = yield* Triples; + return yield* triples.transact( + [ + { op: "retract", id: preview.previous.id }, + { op: "assert", ...preview.assertion }, + ], + { + actor: "workbench/local-user", + commandId: `workbench/edit/${crypto.randomUUID()}`, + configSnapshot: preview.configSnapshot, + preconditions: [{ _tag: "TripleLive", id: preview.previous.id }], + }, + ); + }); +export const createWorker = (name: string, role: string, employer: string, state: string) => + Effect.gen(function* () { + if ( + !name.trim() || + !role.trim() || + !organizations.some((org) => org.id === employer) || + !fieldOptions.state.includes(state) + ) + return yield* Effect.fail( + new Error("Enter a name, role, organization, and supported state."), + ); + const triples = yield* Triples; + const id = `worker:${crypto.randomUUID()}`; + const inputs = [ + ...Object.entries({ + name: name.trim(), + role: role.trim(), + state, + i9: "Missing", + status: "Active", + }).map(([key, value]) => fact(id, "Worker", `:worker/${key}`, string(value))), + fact(id, "Worker", ":worker/employer", ref(EntityId.make(employer))), + fact(id, "Worker", ":worker/blocked", boolean(false)), + ]; + yield* triples.transact( + inputs.map((input) => ({ op: "assert" as const, ...input })), + { actor: "workbench/local-user", commandId: `workbench/create/${id}` }, + ); + return id; + }); diff --git a/packages/workbench/src/icons.ts b/packages/workbench/src/icons.ts new file mode 100644 index 0000000..8b33f36 --- /dev/null +++ b/packages/workbench/src/icons.ts @@ -0,0 +1,32 @@ +const paths: Record = { + grid: '', + people: + '', + building: + '', + check: '', + tasks: + '', + graph: + '', + clock: '', + search: '', + chevron: '', + down: '', + plus: '', + filter: '', + sort: '', + columns: '', + arrow: '', + close: '', + download: '', + book: '', + code: '', + board: + '', + spark: '', + info: '', + link: '', +}; +export const icon = (name: string, size = 16) => + ``; diff --git a/packages/workbench/src/main.ts b/packages/workbench/src/main.ts new file mode 100644 index 0000000..b3c25a9 --- /dev/null +++ b/packages/workbench/src/main.ts @@ -0,0 +1,497 @@ +import { ManagedRuntime } from "effect"; +import { + applyChange, + createWorker, + fieldOptions, + initialize, + organizations, + previewChange, + readWorkbench, + textValue, + WorkbenchLayer, + type ChangePreview, + type EditableField, + type WorkbenchData, + type Worker, +} from "./data.js"; +import { icon } from "./icons.js"; +import "@fontsource-variable/ibm-plex-sans"; +import "@fontsource/ibm-plex-mono/400.css"; +import "./styles.css"; + +const runtime = ManagedRuntime.make(WorkbenchLayer); +const app = document.querySelector("#app")!; +let data: WorkbenchData; +let selected = "worker:2"; +let page = "workers"; +let view = "table"; +let query = ""; +let filter = "all"; +let sort = false; +let showRole = true; +let inspectorTab = "explanation"; +let modal: "create" | "edit" | "help" | null = null; +let preview: ChangePreview | null = null; +let busy = false; +let toast = ""; +let error = ""; +let returnFocus: HTMLElement | null = null; +let noticeTimer: ReturnType; +const esc = (value: unknown) => + String(value).replace( + /[&<>"']/g, + (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!, + ); +const initials = (name: string) => + name + .split(" ") + .map((part) => part[0]) + .slice(0, 2) + .join(""); +const orgFor = (worker: Worker) => organizations.find((org) => org.id === worker.employer)!; +const btn = (action: string, label: string, symbol = "", cls = "", extra = "") => + ``; +const pill = (label: string, kind = "neutral") => + `${esc(label)}`; +const eligiblePill = (eligible: boolean) => + `${icon(eligible ? "check" : "close", 13)}${eligible ? "Eligible" : "Not eligible"}`; +const orgBadge = (worker: Worker) => + `${orgFor(worker).name[0]}${esc(orgFor(worker).name)}`; +const visibleWorkers = () => + data.workers + .filter( + (worker) => + `${worker.name} ${worker.role} ${orgFor(worker).name} ${worker.state}` + .toLowerCase() + .includes(query.toLowerCase()) && + (filter === "all" || (filter === "eligible" ? worker.eligible : !worker.eligible)), + ) + .sort((a, b) => (sort ? a.name.localeCompare(b.name) : 0)); +const notice = (message: string) => { + toast = message; + clearTimeout(noticeTimer); + noticeTimer = setTimeout(() => { + toast = ""; + render(); + }, 5000); +}; +const run = async (work: () => Promise) => { + if (busy) return; + busy = true; + error = ""; + render(); + try { + await work(); + } catch (cause) { + error = cause instanceof Error ? cause.message : String(cause); + } finally { + busy = false; + render(); + } +}; +const refresh = async () => { + data = await runtime.runPromise(readWorkbench); +}; + +function sidebar() { + const nav = (id: string, label: string, symbol: string, count?: number) => + btn( + `page:${id}`, + `${label}${count === undefined ? "" : `${count}`}`, + symbol, + `nav-item ${page === id ? "active" : ""}`, + `aria-label="${label}" ${page === id ? 'aria-current="page"' : ""}`, + ); + return ``; +} +function header() { + const title = ( + { + workers: "Workers", + organizations: "Organizations", + tasks: "Tasks", + ontology: "Ontology", + rules: "Rules & derivations", + activity: "Activity", + } as Record + )[page]; + return `
Browser-local demo${btn("help", "", "info", "icon-button", 'aria-label="About this workspace"')}YO
`; +} +function heading(title: string, subtitle: string, symbol: string, actions = "") { + return `
${icon(symbol, 25)}
WORKFORCE OPERATIONS

${title}

${subtitle}

${actions}
`; +} +function stats() { + const eligible = data.workers.filter((worker) => worker.eligible).length; + const cards = [ + ["Total workers", data.workers.length, "Across 3 organizations", "people", ""], + [ + "Eligible to work", + eligible, + `${Math.round((eligible / data.workers.length) * 100)}% of your workforce`, + "check", + "green", + ], + [ + "Needs attention", + data.workers.length - eligible, + "Review eligibility blockers", + "spark", + "amber", + ], + [ + "Open tasks", + data.tasks.filter((task) => task.status === "Open").length, + "Linked to your workers", + "tasks", + "", + ], + ]; + return `
${cards.map(([label, value, caption, symbol, color]) => `
${label}${icon(String(symbol), 15)}
${value}${icon(String(symbol), 25)}
${caption}
`).join("")}
`; +} +function workerTable() { + const rows = visibleWorkers(); + return `
${rows.map((worker, index) => ``).join("")}
#${icon("people", 13)} Worker${icon("link", 13)} EmployerStateStatusI-9 verificationƒ Eligibilityƒ Tasks
${String(index + 1).padStart(2, "0")}${btn(`edit:${worker.id}:state`, esc(worker.state), "", "editable", 'aria-label="Edit state for ' + esc(worker.name) + '"')}${btn(`edit:${worker.id}:status`, pill(worker.status, worker.status === "Active" ? "green" : "neutral"), "", "cell-button")}${btn(`edit:${worker.id}:i9`, `${icon(worker.i9 === "Complete" ? "check" : worker.i9 === "Missing" ? "close" : "clock", 13)}${esc(worker.i9)}`, "", "cell-button")}${btn(`select:${worker.id}`, eligiblePill(worker.eligible), "", "cell-button", 'aria-label="Explain eligibility for ' + esc(worker.name) + '"')}${btn(`tasks:${worker.id}`, String(worker.tasks).padStart(2, "0"), "", `task-count ${worker.tasks ? "has-tasks" : ""}`)}
${rows.length ? "" : `
${icon("search", 30)}

No workers found

Try a different search or clear your filter.

${btn("reset", "Clear search & filters", "", "button")}
`}
`; +} +function graph() { + const rows = visibleWorkers(); + return `
${icon("graph")} Workforce relationships Click a worker to inspect
${organizations + .map( + (org) => + `
${org.name[0]}${org.name}Organization
${rows + .filter((worker) => worker.employer === org.id) + .map( + (worker) => + ``, + ) + .join("")}
`, + ) + .join( + "", + )}
Eligible Needs attention Lines represent employer references stored in Triplex
`; +} +function board() { + return `
${["Complete", "In review", "Missing"] + .map((status) => { + const workers = visibleWorkers().filter((worker) => worker.i9 === status); + return `

${pill(status, status === "Complete" ? "green" : "amber")}${workers.length}

${workers.map((worker) => ``).join("") || '

No workers in this stage.

'}
`; + }) + .join("")}
`; +} +function inspector() { + const worker = data.workers.find((row) => row.id === selected); + if (!worker) return ""; + const candidate = data.evaluation.candidates.find( + (item) => item.identity["?worker"] === worker.id, + ); + const checks = [ + ["Employment is active", worker.status === "Active", ":worker/status", worker.status], + ["I-9 verification is complete", worker.i9 === "Complete", ":worker/i9", worker.i9], + [ + "No blocking violations", + !worker.blocked, + ":worker/blocked", + worker.blocked ? "Blocking violation" : "None", + ], + ] as const; + return ``; +} +function workersPage() { + return `${heading("Workers", "Your workforce, connected. Explore objects, understand decisions, and act with context.", "people", btn("export", "Export", "download", "button") + btn("create", "Add worker", "plus", "button primary"))}${stats()}
${[ + ["table", "Table", "grid"], + ["board", "Board", "board"], + ["graph", "Graph", "graph"], + ] + .map(([id, label, symbol]) => btn(`view:${id}`, label!, symbol, view === id ? "active" : "")) + .join( + "", + )}
Live view
${btn("sort", "Sort", "sort", sort ? "tool-active" : "", `aria-pressed="${sort}"`)}${btn("columns", "Roles", "columns", showRole ? "" : "tool-active", `aria-pressed="${showRole}"`)}
${view === "table" ? workerTable() : view === "graph" ? graph() : board()}
${inspector()}
${icon("info", 14)} Click an eligibility cell to see why. Click a state or verification value to preview a change.POWERED BY triplex
`; +} +function otherPage() { + if (page === "organizations") + return `${heading("Organizations", "The organizations connected to your workforce.", "building")}
${organizations + .map((org) => { + const workers = data.workers.filter((worker) => worker.employer === org.id); + return `
${org.name[0]}

${org.name}

${org.sector}

${workers.length} workers · ${workers.filter((worker) => worker.eligible).length} eligible
${btn(`org:${org.id}`, "Explore workers", "arrow", "button")}
`; + }) + .join("")}
`; + if (page === "ontology") + return `${heading("Ontology", "Objects and relationships that describe your operational world.", "graph")}${graph()}`; + if (page === "rules") + return `${heading("Rules & derivations", "Understand the logic behind every derived eligibility result.", "code")}
${pill("Live", "green")} workforce-v1

Worker eligibility

A worker is eligible when their employment is active, their I-9 is complete, and they have no blocking violations.

${esc(JSON.stringify(data.definition.query, null, 2))}
${btn("page:workers", "Explore results", "arrow", "button")}
`; + if (page === "tasks") { + const tasks = data.tasks.filter((task) => !query || task.worker === query); + return `${heading("Tasks", "Open work linked to the people it belongs to. Demo tasks are managed independently of eligibility.", "tasks", query ? btn("all-tasks", "Show all tasks", "", "button") : "")}
${ + tasks + .map((task) => { + const worker = data.workers.find((row) => row.id === task.worker)!; + return `
${icon("tasks", 22)}

${esc(task.title)}

${esc(worker.name)} · ${esc(orgFor(worker).name)}

${pill(task.status, "amber")}${btn(`worker:${worker.id}`, "View worker", "arrow", "button")}
`; + }) + .join("") || + '

No open tasks

This worker has no linked tasks.

' + }
`; + } + return `${heading("Activity", "An attributed, atomic history of changes to your workspace.", "clock")}
${data.journal + .filter((tx) => tx.actor?.startsWith("workbench/")) + .map( + (tx) => + `
${icon("clock", 20)}

${tx.actor === "workbench/demo" ? "Demo workforce imported" : tx.commandId?.includes("create") ? "Worker created" : "Worker facts updated"}

${esc(tx.actor)} · ${new Date(tx.instant).toLocaleString()}

${esc(tx.txId)}
${tx.changes.length} fact changes · position ${tx.position}${tx.changes.map((change) => `
${change.op === "assert" ? "+" : "−"} ${esc(change.entityId)} ${esc(change.attribute)} = ${esc(change.value?.value)}
`).join("")}
${pill("Committed", "green")}
`, + ) + .join("")}
`; +} +let editField: EditableField = "i9"; +let editValue = "Missing"; +let createDraft = { name: "", role: "", employer: "org:acme", state: "CA" }; +function dialog() { + if (!modal) return ""; + const worker = data.workers.find((row) => row.id === selected)!; + const selectOptions = (items: readonly string[], current: string) => + items + .map((item) => ``) + .join(""); + const body = + modal === "help" + ? `
${icon("spark", 26)}

A spreadsheet with context.

Workbench connects the table you work in to the world it describes.

01 · Explore your workforceSearch, filter, and switch between table, board, and graph views.

02 · Follow the reasoningClick an eligibility cell to inspect its rule and source facts.

03 · Try a changeEdit a state, status, or I-9 value. Review the hypothetical result, then apply it as one attributed transaction.

This demo runs in your browser. Reloading resets the data. It is not connected to a production workforce system.
${btn("dismiss", "Start exploring", "arrow", "button primary full")}` + : modal === "create" + ? `

Add a worker

Create a new object in your workforce graph.

New workers start active with a missing I-9. You can preview verification changes from the table.
${btn("dismiss", "Cancel", "", "button")}
` + : `
${icon("spark", 24)}
HYPOTHETICAL OVERLAY

Preview a change

See what changes for ${esc(worker.name)} before applying.

${preview ? `

${esc(textValue(preview.previous))} ${icon("arrow")} ${esc(preview.value)}

${eligiblePill(worker.eligible)} ${icon("arrow")} ${eligiblePill(preview.eligible)}

${worker.eligible === preview.eligible ? "Eligibility is unchanged by this edit." : preview.eligible ? "This worker would become eligible to work." : "This worker would no longer be eligible to work."}
Evaluated against a pinned snapshot. Applying records the old and new facts in one transaction. Linked demo tasks remain independently managed.
` : '
Your proposed edit is evaluated without changing any stored facts.
'}
${btn("dismiss", "Cancel", "", "button")}${preview ? btn("apply", busy ? "Applying…" : "Apply change", "check", "button primary", busy ? "disabled" : "") : ``}
`; + return ``; +} +function render() { + if (!data) return; + const active = document.activeElement as HTMLInputElement | null; + const focusId = active?.id; + const selection = active?.tagName === "INPUT" ? active.selectionStart : null; + app.innerHTML = `${sidebar()}
${header()}
${page === "workers" ? workersPage() : otherPage()}
All changes committed locally${data.workers.length + organizations.length + data.tasks.length} objects/${data.release.slice(0, 19)}…/Triplex engine
${dialog()}${toast ? `
${icon("check")}${esc(toast)}
` : ""}${error && !modal ? `` : ""}`; + if (focusId) { + const input = document.getElementById(focusId) as HTMLInputElement | null; + input?.focus(); + if (selection !== null && input?.tagName === "INPUT") + input.setSelectionRange(selection, selection); + } +} +function openModal(kind: typeof modal) { + returnFocus = document.activeElement as HTMLElement; + modal = kind; + preview = null; + error = ""; + render(); + app.querySelector(".dialog input, .dialog select, .dialog .button")?.focus(); +} +function closeModal() { + modal = null; + preview = null; + error = ""; + render(); + const action = returnFocus?.dataset["action"]; + if (action) + [...app.querySelectorAll("[data-action]")] + .find((node) => node.dataset["action"] === action) + ?.focus(); +} +app.addEventListener("click", (event) => { + const button = (event.target as HTMLElement).closest("[data-action]"); + const action = button?.dataset["action"]; + if (!action || busy) return; + if (action.startsWith("page:")) { + page = action.slice(5); + query = ""; + } else if (action.startsWith("view:")) view = action.slice(5); + else if (action.startsWith("select:")) { + selected = action.slice(7); + if (page === "ontology") { + page = "workers"; + view = "graph"; + query = ""; + filter = "all"; + } + inspectorTab = "explanation"; + } else if (action.startsWith("worker:")) { + selected = action.slice(7); + page = "workers"; + query = ""; + } else if (action.startsWith("inspect:")) inspectorTab = action.slice(8); + else if (action.startsWith("tasks:")) { + page = "tasks"; + query = action.slice(6); + } else if (action.startsWith("org:")) { + page = "workers"; + query = organizations.find((org) => org.id === action.slice(4))!.name; + filter = "all"; + } else if (action.startsWith("edit:")) { + const parts = action.slice(5).split(":"); + editField = parts.pop() as EditableField; + selected = parts.join(":"); + editValue = data.workers.find((worker) => worker.id === selected)![editField]; + openModal("edit"); + return; + } else if (action === "create" || action === "help") { + if (action === "create") + createDraft = { name: "", role: "", employer: "org:acme", state: "CA" }; + openModal(action); + return; + } else if (action === "dismiss") { + closeModal(); + return; + } else if (action === "sort") sort = !sort; + else if (action === "columns") showRole = !showRole; + else if (action === "close-inspector") selected = ""; + else if (action === "reset") { + query = ""; + filter = "all"; + } else if (action === "all-tasks") query = ""; + else if (action === "clear-error") error = ""; + else if (action === "apply" && preview) { + const change = preview; + void run(async () => { + await runtime.runPromise(applyChange(change)); + await refresh(); + modal = null; + preview = null; + notice(`Updated ${change.worker.name}. Change recorded in Activity.`); + }); + return; + } else if (action === "export") { + const cell = (value: string) => `"${value.replaceAll('"', '""').replace(/^[=+@-]/, "'$&")}"`; + const csv = [ + ["Worker", "Role", "Employer", "State", "Status", "I-9", "Eligible", "Open tasks"], + ...visibleWorkers().map((worker) => [ + worker.name, + worker.role, + orgFor(worker).name, + worker.state, + worker.status, + worker.i9, + String(worker.eligible), + String(worker.tasks), + ]), + ] + .map((row) => row.map(cell).join(",")) + .join("\r\n"); + const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8;" })); + const link = document.createElement("a"); + link.href = url; + link.download = "workbench-workers.csv"; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + notice("Exported the current worker view."); + } + render(); +}); +app.addEventListener("input", (event) => { + const target = event.target as HTMLInputElement; + if (target.id === "worker-search") { + query = target.value; + render(); + } else if (target.closest("#create-form")) { + createDraft = { ...createDraft, [target.name]: target.value }; + } +}); +app.addEventListener("change", (event) => { + const target = event.target as HTMLSelectElement; + if (target.id === "filter") { + filter = target.value; + render(); + } else if (target.id === "edit-field") { + editField = target.value as EditableField; + editValue = data.workers.find((worker) => worker.id === selected)![editField]; + preview = null; + render(); + } else if (target.closest("#preview-form")) { + editValue = target.value; + preview = null; + render(); + } else if (target.closest("#create-form")) { + createDraft = { ...createDraft, [target.name]: target.value }; + } +}); +app.addEventListener("submit", (event) => { + event.preventDefault(); + const form = event.target as HTMLFormElement; + const values = new FormData(form); + if (form.id === "create-form") + void run(async () => { + selected = await runtime.runPromise( + createWorker( + String(values.get("name")), + String(values.get("role")), + String(values.get("employer")), + String(values.get("state")), + ), + ); + await refresh(); + modal = null; + query = ""; + filter = "all"; + notice("Worker added to the graph."); + }); + else if (form.id === "preview-form") + void run(async () => { + const worker = data.workers.find((row) => row.id === selected)!; + const value = String(values.get("value")); + if (worker[editField] === value) + throw new Error("Choose a different value to preview a change."); + preview = await runtime.runPromise(previewChange(data, worker, editField, value)); + }); +}); +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && modal && !busy) closeModal(); + if ( + event.key === "/" && + !modal && + !(event.target instanceof HTMLInputElement) && + !(event.target instanceof HTMLSelectElement) + ) { + event.preventDefault(); + document.getElementById("worker-search")?.focus(); + } + if (event.key === "Tab" && modal) { + const nodes = [ + ...app.querySelectorAll( + ".dialog button:not(:disabled), .dialog input, .dialog select", + ), + ]; + const first = nodes[0]; + const last = nodes.at(-1); + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } + } +}); +window.addEventListener("pagehide", () => { + void runtime.dispose(); +}); +void (async () => { + try { + await runtime.runPromise(initialize); + await refresh(); + render(); + } catch (cause) { + app.innerHTML = `

Workbench couldn’t start

${esc(cause)}

`; + } +})(); diff --git a/packages/workbench/src/styles.css b/packages/workbench/src/styles.css new file mode 100644 index 0000000..d2573a3 --- /dev/null +++ b/packages/workbench/src/styles.css @@ -0,0 +1,2208 @@ +:root { + font-family: "IBM Plex Sans Variable", sans-serif; + color: #28372f; + background: #fbfcfa; + font-synthesis: none; + font-size: 13px; + --green: #176b50; + --border: #e4e8e1; + --muted: #829087; + --sidebar: 224px; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +button, +input, +select { + font: inherit; +} +button, +a, +input, +select { + -webkit-tap-highlight-color: transparent; +} +button { + cursor: pointer; + color: inherit; + background: none; + border: 0; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; +} +button:disabled { + opacity: 0.55; + cursor: wait; +} +button:hover { + color: var(--green); +} +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid #378b6d; + outline-offset: 3px; +} +button svg { + flex-shrink: 0; +} +a { + color: inherit; + text-decoration: none; +} +h1, +h2, +h3, +p { + margin: 0; +} +svg { + vertical-align: middle; + flex-shrink: 0; +} +code, +kbd, +.object-id, +.row-number, +.task-count, +.metadata dd { + font-family: "IBM Plex Mono", monospace; +} +input, +select { + color: inherit; +} +input { + min-width: 0; +} +.sidebar { + width: var(--sidebar); + position: fixed; + inset: 0 auto 0 0; + background: #f1f3ed; + border-right: 1px solid #dfe4da; + display: flex; + flex-direction: column; + padding: 26px 16px 0; + z-index: 5; +} +.brand { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 22px; + letter-spacing: -1px; + margin: 0 7px 29px; +} +.brand-mark { + display: flex; + position: relative; + align-items: center; + justify-content: center; + width: 29px; + height: 30px; + color: white; + background: var(--green); + border-radius: 7px; + font-weight: 500; + font-size: 26px; + line-height: 1; + padding-bottom: 6px; + letter-spacing: -3px; +} +.brand-mark span { + font-size: 20px; + position: absolute; + right: 3px; + bottom: 1px; +} +.beta { + font-size: 8px; + letter-spacing: 0.8px; + color: #788578; + border: 1px solid #cdd6c8; + padding: 2px 3px; + border-radius: 3px; + align-self: center; + margin-left: 1px; +} +.workspace { + display: flex; + align-items: center; + gap: 10px; + background: #f9faf6; + border: 1px solid #dfe5d8; + padding: 11px 9px; + border-radius: 7px; + font-weight: 500; + font-size: 12px; +} +.workspace-mark { + display: grid; + place-items: center; + width: 29px; + height: 30px; + border: 1px solid #d4ddce; + background: #e9eee1; + border-radius: 6px; + font-size: 17px; + font-family: Georgia, serif; +} +.workspace div span, +.profile div span { + display: block; + color: #889084; + font-size: 10px; + margin-top: 3px; + font-weight: 400; +} +.workspace-dot { + width: 5px; + height: 5px; + background: #7b9870; + border-radius: 50%; + margin-left: auto; +} +.nav-section { + color: #899182; + font-size: 9px; + letter-spacing: 1.45px; + font-weight: 600; + margin: 27px 10px 10px; +} +.nav-section.second { + margin-top: 31px; +} +nav { + display: grid; + gap: 4px; +} +.nav-item { + width: 100%; + justify-content: flex-start; + padding: 11px 12px; + font-size: 12px; + border-radius: 5px; + color: #707c70; + gap: 11px; +} +.nav-item svg { + width: 16px; +} +.nav-item.active { + background: #e1eade; + color: #236047; + font-weight: 500; +} +.nav-item.active svg { + color: #246b50; +} +.nav-item:hover { + background: #e7ece1; +} +.nav-count { + margin-left: auto; + color: #7c8b77; + font-size: 10px; +} +.nav-item.active .nav-count { + background: #d4e1cf; + padding: 1px 5px; + border-radius: 4px; + color: #486e42; +} +.sidebar-bottom { + margin-top: auto; + padding-top: 50px; +} +.connected-card { + border: 1px solid #dce2d5; + border-radius: 6px; + margin: 0 4px 20px; + padding: 14px 12px; + background: linear-gradient(125deg, #f3f5eb, #e9efdf); +} +.connected-card > .live-dot { + margin-bottom: 10px; +} +.connected-card strong { + font-size: 11px; + display: block; + font-weight: 500; +} +.connected-card p { + color: #819078; + font-size: 11px; + line-height: 1.7; + margin-top: 6px; +} +.text-button { + font-size: 10px; + font-weight: 500; + color: #547849; + flex-direction: row-reverse; + margin-top: 10px; + padding: 0; +} +.text-button svg { + width: 13px; +} +.guide { + font-size: 11px; + padding-bottom: 20px; +} +.profile { + border-top: 1px solid #dde3d6; + display: flex; + align-items: center; + gap: 9px; + margin: 0 -16px; + padding: 17px 20px; + font-size: 12px; +} +.local-label { + margin-left: auto; + color: #94a08b; + font-size: 8px; + letter-spacing: 1px; +} +.avatar { + width: 30px; + height: 30px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + background: #e7ece6; + color: #75866f; + flex-shrink: 0; + letter-spacing: 0.3px; +} +.profile-avatar { + background: #e0e7d7; + color: #587449; +} +.avatar.tiny { + width: 25px; + height: 25px; + font-size: 9px; + background: #e8eddf; +} +.main-shell { + margin-left: var(--sidebar); + min-height: 100vh; + display: flex; + flex-direction: column; +} +.topbar { + height: 61px; + border-bottom: 1px solid var(--border); + padding: 0 31px; + display: flex; + justify-content: space-between; + align-items: center; + background: #fff; +} +.breadcrumb, +.topbar-right { + display: flex; + align-items: center; + gap: 14px; +} +.breadcrumb { + font-size: 11px; + color: #99a197; +} +.breadcrumb strong { + font-weight: 400; + color: #4b584d; +} +.breadcrumb > svg:first-child { + color: #72816f; +} +.topbar-right { + gap: 20px; +} +.local-status { + font-size: 10px; + color: #899582; + display: flex; + gap: 7px; + align-items: center; +} +.live-dot { + display: inline-block; + width: 5px; + height: 5px; + background: #68a070; + border-radius: 50%; + flex-shrink: 0; +} +.icon-button { + padding: 5px; + color: #86917f; +} +.topbar .icon-button { + border-left: 1px solid var(--border); + padding-left: 20px; +} +main { + padding: 32px 30px 24px; + flex: 1; + min-width: 0; +} +.page-heading { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 27px; +} +.title-icon { + background: #e9f0e7; + border: 1px solid #dce8d9; + color: #4b7956; + width: 49px; + height: 49px; + border-radius: 11px; + display: grid; + place-items: center; +} +.eyebrow { + font-size: 8px; + letter-spacing: 1.7px; + color: #899580; + font-weight: 500; + margin-bottom: 5px; +} +h1 { + font-weight: 500; + font-size: 29px; + letter-spacing: -0.7px; + line-height: 1.1; +} +.page-heading p { + font-size: 11px; + color: #85907f; + margin-top: 10px; +} +.heading-actions { + margin-left: auto; + display: flex; + gap: 9px; + align-self: center; +} +.button { + background: #fff; + border: 1px solid #dce3d8; + border-radius: 5px; + min-height: 33px; + padding: 8px 12px; + font-size: 11px; + font-weight: 500; + white-space: nowrap; +} +.button:hover { + background: #f3f7ef; + border-color: #b7cbb5; +} +.button.primary { + color: white; + background: var(--green); + border-color: var(--green); + box-shadow: 0 1px 2px #0f49301a; +} +.button.primary:hover { + background: #10533d; +} +.stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + border: 1px solid var(--border); + border-radius: 7px; + overflow: hidden; + background: white; + margin-bottom: 27px; + box-shadow: 0 2px 4px #1a3b1403; +} +.stat { + padding: 17px 20px 15px; + position: relative; + border-right: 1px solid var(--border); +} +.stat:last-child { + border: 0; +} +.stat-label { + font-size: 11px; + color: #7c8975; + display: flex; + align-items: center; + justify-content: space-between; +} +.stat-label svg { + color: #93a18b; +} +.stat-value { + font-size: 31px; + font-weight: 450; + letter-spacing: -1px; + margin-top: 7px; + display: flex; + align-items: center; + justify-content: space-between; +} +.green { + color: #438260; +} +.amber { + color: #b58a40; +} +.stat-caption { + font-size: 10px; + color: #8b9683; + margin-top: 5px; +} +.work-area { + background: #fff; + border: 1px solid var(--border); + border-radius: 7px; + overflow: hidden; + box-shadow: 0 2px 5px #29442203; +} +.view-bar { + height: 47px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 17px; +} +.view-tabs { + height: 100%; + display: flex; + gap: 22px; +} +.view-tabs button { + border-bottom: 2px solid transparent; + color: #8d9686; + padding: 0 3px; + font-size: 11px; +} +.view-tabs button.active { + color: #327255; + border-color: #327255; + font-weight: 500; +} +.view-tabs svg { + width: 14px; +} +.view-caption { + font-size: 9px; + display: flex; + align-items: center; + gap: 6px; + color: #8f9c85; +} +.sheet-layout { + display: flex; + align-items: stretch; +} +.sheet { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} +.toolbar { + height: 57px; + padding: 12px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.search { + display: flex; + align-items: center; + gap: 7px; + color: #9ba692; + max-width: 235px; + min-width: 135px; +} +.search input { + border: 0; + background: none; + font-size: 11px; + outline: none; + width: 100%; + height: 29px; +} +.search input::placeholder { + color: #9ba293; +} +.search kbd { + color: #a1a99b; + border: 1px solid #e5e9df; + border-radius: 3px; + padding: 1px 4px; + font-size: 9px; +} +.tools { + display: flex; + align-items: center; + gap: 14px; + white-space: nowrap; +} +.tools button, +.filter-control { + font-size: 10px; + color: #829077; + gap: 5px; + padding: 0; +} +.tools svg { + width: 14px; +} +.tools .tool-active { + color: var(--green); +} +.filter-control { + display: flex; + align-items: center; + gap: 3px; +} +.filter-control select { + max-width: 130px; + border: none; + background: transparent; + font-size: 10px; + outline-offset: 1px; + cursor: pointer; +} +.table-scroll { + overflow: auto; + min-height: 558px; + flex: 1; +} +table { + border-collapse: collapse; + width: 100%; + text-align: left; + white-space: nowrap; + font-size: 11px; +} +th { + font-weight: 400; + color: #829079; + font-size: 10px; + background: #f8faf5; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + padding: 12px 10px; +} +th svg { + margin-right: 4px; + opacity: 0.7; +} +th.row-number, +td.row-number { + padding-left: 16px; + width: 42px; + font-size: 9px; + color: #a1ab98; +} +td { + height: 48px; + padding: 0 10px; + border-bottom: 1px solid #edf0e8; +} +td:last-child, +th:last-child { + padding-right: 14px; +} +tbody tr.selected { + background: #f0f6ed; +} +tbody tr.selected td:first-child { + box-shadow: inset 2px 0 #5e8a53; + color: #6c8b5e; +} +tbody tr:hover { + background: #f7faf4; +} +.person-cell { + padding: 0; + justify-content: start; + text-align: left; + gap: 9px; + min-width: 144px; +} +.person-cell strong { + font-weight: 450; + font-size: 11px; + display: block; +} +.person-cell small { + display: block; + font-size: 9px; + color: #919c87; + margin-top: 3px; +} +.tone-0 { + background: #e7e8f1; + color: #8f89ad; +} +.tone-1 { + background: #e4ebdf; + color: #7c926e; +} +.tone-2 { + background: #f1e6e0; + color: #b28a76; +} +.tone-3 { + background: #e4eaee; + color: #8a9ba8; +} +.tone-4 { + background: #ece5ed; + color: #a18aa8; +} +.org-cell { + font-size: 10px; + color: #7f8d76; + padding: 0; + gap: 6px; + justify-content: start; +} +.org-mark { + display: inline-grid; + place-items: center; + width: 17px; + height: 17px; + border-radius: 4px; + font-size: 9px; + font-weight: 500; + flex-shrink: 0; +} +.purple { + background: #e9e3f1; + color: #9a84b7; +} +.blue { + background: #e1ebf2; + color: #7f9fbb; +} +.orange { + background: #f3e8da; + color: #c39e71; +} +.editable { + padding: 6px 4px; + color: #85917e; + font-size: 10px; + border-radius: 3px; +} +.editable:hover { + background: #e5edde; +} +.cell-button { + padding: 5px 0; +} +.pill { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 9px; + border-radius: 4px; + background: #f0f2eb; + color: #8f9786; + padding: 4px 6px; + font-weight: 400; + line-height: 1; + white-space: nowrap; +} +.pill.green { + background: #ecf3e9; + color: #79916d; +} +.pill.amber { + background: #f7f0e1; + color: #b99b67; +} +.dot { + background: currentColor; + width: 4px; + height: 4px; + border-radius: 50%; +} +.verification { + display: inline-flex; + gap: 5px; + align-items: center; + font-size: 10px; +} +.complete { + color: #8c9982; +} +.missing { + color: #c19172; +} +.review { + color: #b3a170; +} +.derived { + background: #f2f7ed; + color: #819773; +} +.derived-cell { + background: #f4f8ef55; +} +.eligibility { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 10px; + white-space: nowrap; +} +.eligibility.yes { + color: #769966; +} +.eligibility.no { + color: #c29a68; +} +.task-count { + font-size: 10px; + color: #a5ae9c; + padding: 5px 7px; +} +.task-count.has-tasks { + color: #849471; + background: #eef3e6; + border-radius: 4px; +} +.table-footer { + margin-top: auto; + height: 38px; + padding: 0 16px; + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid var(--border); + font-size: 9px; + color: #98a18e; +} +.table-footer > span:last-child { + display: flex; + align-items: center; + gap: 7px; +} +.formula-key { + color: #7f9b6d; + font-family: Georgia, serif; + font-style: italic; + font-size: 13px; +} +.inspector { + width: 287px; + flex-shrink: 0; + border-left: 1px solid var(--border); + background: #fdfefb; +} +.inspector-heading { + height: 45px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 17px; +} +.inspector-heading > span { + font-size: 8px; + letter-spacing: 1.1px; + color: #9ca78f; + display: flex; + align-items: center; + gap: 7px; +} +.inspector-heading svg { + width: 13px; +} +.inspector-person { + padding: 7px 20px 20px; +} +.avatar.large { + width: 38px; + height: 38px; + font-size: 12px; + background: #e8eddd; + color: #8a9b73; +} +.inspector-person h2 { + font-size: 19px; + letter-spacing: -0.3px; + font-weight: 450; + margin-top: 10px; +} +.inspector-person p { + font-size: 10px; + color: #99a18e; + margin-top: 4px; +} +.object-id { + font-size: 8px; + color: #a4ae99; + display: flex; + align-items: center; + gap: 6px; + margin-top: 10px; +} +.inspector-tabs { + border-bottom: 1px solid var(--border); + display: flex; + gap: 22px; + padding: 0 19px; + height: 35px; +} +.inspector-tabs button { + font-size: 10px; + color: #99a38e; + padding: 0 0 9px; + border-bottom: 2px solid transparent; +} +.inspector-tabs button.active { + color: #5c7f49; + border-color: #5c7f49; +} +.inspector-content { + padding: 17px 17px 18px; +} +.result-card { + border: 1px solid #eee5ce; + border-radius: 5px; + padding: 12px; + background: #faf7ed; +} +.result-card > div { + font-size: 10px; + color: #a58e61; + display: flex; + justify-content: space-between; + align-items: center; +} +.result-card p { + font-size: 9px; + color: #b2a17c; + margin-top: 8px; +} +.result-good { + background: #f0f6ec; + border-color: #dce8d3; +} +.result-good > div, +.result-good p { + color: #819d6d; +} +.section-label { + font-size: 8px; + letter-spacing: 1px; + color: #9aa48e; + margin: 20px 0 14px; + font-weight: 500; +} +.proof-tree { + padding: 0 1px; +} +.proof-step { + position: relative; + padding: 0 0 21px 23px; + border-left: 1px solid #e3e8da; + margin-left: 7px; +} +.proof-step:last-child { + border-color: transparent; + padding-bottom: 0; +} +.proof-icon { + position: absolute; + left: -8px; + top: 0; + display: grid; + place-items: center; + width: 15px; + height: 15px; + border-radius: 50%; + background: #edf3e6; + color: #8ea574; + border: 3px solid #fdfefb; +} +.proof-step.fail .proof-icon { + background: #f8edd9; + color: #b9a074; +} +.proof-step strong { + font-size: 10px; + font-weight: 400; + color: #7d8b71; +} +.proof-step.fail strong { + color: #ac966b; +} +.proof-fact { + margin-top: 7px; + padding: 7px 8px; + background: #f4f6ef; + border: 1px solid #e9ede1; + border-radius: 3px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 4px; +} +.proof-fact code { + font-size: 7px; + color: #a0ab93; +} +.proof-fact span { + font-size: 8px; + color: #8b987e; +} +.fail .proof-fact { + background: #fbf7ee; + border-color: #efe7d7; +} +.rule-note { + display: flex; + gap: 9px; + margin-top: 21px; + padding-top: 17px; + border-top: 1px solid var(--border); + color: #98a58c; +} +.rule-note strong { + font-size: 10px; + font-weight: 400; + color: #849578; +} +.rule-note strong span { + font-size: 8px; + padding: 1px 3px; + background: #f0f3e9; + color: #a1ac92; + margin-left: 4px; +} +.rule-note p { + font-size: 8px; + color: #a2ad96; + margin-top: 5px; +} +.metadata { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px 6px; + margin: 17px 0; + color: #a3ad98; + font-size: 8px; +} +.metadata dd { + margin: 0; + text-align: right; + color: #98a48b; + font-size: 8px; + overflow-wrap: anywhere; +} +.what-if { + position: relative; + margin-top: 20px; + padding: 13px 11px; + background: #f1f5e9; + border: 1px solid #e4ead9; + border-radius: 5px; +} +.what-if-icon { + position: absolute; + right: 12px; + top: 12px; + color: #91a474; +} +.what-if h3 { + font-weight: 450; + font-size: 11px; + color: #7b9168; +} +.what-if p { + font-size: 9px; + line-height: 1.7; + color: #9aa88a; + margin-top: 7px; + max-width: 185px; +} +.what-if .button { + margin-top: 12px; + background: #fdfef9; + color: #84996f; + font-size: 10px; + justify-content: space-between; + min-height: 28px; + padding: 6px 9px; + border-color: #e0e8d2; +} +.full { + width: 100%; +} +.workspace-hint { + margin-top: 15px; + font-size: 9px; + color: #9aa68e; + display: flex; + align-items: center; + gap: 6px; +} +.workspace-hint > span { + margin-left: auto; + font-size: 7px; + letter-spacing: 1px; +} +.workspace-hint strong { + font-size: 12px; + letter-spacing: -0.5px; + margin-left: 6px; + font-weight: 500; + color: #889b79; +} +.statusbar { + height: 30px; + padding: 0 30px; + border-top: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + background: #f8faf5; + color: #9ba68f; + font-size: 8px; +} +.statusbar > span { + display: flex; + align-items: center; + gap: 6px; +} +.status-divider { + margin: 0 9px; + color: #c2cbbb; +} +.empty { + padding: 65px 20px; + text-align: center; + color: #92a080; +} +.empty svg { + margin-bottom: 15px; +} +.empty h3 { + font-size: 16px; + font-weight: 500; +} +.empty p { + margin: 9px 0 17px; + font-size: 12px; +} +.graph-view { + background-color: #fbfcf8; + background-image: radial-gradient(#dce4d5 1px, transparent 1px); + background-size: 18px 18px; + padding: 20px; + min-height: 560px; +} +.graph-caption { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: #788970; +} +.graph-caption span { + margin-left: auto; + color: #a0ab94; + font-size: 9px; +} +.graph-columns { + display: flex; + gap: 25px; + justify-content: space-around; + padding: 36px 5px; +} +.graph-cluster { + flex: 1; + min-width: 160px; + max-width: 290px; +} +.graph-org { + padding: 16px 12px; + text-align: center; + background: white; + border: 1px solid #d6e2cc; + border-radius: 7px; + box-shadow: 0 3px 8px #42613706; + position: relative; +} +.graph-org .org-mark { + width: 28px; + height: 28px; + font-size: 14px; + display: flex; + margin: auto auto 10px; +} +.graph-org strong { + font-size: 11px; + font-weight: 500; +} +.graph-org small { + display: block; + font-size: 9px; + color: #9aa98c; + margin-top: 5px; +} +.graph-branches { + border-left: 1px solid #cfdcc3; + margin: 0 0 0 15px; + padding: 26px 0 0 17px; +} +.graph-node { + position: relative; + width: 100%; + justify-content: start; + text-align: left; + padding: 10px 8px; + background: #fff; + border: 1px solid #dfe7d8; + border-radius: 5px; + margin-bottom: 16px; + font-size: 10px; + gap: 8px; +} +.graph-node:before { + content: ""; + position: absolute; + left: -18px; + top: 50%; + width: 17px; + height: 1px; + background: #cfdcc3; +} +.graph-node .avatar { + width: 24px; + height: 24px; + font-size: 8px; +} +.graph-node small { + display: block; + font-size: 8px; + color: #9ba68f; + margin-top: 3px; +} +.node-dot { + width: 5px; + height: 5px; + background: currentColor; + border-radius: 50%; + margin-left: auto; +} +.node-selected { + border-color: #8fac7b; + background: #f4f9ee; +} +.graph-legend { + display: flex; + gap: 9px; + align-items: center; + color: #9aa88d; + font-size: 9px; +} +.graph-legend > span:last-child { + margin-left: auto; +} +.amber-bg { + background: #c2a166; +} +.board { + display: flex; + gap: 12px; + padding: 10px 16px 25px; + min-height: 558px; + overflow: auto; +} +.board-column { + flex: 1; + min-width: 185px; + background: #f7f9f3; + border-radius: 6px; + padding: 12px; +} +.board-column h3 { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 10px; + font-weight: 400; + color: #9baa8c; + margin-bottom: 16px; +} +.board-card { + display: block; + width: 100%; + text-align: left; + padding: 13px; + background: white; + border: 1px solid #e3e9db; + border-radius: 5px; + margin-bottom: 10px; +} +.board-card .avatar { + margin-bottom: 8px; +} +.board-card strong { + font-size: 11px; + font-weight: 500; + display: block; +} +.board-card small { + display: block; + color: #9aa58e; + font-size: 9px; + margin-top: 4px; +} +.board-card > div { + border-top: 1px solid #eef1e8; + margin-top: 12px; + padding-top: 10px; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 8px; + color: #97a48a; +} +.board-card .eligibility { + font-size: 9px; +} +.muted { + color: #92a083; + font-size: 11px; +} +.organization-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; +} +.organization-card, +.rule-page { + background: white; + border: 1px solid var(--border); + border-radius: 8px; + padding: 28px; +} +.organization-card > .org-mark { + width: 40px; + height: 40px; + font-size: 22px; + border-radius: 8px; +} +.organization-card h2 { + font-weight: 500; + font-size: 20px; + margin: 20px 0 6px; +} +.organization-card p { + color: #93a088; + font-size: 11px; +} +.organization-card > div { + margin: 25px 0; + color: #94a285; + font-size: 11px; +} +.organization-card > div strong { + font-size: 25px; + font-weight: 400; + color: #54804d; + margin-right: 4px; +} +.organization-card > div span { + margin: 0 10px; +} +.rule-page { + max-width: 850px; +} +.rule-page h2 { + font-size: 24px; + font-weight: 500; + margin: 20px 0 10px; +} +.rule-page p { + color: #8a9a7c; + line-height: 1.8; + max-width: 650px; + font-size: 12px; +} +.rule-page pre { + font-size: 11px; + line-height: 1.8; + overflow: auto; + padding: 20px; + border: 1px solid #e0e8d9; + border-radius: 6px; + background: #f5f8f0; + color: #69865a; +} +.rule-page .metadata { + font-size: 11px; + gap: 14px; +} +.rule-page .metadata dd { + font-size: 10px; +} +.task-list, +.activity-list { + border: 1px solid var(--border); + border-radius: 7px; + overflow: hidden; + background: white; +} +.task-list article, +.activity-list article { + display: flex; + align-items: center; + gap: 18px; + padding: 22px 25px; + border-bottom: 1px solid var(--border); +} +.task-list article:last-child, +.activity-list article:last-child { + border: 0; +} +.task-list article > svg { + color: #8ea177; +} +.task-list h3, +.activity-list h3 { + font-size: 14px; + font-weight: 500; +} +.task-list p, +.activity-list p { + font-size: 11px; + color: #94a087; + margin-top: 6px; +} +.task-list .pill { + margin-left: auto; +} +.activity-list article { + align-items: start; +} +.activity-list article > .pill { + margin-left: auto; +} +.activity-icon { + padding: 9px; + background: #edf3e6; + color: #86a070; + border-radius: 50%; +} +.activity-list code { + font-size: 9px; + color: #a3ad95; + display: block; + margin-top: 10px; +} +.activity-list details { + font-size: 10px; + color: #849675; + margin-top: 12px; + line-height: 1.8; +} +.activity-list summary { + cursor: pointer; +} +.history-change { + font: + 9px/1.9 "IBM Plex Mono", + monospace; + overflow-wrap: anywhere; + color: #95a386; +} +.fact-item { + padding: 12px 0; + border-bottom: 1px solid var(--border); + display: grid; + gap: 7px; +} +.fact-item code { + font-size: 9px; + color: #8f9e80; +} +.fact-item strong { + font-size: 12px; + font-weight: 500; + overflow-wrap: anywhere; +} +.fact-item small { + font-size: 7px; + color: #a5af99; + overflow-wrap: anywhere; +} +.history-item { + padding: 0 0 20px; + font-size: 11px; +} +.history-item > svg { + color: #96a584; + margin-right: 5px; +} +.history-item strong { + font-weight: 500; + font-size: 10px; +} +.history-item p, +.history-item small { + font-size: 9px; + color: #99a58c; + margin: 8px 0; + display: block; +} +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 20; + background: #1e322349; + backdrop-filter: blur(3px); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} +.dialog { + position: relative; + background: #fffefb; + width: 460px; + max-height: calc(100vh - 48px); + overflow: auto; + padding: 30px; + border: 1px solid #e1e6d8; + border-radius: 12px; + box-shadow: 0 24px 100px #20382426; +} +.dialog-close { + position: absolute; + right: 14px; + top: 14px; +} +.dialog-icon { + display: grid; + place-items: center; + background: #edf3e5; + color: #829d6b; + border: 1px solid #dfe9d4; + width: 45px; + height: 45px; + border-radius: 10px; + margin-bottom: 20px; +} +.dialog h2 { + font-size: 25px; + font-weight: 500; + letter-spacing: -0.5px; + margin: 5px 0 10px; +} +.dialog > p { + font-size: 12px; + color: #8c9a7e; + line-height: 1.7; + margin-bottom: 22px; +} +.dialog label { + display: block; + font-size: 11px; + color: #7e8d72; + margin-top: 15px; +} +.dialog input, +.dialog select { + display: block; + width: 100%; + border: 1px solid #dce4d4; + border-radius: 5px; + background: #fff; + padding: 10px; + margin-top: 7px; + min-height: 39px; + font-size: 12px; +} +.dialog input::placeholder { + color: #b0b8a6; +} +.dialog-actions { + display: flex; + justify-content: end; + gap: 10px; + margin-top: 24px; +} +.dialog-actions .button { + font-size: 12px; + min-height: 36px; +} +.demo-note { + font-size: 11px; + color: #95a286; + background: #f3f6ed; + border: 1px solid #e6ecdd; + line-height: 1.8; + padding: 12px; + border-radius: 5px; + margin: 20px 0; +} +.help-steps p { + font-size: 12px; + color: #97a28c; + line-height: 1.8; + margin: 16px 0; +} +.help-steps b { + display: block; + color: #69845a; + font-weight: 500; + margin-bottom: 3px; +} +.edit-fields { + display: flex; + gap: 15px; +} +.edit-fields label { + flex: 1; +} +.preview-result { + padding: 4px 15px 17px; + margin-top: 22px; + border: 1px solid #e0e8d5; + border-radius: 6px; + background: #f6f9f0; +} +.preview-result .section-label { + margin-top: 14px; +} +.preview-result p { + display: flex; + align-items: center; + gap: 15px; + font-size: 13px; + color: #8c9a7e; +} +.preview-result p > svg { + color: #b0bda3; + width: 14px; +} +.preview-result small { + display: block; + color: #9baa8b; + font-size: 10px; + margin-top: 13px; +} +.toast { + position: fixed; + bottom: 48px; + left: calc(50% + 80px); + transform: translateX(-50%); + z-index: 30; + background: #24573f; + color: #edf5e9; + border: 1px solid #447957; + border-radius: 7px; + box-shadow: 0 5px 20px #1739231a; + padding: 13px 20px; + font-size: 12px; + display: flex; + align-items: center; + gap: 10px; + max-width: 90vw; +} +.error { + color: #a45e47; + border: 1px solid #eccfc4; + background: #fff4ee; + padding: 14px; + border-radius: 6px; + margin-top: 15px; + font-size: 12px; + overflow-wrap: anywhere; +} +.floating-error { + position: fixed; + bottom: 40px; + left: 260px; + right: 30px; + z-index: 40; +} +.loading, +.startup-error { + padding: 100px; + text-align: center; + color: #739165; +} +.startup-error p { + margin: 20px auto; + max-width: 700px; + overflow-wrap: anywhere; +} +.startup-error button { + border: 1px solid #739165; + padding: 10px 20px; + border-radius: 5px; +} +@media (min-width: 1550px) { + main { + padding: 36px 40px; + } + .table-scroll { + min-height: 590px; + } + td { + height: 51px; + } + .inspector { + width: 310px; + } + .inspector-content { + padding: 20px; + } + .person-cell { + min-width: 175px; + } + .person-cell strong { + font-size: 12px; + } + .org-cell { + font-size: 11px; + } + table { + font-size: 12px; + } + .page-heading p { + font-size: 12px; + } + .stat { + padding: 20px 24px; + } +} +@media (max-width: 1250px) { + :root { + --sidebar: 192px; + } + .sidebar { + padding-left: 12px; + padding-right: 12px; + } + .brand { + font-size: 20px; + gap: 6px; + margin-left: 2px; + } + .beta { + font-size: 7px; + } + .profile { + margin: 0 -12px; + } + .inspector { + width: 254px; + } + main { + padding: 25px 20px; + } + .page-heading p { + max-width: 440px; + line-height: 1.6; + } + .heading-actions { + gap: 6px; + } + .tools { + gap: 9px; + } + .toolbar { + padding: 10px; + } + .search { + max-width: 150px; + } + .inspector-tabs { + gap: 18px; + } + .graph-columns { + gap: 12px; + } + .graph-view { + overflow: auto; + } + .sheet .graph-columns { + min-width: 650px; + } + .topbar { + padding: 0 22px; + } + .workspace-hint > span { + display: none; + } + .connected-card { + padding: 12px 9px; + } +} +@media (max-width: 1000px) { + .sheet-layout { + position: relative; + display: block; + } + .inspector { + width: 100%; + border-left: 0; + border-top: 1px solid var(--border); + } + .inspector-person { + display: grid; + grid-template-columns: 45px 1fr; + column-gap: 12px; + } + .inspector-person .avatar { + grid-row: 1/4; + } + .inspector-person h2 { + margin: 0; + } + .inspector-person .object-id { + margin-top: 5px; + } + .inspector-content { + max-width: 650px; + } + .table-scroll { + min-height: 0; + } + td { + height: 51px; + } + .stats { + margin-bottom: 20px; + } + .stat { + padding: 14px; + } + .stat-value { + font-size: 27px; + } + .title-icon { + display: none; + } + .page-heading { + gap: 8px; + } + .organization-cards { + grid-template-columns: 1fr; + } + .topbar-right { + gap: 10px; + } + .workspace-hint { + line-height: 1.7; + } + .statusbar > span:last-child { + display: none; + } + .graph-columns { + min-width: 600px; + } +} +@media (max-width: 650px) { + :root { + --sidebar: 60px; + } + .sidebar { + padding: 19px 7px; + } + .brand { + font-size: 0; + margin: 0 auto 28px; + } + .brand-mark { + font-size: 26px; + width: 31px; + } + .beta, + .workspace, + .nav-section, + .nav-item > span, + .connected-card, + .profile, + .guide, + .sidebar-bottom { + display: none; + } + .nav-item { + justify-content: center; + padding: 13px 0; + } + .nav-item svg { + width: 19px; + height: 19px; + } + nav { + margin-bottom: 25px; + } + .topbar { + height: 54px; + padding: 0 15px; + } + .breadcrumb { + gap: 9px; + font-size: 10px; + } + .topbar-right .local-status { + display: none; + } + .topbar .icon-button { + border: 0; + padding: 5px; + } + .topbar-right { + gap: 6px; + } + main { + padding: 23px 13px; + } + .page-heading { + flex-wrap: wrap; + margin-bottom: 20px; + } + .page-heading h1 { + font-size: 27px; + } + .page-heading p { + font-size: 11px; + line-height: 1.6; + } + .heading-actions { + margin: 8px 0 0; + width: 100%; + justify-content: end; + } + .stats { + grid-template-columns: repeat(2, 1fr); + gap: 0; + } + .stat { + border-bottom: 1px solid var(--border); + padding: 13px; + } + .stat:nth-child(2) { + border-right: 0; + } + .stat:nth-child(3) { + border-bottom: 0; + } + .stat-value { + font-size: 27px; + } + .stat-caption { + font-size: 9px; + } + .toolbar { + flex-wrap: wrap; + height: auto; + gap: 10px; + } + .search { + max-width: none; + width: 100%; + } + .tools { + padding-bottom: 3px; + gap: 20px; + } + .view-tabs { + gap: 16px; + } + .view-bar { + padding: 0 11px; + } + .table-scroll { + max-height: 540px; + } + .workspace-hint { + font-size: 8px; + align-items: start; + } + .workspace-hint svg { + margin-top: 3px; + } + .statusbar { + padding: 0 14px; + } + .modal-backdrop { + padding: 12px; + } + .dialog { + padding: 24px; + width: 100%; + max-height: calc(100vh - 24px); + } + .dialog h2 { + font-size: 23px; + } + .edit-fields { + display: block; + } + .toast { + left: 50%; + width: 85vw; + font-size: 11px; + } + .floating-error { + left: 75px; + right: 15px; + } + .task-list article { + flex-wrap: wrap; + padding: 18px; + gap: 12px; + } + .task-list article > div { + flex: 1; + min-width: 140px; + } + .task-list .pill { + margin: 0; + } + .activity-list article { + padding: 16px; + gap: 10px; + } + .activity-list article > .pill { + display: none; + } + .rule-page { + padding: 20px; + } + .rule-page .metadata { + display: block; + } + .rule-page .metadata dd { + text-align: left; + margin: 5px 0 15px; + } + .graph-caption span { + display: none; + } + .loading { + padding: 80px 20px; + } +} +/* Keep dense operational data legible against the quiet workspace surfaces. */ +.nav-item { + font-size: 13px; + color: #65725f; +} +.nav-section { + color: #75816c; +} +.page-heading p, +.stat-label, +.stat-caption, +.local-status { + color: #6d7c63; +} +.stat-symbol { + color: #d8e2d0; + display: flex; +} +.green .stat-symbol { + color: #a9c9a0; +} +.amber .stat-symbol { + color: #ddc799; +} +.person-cell strong { + font-size: 12px; +} +.person-cell small { + font-size: 10px; + color: #77866c; +} +.org-cell { + font-size: 11px; + color: #6d7e61; +} +th { + font-size: 11px; + color: #6d7f61; +} +.editable, +.verification, +.eligibility { + font-size: 11px; +} +.editable, +.complete { + color: #6a7e5d; +} +.missing, +.eligibility.no { + color: #a0713c; +} +.review { + color: #8d7a44; +} +.eligibility.yes { + color: #4f7c3b; +} +.pill { + font-size: 10px; +} +.pill.green { + color: #537843; +} +.pill.amber { + color: #967b43; +} +.pill.neutral { + color: #6d7862; +} +.search input, +.tools button, +.filter-control select { + font-size: 11px; + color: #6a7b5e; +} +.view-tabs button { + font-size: 12px; + color: #718065; +} +.inspector-person p { + font-size: 11px; + color: #79876d; +} +.inspector-tabs button { + font-size: 11px; + color: #7c8b6f; +} +.result-card > div { + font-size: 11px; + color: #947c4e; +} +.result-card p { + font-size: 10px; + color: #927e57; +} +.proof-step strong { + font-size: 11px; + color: #637954; +} +.proof-step.fail strong { + color: #95773f; +} +.proof-fact code { + font-size: 8px; + color: #778969; +} +.proof-fact span { + font-size: 9px; + color: #71825f; +} +.section-label { + color: #778968; +} +.rule-note strong { + font-size: 11px; + color: #6c825b; +} +.rule-note p { + font-size: 9px; + color: #839571; +} +.metadata, +.metadata dd { + font-size: 9px; + color: #778b65; +} +.what-if h3 { + font-size: 12px; + color: #5d7b48; +} +.what-if p { + font-size: 10px; + color: #778e63; +} +.what-if .button { + font-size: 11px; + color: #5e7c47; +} +.inspector-person { + padding-bottom: 14px; +} +.proof-step { + padding-bottom: 15px; +} +.inspector-content { + padding-top: 14px; +} +.what-if { + margin-top: 15px; +} +.what-if .button { + flex-direction: row-reverse; +} +.object-id { + color: #7d8e6e; +} +.table-footer { + color: #78896a; + font-size: 10px; +} +.workspace-hint { + color: #7c8d6e; +} +.demo-note, +.dialog > p { + color: #748464; +} +.dialog label { + color: #617553; +} +.graph-node { + font-size: 11px; +} +.graph-node small { + color: #7b8e69; + font-size: 9px; +} +.graph-caption, +.graph-legend { + color: #708460; +} +.board-card small { + color: #7b8d6c; +} +.task-list p, +.activity-list p { + color: #758668; +} +.history-change { + color: #6d815e; +} +.statusbar { + color: #798b69; +} diff --git a/packages/workbench/tsconfig.json b/packages/workbench/tsconfig.json new file mode 100644 index 0000000..e0caf09 --- /dev/null +++ b/packages/workbench/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noEmit": true, + "types": ["node", "vite/client"] + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/packages/workbench/vite.config.ts b/packages/workbench/vite.config.ts new file mode 100644 index 0000000..6150f9a --- /dev/null +++ b/packages/workbench/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vite"; + +export default defineConfig({}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 297d585..7b9e15a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -420,6 +420,34 @@ importers: specifier: 'catalog:' version: 4.1.11(@types/node@25.9.1)(vite@8.2.2(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.19.0)(yaml@2.9.0)) + packages/workbench: + dependencies: + '@bjacobso/triplex': + specifier: workspace:^ + version: link:../core + '@fontsource-variable/ibm-plex-sans': + specifier: 'catalog:' + version: 5.3.0 + '@fontsource/ibm-plex-mono': + specifier: 'catalog:' + version: 5.3.0 + effect: + specifier: 'catalog:' + version: 4.0.0-rc.112 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 25.9.1 + typescript: + specifier: 'catalog:' + version: 6.0.2 + vite: + specifier: 'catalog:' + version: 8.2.2(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.19.0)(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: 4.1.11(@types/node@25.9.1)(vite@8.2.2(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.19.0)(yaml@2.9.0)) + test/integration: dependencies: '@bjacobso/triplex': diff --git a/turbo.json b/turbo.json index 1b5e1e5..0b7a524 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,14 @@ "tasks": { "build": { "dependsOn": ["^build"], - "inputs": ["src/**", "package.json", "tsconfig.json", "tsdown.config.ts"], + "inputs": [ + "src/**", + "package.json", + "tsconfig.json", + "tsdown.config.ts", + "index.html", + "vite.config.ts" + ], "outputs": ["dist/**"] }, "typecheck": {